<?php

/**
 * 404 Not Found Monitor Module
 *
 * Monitors and tracks 404 errors with automatic redirect suggestions
 *
 * @package ProRank\SEO\Modules\TechnicalSEO
 * @since   1.5.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\TechnicalSEO;

use ProRank\SEO\Modules\BaseModule;
use ProRank\SEO\Plugin;
use ProRank\SEO\Database\RedirectsTableInstaller;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}


/**
 * NotFoundMonitorModule class
 */


class NotFoundMonitorModule extends BaseModule {
    
    /**
     * Module slug
     *
     * @var string
     */
    protected string $slug = '404-monitor';
    
    /**
     * Module name
     *
     * @var string
     */
    protected string $name = '404 Error Monitor';
    
    /**
     * Module description
     *
     * @var string
     */
    protected string $description = 'Track 404 errors and automatically suggest redirects to improve user experience and SEO';
    
    /**
     * Module category
     *
     * @var string
     */
    protected string $category = 'technical-seo';
    
    /**
     * Module tier
     *
     * @var string
     */
    protected string $feature_tier = 'free';
    
    /**
     * Parent module
     *
     * @var string|null
     */
    protected ?string $parent_slug = 'redirects';
    
    /**
     * Table name cache
     *
     * @var string|null
     */
    private ?string $table_name = null;

    /**
     * Track whether table readiness was verified this request.
     *
     * @var bool
     */
    private bool $table_ready_checked = false;
    
    /**
     * Initialize module hooks
     *
     * @return void
     */
    public function init_hooks(): void {
        $this->ensure_table_ready();

        // Hook into 404 detection
        add_action('template_redirect', [$this, 'track_404_error'], 999);

        // Keep 404 monitor state aligned with redirects created outside the
        // "Create Redirect" button flow (manual redirect manager, imports, etc).
        add_action('prorank_redirect_created', [$this, 'sync_404s_for_redirect'], 10, 1);
        add_action('prorank_redirect_updated', [$this, 'sync_404s_for_redirect'], 10, 1);
        add_action('prorank_redirect_deleted', [$this, 'unlink_404s_for_redirect'], 10, 1);
        
        // Register REST routes
        add_action('rest_api_init', [$this, 'register_rest_routes']);
        
        // Add cron job for cleanup
        add_action('prorank_404_cleanup', [$this, 'cleanup_old_entries']);
        
        // Schedule cleanup if not already scheduled
        if (!wp_next_scheduled('prorank_404_cleanup')) {
            wp_schedule_event(time(), 'daily', 'prorank_404_cleanup');
        }
        
        // Add admin notices for high 404 counts
        add_action('admin_notices', [$this, 'show_404_alerts']);
    }
    
    /**
     * Track 404 errors
     *
     * @return void
     */
    public function track_404_error(): void {
        if (!$this->ensure_table_ready()) {
            return;
        }

        if (!get_option('prorank_404_monitoring_enabled', true)) {
            return;
        }

        if (!is_404()) {
            return;
        }
        
        // Skip if in admin or doing AJAX
        if (is_admin() || wp_doing_ajax()) {
            return;
        }
        
        // Get current URL
        $url = $this->get_current_url();
        if (empty($url)) {
            return;
        }

        $referrer = prorank_get_server_var('HTTP_REFERER') ?: null;
        $user_agent = prorank_get_server_var('HTTP_USER_AGENT') ?: null;

        // Skip if URL should be ignored
        if ($this->should_ignore_url($url)) {
            return;
        }

        // Skip known probe/scanner noise so 404 monitor stays SEO-actionable.
        if ($this->is_noise_404_entry($url, $referrer, $user_agent)) {
            return;
        }

        // Record the 404 error
        $this->record_404_error($url);
        
        if (get_option('prorank_auto_suggest_redirects', false)) {
            $this->suggest_redirect($url);
        }
    }
    
    /**
     * Get current URL
     *
     * @return string
     */
    private function get_current_url(): string {
        $protocol = prorank_get_server_var('HTTPS') === 'on' ? 'https' : 'http';
        $host = prorank_get_server_var( 'HTTP_HOST' );
        $uri = prorank_get_server_var( 'REQUEST_URI' );
        
        return $protocol . '://' . $host . $uri;
    }
    
