<?php

namespace Track404s;

use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\FrontendRequestGuard;
use WP_PowerSuite\Core\RequestPathHelper;

class Module extends AbstractModule {

	private const MODULE_ID   = 'track-404s';
	private const MODULE_VERSION = '1.2.3';
	public const DEFAULT_FILTER = 'redirected_only';
	private const WIDGET_CACHE_KEY = 'wpps_track_404s_widget';
	private const WIDGET_CACHE_EXPIRY = 300;
	private const TABLE_NAME  = 'wpps_404_logs';
	private const RATE_LIMIT_SECONDS = 60;
	private const CRON_HOOK            = 'wpps_track_404s_cleanup';
	private const MIN_RETENTION_DAYS   = 7;

private ?array $settings_cache = null;

	/** URL hash already counted on this request (prevents duplicate hit_count bumps). */
	private static ?string $request_hit_url_hash = null;

	/** Default URL patterns to ignore (security scans, browser noise, etc.) */
	private const DEFAULT_IGNORE_PATTERNS = [
		'.env',
		'.git',
		'wp-config',
		'favicon.ico',
		'apple-touch-icon',
		'xmlrpc',
		'wp-login.php',
		'robots.txt',
		'wp-sitemap',
		'/feed',
		'phpmyadmin',
		'mysql',
		'adminer',
	];

