<?php
/**
 * Handles chunked checking of already-fixed issues to prevent gateway timeouts.
 * 
 * This class processes large batches of issues in small chunks to avoid
 * server timeouts when many items have already been fixed.
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

class Rank_Already_Fixed_Checker {

    /**
     * Number of items to check per chunk
     */
    const CHUNK_SIZE = 100;

    /**
     * Maximum execution time per request in seconds
     */
    const MAX_EXECUTION_TIME = 8;

    /**
     * Seen image URLs for cross-chunk dedup of image_optimize issues.
     * A large image is one physical file; once optimized it is fixed
     * everywhere, so we only want to count it once even when its
     * (page, image) issues are split across multiple chunks. Reset at the
     * start of every AJAX request via reset_image_dedup().
     *
     * @var array<string, true>
     */
    private static $seen_image_urls = array();

    /**
     * Reset the cross-chunk image dedup set. Call this at the start of any
     * AJAX request that will call check_chunk() / check_database_fixes_by_items()
     * to ensure the dedup doesn't carry over from a previous request.
     */
    public static function reset_image_dedup() {
        self::$seen_image_urls = array();
    }
    
    /**
     * Check already fixed issues in chunks
     *
     * @param array $batch_data The batch data containing issues
     * @param int $offset Starting offset for this chunk
     * @param bool $return_keys Whether to return the keys of fixed items
     * @return array Response data with chunk results
     */
    public static function check_chunk($batch_data, $offset = 0, $return_keys = false) {
        $start_time = microtime(true);
        
        // Validate input
        if (!is_array($batch_data)) {
            return array(
                'already_fixed' => 0,
                'checked_in_chunk' => 0,
                'total_checked' => 0,
                'total_issues' => 0,
                'has_more' => false,
                'next_offset' => 0,
                'progress_percent' => 100,
                'fixed_keys' => array(),
                'message' => 'Invalid batch data.'
            );
        }
        
        $issues = isset($batch_data['issues']) && is_array($batch_data['issues']) ? $batch_data['issues'] : array();
        $issue_type = isset($batch_data['issue_type']) ? $batch_data['issue_type'] : '';
        $total_issues = count($issues);
        
        if ($total_issues === 0) {
            return array(
                'already_fixed' => 0,
                'checked_in_chunk' => 0,
                'total_checked' => 0,
                'total_issues' => 0,
                'has_more' => false,
                'next_offset' => 0,
                'progress_percent' => 100,
                'fixed_keys' => array(),
                'message' => 'No issues to check.'
            );
        }

        // Calculate chunk boundaries
        $chunk_start = max(0, intval($offset));
        $chunk_end = min($total_issues, $chunk_start + self::CHUNK_SIZE);
        $chunk_issues = array_slice($issues, $chunk_start, self::CHUNK_SIZE, true);

        // For large_images, also count the total unique images across the
        // *entire* batch (not just this chunk) so the UI can show
        // "73 total images used on 382 pages". This is cheap: one pass over
        // the issues array extracting image_url values.
        //
        // IMPORTANT: if this batch has been re-checked, the remaining
        // $issues only contains the not-yet-fixed images. We must also
        // pull image URLs out of the stored already_fixed_issues (if any)
        // so the headline shows the ORIGINAL total — not a shrinking
        // number that gets smaller on every re-run.
        $total_unique_images = null;
        if ( $issue_type === 'large_images' && $total_issues > 0 ) {
            $unique = array();
            foreach ( $issues as $iss ) {
                $iu = isset( $iss['issue_identifier'] ) ? (string) $iss['issue_identifier'] : '';
                if ( $iu !== '' ) {
                    $unique[ $iu ] = true;
                }
            }
            if ( isset( $batch_data['already_fixed_issues'] ) && is_array( $batch_data['already_fixed_issues'] ) ) {
                foreach ( $batch_data['already_fixed_issues'] as $iss ) {
                    if ( ! is_array( $iss ) ) {
                        continue;
                    }
                    $iu = '';
                    if ( isset( $iss['image_url'] ) && $iss['image_url'] !== '' ) {
                        $iu = (string) $iss['image_url'];
                    } elseif ( isset( $iss['issue_identifier'] ) && $iss['issue_identifier'] !== '' ) {
                        $iu = (string) $iss['issue_identifier'];
                    }
                    if ( $iu !== '' ) {
                        $unique[ $iu ] = true;
                    }
                }
            }
            $total_unique_images = count( $unique );
        }

        // Check for already fixed issues - count actual items, not URLs
        $already_fixed_count = 0;
        $fixed_keys = array();

        if (!empty($chunk_issues)) {
            $result = self::check_fixed_status_by_items($chunk_issues, $issue_type, $start_time, $return_keys);
            if ($return_keys && is_array($result)) {
                $already_fixed_count = $result['count'];
                $fixed_keys = $result['keys'];
            } else {
                $already_fixed_count = $result;
            }
        }

        // Calculate progress
        $total_checked = $chunk_end;
        $has_more = $chunk_end < $total_issues;
        $next_offset = $has_more ? $chunk_end : 0;
        $progress_percent = $total_issues > 0 ? round(($total_checked / $total_issues) * 100) : 100;
        
        return array(
            'already_fixed' => $already_fixed_count,
            'checked_in_chunk' => count($chunk_issues),
            'total_checked' => $total_checked,
            'total_issues' => $total_issues,
            'has_more' => $has_more,
            'next_offset' => $next_offset,
            'progress_percent' => $progress_percent,
            'fixed_keys' => $fixed_keys,
            'total_unique_images' => $total_unique_images,
            'message' => $has_more
                ? "Checked {$total_checked} of {$total_issues} issues..."
                : "Check complete: Found {$already_fixed_count} already fixed in {$total_checked} issues."
        );
    }

    /**
     * Extract page URL from issue data
     * 
     * @param array $issue Issue data
     * @return string|null Page URL or null
     */
    private static function extract_page_url($issue) {
        $page_url = null;
        
        // Try different ways to get the page URL
        if (isset($issue['page']['path_full'])) {
            $page_url = $issue['page']['path_full'];
        } elseif (isset($issue['page']['path'])) {
            // Construct full URL from path
            $site_url = site_url();
            if (substr($site_url, -1) !== '/') {
                $site_url .= '/';
            }
            $path = ltrim($issue['page']['path'], '/');
            $page_url = $site_url . $path;
        }
        
        return $page_url;
    }

    /**
     * Check if items have already been fixed (counts actual items, not just URLs)
     *
     * @param array $chunk_issues Issues in current chunk
     * @param string $issue_type Type of issue
     * @param float $start_time Start time for timeout checking
     * @return int Count of already fixed items
     */
    private static function check_fixed_status_by_items($chunk_issues, $issue_type, $start_time, $return_keys = false) {
        global $wpdb;
        
        // Get database override type from mapping
        $db_category_map = self::get_category_db_mapping();
        $db_override_type = isset($db_category_map[$issue_type]) ? $db_category_map[$issue_type] : null;
        
        $already_fixed_count = 0;
        $fixed_keys = array();
        
        if ($db_override_type) {
            // Check database for fixes - count individual items
            $result = self::check_database_fixes_by_items($chunk_issues, $db_override_type, $issue_type, $start_time, $return_keys);
            if ($return_keys && is_array($result)) {
                $already_fixed_count = $result['count'];
                $fixed_keys = $result['keys'];
            } else {
                $already_fixed_count = $result;
            }
            
        } elseif (in_array($issue_type, array('broken_internal_links', 'broken_external_links', 'broken_links_internal', 'broken_links_external'))) {
            // Check option-based fixes (broken links) - count individual items
            $result = self::check_option_fixes($chunk_issues, $issue_type, $return_keys);
            if ($return_keys && is_array($result)) {
                $already_fixed_count = $result['count'];
                $fixed_keys = $result['keys'];
            } else {
                $already_fixed_count = $result;
            }
        }
        
        if ($return_keys) {
            return array(
                'count' => $already_fixed_count,
                'keys' => $fixed_keys
            );
        }
        
        return $already_fixed_count;
    }

    /**
     * Check database for already fixed issues - counts individual items
     *
     * @param array $chunk_issues Issues to check
     * @param string $db_override_type Database override type
     * @param string $issue_type Issue type
     * @param float $start_time Start time for timeout checking
     * @return int Count of fixed items
     */
    private static function check_database_fixes_by_items($chunk_issues, $db_override_type, $issue_type, $start_time, $return_keys = false) {
        global $wpdb;
        
        if (!defined('RANK_TABLE_NAME') || empty(RANK_TABLE_NAME)) {
            return 0;
        }
        
        $table_name = $wpdb->prefix . RANK_TABLE_NAME;
        $fixed_count = 0;
        $fixed_keys = array();
        
        // For image fixes (alt + optimize) we check each image individually
        if (in_array($db_override_type, array('image_alt', 'image_optimize'), true)) {
            foreach ($chunk_issues as $key => $issue) {
                // Check timeout
                if ((microtime(true) - $start_time) > self::MAX_EXECUTION_TIME) {
                    break;
                }
                
                $page_url = self::extract_page_url($issue);
                $image_url = $issue['issue_identifier'] ?? null;
                
                if (!$page_url || !$image_url) {
                    continue;
                }
                
                // Normalize URLs for consistent matching (fixes caching/matching issues)
                $normalized_page_url = function_exists('rank_normalize_url') ? rank_normalize_url($page_url) : $page_url;
                $normalized_image_url = function_exists('rank_normalize_url') ? rank_normalize_url($image_url) : $image_url;
                
                // Create URL variations to handle trailing slash differences
                $page_url_with_slash = $normalized_page_url . '/';
                $image_url_with_slash = $normalized_image_url . '/';
                
                if ($db_override_type === 'image_optimize') {
                    // Page-agnostic: a large image is one physical file. Once
                    // optimized it is fixed everywhere, so every page that
                    // uses it should count as "already fixed". Dedupe by
                    // image_url so the count is the number of unique optimized
                    // images, not the number of (page, image) issues. The
                    // dedup set lives on the class so it carries across chunks
                    // within the same AJAX request — see reset_image_dedup().
                    if ( isset( self::$seen_image_urls[ $normalized_image_url ] ) ) {
                        // Already counted this image in an earlier chunk — skip
                        // both the DB hit and the counter increment.
                        continue;
                    }
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (image_url = %s OR image_url = %s OR image_url = %s)
                         LIMIT 1",
                        $db_override_type,
                        $normalized_image_url, $image_url_with_slash, $image_url
                    ));
                    if ( $exists && $exists > 0 ) {
                        self::$seen_image_urls[ $normalized_image_url ] = true;
                    }
                } else {
                    // Alt tags are page-specific: match image URL AND page URL.
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (image_url = %s OR image_url = %s OR image_url = %s)
                         AND (original_page_url = %s OR original_page_url = %s OR original_page_url = %s)
                         LIMIT 1",
                        $db_override_type,
                        $normalized_image_url, $image_url_with_slash, $image_url,
                        $normalized_page_url, $page_url_with_slash, $page_url
                    ));
                }
                
                if ($exists && $exists > 0) {
                    $fixed_count++;
                    if ($return_keys) {
                        $fixed_keys[] = $key;
                    }
                }
            }
        } else {
            // For meta descriptions and titles, check by page URL
            foreach ($chunk_issues as $key => $issue) {
                // Check timeout
                if ((microtime(true) - $start_time) > self::MAX_EXECUTION_TIME) {
                    break;
                }
                
                $page_url = self::extract_page_url($issue);
                
                if (!$page_url) {
                    continue;
                }
                
                // Normalize URL for consistent matching
                $normalized_page_url = function_exists('rank_normalize_url') ? rank_normalize_url($page_url) : $page_url;
                $page_url_with_slash = $normalized_page_url . '/';

                // long_h1_h2_tag is tracked per heading (a page can have several
                // over-long headings). Match the specific heading via its synthetic
                // object_id so a fixed H1 doesn't mask a still-too-long H2. All other
                // types are page-level (object_id 0), so we don't constrain object_id.
                $heading_object_id = null;
                if ($issue_type === 'long_h1_h2_tag' && function_exists('rank_heading_identity')) {
                    $heading = isset($issue['issue_identifier']) ? (string) $issue['issue_identifier']
                        : (isset($issue['parameters']['tag']) ? (string) $issue['parameters']['tag'] : '');
                    $heading_object_id = rank_heading_identity($heading);
                }

                // Check if this page has been fixed for this override type AND this specific
                // issue_type (so a prior missing/short fix doesn't mark a long one as fixed)
                // Use URL variations to match regardless of how URLs were stored
                if ($heading_object_id !== null) {
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (original_page_url = %s OR original_page_url = %s OR original_page_url = %s)
                         AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))
                         AND object_id = %d
                         LIMIT 1",
                        $db_override_type,
                        $normalized_page_url, $page_url_with_slash, $page_url,
                        $issue_type, $issue_type, $db_override_type,
                        $heading_object_id
                    ));
                } else {
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (original_page_url = %s OR original_page_url = %s OR original_page_url = %s)
                         AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))
                         LIMIT 1",
                        $db_override_type,
                        $normalized_page_url, $page_url_with_slash, $page_url,
                        $issue_type, $issue_type, $db_override_type
                    ));
                }
                
                if ($exists && $exists > 0) {
                    $fixed_count++;
                    if ($return_keys) {
                        $fixed_keys[] = $key;
                    }
                }
            }
        }
        
        if ($return_keys) {
            return array(
                'count' => $fixed_count,
                'keys' => $fixed_keys
            );
        }
        
        return $fixed_count;
    }

    /**
     * Check option-based fixes for broken links - counts individual items
     *
     * @param array $chunk_issues Issues to check
     * @param string $issue_type Issue type
     * @return int Count of fixed items (individual occurrences, not unique URLs)
     */
    private static function check_option_fixes($chunk_issues, $issue_type, $return_keys = false) {
        $option_name_map = self::get_category_option_mapping();
        $option_name = isset($option_name_map[$issue_type]) ? $option_name_map[$issue_type] : null;
        
        if (!$option_name) {
            return 0;
        }
        
        $replacements = get_option($option_name, array());
        
        if (!is_array($replacements) || empty($replacements)) {
            return 0;
        }
        
        // Count each fixed item individually (not unique URLs)
        $fixed_count = 0;
        $fixed_keys = array();
        
        foreach ($chunk_issues as $key => $issue) {
            $broken_url = $issue['issue_identifier'] ?? null;
            $page_url = self::extract_page_url($issue);
            
            if (!$broken_url || !$page_url) {
                continue;
            }
            
            // Use the same normalization function as the storage code
            if (function_exists('rank_normalize_url')) {
                $normalized_page_url = rank_normalize_url($page_url);
                $normalized_broken_url = rank_normalize_url($broken_url);
            } else {
                $normalized_page_url = $page_url;
                $normalized_broken_url = $broken_url;
            }
            
            // Check using the same composite key format as storage
            // Format: broken_url::context::md5(page_url)
            $context_hash = md5($normalized_page_url);
            $composite_key = $normalized_broken_url . '::context::' . $context_hash;
            
            // Check if this specific broken URL + page context has a replacement
            if (isset($replacements[$composite_key])) {
                $fixed_count++;
                if ($return_keys) {
                    $fixed_keys[] = $key;
                }
            }
        }
        
        if ($return_keys) {
            return array(
                'count' => $fixed_count,
                'keys' => $fixed_keys
            );
        }
        
        return $fixed_count;
    }

    /**
     * Check if a single issue is already fixed
     *
     * @param array $issue The issue to check
     * @param string $issue_type The issue type
     * @return bool True if fixed, false otherwise
     */
    public static function is_issue_fixed($issue, $issue_type) {
        global $wpdb;
        
        // Get database override type from mapping
        $db_category_map = self::get_category_db_mapping();
        $db_override_type = isset($db_category_map[$issue_type]) ? $db_category_map[$issue_type] : null;
        
        if ($db_override_type) {
            // Check database for this specific issue
            if (!defined('RANK_TABLE_NAME') || empty(RANK_TABLE_NAME)) {
                return false;
            }
            
            $table_name = $wpdb->prefix . RANK_TABLE_NAME;
            $page_url = self::extract_page_url($issue);
            
            if (!$page_url) {
                return false;
            }
            
            // Normalize URL for consistent matching
            $normalized_page_url = function_exists('rank_normalize_url') ? rank_normalize_url($page_url) : $page_url;
            $page_url_with_slash = $normalized_page_url . '/';
            
            if (in_array($db_override_type, array('image_alt', 'image_optimize'), true)) {
                $image_url = $issue['issue_identifier'] ?? null;
                if (!$image_url) {
                    return false;
                }

                // Normalize image URL as well
                $normalized_image_url = function_exists('rank_normalize_url') ? rank_normalize_url($image_url) : $image_url;
                $image_url_with_slash = $normalized_image_url . '/';

                if ( $db_override_type === 'image_optimize' ) {
                    // Page-agnostic: one optimized image is fixed everywhere.
                    // Match by image_url alone so any page using this image
                    // is marked as already fixed.
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (image_url = %s OR image_url = %s OR image_url = %s)
                         LIMIT 1",
                        $db_override_type,
                        $normalized_image_url, $image_url_with_slash, $image_url
                    ));
                } else {
                    // Alt tags are page-specific — match image URL AND page URL.
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (image_url = %s OR image_url = %s OR image_url = %s)
                         AND (original_page_url = %s OR original_page_url = %s OR original_page_url = %s)
                         LIMIT 1",
                        $db_override_type,
                        $normalized_image_url, $image_url_with_slash, $image_url,
                        $normalized_page_url, $page_url_with_slash, $page_url
                    ));
                }

                return ($exists && $exists > 0);
            } else {
                // long_h1_h2_tag is tracked per heading (see check_database_fixes_by_items).
                // Match the specific heading via its synthetic object_id so a fixed H1
                // doesn't mask a still-too-long H2 on the same page.
                $heading_object_id = null;
                if ($issue_type === 'long_h1_h2_tag' && function_exists('rank_heading_identity')) {
                    $heading = isset($issue['issue_identifier']) ? (string) $issue['issue_identifier']
                        : (isset($issue['parameters']['tag']) ? (string) $issue['parameters']['tag'] : '');
                    $heading_object_id = rank_heading_identity($heading);
                }

                // Meta descriptions and titles - check with URL variations and the specific
                // issue_type (so a prior missing/short fix doesn't mark a long one as fixed)
                if ($heading_object_id !== null) {
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (original_page_url = %s OR original_page_url = %s OR original_page_url = %s)
                         AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))
                         AND object_id = %d
                         LIMIT 1",
                        $db_override_type,
                        $normalized_page_url, $page_url_with_slash, $page_url,
                        $issue_type, $issue_type, $db_override_type,
                        $heading_object_id
                    ));
                } else {
                    $exists = $wpdb->get_var($wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name`
                         WHERE override_type = %s
                         AND (original_page_url = %s OR original_page_url = %s OR original_page_url = %s)
                         AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))
                         LIMIT 1",
                        $db_override_type,
                        $normalized_page_url, $page_url_with_slash, $page_url,
                        $issue_type, $issue_type, $db_override_type
                    ));
                }
                
                return ($exists && $exists > 0);
            }
        } elseif (in_array($issue_type, array('broken_internal_links', 'broken_external_links', 'broken_links_internal', 'broken_links_external'))) {
            // Check option-based fixes (broken links)
            $option_name_map = self::get_category_option_mapping();
            $option_name = isset($option_name_map[$issue_type]) ? $option_name_map[$issue_type] : null;
            
            if (!$option_name) {
                return false;
            }
            
            $replacements = get_option($option_name, array());
            if (!is_array($replacements) || empty($replacements)) {
                return false;
            }
            
            $broken_url = $issue['issue_identifier'] ?? null;
            $page_url = self::extract_page_url($issue);
            
            if (!$broken_url || !$page_url) {
                return false;
            }
            
            // Use the same normalization function as the storage code
            if (function_exists('rank_normalize_url')) {
                $normalized_page_url = rank_normalize_url($page_url);
                $normalized_broken_url = rank_normalize_url($broken_url);
            } else {
                $normalized_page_url = $page_url;
                $normalized_broken_url = $broken_url;
            }
            
            // Check using the same composite key format as storage
            $context_hash = md5($normalized_page_url);
            $composite_key = $normalized_broken_url . '::context::' . $context_hash;
            
            return isset($replacements[$composite_key]);
        }
        
        return false;
    }

    /**
     * Get mapping between frontend category keys and database override types
     * 
     * @return array Associative array mapping frontend keys to database types
     */
    private static function get_category_db_mapping() {
        return array(
            // Meta description mappings
            'missing_meta_description' => 'meta_description',
            'short_meta_description'   => 'meta_description',
            'long_meta_description'    => 'meta_description',
            'duplicate_meta_description' => 'meta_description',

            // Title tag mappings
            'missing_title_tag'        => 'title',
            'short_title_tag'          => 'title',
            'long_title_tag'           => 'title',
            'duplicate_title'          => 'title',
            
            // Image alt tag mappings
            'missing_alt_tags'         => 'image_alt',
            'image_alt'                => 'image_alt',
            
            // Content fix mappings
            'long_h1_h2_tag'           => 'content_fix',
            'missing_h1_tag'           => 'content_fix',

            // Image optimization mappings (keyed on the image file, like alt tags)
            'large_images'             => 'image_optimize'
        );
    }

    /**
     * Get mapping between frontend category keys and option names
     * 
     * @return array Associative array mapping frontend keys to option names
     */
    private static function get_category_option_mapping() {
        return array(
            // Internal links mappings
            'broken_internal_links'    => 'rank_internal_url_replacements',
            'broken_links_internal'    => 'rank_internal_url_replacements',
            
            // External links mappings
            'broken_external_links'    => 'rank_external_url_replacements',
            'broken_links_external'    => 'rank_external_url_replacements'
        );
    }
}