    /**
     * Check if URL should be ignored
     *
     * @param string $url URL to check
     * @return bool
     */
    private function should_ignore_url(string $url): bool {
        // Default ignore patterns (always applied)
        $default_patterns = [
            '/wp-admin/',
            '/wp-content/uploads/',
            '/wp-includes/',
            '.php',
            'xmlrpc.php',
            'wp-login.php',
            '/feed/',
            'robots.txt',
            'sitemap.xml',
            '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp',
            '.css', '.js',
            '.woff', '.woff2', '.ttf', '.eot',
            '.ico',
            '.map',
        ];

        // Check default patterns
        foreach ($default_patterns as $pattern) {
            if (stripos($url, $pattern) !== false) {
                return true;
            }
        }

        // Get custom exclusion rules
        $custom_rules = get_option('prorank_404_exclusion_rules', []);

        if (!empty($custom_rules)) {
            foreach ($custom_rules as $rule) {
                if (empty($rule['pattern']) || empty($rule['enabled'])) {
                    continue;
                }

                $pattern = $rule['pattern'];
                $type = $rule['type'] ?? 'contains';

                switch ($type) {
                    case 'exact':
                        if ($url === $pattern) {
                            return true;
                        }
                        break;

                    case 'starts_with':
                        if (strpos($url, $pattern) === 0) {
                            return true;
                        }
                        break;

                    case 'ends_with':
                        if (substr($url, -strlen($pattern)) === $pattern) {
                            return true;
                        }
                        break;

                    case 'regex':
                        // Validate and test regex
                        if (@preg_match($pattern, $url)) {
                            return true;
                        }
                        break;

                    case 'contains':
                    default:
                        if (stripos($url, $pattern) !== false) {
                            return true;
                        }
                        break;
                }
            }
        }

        return false;
    }
    