	/** Bot user-agent patterns (when Exclude Bots is on) */
	private const BOT_PATTERNS = [
		'googlebot',
		'bingbot',
		'slurp',
		'duckduckbot',
		'baiduspider',
		'yandexbot',
		'facebookexternalhit',
		'twitterbot',
		'linkedinbot',
		'whatsapp',
		'semrushbot',
		'ahrefsbot',
		'moz.com',
		'petalbot',
		'python-requests',
		'curl/',
		'wget/',
		'go-http-client',
		'screaming frog',
		'sitebulb',
		'uptimerobot',
		'pingdom',
		'gtmetrix',
		'feedvalidator',
	];

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( '404 Error Log', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Log 404 hits with referrers and counts to guide redirects and content fixes.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'frontend', 'ajax', 'cron' ];
	}

	public function getDependencies(): array {
		return [];
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

		protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		if ( is_admin() ) {
		add_action( 'admin_init', [ $this, 'maybeCreateTable' ], 999 );
		add_action( 'admin_init', [ $this, 'maybeMigrateTable' ], 1000 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAdminAssets' ] );
		add_action( 'wp_ajax_wpps_track_404s_export', [ $this, 'handleExport' ] );
		add_action( 'wp_ajax_wpps_track_404s_get_logs', [ $this, 'handleGetLogs' ] );
		add_action( 'wp_ajax_wpps_track_404s_get_stats', [ $this, 'handleGetStats' ] );
		add_action( 'wp_ajax_wpps_track_404s_delete', [ $this, 'handleDelete' ] );
		add_action( 'wp_ajax_wpps_track_404s_delete_all', [ $this, 'handleDeleteAll' ] );
		add_action( 'wp_dashboard_setup', [ $this, 'addDashboardWidget' ] );
		}

		// Cron: daily cleanup by retention days
		add_action( self::CRON_HOOK, [ $this, 'runRetentionCleanup' ] );
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
		wp_schedule_event( time(), 'daily', self::CRON_HOOK );
		}// Log 404s on frontend before 404 Redirect (template_redirect priority 999).
		add_action( 'template_redirect', [ $this, 'maybeLog404' ], 0 );
		add_action( 'wpps_404_auto_redirect', [ $this, 'recordAutoRedirect' ], 10, 3 );
		add_action( 'wpps_redirect_manager_redirect', [ $this, 'recordManagedRedirect' ], 10, 4 );
	}


	/**
	 * Enqueue modal / dashboard stylesheet (dashboard does not load wp-powersuite-admin).
	 */
	public function enqueueAdminAssets( string $hook_suffix ): void {
		if ( ! in_array( $hook_suffix, [ 'toplevel_page_powersuite', 'index.php' ], true ) ) {
			return;
		}

		$base = defined( 'WP_POWERSUITE_PLUGIN_URL' ) ? WP_POWERSUITE_PLUGIN_URL : '';
		if ( $base === '' ) {
			return;
		}

		// wp-powersuite-admin is only registered on PowerSuite screens; requiring it
		// on the Dashboard prevents this stylesheet from printing at all.
		$deps = wp_style_is( 'wp-powersuite-admin', 'registered' ) ? [ 'wp-powersuite-admin' ] : [];

		wp_enqueue_style(
			'wpps-track-404s-admin',
			wp_powersuite_module_asset_url( __FILE__, 'assets/admin.css' ),
			$deps,
			self::MODULE_VERSION
		);
	}

	/**
	 * Register the 404 Error Log widget on the WordPress Dashboard (only when module is enabled).
	 */
	public function addDashboardWidget(): void {
		if ( ! $this->isEnabled() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		wp_add_dashboard_widget(
			'wpps_track_404s_widget',
			__( '404 Error Log', 'wp-powersuite' ),
			[ $this, 'renderDashboardWidget' ],
			null,
			null,
			'side'
		);
	}

	/**
	 * Output the 404 Error Log dashboard widget: stats and actions.
	 */
	public function renderDashboardWidget(): void {
		$data       = $this->getWidgetData();
		$export_url = add_query_arg(
			[
				'action' => 'wpps_track_404s_export',
				'nonce'  => $data['export_nonce'],
				'filter' => Module::DEFAULT_FILTER,
			],
			admin_url( 'admin-ajax.php' )
		);
		$configure_url = admin_url( 'admin.php?page=powersuite' );
		?>
		<p class="description wpps-404-dashboard-intro">
			<?php esc_html_e( 'Track every 404 error: see which URLs are hit, referrers, and hit counts.', 'wp-powersuite' ); ?>
		</p>
		<div class="wpps-404-dashboard-stats">
			<div class="wpps-404-dashboard-stat">
				<span class="wpps-404-dashboard-value"><?php echo esc_html( number_format_i18n( $data['total_hits'] ) ); ?></span>
				<span class="wpps-404-dashboard-label"><?php esc_html_e( 'Total Hits', 'wp-powersuite' ); ?></span>
			</div>
			<div class="wpps-404-dashboard-stat">
				<span class="wpps-404-dashboard-value"><?php echo esc_html( number_format_i18n( $data['unique_urls'] ) ); ?></span>
				<span class="wpps-404-dashboard-label"><?php esc_html_e( 'Unique URLs', 'wp-powersuite' ); ?></span>
			</div>
			<div class="wpps-404-dashboard-stat">
				<span class="wpps-404-dashboard-value"><?php echo esc_html( number_format_i18n( $data['new_today'] ) ); ?></span>
				<span class="wpps-404-dashboard-label"><?php esc_html_e( 'New Today', 'wp-powersuite' ); ?></span>
			</div>
		</div>
		<p class="wpps-404-dashboard-actions">
			<a href="<?php echo esc_url( $configure_url ); ?>" class="button button-primary wpps-404-dashboard-view-log" onclick="sessionStorage.setItem('wpps_reopen_modal','track-404s');">
				<?php esc_html_e( 'View full log', 'wp-powersuite' ); ?>
			</a>
			<a href="<?php echo esc_url( $export_url ); ?>" class="button wpps-404-dashboard-export" target="_blank" rel="noopener">
				<?php esc_html_e( 'Export CSV', 'wp-powersuite' ); ?>
			</a>
		</p>
		<p class="wpps-404-dashboard-config">
			<a href="<?php echo esc_url( $configure_url ); ?>" onclick="sessionStorage.setItem('wpps_reopen_modal','track-404s');"><?php esc_html_e( 'Configure 404 Error Log', 'wp-powersuite' ); ?></a>
		</p>
		<?php
	}

	/**
	 * Cached stats for the dashboard widget.
	 *
	 * @return array{total_hits: int, unique_urls: int, new_today: int, export_nonce: string}
	 */
	public function getWidgetData(): array {
		$cache_key = self::WIDGET_CACHE_KEY . '_' . get_current_blog_id();
		$cached    = get_transient( $cache_key );
		if (
			is_array( $cached )
			&& isset( $cached['total_hits'], $cached['unique_urls'], $cached['new_today'] )
			&& ! array_key_exists( 'top_404s', $cached )
		) {
			$cached['export_nonce'] = wp_create_nonce( 'wpps_track_404s_export' );

			return $cached;
		}

		$data = $this->getDashboardData(
			Module::DEFAULT_FILTER,
			[
				'include_top'    => false,
				'include_recent' => false,
			]
		);

		$payload = [
			'total_hits'  => (int) $data['total_hits'],
			'unique_urls' => (int) $data['unique_urls'],
			'new_today'   => (int) $data['new_today'],
		];
		set_transient( $cache_key, $payload, self::WIDGET_CACHE_EXPIRY );

		$payload['export_nonce'] = (string) $data['export_nonce'];

		return $payload;
	}

	/**
	 * Get dashboard stats and rows.
	 *
	 * @param string               $filter unresolved|redirected_only|all (pages_only accepted as alias for unresolved).
	 * @param array{include_top?: bool, include_recent?: bool} $args Optional query toggles.
	 * @return array{total_hits: int, unique_urls: int, new_today: int, top_404s: array, recent_404s: array, export_nonce: string, admin_nonce: string}
	 */
	public function getDashboardData( string $filter = Module::DEFAULT_FILTER, array $args = [] ): array {
		$this->ensureTableExists();
		$file_filter    = $this->buildFilterSql( $filter );
		$include_top    = ! array_key_exists( 'include_top', $args ) || ! empty( $args['include_top'] );
		$include_recent = ! array_key_exists( 'include_recent', $args ) || ! empty( $args['include_recent'] );

		global $wpdb;
		$table = self::getTableName();

		$top_404s    = [];
		$recent_404s = [];
		if ( $include_top ) {
			$top_404s = $wpdb->get_results(
				"SELECT id, url, hit_count, first_seen, last_seen, referrer, redirect_to FROM {$table} WHERE 1=1{$file_filter} ORDER BY hit_count DESC LIMIT 5",
				ARRAY_A
			) ?: [];
		}
		if ( $include_recent ) {
			$recent_404s = $wpdb->get_results(
				"SELECT id, url, hit_count, first_seen, last_seen, referrer, user_agent, redirect_to FROM {$table} WHERE 1=1{$file_filter} ORDER BY last_seen DESC LIMIT 100",
				ARRAY_A
			) ?: [];
		}

		return [
			'total_hits'   => (int) $wpdb->get_var( "SELECT COALESCE(SUM(hit_count), 0) FROM {$table} WHERE 1=1{$file_filter}" ),
			'unique_urls'  => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE 1=1{$file_filter}" ),
			'new_today'    => (int) $wpdb->get_var( $wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE first_seen >= %s{$file_filter}",
				current_time( 'Y-m-d 00:00:00' )
			) ),
			'top_404s'     => $top_404s,
			'recent_404s'  => $recent_404s,
			'export_nonce' => wp_create_nonce( 'wpps_track_404s_export' ),
			'admin_nonce'  => wp_create_nonce( 'wpps_track_404s_admin' ),
		];
	}

	private function clearWidgetCache(): void {
		delete_transient( self::WIDGET_CACHE_KEY . '_' . get_current_blog_id() );
	}

	/**
	 * Get report data for email reports.
	 *
	 * @param array $date_range ['from' => 'Y-m-d', 'to' => 'Y-m-d']
	 * @return array{total_404s: int, unique_urls: int, top_404_urls: array}
	 */
	public function getReportData( array $date_range ): array {
		$this->ensureTableExists();
		$from = $date_range['from'] ?? gmdate( 'Y-m-d' );
		$to   = $date_range['to'] ?? gmdate( 'Y-m-d' );
		global $wpdb;
		$table = self::getTableName();
		$total_404s = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COALESCE(SUM(hit_count), 0) FROM {$table} WHERE DATE(first_seen) BETWEEN %s AND %s",
			$from,
			$to
		) );
		$unique_urls = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*) FROM {$table} WHERE DATE(first_seen) BETWEEN %s AND %s",
			$from,
			$to
		) );
		$top_urls = $wpdb->get_results( $wpdb->prepare(
			"SELECT url, hit_count as hits FROM {$table} WHERE DATE(first_seen) BETWEEN %s AND %s ORDER BY hit_count DESC LIMIT 5",
			$from,
			$to
		), ARRAY_A ) ?: [];
		return apply_filters( 'wpps_track_404s_report_data', [
			'total_404s'   => $total_404s,
			'unique_urls'  => $unique_urls,
			'top_404_urls' => $top_urls,
		], $date_range );
	}

	private function normalizeFilter( string $filter ): string {
		if ( $filter === 'pages_only' ) {
			return 'unresolved';
		}
		if ( ! in_array( $filter, [ 'unresolved', 'redirected_only', 'all' ], true ) ) {
			return 'unresolved';
		}

		return $filter;
	}

	private function contentUrlExcludeSql(): string {
		return " AND url NOT REGEXP '\\\\.(css|js|map|jpg|jpeg|png|gif|svg|webp|avif|ico|woff2?|ttf|eot|pdf|zip|xml|json|txt|php)(\\\\?|$)'";
	}

	private function buildFilterSql( string $filter ): string {
		$filter = $this->normalizeFilter( $filter );
		$sql    = '';

		if ( $filter === 'unresolved' || $filter === 'redirected_only' ) {
			$sql = $this->contentUrlExcludeSql();
		}

		if ( $filter === 'redirected_only' ) {
			$sql .= " AND redirect_to IS NOT NULL AND redirect_to != ''";
		} elseif ( $filter === 'unresolved' ) {
			$sql .= " AND (redirect_to IS NULL OR redirect_to = '')";
		}

		return $sql;
	}

	public function handleExport(): void {
		check_ajax_referer( 'wpps_track_404s_export', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		$this->ensureTableExists();
		$filter = isset( $_GET['filter'] ) ? sanitize_text_field( wp_unslash( $_GET['filter'] ) ) : 'all';
		$file_filter = $this->buildFilterSql( $filter );

		global $wpdb;
		$table = self::getTableName();
		$rows  = $wpdb->get_results( "SELECT url, hit_count, first_seen, last_seen, referrer, redirect_to FROM {$table} WHERE 1=1{$file_filter} ORDER BY hit_count DESC", ARRAY_A );

		$csv = "URL,Hit Count,First Seen,Last Seen,Referrer,Auto Redirect To\n";
		foreach ( $rows as $row ) {
			$cells = [
				$row['url'] ?? '',
				(string) ( $row['hit_count'] ?? 0 ),
				$row['first_seen'] ?? '',
				$row['last_seen'] ?? '',
				$row['referrer'] ?? '',
				$row['redirect_to'] ?? '',
			];
			$cells = array_map( [ $this, 'sanitizeCsvCell' ], $cells );
			$csv .= '"' . implode( '","', array_map( function ( $v ) { return str_replace( '"', '""', $v ); }, $cells ) ) . "\"\n";
		}

		$filename = '404-logs-' . gmdate( 'Y-m-d' ) . '.csv';
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=' . $filename );
		echo "\xEF\xBB\xBF"; // UTF-8 BOM
		echo $csv;
		exit;
	}

	/** Prevent CSV injection (formula injection) by prefixing dangerous characters. */
	private function sanitizeCsvCell( string $value ): string {
		if ( preg_match( '/^[=+\-@\t\r]/', $value ) ) {
			return "'" . $value;
		}
		return $value;
	}

	public function handleGetLogs(): void {
		check_ajax_referer( 'wpps_track_404s_admin', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$filter  = isset( $_POST['filter'] ) ? sanitize_text_field( wp_unslash( $_POST['filter'] ) ) : Module::DEFAULT_FILTER;
		$search  = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
		$offset  = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : 0;
		$limit   = 50;
		$orderby = isset( $_POST['orderby'] ) ? sanitize_text_field( wp_unslash( $_POST['orderby'] ) ) : 'last_seen';
		$order   = isset( $_POST['order'] ) ? sanitize_text_field( wp_unslash( $_POST['order'] ) ) : 'DESC';

		$allowed_orderby = [ 'url', 'hit_count', 'first_seen', 'last_seen' ];
		if ( ! in_array( $orderby, $allowed_orderby, true ) ) {
			$orderby = 'last_seen';
		}
		$order = strtoupper( $order ) === 'ASC' ? 'ASC' : 'DESC';

		$this->ensureTableExists();
		global $wpdb;
		$table = self::getTableName();
		$file_filter = $this->buildFilterSql( $filter );
		$conditions = [];

		if ( $search !== '' ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';
			$conditions[] = $wpdb->prepare( '(url LIKE %s OR referrer LIKE %s OR redirect_to LIKE %s)', $like, $like, $like );
		}
		$where = empty( $conditions ) ? '' : ' AND ' . implode( ' AND ', $conditions );
		$query = "SELECT id, url, hit_count, first_seen, last_seen, referrer, redirect_to FROM {$table} WHERE 1=1{$file_filter}{$where} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
		$logs = $wpdb->get_results( $wpdb->prepare( $query, $limit, $offset ), ARRAY_A );
		$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE 1=1{$file_filter}{$where}" );

		wp_send_json_success( [
			'logs'   => $logs ?: [],
			'total'  => $total,
			'offset' => $offset,
			'limit'  => $limit,
		] );
	}

	public function handleGetStats(): void {
		check_ajax_referer( 'wpps_track_404s_admin', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$filter = isset( $_POST['filter'] ) ? sanitize_text_field( wp_unslash( $_POST['filter'] ) ) : Module::DEFAULT_FILTER;
		$data = $this->getDashboardData( $filter );
		unset( $data['export_nonce'], $data['admin_nonce'] );
		wp_send_json_success( $data );
	}

	public function handleDelete(): void {
		check_ajax_referer( 'wpps_track_404s_admin', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0;
		if ( $id <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid ID.', 'wp-powersuite' ) ] );
		}
		global $wpdb;
		$table = self::getTableName();
		$deleted = $wpdb->delete( $table, [ 'id' => $id ], [ '%d' ] );
		if ( $deleted ) {
			$this->clearWidgetCache();
			wp_send_json_success();
		}
		wp_send_json_error( [ 'message' => __( 'Failed to delete.', 'wp-powersuite' ) ] );
	}

	public function handleDeleteAll(): void {
		check_ajax_referer( 'wpps_track_404s_admin', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		global $wpdb;
		$table = self::getTableName();
		$wpdb->query( "TRUNCATE TABLE {$table}" );
		$this->clearWidgetCache();
		wp_send_json_success();
	}

	public function runRetentionCleanup(): void {
		if ( ! $this->isEnabled() ) {
			return;
		}
		$settings = $this->getSettings();
		$days = max( self::MIN_RETENTION_DAYS, (int) ( $settings['retention_days'] ?? 30 ) );
		$this->ensureTableExists();
		global $wpdb;
		$table = self::getTableName();
		$before = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE last_seen < %s", $before ) );
		$this->clearWidgetCache();
	}

	public function maybeLog404(): void {
		if ( ! is_404() || FrontendRequestGuard::shouldSkipPublic404Action() ) {
			return;
		}

		$settings = $this->getSettings();
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$path        = RequestPathHelper::pathFromRequestUri( $request_uri );
		if ( $path === '' || FrontendRequestGuard::shouldSkip404Path( $path ) ) {
			return;
		}

		$url = $this->resolveLogUrl();
		if ( $this->shouldIgnoreUrl( $url, $settings ) ) {
			return;
		}

		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( ! empty( $settings['exclude_bots'] ) && $this->isBot( $user_agent ) ) {
			return;
		}

		$ip = $this->getClientIp();
		$rate_key = 'wpps_404_rate_' . md5( $ip . '|' . $url );
		if ( get_transient( $rate_key ) ) {
			return;
		}
		set_transient( $rate_key, 1, self::RATE_LIMIT_SECONDS );

		$referrer = '';
		if ( ! empty( $settings['track_referrer'] ) && isset( $_SERVER['HTTP_REFERER'] ) ) {
			$referrer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );
			if ( strlen( $referrer ) > 500 ) {
				$referrer = substr( $referrer, 0, 497 ) . '...';
			}
		}
		if ( strlen( $user_agent ) > 500 ) {
			$user_agent = substr( $user_agent, 0, 497 ) . '...';
		}

		if ( ! $this->tableIsReadyForFrontendWrite() ) {
			return;
		}

		$this->upsertLog( $url, $referrer, $user_agent );
	}

	/**
	 * Record that 404 Redirect sent this missing URL to a fallback destination.
	 *
	 * @param string $url           Request path (no query string).
	 * @param string $redirect_to   Destination URL from 404 Redirect.
	 * @param int    $redirect_code HTTP redirect status code.
	 */
	public function recordAutoRedirect( string $url, string $redirect_to, int $redirect_code ): void {
		unset( $redirect_code );

		$log_url = $this->resolveLogUrl( $url );
		if ( $log_url === '' ) {
			return;
		}

		$redirect_to = esc_url_raw( $redirect_to );
		if ( $redirect_to === '' ) {
			return;
		}
		if ( strlen( $redirect_to ) > 2048 ) {
			$redirect_to = substr( $redirect_to, 0, 2048 );
		}

		$referrer = '';
		if ( isset( $_SERVER['HTTP_REFERER'] ) ) {
			$referrer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );
			if ( strlen( $referrer ) > 500 ) {
				$referrer = substr( $referrer, 0, 497 ) . '...';
			}
		}

		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( strlen( $user_agent ) > 500 ) {
			$user_agent = substr( $user_agent, 0, 497 ) . '...';
		}

		if ( ! $this->tableIsReadyForFrontendWrite() ) {
			return;
		}

		// Same request as maybeLog404 — attach redirect destination without a second hit.
		$this->upsertLog( $log_url, $referrer, $user_agent, $redirect_to, false );
	}

	/**
	 * Record a Redirect Manager rule hit for correlation in the 404 log (M9).
	 *
	 * @param string               $path   Request path.
	 * @param string               $target Destination URL.
	 * @param int                  $code   HTTP status code.
	 * @param array<string, mixed> $rule   Matched redirect rule.
	 */
	public function recordManagedRedirect( string $path, string $target, int $code, array $rule ): void {
		unset( $code, $rule );

		$log_url = $this->resolveLogUrl( $path );
		if ( $log_url === '' ) {
			return;
		}

		$redirect_to = esc_url_raw( $target, [ 'http', 'https' ] );
		if ( $redirect_to === '' ) {
			return;
		}

		$referrer = '';
		if ( isset( $_SERVER['HTTP_REFERER'] ) ) {
			$referrer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );
			if ( strlen( $referrer ) > 500 ) {
				$referrer = substr( $referrer, 0, 497 ) . '...';
			}
		}

		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( strlen( $user_agent ) > 500 ) {
			$user_agent = substr( $user_agent, 0, 497 ) . '...';
		}

		if ( ! $this->tableIsReadyForFrontendWrite() ) {
			return;
		}

		$this->upsertLog( $log_url, $referrer, $user_agent, $redirect_to, true );
	}

	/**
	 * Normalize the URL key used for logging (path, optional query string).
	 */
	private function resolveLogUrl( string $fallback_path = '' ): string {
		$settings    = $this->getSettings();
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		if ( $request_uri !== '' ) {
			return RequestPathHelper::pathWithOptionalQuery( $request_uri, ! empty( $settings['log_query_strings'] ) );
		}

		return RequestPathHelper::pathFromRequestUri( $fallback_path );
	}

	private function normalizeRequestPath( string $request_uri ): string {
		return RequestPathHelper::pathFromRequestUri( $request_uri );
	}

	private function shouldIgnoreUrl( string $url, array $settings ): bool {
		$patterns = $settings['ignore_patterns'] ?? [];
		if ( empty( $patterns ) ) {
			$patterns = self::DEFAULT_IGNORE_PATTERNS;
		}
		$url_lower = strtolower( $url );
		foreach ( $patterns as $pattern ) {
			$pattern = trim( $pattern );
			if ( $pattern === '' ) {
				continue;
			}
			if ( str_contains( $url_lower, strtolower( $pattern ) ) ) {
				return true;
			}
		}
		return false;
	}

	private function isBot( string $user_agent ): bool {
		if ( $user_agent === '' ) {
			return false;
		}
		$ua_lower = strtolower( $user_agent );
		foreach ( self::BOT_PATTERNS as $bot ) {
			if ( str_contains( $ua_lower, $bot ) ) {
				return true;
			}
		}
		return false;
	}

	private function getClientIp(): string {
		$trust_proxy = (bool) apply_filters( 'wpps_track_404s_trust_proxy_headers', false );
		$keys        = $trust_proxy
			? [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' ]
			: [ 'REMOTE_ADDR' ];
		foreach ( $keys as $key ) {
			if ( ! empty( $_SERVER[ $key ] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
				if ( strpos( $ip, ',' ) !== false ) {
					$ip = trim( explode( ',', $ip )[0] );
				}
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}
		return '0.0.0.0';
	}

	private function tableIsReadyForFrontendWrite(): bool {
		global $wpdb;
		$table     = $wpdb->prefix . self::TABLE_NAME;
		$cache_key = 'wpps_404_table_exists';

		$exists = wp_cache_get( $cache_key );
		if ( $exists === false ) {
			$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
			wp_cache_set( $cache_key, $exists, '', 3600 );
		}

		return (bool) $exists;
	}

	private function ensureTableExists(): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_NAME;
		$cache_key = 'wpps_404_table_exists';
		$exists = wp_cache_get( $cache_key );
		if ( $exists === false ) {
			$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
			wp_cache_set( $cache_key, $exists, '', 3600 );
		}
		if ( ! $exists ) {
			$this->maybeCreateTable();
			return;
		}

		$this->maybeMigrateTable();
	}

	public function maybeCreateTable(): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_NAME;
		$cache_key = 'wpps_404_table_exists';
		$exists = wp_cache_get( $cache_key );
		if ( $exists === false ) {
			$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
			wp_cache_set( $cache_key, $exists, '', 3600 );
		}
		if ( $exists ) {
			return;
		}
		$charset = $wpdb->get_charset_collate();
		$sql = "CREATE TABLE IF NOT EXISTS {$table} (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			url varchar(2048) NOT NULL,
			url_hash char(64) NOT NULL,
			hit_count int(11) NOT NULL DEFAULT 1,
			first_seen datetime NOT NULL,
			last_seen datetime NOT NULL,
			referrer varchar(512) DEFAULT NULL,
			user_agent varchar(512) DEFAULT NULL,
			redirect_to varchar(2048) DEFAULT NULL,
			PRIMARY KEY (id),
			UNIQUE KEY url_hash (url_hash),
			KEY last_seen (last_seen),
			KEY hit_count (hit_count)
		) {$charset};";
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
		wp_cache_set( $cache_key, true, '', 3600 );
	}

	public function maybeMigrateTable(): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_NAME;
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
			return;
		}

		$col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'url_hash'" );
		if ( ! $col ) {
			$wpdb->query( "ALTER TABLE {$table} ADD COLUMN url_hash char(64) NULL AFTER url" );
			$rows = $wpdb->get_results( "SELECT id, url FROM {$table}", ARRAY_A );
			foreach ( $rows as $row ) {
				$hash = hash( 'sha256', $row['url'] );
				$wpdb->update( $table, [ 'url_hash' => $hash ], [ 'id' => $row['id'] ], [ '%s' ], [ '%d' ] );
			}
			$wpdb->query( "ALTER TABLE {$table} MODIFY url_hash char(64) NOT NULL" );
			$wpdb->query( "ALTER TABLE {$table} ADD UNIQUE KEY url_hash (url_hash)" );
		}

		$redirect_col = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'redirect_to'" );
		if ( ! $redirect_col ) {
			$wpdb->query( "ALTER TABLE {$table} ADD COLUMN redirect_to varchar(2048) DEFAULT NULL AFTER user_agent" );
		}
	}

	private function upsertLog( string $url, string $referrer, string $user_agent, ?string $redirect_to = null, bool $increment_hit = true ): void {
		global $wpdb;
		$table = self::getTableName();
		$url_hash = hash( 'sha256', $url );
		$now = current_time( 'mysql' );

		if ( $increment_hit && self::$request_hit_url_hash === $url_hash ) {
			$increment_hit = false;
		}

		$existing = $wpdb->get_row( $wpdb->prepare(
			"SELECT id, hit_count FROM {$table} WHERE url_hash = %s LIMIT 1",
			$url_hash
		) );
		if ( $existing ) {
			$update  = [];
			$formats = [];
			if ( $increment_hit ) {
				$update['hit_count'] = (int) $existing->hit_count + 1;
				$formats[]           = '%d';
				self::$request_hit_url_hash = $url_hash;
			}
			$update['last_seen']  = $now;
			$update['referrer']   = $referrer;
			$update['user_agent'] = $user_agent;
			$formats[]            = '%s';
			$formats[]            = '%s';
			$formats[]            = '%s';
			if ( $redirect_to !== null && $redirect_to !== '' ) {
				$update['redirect_to'] = $redirect_to;
				$formats[]             = '%s';
			}
			$wpdb->update(
				$table,
				$update,
				[ 'id' => $existing->id ],
				$formats,
				[ '%d' ]
			);
		} else {
			$wpdb->insert(
				$table,
				[
					'url'         => $url,
					'url_hash'    => $url_hash,
					'hit_count'   => 1,
					'first_seen'  => $now,
					'last_seen'   => $now,
					'referrer'    => $referrer,
					'user_agent'  => $user_agent,
					'redirect_to' => $redirect_to,
				],
				[ '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ]
			);
			if ( $increment_hit ) {
				self::$request_hit_url_hash = $url_hash;
			}
		}
	}

	public static function getTableName(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_NAME;
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$raw = $this->moduleSettings()->all( [] );
		$this->settings_cache = self::normalizeSettings( is_array( $raw ) ? $raw : [] );

		return $this->settings_cache;
	}

	/**
	 * Normalize settings for save/load (H6).
	 *
	 * @param array<string, mixed> $settings Raw settings.
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $settings ): array {
		$defaults = [
			'exclude_bots'    => true,
			'track_referrer'  => true,
			'ignore_patterns' => self::DEFAULT_IGNORE_PATTERNS,
			'retention_days'  => 30,
			'log_query_strings' => false,
		];

		$merged = array_merge( $defaults, $settings );

		$patterns = $merged['ignore_patterns'] ?? $defaults['ignore_patterns'];
		if ( is_string( $patterns ) ) {
			$patterns = array_filter( array_map( 'trim', explode( ',', $patterns ) ) );
		} elseif ( ! is_array( $patterns ) ) {
			$patterns = self::DEFAULT_IGNORE_PATTERNS;
		}
		if ( $patterns === [] ) {
			$patterns = self::DEFAULT_IGNORE_PATTERNS;
		}

		$patterns = array_values( array_filter( array_map( static function ( $pattern ) {
			$pattern = sanitize_text_field( (string) $pattern );
			return $pattern !== '' ? $pattern : null;
		}, $patterns ) ) );

		return [
			'exclude_bots'        => ! empty( $merged['exclude_bots'] ),
			'track_referrer'      => ! empty( $merged['track_referrer'] ),
			'ignore_patterns'     => $patterns,
			'retention_days'      => max( self::MIN_RETENTION_DAYS, min( 99999, (int) ( $merged['retention_days'] ?? 30 ) ) ),
			'log_query_strings'   => ! empty( $merged['log_query_strings'] ),
		];
	}

	public function getDefaultSettings(): array {
		return [
			'exclude_bots'      => true,
			'track_referrer'    => true,
			'ignore_patterns'   => implode( ', ', self::DEFAULT_IGNORE_PATTERNS ),
			'retention_days'    => 30,
			'log_query_strings' => false,
		];
	}

}