    /**
     * Record 404 error in database
     *
     * @param string $url URL that triggered 404
     * @return void
     */
    private function record_404_error(string $url): void {
        global $wpdb;
        
        $table = $this->get_table_name();
        
        // Get additional data
        $referrer = prorank_get_server_var('HTTP_REFERER') ?: null;
        $user_agent = prorank_get_server_var('HTTP_USER_AGENT') ?: null;
        $ip_address = $this->get_client_ip();
        
        // Parse URL for path only
        $parsed = wp_parse_url($url);
        $path = $parsed['path'] ?? '/';
        if (!empty($parsed['query'])) {
            $path .= '?' . $parsed['query'];
        }
        
        // Check if URL already exists
        $existing = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT id, hits FROM {$table} WHERE url = %s",
                $path
            )
        );
        
        if ($existing) {
            // Update existing record
            $wpdb->update(
                $table,
                [
                    'hits' => $existing->hits + 1,
                    'last_hit' => current_time('mysql'),
                    'referrer' => $referrer,
                    'user_agent' => $user_agent,
                    'ip_address' => $ip_address,
                ],
                ['id' => $existing->id],
                ['%d', '%s', '%s', '%s', '%s'],
                ['%d']
            );
        } else {
            // Insert new record
            $wpdb->insert(
                $table,
                [
                    'url' => $path,
                    'referrer' => $referrer,
                    'user_agent' => $user_agent,
                    'ip_address' => $ip_address,
                    'hits' => 1,
                    'first_hit' => current_time('mysql'),
                    'last_hit' => current_time('mysql'),
                    'status' => 'unresolved',
                ],
                ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
            );
        }
    }
    
    /**
     * Get client IP address
     *
     * @return string|null
     */
    private function get_client_ip(): ?string {
        $ip_keys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
        
        foreach ($ip_keys as $key) {
            $ip = prorank_get_server_var($key);
            if ($ip !== '') {
                if (strpos($ip, ',') !== false) {
                    $ip = explode(',', $ip)[0];
                }
                $ip = trim($ip);
                if (filter_var($ip, FILTER_VALIDATE_IP)) {
                    return $ip;
                }
            }
        }
        
        return null;
    }
    
    /**
     * Suggest redirect for 404 URL
     *
     * @param string $url URL that triggered 404
     * @return void
     */
    private function suggest_redirect(string $url): void {
        global $wpdb;
        
        // Parse URL for path
        $parsed = wp_parse_url($url);
        $path = $parsed['path'] ?? '/';
        
        // Remove trailing slash for comparison
        $path = rtrim($path, '/');
        
        // Try to find similar existing pages
        $similar_pages = $this->find_similar_pages($path);
        
        if (!empty($similar_pages)) {
            $best_match = $similar_pages[0];
            $similarity = $this->calculate_similarity($path, $best_match['url']);
            
            // If similarity is high enough, update the 404 record with suggestion
            if ($similarity > 0.7) {
                $table = $this->get_table_name();
                $wpdb->update(
                    $table,
                    [
                        'auto_suggested' => 1,
                        'suggestion_url' => $best_match['url'],
                        'similarity_score' => $similarity * 100,
                    ],
                    ['url' => $path],
                    ['%d', '%s', '%f'],
                    ['%s']
                );
            }
        }
    }
    
    /**
     * Find similar existing pages
     *
     * @param string $path Path to match
     * @return array
     */
    private function find_similar_pages(string $path): array {
        global $wpdb;
        
        // Extract keywords from path
        $keywords = $this->extract_keywords($path);
        
        if (empty($keywords)) {
            return [];
        }
        
        // Search for posts/pages with similar URLs or titles
        $results = [];
        
        // Search in posts
        $posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT ID, post_name, post_title, guid 
                FROM {$wpdb->posts} 
                WHERE post_status = 'publish' 
                AND post_type IN ('post', 'page') 
                AND (post_name LIKE %s OR post_title LIKE %s)
                LIMIT 10",
                '%' . $wpdb->esc_like($keywords[0]) . '%',
                '%' . $wpdb->esc_like($keywords[0]) . '%'
            )
        );
        
        foreach ($posts as $post) {
            $results[] = [
                'url' => get_permalink($post->ID),
                'title' => $post->post_title,
                'slug' => $post->post_name,
            ];
        }
        
        return $results;
    }
    
    /**
     * Extract keywords from path
     *
     * @param string $path URL path
     * @return array
     */
    private function extract_keywords(string $path): array {
        // Remove common prefixes and suffixes
        $path = trim($path, '/');
        $path = preg_replace('/\.(html?|php|aspx?)$/i', '', $path);
        
        // Split by common delimiters
        $parts = preg_split('/[-_\/\s]+/', $path);
        
        // Filter out common words and short words
        $stopwords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for'];
        $keywords = array_filter($parts, function($word) use ($stopwords) {
            return strlen($word) > 2 && !in_array(strtolower($word), $stopwords);
        });
        
        return array_values($keywords);
    }
    
    /**
     * Calculate similarity between two strings
     *
     * @param string $str1 First string
     * @param string $str2 Second string
     * @return float Similarity score (0-1)
     */
    private function calculate_similarity(string $str1, string $str2): float {
        $str1 = strtolower($str1);
        $str2 = strtolower($str2);
        
        // Use Levenshtein distance for similarity
        $max_len = max(strlen($str1), strlen($str2));
        if ($max_len === 0) {
            return 1.0;
        }
        
        $distance = levenshtein($str1, $str2);
        $similarity = 1 - ($distance / $max_len);
        
        return max(0, $similarity);
    }
    
    /**
     * Register REST routes
     *
     * @return void
     */
    public function register_rest_routes(): void {
        register_rest_route('prorank-seo/v1', '/404-monitor', [
            [
                'methods' => 'GET',
                'callback' => [$this, 'get_404_errors'],
                'permission_callback' => [$this, 'check_permissions'],
            ],
        ]);
        
        register_rest_route('prorank-seo/v1', '/404-monitor/(?P<id>\d+)', [
            [
                'methods' => 'DELETE',
                'callback' => [$this, 'delete_404_error'],
                'permission_callback' => [$this, 'check_permissions'],
            ],
            [
                'methods' => 'POST',
                'callback' => [$this, 'resolve_404_error'],
                'permission_callback' => [$this, 'check_permissions'],
            ],
        ]);
        
        register_rest_route('prorank-seo/v1', '/404-monitor/stats', [
            'methods' => 'GET',
            'callback' => [$this, 'get_404_stats'],
            'permission_callback' => [$this, 'check_permissions'],
        ]);
        
        register_rest_route('prorank-seo/v1', '/404-monitor/create-redirect', [
            'methods' => 'POST',
            'callback' => [$this, 'create_redirect_from_404'],
            'permission_callback' => [$this, 'check_permissions'],
        ]);

        register_rest_route('prorank-seo/v1', '/404-monitor/exclusions', [
            [
                'methods' => 'GET',
                'callback' => [$this, 'get_exclusion_rules'],
                'permission_callback' => [$this, 'check_permissions'],
            ],
            [
                'methods' => 'POST',
                'callback' => [$this, 'update_exclusion_rules'],
                'permission_callback' => [$this, 'check_permissions'],
            ],
        ]);
    }
    
    /**
     * Get 404 errors
     *
     * @param \WP_REST_Request $request
     * @return \WP_REST_Response
     */
    public function get_404_errors(\WP_REST_Request $request): \WP_REST_Response {
        global $wpdb;

        $table = $this->get_table_name();
        
        // Get parameters
        $page = max(1, (int) $request->get_param('page'));
        $per_page = max(1, min(100, (int) $request->get_param('per_page') ?: 20));
        $status = $request->get_param('status');
        $orderby = $request->get_param('orderby') ?: 'hits';
        $order = strtoupper($request->get_param('order') ?: 'DESC');
        $search = (string) ($request->get_param('search') ?: '');
        $min_hits = max(0, (int) $request->get_param('min_hits'));
        $include_noise = rest_sanitize_boolean($request->get_param('include_noise'));
        $allowed_orderby = ['hits', 'last_seen', 'created_at', 'url', 'status'];
        if (!in_array($orderby, $allowed_orderby, true)) {
            $orderby = 'hits';
        }
        $order = $order === 'ASC' ? 'ASC' : 'DESC';

        $orderby_map = [
            'hits' => 'hits',
            'last_seen' => 'last_hit',
            'created_at' => 'first_hit',
            'url' => 'url',
            'status' => 'status',
        ];
        $orderby_column = $orderby_map[$orderby] ?? 'hits';

        // Build query
        $where = '1=1';
        $params = [];

        if ($status) {
            $where .= ' AND status = %s';
            $params[] = $status;
        }

        // Fetch candidate rows, then apply actionable filtering in PHP
        // so probe/scanner URLs do not pollute SEO workflow by default.
        $query = "SELECT * FROM %i WHERE {$where} ORDER BY {$orderby_column} {$order}";
        $query_args = array_merge([$table], $params);
        $rows = $wpdb->get_results($wpdb->prepare($query, ...$query_args)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $filtered_rows = array_values(array_filter((array) $rows, function ($row) use ($include_noise, $search, $min_hits): bool {
            if (!$include_noise && $this->is_noise_404_entry((string) ($row->url ?? ''), (string) ($row->referrer ?? ''), (string) ($row->user_agent ?? ''))) {
                return false;
            }

            if ($min_hits > 0 && (int) ($row->hits ?? 0) < $min_hits) {
                return false;
            }

            if ($search !== '') {
                $haystack = strtolower(
                    (string) ($row->url ?? '') . ' ' .
                    (string) ($row->referrer ?? '') . ' ' .
                    (string) ($row->user_agent ?? '')
                );
                return strpos($haystack, strtolower($search)) !== false;
            }

            return true;
        }));

        $total = count($filtered_rows);
        $offset = ($page - 1) * $per_page;
        $errors = array_slice($filtered_rows, $offset, $per_page);

        return new \WP_REST_Response([
            'errors' => $errors,
            'total' => $total,
            'total_pages' => (int) ceil($total / $per_page),
            'page' => $page,
            'per_page' => $per_page,
            'min_hits' => $min_hits,
            'include_noise' => (bool) $include_noise,
        ]);
    }
    
    /**
     * Get 404 statistics
     *
     * @param \WP_REST_Request $request REST request.
     * @return \WP_REST_Response
     */
    public function get_404_stats(\WP_REST_Request $request): \WP_REST_Response {
        global $wpdb;

        $table = $this->get_table_name();

        $include_noise = rest_sanitize_boolean($request->get_param('include_noise'));
        $status = (string) ($request->get_param('status') ?: '');
        $search = strtolower((string) ($request->get_param('search') ?: ''));
        $min_hits = max(0, (int) $request->get_param('min_hits'));
        $rows = $wpdb->get_results(
            "SELECT id, url, referrer, user_agent, hits, first_hit, last_hit, status, auto_suggested, suggestion_url
            FROM {$table}"
        );

        $matches_filters = static function ($row) use ($status, $search, $min_hits): bool {
            if ($status !== '' && (string) ($row->status ?? '') !== $status) {
                return false;
            }

            if ($min_hits > 0 && (int) ($row->hits ?? 0) < $min_hits) {
                return false;
            }

            if ($search !== '') {
                $haystack = strtolower(
                    (string) ($row->url ?? '') . ' ' .
                    (string) ($row->referrer ?? '') . ' ' .
                    (string) ($row->user_agent ?? '')
                );

                if (strpos($haystack, $search) === false) {
                    return false;
                }
            }

            return true;
        };

        $actionable_rows = array_values(array_filter((array) $rows, function ($row) use ($matches_filters): bool {
            return $matches_filters($row)
                && !$this->is_noise_404_entry((string) ($row->url ?? ''), (string) ($row->referrer ?? ''), (string) ($row->user_agent ?? ''));
        }));

        $noise_rows = array_values(array_filter((array) $rows, function ($row) use ($matches_filters): bool {
            return $matches_filters($row)
                && $this->is_noise_404_entry((string) ($row->url ?? ''), (string) ($row->referrer ?? ''), (string) ($row->user_agent ?? ''));
        }));

        $selected_rows = $include_noise
            ? array_values(array_filter((array) $rows, $matches_filters))
            : $actionable_rows;

        $resolved_count = 0;
        $suggested_count = 0;
        $total_hits = 0;
        foreach ($selected_rows as $row) {
            $hits = (int) ($row->hits ?? 0);
            $total_hits += $hits;
            if (($row->status ?? '') === 'resolved') {
                $resolved_count++;
            }
            if ((int) ($row->auto_suggested ?? 0) === 1) {
                $suggested_count++;
            }
        }

        $stats = (object) [
            'total_urls' => count($selected_rows),
            'total_hits' => $total_hits,
            'resolved_count' => $resolved_count,
            'suggested_count' => $suggested_count,
            'raw_total_urls' => count((array) $rows),
            'raw_total_hits' => array_reduce((array) $rows, static function (int $carry, $row): int {
                return $carry + (int) ($row->hits ?? 0);
            }, 0),
            'noise_filtered_urls' => count($noise_rows),
            'noise_filtered_hits' => array_reduce($noise_rows, static function (int $carry, $row): int {
                return $carry + (int) ($row->hits ?? 0);
            }, 0),
            'noise_filter_active' => !$include_noise,
        ];

        $top_source_rows = array_values(array_filter($selected_rows, static function ($row): bool {
            return ($row->status ?? '') === 'unresolved';
        }));
        usort($top_source_rows, static function ($a, $b): int {
            return ((int) ($b->hits ?? 0)) <=> ((int) ($a->hits ?? 0));
        });
        $top_errors = array_slice($top_source_rows, 0, 10);

        $recent_source_rows = $top_source_rows;
        usort($recent_source_rows, static function ($a, $b): int {
            return strcmp((string) ($b->last_hit ?? ''), (string) ($a->last_hit ?? ''));
        });
        $recent_errors = array_slice(array_map(static function ($row): object {
            return (object) [
                'url' => $row->url ?? '',
                'hits' => (int) ($row->hits ?? 0),
                'last_hit' => $row->last_hit ?? null,
            ];
        }, $recent_source_rows), 0, 10);

        return new \WP_REST_Response([
            'stats' => $stats,
            'top_errors' => $top_errors,
            'recent_errors' => $recent_errors,
            'filters' => [
                'status' => $status,
                'search' => $search,
                'min_hits' => $min_hits,
                'include_noise' => $include_noise,
            ],
        ]);
    }
    
    /**
     * Create redirect from 404 error
     *
     * @param \WP_REST_Request $request
     * @return \WP_REST_Response|\WP_Error
     */
    public function create_redirect_from_404(\WP_REST_Request $request) {
        global $wpdb;
        
        $id = (int) $request->get_param('id');
        $target_url = (string) ($request->get_param('target_url') ?? '');
        $redirect_type = (int) $request->get_param('type') ?: 301;
        
        // Get 404 error
        $table = $this->get_table_name();
        $error = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM {$table} WHERE id = %d",
                $id
            )
        );
        
        if (!$error) {
            return new \WP_Error('not_found', __('404 error not found', 'prorank-seo'), ['status' => 404]);
        }
        
        $resolved_target_url = $target_url !== '' ? $target_url : (string) ($error->suggestion_url ?? '');
        if ($resolved_target_url === '') {
            return new \WP_Error(
                'missing_target',
                __('No redirect suggestion found. Please choose a target URL first.', 'prorank-seo'),
                ['status' => 400]
            );
        }

        // Create redirect
        $redirects_table = $wpdb->prefix . 'prorank_redirects';
        $result = $wpdb->insert(
            $redirects_table,
            [
                'source_url' => $error->url,
                'target_url' => $resolved_target_url,
                'type' => $redirect_type,
                'status' => 'active',
/* translators: %1$: value */
                'notes' => sprintf(__('Auto-created from 404 monitor (ID: %1$d)', 'prorank-seo'), $id),
            ],
            ['%s', '%s', '%d', '%s', '%s']
        );
        
        if ($result === false) {
            return new \WP_Error('db_error', __('Failed to create redirect', 'prorank-seo'), ['status' => 500]);
        }
        
        $redirect_id = $wpdb->insert_id;
        do_action('prorank_redirect_created', $redirect_id);
        
        // Update 404 error status
        $wpdb->update(
            $table,
            [
                'status' => 'resolved',
                'redirect_id' => $redirect_id,
            ],
            ['id' => $id],
            ['%s', '%d'],
            ['%d']
        );
        
        return new \WP_REST_Response([
            'success' => true,
            'redirect_id' => $redirect_id,
            'message' => __('Redirect created successfully', 'prorank-seo'),
        ]);
    }
    
    /**
     * Delete 404 error
     *
     * @param \WP_REST_Request $request
     * @return \WP_REST_Response|\WP_Error
     */
    public function delete_404_error(\WP_REST_Request $request) {
        global $wpdb;
        
        $id = (int) $request->get_param('id');
        $table = $this->get_table_name();
        
        $result = $wpdb->delete(
            $table,
            ['id' => $id],
            ['%d']
        );
        
        if ($result === false) {
            return new \WP_Error('db_error', __('Failed to delete 404 error', 'prorank-seo'), ['status' => 500]);
        }
        
        return new \WP_REST_Response(null, 204);
    }
    
    /**
     * Resolve 404 error
     *
     * @param \WP_REST_Request $request
     * @return \WP_REST_Response|\WP_Error
     */
    public function resolve_404_error(\WP_REST_Request $request) {
        global $wpdb;
        
        $id = (int) $request->get_param('id');
        $table = $this->get_table_name();
        
        $result = $wpdb->update(
            $table,
            ['status' => 'resolved'],
            ['id' => $id],
            ['%s'],
            ['%d']
        );
        
        if ($result === false) {
            return new \WP_Error('db_error', __('Failed to resolve 404 error', 'prorank-seo'), ['status' => 500]);
        }
        
        return new \WP_REST_Response([
            'success' => true,
            'message' => __('404 error marked as resolved', 'prorank-seo'),
        ]);
    }

    /**
     * Sync matching 404 rows when a redirect is created or updated.
     *
     * @param int $redirect_id Redirect ID.
     * @return void
     */
    public function sync_404s_for_redirect(int $redirect_id): void {
        global $wpdb;

        if (!$this->ensure_table_ready() || $redirect_id <= 0) {
            return;
        }

        $redirects_table = $wpdb->prefix . 'prorank_redirects';
        $table = $this->get_table_name();
        $redirect = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT id, source_url, status, is_regex FROM {$redirects_table} WHERE id = %d",
                $redirect_id
            )
        );

        if (!$redirect) {
            return;
        }

        $candidates = [];
        if ((int) ($redirect->is_regex ?? 0) === 0) {
            $candidates = $this->build_404_url_candidates((string) $redirect->source_url);
        }

        if (!empty($candidates)) {
            $placeholders = implode(', ', array_fill(0, count($candidates), '%s'));
            $wpdb->query(
                $wpdb->prepare(
                    "UPDATE {$table}
                    SET status = 'unresolved', redirect_id = NULL
                    WHERE redirect_id = %d
                    AND url NOT IN ({$placeholders})",
                    $redirect_id,
                    ...$candidates
                )
            );
        } else {
            $this->unlink_404s_for_redirect($redirect_id);
        }

        if (($redirect->status ?? '') !== 'active' || empty($candidates)) {
            return;
        }

        $placeholders = implode(', ', array_fill(0, count($candidates), '%s'));
        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$table}
                SET status = 'resolved', redirect_id = %d
                WHERE url IN ({$placeholders})",
                $redirect_id,
                ...$candidates
            )
        );
    }

    /**
     * Mark linked 404s unresolved when their redirect is deleted.
     *
     * @param int $redirect_id Redirect ID.
     * @return void
     */
    public function unlink_404s_for_redirect(int $redirect_id): void {
        global $wpdb;

        if (!$this->ensure_table_ready() || $redirect_id <= 0) {
            return;
        }

        $table = $this->get_table_name();
        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$table}
                SET status = 'unresolved', redirect_id = NULL
                WHERE redirect_id = %d",
                $redirect_id
            )
        );
    }

    /**
     * Build slash-tolerant URL candidates for syncing 404 rows to redirects.
     *
     * @param string $url Source path or same-site URL.
     * @return array<int, string>
     */
    private function build_404_url_candidates(string $url): array {
        $url = trim(wp_unslash($url));
        if ($url === '') {
            return [];
        }

        if (preg_match('#^https?://#i', $url)) {
            $parts = wp_parse_url($url);
            if (is_array($parts)) {
                $path = isset($parts['path']) ? (string) $parts['path'] : '/';
                if (!empty($parts['query'])) {
                    $path .= '?' . $parts['query'];
                }
                $url = $path;
            }
        }

        $parts = explode('?', $url, 2);
        $path = '/' . ltrim((string) ($parts[0] ?? '/'), '/');
        $query = (string) ($parts[1] ?? '');

        $path = $path === '/' ? '/' : untrailingslashit($path);
        if ($path === '') {
            $path = '/';
        }

        $variants = [$query !== '' ? $path . '?' . $query : $path];

        if ($path !== '/') {
            $with_slash = trailingslashit($path);
            $variants[] = $query !== '' ? $with_slash . '?' . $query : $with_slash;
        }

        return array_values(array_unique(array_filter($variants, static function ($candidate): bool {
            return $candidate !== '';
        })));
    }
    
    /**
     * Get exclusion rules
     *
     * @return \WP_REST_Response
     */
    public function get_exclusion_rules(): \WP_REST_Response {
        $rules = get_option('prorank_404_exclusion_rules', []);

        // Ensure each rule has all expected fields
        $formatted_rules = array_map(function($rule) {
            return [
                'id' => $rule['id'] ?? uniqid(),
                'pattern' => $rule['pattern'] ?? '',
                'type' => $rule['type'] ?? 'contains',
                'enabled' => $rule['enabled'] ?? true,
                'description' => $rule['description'] ?? '',
            ];
        }, $rules);

        return new \WP_REST_Response([
            'rules' => $formatted_rules,
            'available_types' => [
                'contains' => __('Contains', 'prorank-seo'),
                'exact' => __('Exact match', 'prorank-seo'),
                'starts_with' => __('Starts with', 'prorank-seo'),
                'ends_with' => __('Ends with', 'prorank-seo'),
                'regex' => __('Regular expression', 'prorank-seo'),
            ],
        ]);
    }

    /**
     * Update exclusion rules
     *
     * @param \WP_REST_Request $request
     * @return \WP_REST_Response|\WP_Error
     */
    public function update_exclusion_rules(\WP_REST_Request $request) {
        $rules = $request->get_param('rules') ?: [];

        // Validate and sanitize rules
        $validated_rules = [];
        foreach ($rules as $rule) {
            $pattern = sanitize_text_field($rule['pattern'] ?? '');

            if (empty($pattern)) {
                continue;
            }

            $type = in_array($rule['type'] ?? '', ['contains', 'exact', 'starts_with', 'ends_with', 'regex'])
                ? $rule['type']
                : 'contains';

            // Validate regex patterns
            if ($type === 'regex') {
                if (@preg_match($pattern, '') === false) {
                    return new \WP_Error(
                        'invalid_regex',
/* translators: %1$: value */
                        sprintf(__('Invalid regex pattern: %1$s', 'prorank-seo'), $pattern),
                        ['status' => 400]
                    );
                }
            }

            $validated_rules[] = [
                'id' => $rule['id'] ?? uniqid(),
                'pattern' => $pattern,
                'type' => $type,
                'enabled' => (bool) ($rule['enabled'] ?? true),
                'description' => sanitize_text_field($rule['description'] ?? ''),
            ];
        }

        // Save rules
        update_option('prorank_404_exclusion_rules', $validated_rules);

        return new \WP_REST_Response([
            'success' => true,
            'rules' => $validated_rules,
            'message' => __('Exclusion rules updated successfully', 'prorank-seo'),
        ]);
    }

    /**
     * Check REST permissions
     *
     * @return bool
     */
    public function check_permissions(): bool {
        return current_user_can('manage_options');
    }
    
    /**
     * Cleanup old entries
     *
     * @return void
     */
    public function cleanup_old_entries(): void {
        global $wpdb;
        
        $table = $this->get_table_name();
        $days = (int) get_option('prorank_404_retention_days', 90);
        $cutoff = wp_date('Y-m-d H:i:s', strtotime("-{$days} days"));
        
        // Delete old resolved entries
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$table} 
                WHERE status = 'resolved' 
                AND last_hit < %s",
                $cutoff
            )
        );
        
        // Delete low-hit unresolved entries older than 30 days
        $old_cutoff = wp_date('Y-m-d H:i:s', strtotime('-30 days'));
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$table} 
                WHERE status = 'unresolved' 
                AND hits <= 2 
                AND last_hit < %s",
                $old_cutoff
            )
        );
    }
    
    /**
     * Show admin notices for high 404 counts
     *
     * @return void
     */
    public function show_404_alerts(): void {
        if (!current_user_can('manage_options')) {
            return;
        }
        
        // Only show on dashboard and ProRank pages
        $screen = get_current_screen();
        if (!$screen || (!in_array($screen->id, ['dashboard', 'toplevel_page_prorank-seo']))) {
            return;
        }
        
        global $wpdb;
        $table = $this->get_table_name();

        if (!$this->table_exists($table)) {
            return;
        }
        
        // Check for high 404 count
        $threshold = (int) get_option('prorank_404_alert_threshold', 50);
        $unresolved = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*)
                FROM {$table}
                WHERE status = %s
                AND hits >= %d",
                'unresolved',
                $threshold
            )
        );

        // Recompute unresolved count to exclude scanner/probe noise.
        if ((int) $unresolved > 0) {
            $candidates = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT url, referrer, user_agent, hits
                    FROM {$table}
                    WHERE status = %s
                    AND hits >= %d",
                    'unresolved',
                    $threshold
                )
            );

            $unresolved = count(array_filter((array) $candidates, function ($row): bool {
                return !$this->is_noise_404_entry((string) ($row->url ?? ''), (string) ($row->referrer ?? ''), (string) ($row->user_agent ?? ''));
            }));
        }
        
        if ($unresolved > 0) {
            $url = add_query_arg(
                [
                    'page' => 'prorank-technical-seo',
                    'module' => 'redirects',
                    'redirect_tab' => '404-monitor',
                    'redirect_status' => 'unresolved',
                    'redirect_min_hits' => (int) $threshold,
                ],
                admin_url('admin.php')
            );
            ?>
            <div class="notice notice-warning is-dismissible">
                <p>
                    <strong><?php esc_html_e('ProRank SEO:', 'prorank-seo'); ?></strong>
                    <?php
                    $message = sprintf(
                        /* translators: 1: number of URLs, 2: admin URL */
                        __( '404 Monitor: %1$d URLs have high 404 hit counts. <a href="%2$s">Review and fix them</a>. This alert is separate from Site Audit history.', 'prorank-seo' ),
                        number_format_i18n( (int) $unresolved ),
                        esc_url( $url )
                    );
                    echo wp_kses(
                        $message,
                        [
                            'a' => [
                                'href' => [],
                            ],
                        ]
                    );
                    ?>
                </p>
            </div>
            <?php
        }
    }
    
    /**
     * Get table name
     *
     * @return string
     */
    private function get_table_name(): string {
        if ($this->table_name === null) {
            global $wpdb;
            $this->table_name = $wpdb->prefix . 'prorank_404_monitor';
        }
        
        return $this->table_name;
    }

    /**
     * Check if a table exists.
     *
     * @param string $table_name Table name.
     * @return bool
     */
    private function table_exists(string $table_name): bool {
        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
        return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table_name)) === $table_name;
    }

    /**
     * Ensure required 404 monitor table exists before DB operations.
     *
     * @return bool
     */
    private function ensure_table_ready(): bool {
        if ($this->table_ready_checked) {
            return $this->table_exists($this->get_table_name());
        }

        $this->table_ready_checked = true;
        $table = $this->get_table_name();
        if ($this->table_exists($table)) {
            return true;
        }

        if (class_exists(RedirectsTableInstaller::class)) {
            RedirectsTableInstaller::install();
        }

        return $this->table_exists($table);
    }

    /**
     * Classify whether a 404 entry is scanner/probe noise rather than actionable SEO issue.
     *
     * @param string      $url       Requested URL/path.
     * @param string|null $referrer  Request referrer.
     * @param string|null $user_agent Request user agent.
     * @return bool
     */
    private function is_noise_404_entry(string $url, ?string $referrer = null, ?string $user_agent = null): bool {
        $parsed = wp_parse_url($url);
        $path = (string) ($parsed['path'] ?? $url);
        $query = (string) ($parsed['query'] ?? '');
        $needle = strtolower($path . ($query !== '' ? ('?' . $query) : ''));

        if ($needle === '') {
            return false;
        }

        // Common vulnerability scanners and bot probes.
        $contains_patterns = [
            '/-/-/-/-',
            '/.well-known/sg-hosted-ping',
            '/.well-known/traffic-advice',
            '/.well-known/private-click-measurement',
            '/.git/',
            '/.env',
            '/.env.',
            '/wp-config',
            '/phpmyadmin',
            '/cgi-bin',
            '/vendor/phpunit',
            '/server-status',
            '/_ignition',
            '/.aws/credentials',
            '/id_rsa',
        ];

        foreach ($contains_patterns as $pattern) {
            if (strpos($needle, $pattern) !== false) {
                return true;
            }
        }

        if (preg_match('#^/\.well-known(?:/|$)#', $needle)) {
            return true;
        }

        if (preg_match('#^/\.(?:env|git|svn|hg|bzr)(?:$|[/.])#', $needle)) {
            return true;
        }

        // Missing dotfiles/backups are almost always automated probes.
        if (preg_match('#\.(?:bak|old|sql|ini|log|conf|yml|yaml|swp)(?:$|\?)#', $needle)) {
            return true;
        }

        $is_noise = false;

        /**
         * Filter noise classification for 404 monitor entries.
         *
         * @param bool  $is_noise   Current classification.
         * @param array $entry_data Entry context.
         */
        return (bool) apply_filters('prorank_404_is_noise_entry', $is_noise, [
            'url' => $url,
            'path' => $path,
            'query' => $query,
            'referrer' => (string) ($referrer ?? ''),
            'user_agent' => (string) ($user_agent ?? ''),
        ]);
    }
}
