<?php
/**
 * Handles AJAX requests for the RANK AI plugin.
 */

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

class Rank_Ajax {

    /**
     * Initialize AJAX hooks.
     */
    public static function init() {
        // Debug logs AJAX handler
        add_action( 'wp_ajax_rank_get_debug_logs', array( __CLASS__, 'get_debug_logs' ) );
        // Action hooks for the API-based workflow
        add_action( 'wp_ajax_rank_start_processing', array( __CLASS__, 'ajax_start_processing' ) );
        add_action( 'wp_ajax_rank_delete_all_data', array( __CLASS__, 'ajax_delete_all_data' ) );
        add_action( 'wp_ajax_rank_get_fixes_data', array( __CLASS__, 'ajax_get_fixes_data' ) );
        add_action( 'wp_ajax_rank_get_all_fix_counts', array( __CLASS__, 'ajax_get_all_fix_counts' ) );
        add_action( 'wp_ajax_rank_prepare_health_issues', array( __CLASS__, 'ajax_prepare_health_issues' ) );
        add_action( 'wp_ajax_rank_get_batch_state', array( __CLASS__, 'ajax_get_batch_state' ) );
        add_action( 'wp_ajax_rank_record_decision', array( __CLASS__, 'ajax_record_decision' ) );
        add_action( 'wp_ajax_rank_schedule_tasks', array( __CLASS__, 'ajax_schedule_tasks' ) );
        add_action( 'wp_ajax_rank_get_health_issues_overview_optimized', array( __CLASS__, 'ajax_get_health_issues_overview_optimized' ) );
        add_action( 'wp_ajax_rank_clear_pending_decisions', array( __CLASS__, 'ajax_clear_pending_decisions' ) );
        add_action( 'wp_ajax_rank_update_fix_value', array( __CLASS__, 'ajax_update_fix_value' ) );
        add_action( 'wp_ajax_rank_check_already_fixed', array( __CLASS__, 'ajax_check_already_fixed' ) );
        add_action( 'wp_ajax_rank_filter_already_fixed', array( __CLASS__, 'ajax_filter_already_fixed' ) );
        add_action( 'wp_ajax_rank_toggle_debug', array( __CLASS__, 'ajax_toggle_debug' ) );
        add_action( 'wp_ajax_rank_export_category_csv', array( __CLASS__, 'ajax_export_category_csv' ) );
        
        
    }

    /**
     * Set up common AJAX request handling parameters
     *
     * @param int $timeout Optional timeout in seconds
     * @param bool $abort_on_disconnect Whether to stop execution when client disconnects (default: false)
     */
    private static function setup_ajax_request($timeout = 60, $abort_on_disconnect = false) {
        // Tell PHP to stop execution when the client disconnects if specified
        if ($abort_on_disconnect !== null) {
            ignore_user_abort($abort_on_disconnect);
        }
        
        // Set a reasonable timeout for this specific request if specified
        if ($timeout !== null) {
            set_time_limit($timeout);
        }
        
        // Close the session to prevent blocking
        if (session_status() === PHP_SESSION_ACTIVE) {
            session_write_close();
        }
    }

    /**
     * Verify nonce and user permissions for AJAX requests
     *
     * @param string $nonce_key The nonce key to verify (default: 'rank_ajax_nonce')
     * @param bool $require_admin Whether to require admin capabilities (default: true)
     * @return bool|WP_Error True if authorized, WP_Error otherwise
     */
    private static function verify_ajax_request($nonce_key = 'rank_ajax_nonce', $require_admin = true) {
        // Verify nonce
        if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), $nonce_key)) {
            return new WP_Error('invalid_nonce', 'Nonce verification failed.', array('status' => 403));
        }
        
        // Check user permissions if required
        if ($require_admin && !current_user_can('manage_options')) {
            return new WP_Error('insufficient_permissions', 'Permission denied.', array('status' => 403));
        }
        
        return true;
    }

    /**
     * Validate batch ID from request
     *
     * @param string $batch_id The batch ID to validate
     * @return bool|WP_Error True if valid, WP_Error otherwise
     */
    private static function validate_batch_id($batch_id) {
        if (empty($batch_id)) {
            return new WP_Error('missing_batch_id', 'Missing batch ID.', array('status' => 400));
        }
        
        if (strpos($batch_id, 'rank_batch_') !== 0) {
            return new WP_Error('invalid_batch_id', 'Invalid batch ID format.', array('status' => 400));
        }
        
        return true;
    }

    /**
     * Validate URL existence and check for redirects
     *
     * @param string $url The URL to validate
     * @param array $batch_data Reference to batch data for logging
     * @param string $row_key The current row key for decisions
     * @param array $new_ui_logs Optional array to store new UI logs
     * @return bool|array True if valid, array with error details otherwise
     */
    private static function validate_url($url, &$batch_data, $row_key, &$new_ui_logs = array()) {
        if (empty($url)) {
            return array(
                'valid' => false,
                'message' => 'Empty URL provided.'
            );
        }
        
        $url_validation = rank_validate_url($url);
        
        if (!$url_validation['exists']) {
            $log_message = '';
            
            if ($url_validation['redirects']) {
                $log_message = "🔄 Skipping URL as it redirects to another location: " . $url;
            } else {
                $log_message = "🐞 Skipping URL as it does not exist on our website: " . $url;
            }
            
            // Remove issue from batch instead of adding decision (keeps data small)
            unset($batch_data['issues'][$row_key]);
            // Use counter instead of array to save space
            $batch_data['skipped_count'] = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] + 1 : 1;
            
            // Don't persist ui_logs - just send to frontend
            if (is_array($new_ui_logs)) {
                $new_ui_logs[] = $log_message;
            }
            
            Rank_Processor::add_debug_log("URL validation failed, issue removed from batch: " . $url);
            
            return array(
                'valid' => false,
                'message' => $log_message
            );
        }
        
        return true;
    }

    /**
     * Log error message to error_log with consistent format
     *
     * @param string $method The method where the error occurred
     * @param string $message The error message
     * @param mixed $context Optional additional context (error code, data, etc.)
     */
    private static function log_error($method, $message, $context = null) {
        $log_message = sprintf('RANK AJAX (%s): %s', $method, $message);
        
        if ($context !== null) {
            if (is_array($context) || is_object($context)) {
                $log_message .= ' | Context: ' . json_encode($context);
            } else {
                $log_message .= ' | Context: ' . $context;
            }
        }
        
        error_log($log_message);
    }
    
    /**
     * Send standardized JSON error response and log the error
     *
     * @param string $message Error message
     * @param int $status HTTP status code
     * @param array $data Additional data to include in response
     * @param string $method Optional method name for logging
     */
    private static function send_error($message, $status = 400, $data = array(), $method = null) {
        // Log the error
        if ($method !== null) {
            self::log_error($method, $message, ['status' => $status, 'data' => $data]);
        }
        
        // Send error response
        $response = array_merge(array('message' => $message), $data);
        wp_send_json_error($response, $status);
    }

    /**
     * Send standardized JSON success response
     *
     * @param string $message Success message
     * @param array $data Additional data to include in response
     */
    private static function send_success($message, $data = array()) {
        $response = array_merge(array('message' => $message), $data);
        wp_send_json_success($response);
    }
    
    /**
     * Clean up old batch options (older than 24 hours) to prevent options table bloat
     * Called when creating a new batch
     */
    private static function cleanup_old_batches() {
        global $wpdb;
        
        $cutoff_time = time() - (24 * 60 * 60); // 24 hours ago
        
        // Get all rank_batch_* options
        $batch_options = $wpdb->get_results(
            "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'rank_batch_%'"
        );
        
        if (empty($batch_options)) {
            return;
        }
        
        $deleted_count = 0;
        foreach ($batch_options as $option) {
            $batch_data = maybe_unserialize($option->option_value);
            
            // Check if batch has created_at and is older than 24 hours
            if (is_array($batch_data) && isset($batch_data['created_at'])) {
                if ($batch_data['created_at'] < $cutoff_time) {
                    delete_option($option->option_name);
                    $deleted_count++;
                }
            } else {
                // If no created_at, assume it's old and delete it
                // (legacy batches or corrupted data)
                delete_option($option->option_name);
                $deleted_count++;
            }
        }
        
        if ($deleted_count > 0) {
            Rank_Processor::add_debug_log("Cleaned up {$deleted_count} old batch(es) from options table");
        }
    }
    
    /**
     * Get page URL and display path from issue data
     *
     * @param array $issue_item_data The issue data containing page information
     * @return array Array with 'page_url' and 'page_path' keys
     */
    private static function get_page_details_from_issue($issue_item_data) {
        $api_page_id = $issue_item_data['page_id'] ?? ($issue_item_data['page']['id'] ?? null);
        $wp_page_url = null;
        $wp_page_path_display = 'N/A';

        if ($api_page_id && class_exists('Rank_API_Helper') && method_exists('Rank_API_Helper', 'get_page_details_from_api_id')) {
            $page_details = Rank_API_Helper::get_page_details_from_api_id($api_page_id);
            if ($page_details && !empty($page_details['url'])) {
                $wp_page_url = $page_details['url'];
                $parsed_url = parse_url($wp_page_url);
                $wp_page_path_display = $parsed_url['path'] ?? $wp_page_url;
            }
        }
        
        return array(
            'page_url' => $wp_page_url,
            'page_path' => $wp_page_path_display
        );
    }
    
    /**
     * Build UI display string for an issue based on its type
     *
     * @param string $batch_api_issue_type The issue type for the batch
     * @param string $wp_page_path_display The page path for display
     * @param string $item_identifier_from_issue The item identifier (e.g., image URL)
     * @param string $key The row key for fallback display
     * @return string The formatted UI display string
     */
    private static function build_ui_display_string($batch_api_issue_type, $wp_page_path_display, $item_identifier_from_issue, $key) {
        if ($batch_api_issue_type === 'missing_alt_tags') {
            return ($wp_page_path_display !== 'N/A' ? esc_html($wp_page_path_display) . ' ' : '') . 'Image: ' . esc_html($item_identifier_from_issue ?? 'N/A');
        } elseif ($batch_api_issue_type === 'missing_meta_description') {
            return (esc_html($wp_page_path_display) ?? 'Page') . ' Missing meta description';
        } elseif ($batch_api_issue_type === 'missing_title_tag') {
            return (esc_html($wp_page_path_display) ?? 'Page') . ' Missing title tag';
        } else {
            // Generic display for other types
            $page_display_part = $wp_page_path_display !== 'N/A' ? esc_html($wp_page_path_display) : 'Item';
            $item_display_part = $item_identifier_from_issue ? esc_html($item_identifier_from_issue) : "Details for " . $key;
            return $page_display_part . ($item_identifier_from_issue ? ': ' . $item_display_part : '');
        }
    }
    
    /**
     * 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 [
            // Meta description mappings
            'missing_meta_description' => 'meta_description',
            'short_meta_description'   => 'meta_description',
            'long_meta_description'    => 'meta_description',
            
            // Title tag mappings
            'missing_title_tag'        => 'title',
            'short_title_tag'          => 'title',
            'long_title_tag'           => 'title',
            
            // Image alt tag mappings
            'missing_alt_tags'         => 'image_alt',
            'image_alt'                => 'image_alt'
        ];
    }
    
    /**
     * 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 [
            // 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'
        ];
    }

    /**
     * Normalize URL for search comparison
     *
     * @param string $url The URL to normalize
     * @return string The normalized URL
     */
    private static function normalize_url_for_search($url) {
        if (empty($url)) {
            return '';
        }
        
        // Remove protocol and domain
        $normalized = preg_replace('/^https?:\/\/[^\/]+/', '', $url);
        
        // Ensure starts with /
        if (!empty($normalized) && $normalized[0] !== '/') {
            $normalized = '/' . $normalized;
        }
        
        // Remove trailing slash for consistency
        $normalized = rtrim($normalized, '/') ?: '/';
        
        return strtolower($normalized);
    }

    /**
     * AJAX handler for scheduling tasks from batch data.
     */
    public static function ajax_schedule_tasks() {
        // Set up AJAX request parameters
        self::setup_ajax_request();
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'));
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batch_id']) ? sanitize_key($_POST['batch_id']) : '';
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'));
            return;
        }

        $batch_data = get_option( $batch_id );

        if ( false === $batch_data || ! is_array( $batch_data ) || !isset($batch_data['issues']) || empty( $batch_data['issues'] ) || !isset($batch_data['issue_type']) ) {
             self::log_error('ajax_schedule_tasks', 'Batch data not found, invalid, or empty');
             self::send_error('Batch data not found, invalid, or empty. Please prepare the batch again.', 400, [], 'ajax_schedule_tasks');
             return;
        }

        $issues = $batch_data['issues'];
        $issue_type = $batch_data['issue_type']; // Already checked isset above
        $action_group = 'rank-tasks-' . $batch_id;

        delete_transient( 'rank_log_' . $batch_id );

        $scheduled_count = 0;
        $skipped_count = 0; // Add counter for skipped rows
        
        foreach ( $issues as $index => $issue ) {
            // Skip issues that don't have decisions or were declined
            $row_key = $issue['rank_id']; // rank_id is the unique key for an issue in the batch
            if (!isset($batch_data['decisions'][$row_key]) ||
                !$batch_data['decisions'][$row_key]['approved']) {
                $skipped_count++;
                continue;
            }
            
            $decision = $batch_data['decisions'][$row_key];
            
            // Log the issue being processed

            // Validate the essential data exists
            // Page ID is crucial for context.
            if (!isset($issue['page_id']) || empty($issue['page_id'])) {
                $skipped_count++;
                continue;
            }
            
            // Extract page URL from the issue data (same logic as in ajax_get_batch_state)
            $api_page_full_url = $issue['page']['path_full'] ?? null;
            $page_context_url = $api_page_full_url ? esc_url($api_page_full_url) : null;
            
            // The specific URL of the item to be fixed (e.g., image URL)
            // Prefer 'issue_identifier' as it's directly tied to the issue, fallback to 'parameters.url'
            $original_item_url = $issue['issue_identifier'] ?? ($issue['parameters']['url'] ?? null);
            if (empty($original_item_url)) {
                $skipped_count++;
                continue;
            }
            // Store the URL as-is without encoding to preserve special characters
            $original_item_url = trim($original_item_url);


            $solution = isset($decision['solution']) ? trim($decision['solution']) : null;
            
            // Skip if solution is empty
            if (empty($solution)) {
                $skipped_count++;
                continue;
            }

            // If we reach here, validation passed for this issue
            try {
                $task_args = array(
                    'page_id'           => $issue['page_id'], // Contextual page ID
                    'object_id'         => $issue['page_id'], // Initial object_id, Rank_Processor will refine for images
                    'issue_type'        => $issue_type,       // The overall issue type for the batch
                    'solution'          => $solution,
                    'original_url'      => $original_item_url, // URL of the specific item (e.g. image)
                    'page_context_url'  => $page_context_url, // URL of the page where the issue was found
                    'row_key'           => $row_key,
                    'batch_id'          => $batch_id,
                );


                $action_id = as_enqueue_async_action(
                    'rank_process_single_item_hook', // Use the same hook as ajax_record_decision
                    array( $task_args ),             // Pass arguments as a single array
                    $action_group                    // Action group
                );

                if ( $action_id ) {
                    $scheduled_count++;
                } else {
                    $skipped_count++; // Consider it skipped if scheduling failed
                    // Failed to schedule task
                }
            } catch ( Exception $e ) {
                $skipped_count++;
                // Error scheduling task
            }
        }

        // Response message
        if ( $scheduled_count > 0 ) {
            $message = $scheduled_count . ' tasks scheduled successfully.';
            if ($skipped_count > 0) {
                $message .= ' ' . $skipped_count . ' items were skipped (check PHP error logs for details).';
            }
            wp_send_json_success( array( 'message' => $message ) );
        } else {
             $message = 'No tasks were scheduled.';
             if ($skipped_count > 0) {
                 $message .= ' All ' . $skipped_count . ' items were skipped (check PHP error logs for details).';
             } else {
                 $message .= ' The batch might be empty or contain no approved fixes.';
             }
             self::log_error('ajax_schedule_tasks', $message);
             self::send_error($message, 400, [], 'ajax_schedule_tasks');
             return;
        }
    }

    /**
     * AJAX handler for deleting all override data (custom table and URL replacement option).
     */
    public static function ajax_delete_all_data() {
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_start_processing');
            return;
        }

        global $wpdb;
        $table_name = $wpdb->prefix . RANK_TABLE_NAME;
        $table_truncated = false;
        $options_deleted = true; // Assume success unless one fails
        $db_error = ''; // Initialize error variable

        // Attempt to truncate the custom table
        // Ensure RANK_TABLE_NAME is defined
        if ( defined('RANK_TABLE_NAME') && !empty(RANK_TABLE_NAME) ) { // Basic check
            $result = $wpdb->query( "TRUNCATE TABLE `$table_name`" );
            if ( $result !== false ) {
                $table_truncated = true;
            } else {
                $db_error = $wpdb->last_error; // Capture error before next operation
            }
        } else {
             $db_error = 'RANK_TABLE_NAME constant not defined or invalid.';
             // Error in delete_all_overrides
        }

        // Initialize options_deleted as true (assume success)
        $options_deleted = true;
        
        // For internal URL replacements
        $internal_option = get_option('rank_internal_url_replacements', false);
        if ($internal_option !== false) {
            // Option exists, try to delete it
            if (!delete_option('rank_internal_url_replacements')) {
                $options_deleted = false;
                // Failed to delete rank_internal_url_replacements option
            }
        }
        
        // For external URL replacements
        $external_option = get_option('rank_external_url_replacements', false);
        if ($external_option !== false) {
            // Option exists, try to delete it
            if (!delete_option('rank_external_url_replacements')) {
                $options_deleted = false;
                // Failed to delete rank_external_url_replacements option
            }
        }

        if ( $table_truncated && $options_deleted ) {
            wp_send_json_success( array( 'message' => 'All RANK AI data deleted successfully.' ) );
        } else {
            $error_message = 'Error deleting data.';
            if (!$table_truncated) {
                $error_message .= ' Failed to truncate table.';
                if ($db_error) {
                     $error_message .= ' DB Error: ' . $db_error;
                }
            }
            if (!$options_deleted) {
                 $error_message .= ' Failed to delete one or more URL replacement options.';
            }
            self::log_error('ajax_delete_all_data', $error_message);
            self::send_error($error_message, 500, [], 'ajax_delete_all_data');
            return;
        }
    }

    /**
     * AJAX handler to initiate the processing for a given batch ID.
     * This confirms the batch is ready and allows the frontend to start polling for status.
     */
    public static function ajax_start_processing() {
        // Set up AJAX request parameters
        self::setup_ajax_request();
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'));
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batch_id']) ? sanitize_key($_POST['batch_id']) : '';
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'));
            return;
        }
        
        // Verify batch exists
        $batch_data = get_option( $batch_id );
        if (!$batch_data) {
            self::log_error('ajax_start_processing', 'Batch not found: ' . $batch_id);
            self::send_error('Batch not found.', 404, [], 'ajax_start_processing');
            return;
        }

        // If checks pass, send success to allow JS polling to begin
        wp_send_json_success( 'Processing initiated. Status checks will begin.' );
    }

    /**
     * AJAX handler to fetch the total count of applied fixes for ALL categories.
     */
    public static function ajax_get_all_fix_counts() {
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_get_fixes_data');
            return;
        }

        $categories = rank_get_health_check_categories();
        $counts = [];
        global $wpdb;
        $table_name = $wpdb->prefix . RANK_TABLE_NAME;

        // Get mappings between frontend keys and database/option types
        $db_category_map = self::get_category_db_mapping();
        $option_name_map = self::get_category_option_mapping();

        // Define which categories use the DB vs Options
        $db_override_keys = array_keys($db_category_map);
        $option_override_keys = array_keys($option_name_map);

        foreach ( array_keys( $categories ) as $key ) {
            if ( in_array( $key, $db_override_keys ) ) {
                // Get the corresponding database type from the map
                $db_override_type = isset($db_category_map[$key]) ? $db_category_map[$key] : null;

                if ($db_override_type) {
                    // Get count from Custom Table using the mapped type AND issue_type
                    $count = $wpdb->get_var( $wpdb->prepare(
                        "SELECT COUNT(*) FROM `$table_name` WHERE override_type = %s AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))",
                        $db_override_type,
                        $key,
                        $key,
                        $db_override_type
                    ) );
                    $counts[ $key ] = ( $count !== null ) ? absint( $count ) : 0;
                } else {
                     $counts[ $key ] = 0;
                }

            } elseif ( in_array( $key, $option_override_keys ) || isset($option_name_map[$key]) ) {
                // Get count from Options Table
                $option_name = isset($option_name_map[$key]) ? $option_name_map[$key] : null;
                
                if ($option_name) {
                    $rules = get_option( $option_name, [] );
                    $count = is_array( $rules ) ? count( $rules ) : 0;
                    
                    // Store the count under both the frontend key and the option key
                    $counts[ $key ] = $count;
                    
                    // If this is a broken_links_* key, also store under broken_*_links for backwards compatibility
                    if ($key === 'broken_links_internal') {
                        $counts['broken_internal_links'] = $count;
                    } elseif ($key === 'broken_links_external') {
                        $counts['broken_external_links'] = $count;
                    }
                    
                    // If this is a broken_*_links key, also store under broken_links_* for forwards compatibility
                    if ($key === 'broken_internal_links') {
                        $counts['broken_links_internal'] = $count;
                    } elseif ($key === 'broken_external_links') {
                        $counts['broken_links_external'] = $count;
                    }
                } else {
                    $counts[ $key ] = 0;
                }
            } else {
                // Handle potential future categories or unknown types
                $counts[ $key ] = 0;
            }
        }

        wp_send_json_success( $counts );
    }

    /**
     * AJAX handler to get data for the "Fixes Overview" tab.
     */
    public static function ajax_get_fixes_data() {
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'));
            return;
        }

        $category_key = isset( $_POST['category'] ) ? sanitize_key( $_POST['category'] ) : null;
        $issue_type   = isset( $_POST['issue_type'] ) ? sanitize_key( $_POST['issue_type'] ) : $category_key;
        $page         = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
        $search_query = isset( $_POST['search_query'] ) ? sanitize_text_field( $_POST['search_query'] ) : '';
        $per_page     = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20;
        // Validate per_page - only allow specific values for security
        $allowed_per_page = array(10, 20, 50, 100);
        if (!in_array($per_page, $allowed_per_page)) {
            $per_page = 20; // Default to 20 if invalid value
        }
        $offset       = ( $page - 1 ) * $per_page;

        if ( ! $category_key ) {
            self::log_error('ajax_get_fixes_data', 'Category not specified');
            self::send_error('Category not specified.', 400, [], 'ajax_get_fixes_data');
            return;
        }

        $results      = [];
        $total_items  = 0;
        
        // Dynamically determine if the category uses database overrides or option suggestions
        $all_issue_configs = rank_get_issue_type_config();
        $current_category_config = $all_issue_configs[$category_key] ?? null;

        if (!$current_category_config) {
            self::log_error('ajax_get_fixes_data', 'Unknown category key: ' . $category_key);
            self::send_error('Invalid category specified.', 400, [], 'ajax_get_fixes_data');
            return;
        }

        $handler_type = $current_category_config['handler_type'] ?? null;
        $db_override_type = null; // For database_override types

        if ($handler_type === 'database_override') {
            // Get database override type from mapping
            $db_category_map = self::get_category_db_mapping();
            $db_override_type = isset($db_category_map[$category_key]) ? $db_category_map[$category_key] : null;

            if ($db_override_type) {
                global $wpdb;
                $table_name = $wpdb->prefix . 'rank_seo_overrides';

                // Use the provided issue_type for filtering
                $issue_type_filter = $issue_type;
                
                // For 'image_alt' category key, use 'missing_alt_tags' as the issue_type filter if not specified
                if ($category_key === 'image_alt' && $issue_type === $category_key) {
                    $issue_type_filter = 'missing_alt_tags';
                }
                
                // Build search condition if search query is provided
                $search_condition = '';
                if (!empty($search_query)) {
                    $normalized_search = self::normalize_url_for_search($search_query);
                    $search_condition = $wpdb->prepare(
                        " AND (original_page_url LIKE %s OR original_page_url LIKE %s)",
                        '%' . $wpdb->esc_like($search_query) . '%',
                        '%' . $wpdb->esc_like($normalized_search) . '%'
                    );
                }
                
                $total_items_query = $wpdb->prepare(
                    "SELECT COUNT(DISTINCT override_id) FROM {$table_name} WHERE override_type = %s AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))" . $search_condition,
                    $db_override_type,
                    $issue_type_filter,
                    $issue_type_filter,
                    $db_override_type
                );
                $total_items = (int) $wpdb->get_var( $total_items_query );

                $query = $wpdb->prepare(
                    "SELECT post_id, object_id, override_value, issue_type, original_page_url FROM {$table_name} WHERE override_type = %s AND (issue_type = %s OR (issue_type IS NULL AND %s = %s))" . $search_condition . " ORDER BY override_id DESC LIMIT %d OFFSET %d",
                    $db_override_type,
                    $issue_type_filter,
                    $issue_type_filter,
                    $db_override_type,
                    $per_page,
                    $offset
                );
                $db_results = $wpdb->get_results( $query );


                // Format results
                if ( $db_results ) {
                    foreach ( $db_results as $row ) {
                        // Use the stored original_page_url if available, otherwise fall back to get_permalink
                        $context_url = !empty($row->original_page_url) ? $row->original_page_url : ($row->post_id ? get_permalink( $row->post_id ) : null);
                        $item_details = [
                            'context' => $context_url ? esc_url($context_url) : 'Post ID: ' . $row->post_id,
                            'value'   => esc_html( $row->override_value ),
                            'post_id' => $row->post_id,
                            'object_id' => $row->object_id,
                            'issue_type' => isset($row->issue_type) ? $row->issue_type : $category_key,
                        ];
                        if ($db_override_type === 'image_alt' && $row->object_id > 0) {
                             $attachment_url = wp_get_attachment_url($row->object_id);
                             $item_details['image_src'] = $attachment_url ? esc_url($attachment_url) : 'Attachment ID: ' . $row->object_id;
                             // For images, show relative path + image ID for consistency
                             $relative_path = $context_url ? preg_replace('/^https?:\/\/[^\/]+/', '', $context_url) : ($row->post_id ? 'Post ID: ' . $row->post_id : 'Site-wide');
                             $item_details['context_text'] = $relative_path . ' (Image ID: ' . $row->object_id . ')';
                        } else {
                            // For standard SEO fixes, show relative path instead of title for consistency with broken links
                            $item_details['context_text'] = $context_url ? preg_replace('/^https?:\/\/[^\/]+/', '', $context_url) : ($row->post_id ? 'Post ID: ' . $row->post_id : 'N/A');
                        }
                         $results[] = $item_details;
                    }
                }
            }

        } elseif ( $handler_type === 'option_suggestion' ) {
            // Get option name from mapping
            $option_name_map = self::get_category_option_mapping();
            $option_name = isset($option_name_map[$category_key]) ? $option_name_map[$category_key] : null;

            if ($option_name) {
                $rules = get_option( $option_name, [] );
                
                // Apply search filter if search query is provided
                if (!empty($search_query) && is_array($rules)) {
                    $normalized_search = self::normalize_url_for_search($search_query);
                    $rules = array_filter($rules, function($new_url_data, $old_url) use ($search_query, $normalized_search) {
                        $landing_page_url = '';
                        
                        if (is_array($new_url_data)) {
                            $landing_page_url = $new_url_data['context'] ?? '';
                        }
                        
                        // Search in both the landing page URL and normalized version
                        return stripos($landing_page_url, $search_query) !== false ||
                               stripos(self::normalize_url_for_search($landing_page_url), $normalized_search) !== false;
                    }, ARRAY_FILTER_USE_BOTH);
                }
                
                $total_items = is_array( $rules ) ? count( $rules ) : 0;

                if ( is_array( $rules ) && $total_items > 0 ) {
                    // Manual pagination for option arrays
                    $paginated_rules = array_slice( $rules, $offset, $per_page, true );

                    foreach ( $paginated_rules as $old_url => $new_url_data ) {
                        // Assuming $new_url_data might be an array with 'new_url' and 'context'
                        $suggestion_value = '';
                        $landing_page_url = ''; // Will store the landing page URL
                        $broken_url = $old_url; // Default to the key
                        
                        if (is_array($new_url_data)) {
                            // Get the new URL (replacement)
                            if (isset($new_url_data['new_url'])) {
                                $suggestion_value = $new_url_data['new_url'];
                            }
                            
                            // Get the landing page URL (where the broken link was found)
                            if (isset($new_url_data['context'])) {
                                $landing_page_url = $new_url_data['context'];
                            } elseif (isset($new_url_data['context_post_id'])) {
                                // Legacy format with context_post_id
                                $context_post_id = absint($new_url_data['context_post_id']);
                                $page_title = get_the_title($context_post_id);
                                $page_link = get_permalink($context_post_id);
                                if ($page_title && $page_link) {
                                    $landing_page_url = $page_link;
                                }
                            }
                            
                            // Get the actual broken URL from the stored data
                            // This handles both composite keys (broken_url::context::hash) and row_key fallbacks
                            if (isset($new_url_data['broken_url']) && !empty($new_url_data['broken_url'])) {
                                $broken_url = $new_url_data['broken_url'];
                            } elseif (strpos($old_url, '::context::') !== false) {
                                // Extract broken URL from composite key
                                $broken_url = explode('::context::', $old_url)[0];
                            } elseif (strpos($old_url, 'row_key_') === 0 && isset($new_url_data['row_key'])) {
                                // Try to decode the row_key to extract the issue_identifier
                                // Fix base64 padding if needed
                                $row_key_padded = $new_url_data['row_key'];
                                $mod = strlen($row_key_padded) % 4;
                                if ($mod) {
                                    $row_key_padded .= str_repeat('=', 4 - $mod);
                                }
                                
                                $decoded_base64 = base64_decode($row_key_padded, true);
                                if ($decoded_base64 !== false) {
                                    $decoded_data = json_decode($decoded_base64, true);
                                    if ($decoded_data && isset($decoded_data['payload'])) {
                                        $payload = json_decode($decoded_data['payload'], true);
                                        if ($payload && isset($payload['issue_identifier']) && !empty($payload['issue_identifier'])) {
                                            $broken_url = $payload['issue_identifier'];
                                        }
                                    }
                                }
                            }
                        } elseif (is_string($new_url_data)) {
                            $suggestion_value = $new_url_data; // Legacy format
                        }

                        $results[] = [
                            'context'      => $landing_page_url ?: $old_url, // Landing page URL (where the broken link was found)
                            'value'        => esc_html( $suggestion_value ), // New URL (replacement)
                            'context_text' => $broken_url, // Old URL (the broken link) - now uses the actual broken URL
                            'is_option'    => true,
                            'row_key'      => $new_url_data['row_key'] ?? '', // Include row_key for decoding in frontend
                        ];
                    }
                }
            }
        }

        // Prepare response data
        $response_data = [
            'items'        => $results,
            'total_items'  => $total_items,
            'per_page'     => $per_page,
            'current_page' => $page,
            'total_pages'  => $total_items > 0 ? ceil( $total_items / $per_page ) : 0,
            'category'     => $category_key,
            'issue_type'   => $issue_type,
        ];

        wp_send_json_success( $response_data );
    }

    /**
     * AJAX handler for preparing health issues for a specific category.
     * This fetches all items for the issue type and creates a batch.
     */
    public static function ajax_prepare_health_issues() {
        // Set up AJAX request parameters
        self::setup_ajax_request(25, false);
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_prepare_health_issues');
            return;
        }

        $issue_type = isset( $_POST['issue_type'] ) ? sanitize_key( $_POST['issue_type'] ) : '';
        if ( empty( $issue_type ) ) {
            self::log_error('ajax_prepare_health_issues', 'Issue type not specified');
            self::send_error('Issue type not specified.', 400, [], 'ajax_prepare_health_issues');
            return;
        }

        // Validate issue type
        $categories = rank_get_health_check_categories();
        if ( ! array_key_exists( $issue_type, $categories ) ) {
            self::log_error('ajax_prepare_health_issues', 'Invalid issue type: ' . $issue_type);
            self::send_error('Invalid issue type.', 400, [], 'ajax_prepare_health_issues');
            return;
        }

        // Check if this is a chunked request
        $chunk_size = 250; // amount of issues to be chunked together when processing items. Don't go above 250 (performance)
        $chunk_offset = isset( $_POST['chunk_offset'] ) ? absint( $_POST['chunk_offset'] ) : 0;
        $batch_id = isset( $_POST['batch_id'] ) ? sanitize_key( $_POST['batch_id'] ) : null;
        
        // If no batch_id provided, this is the first request - create batch
        if ( ! $batch_id ) {
            // Clean up old batches (older than 24 hours) to prevent options table bloat
            self::cleanup_old_batches();
            
            // Get available credits to determine preparation limit (only on first request)
            $credits_data = rank_get_ai_credits();
            $available_credits = 0;
            if (!is_wp_error($credits_data) && isset($credits_data['limit']) && isset($credits_data['usage'])) {
                $available_credits = max(0, intval($credits_data['limit']) - intval($credits_data['usage']));
                self::log_error('ajax_prepare_health_issues', "Credits fetched at batch creation: limit={$credits_data['limit']}, usage={$credits_data['usage']}, available={$available_credits}");
            } else {
                self::log_error('ajax_prepare_health_issues', "Failed to fetch credits or invalid format", ['credits_data' => $credits_data]);
            }
            
            // Generate a batch ID and create initial batch data
            $batch_id = 'rank_batch_' . uniqid();
            $batch_data = array(
                'issue_type' => $issue_type,
                'issues'     => array(), // Will be populated chunk by chunk
                'decisions'  => array(),
                'pending'    => 0,
                'complete'   => 0,
                'failed'     => 0,
                'ui_logs'    => array(),
                'created_at' => time(),
                'total_count' => 0, // Will be set from first chunk response
                'chunks_processed' => 0,
                'available_credits' => $available_credits, // Store available credits
                'effective_limit' => 0 // Will be calculated based on credits and max limit
            );
            
            update_option( $batch_id, $batch_data, false );
        }

        // Get existing batch data
        $batch_data = get_option( $batch_id );
        if ( ! $batch_data ) {
            self::send_error('Batch data not found.', 404, [], 'ajax_prepare_health_issues');
            return;
        }

        // Get domain identifier for chunked call
        $domain_identifier = rank_get_domain_identifier();
        if ( is_wp_error( $domain_identifier ) ) {
            self::send_error($domain_identifier->get_error_message(), 400, [], 'ajax_prepare_health_issues');
            return;
        }
        
        // Always use chunked function - every call returns both issues and total count
        $chunk_result = rank_get_issues_chunk( $issue_type, $chunk_size, $chunk_offset, $domain_identifier );
        
        if ( is_wp_error( $chunk_result ) ) {
            self::log_error('ajax_prepare_health_issues', 'Error getting issues chunk: ' . $chunk_result->get_error_message(), [
                'error_code' => $chunk_result->get_error_code(),
                'error_data' => $chunk_result->get_error_data()
            ]);
            self::send_error($chunk_result->get_error_message(), 400, [], 'ajax_prepare_health_issues');
            return;
        }
        
        // Every response includes both issues and total count
        $raw_issues_list = $chunk_result['issues'];
        $total_count = $chunk_result['total'];
        
        // Set total count if not already set
        if ( $batch_data['total_count'] === 0 ) {
            // Calculate effective limit: min(5000, actual_total)
            // Note: We no longer limit by credits - just warn the user instead
            // This allows users to see all issues and process until credits run out
            $max_limit = 5000;
            $available_credits = isset($batch_data['available_credits']) ? $batch_data['available_credits'] : 0;
            $effective_limit = min($total_count, $max_limit);
            
            self::log_error('ajax_prepare_health_issues', "Calculating effective limit: total_count={$total_count}, max_limit={$max_limit}, available_credits={$available_credits}, effective_limit={$effective_limit}");
            
            $batch_data['total_count'] = $total_count; // Store actual total from API
            $batch_data['effective_limit'] = $effective_limit; // Store the capped limit
            
            if ( $total_count === 0 ) {
                wp_send_json_success( array(
                    'batchId' => null,
                    'count' => 0,
                    'total_count' => 0,
                    'message' => 'No issues found for this category.'
                ) );
                return;
            }
        }

        // Get effective limit to check if we need to truncate this chunk
        $effective_limit = isset($batch_data['effective_limit']) ? $batch_data['effective_limit'] : $batch_data['total_count'];
        $current_count = count( $batch_data['issues'] );
        
        // Calculate how many more issues we can add without exceeding the limit
        $remaining_slots = $effective_limit - $current_count;
        
        // Get WordPress base path for filtering
        $wp_home_url = home_url();
        $wp_base_path = parse_url($wp_home_url, PHP_URL_PATH);
        $wp_host = parse_url($wp_home_url, PHP_URL_HOST);
        
        // Normalize host for comparison (remove www. prefix for comparison)
        $normalize_host = function($host) {
            if (empty($host)) return '';
            $host = strtolower($host);
            // Remove www. prefix for comparison
            if (strpos($host, 'www.') === 0) {
                return substr($host, 4);
            }
            return $host;
        };
        $wp_host_normalized = $normalize_host($wp_host);
        
        // Process this chunk and add to batch, but only up to the effective limit
        $chunk_issues_map = [];
        $added_count = 0;
        $skipped_count = 0;
        $skipped_no_rank_id = 0;
        $skipped_host_mismatch = 0;
        $skipped_path_mismatch = 0;
        
        foreach ($raw_issues_list as $issue_item) {
            // Stop adding if we've reached the limit
            if ($added_count >= $remaining_slots) {
                break;
            }
            
            if (isset($issue_item['rank_id']) && !empty($issue_item['rank_id'])) {
                // Filter out URLs that don't belong to this WordPress installation
                $issue_url = null;
                if (isset($issue_item['page']['path_full'])) {
                    $issue_url = $issue_item['page']['path_full'];
                } elseif (isset($issue_item['page']['path'])) {
                    // Construct full URL from path if needed
                    $issue_url = home_url($issue_item['page']['path']);
                }
                
                // Validate URL belongs to this WordPress installation
                // Be very lenient - only check host match to support WPML and other multilingual setups
                if ($issue_url) {
                    $url_host = parse_url($issue_url, PHP_URL_HOST);
                    
                    // Normalize and check host match (handle www vs non-www)
                    $url_host_normalized = $normalize_host($url_host);
                    
                    // Skip only if host clearly doesn't match
                    // Don't do strict path matching here - WPML and other multilingual plugins
                    // can have different path structures (e.g., /da/, /en/) that are still valid
                    if ($url_host_normalized !== $wp_host_normalized) {
                        $skipped_count++;
                        $skipped_host_mismatch++;
                        continue;
                    }
                    // Host matches - path validation will happen later during processing if needed
                }
                // If no URL found, still add the issue - URL validation will happen later during processing
                
                $chunk_issues_map[$issue_item['rank_id']] = $issue_item;
                $added_count++;
            } else {
                $skipped_no_rank_id++;
            }
        }
        
        // Log skipped items with detailed breakdown
        if ($skipped_count > 0 || $skipped_no_rank_id > 0) {
            $skip_details = array();
            if ($skipped_no_rank_id > 0) {
                $skip_details[] = "{$skipped_no_rank_id} without rank_id";
            }
            if ($skipped_host_mismatch > 0) {
                $skip_details[] = "{$skipped_host_mismatch} host mismatch";
            }
            if ($skipped_path_mismatch > 0) {
                $skip_details[] = "{$skipped_path_mismatch} path mismatch";
            }
            $skip_message = "Skipped " . ($skipped_count + $skipped_no_rank_id) . " issues during preparation: " . implode(", ", $skip_details);
            Rank_Processor::add_debug_log($skip_message);
            self::log_error('ajax_prepare_health_issues', $skip_message);
        }

        // Add chunk to batch data (only the items we actually added)
        $batch_data['issues'] = array_merge( $batch_data['issues'], $chunk_issues_map );
        $batch_data['pending'] = count( $batch_data['issues'] );
        $batch_data['chunks_processed']++;
        
        // Update batch data
        update_option( $batch_id, $batch_data, false );

        // Recalculate current count after adding
        $total_count = $batch_data['total_count'];
        $current_count = count( $batch_data['issues'] );
        
        // Stop when we reach the effective limit (min of credits, 5000, or total)
        $has_more = count( $raw_issues_list ) === $chunk_size && $current_count < $effective_limit;

        // Debug logging
        self::log_error('ajax_prepare_health_issues', "Chunk processed: offset={$chunk_offset}, chunk_size=" . count($chunk_issues_map) . ", current_count={$current_count}, effective_limit={$effective_limit}, total_count={$total_count}, has_more=" . ($has_more ? 'true' : 'false'));

        wp_send_json_success( array(
            'batchId' => $batch_id,
            'count' => $current_count,
            'total_count' => $effective_limit, // Return effective limit (min of total, 5000) to frontend
            'actual_total' => $total_count, // Include actual total for reference
            'available_credits' => isset($batch_data['available_credits']) ? $batch_data['available_credits'] : 0, // Pass credits to frontend for warning
            'chunk_size' => count( $chunk_issues_map ),
            'next_offset' => $chunk_offset + $chunk_size,
            'has_more' => $has_more,
            'message' => $has_more ?
                "Prepared {$current_count} of {$effective_limit} issues..." :
                "Preparation complete. Found {$current_count} issues."
        ) );
    }

    /**
     * Get batch state and next issue to process
     */
    public static function ajax_get_batch_state() {
        // Set up AJAX request parameters
        self::setup_ajax_request();
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_get_batch_state');
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batchId']) ? sanitize_text_field($_POST['batchId']) : '';
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'), [], 'ajax_get_batch_state');
            return;
        }

        // Get batch data
        $batch_data = get_option( $batch_id );
        if ( ! $batch_data ) {
            self::log_error('ajax_get_batch_state', 'Batch not found: ' . $batch_id);
            self::send_error('Batch not found.', 404, [], 'ajax_get_batch_state');
            return;
        }


        // Create an array to store only new UI logs for this request
        $new_ui_logs = array();

        $next_row = null;
        $credits_exhausted_flag = false; // Flag to indicate if credits ran out during this call
        $api_error_400_halt_flag = false; // Flag for generic 400 API error
        $batch_status_message = '';
        
        // Debug logging for batch state
        Rank_Processor::add_debug_log("=== BATCH STATE DEBUG ===");
        Rank_Processor::add_debug_log("Batch ID: " . $batch_id);
        Rank_Processor::add_debug_log("Remaining issues in batch: " . count($batch_data['issues']));
        Rank_Processor::add_debug_log("Skipped issues: " . (isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] : 0));
        Rank_Processor::add_debug_log("Approved decisions: " . (isset($batch_data['decisions']) ? count($batch_data['decisions']) : 0));
        
        // Now $batch_data['issues'] is an associative array keyed by rank_id (the complex key)
        // Issues are removed from this array when processed (error or success)
        foreach ( $batch_data['issues'] as $current_row_key => $issue ) {
            // Skip if we already made an approval decision on this one
            // (Error cases are handled by removing from issues array)
            if ( isset( $batch_data['decisions'][$current_row_key] ) ) {
                Rank_Processor::add_debug_log("Skipping row (approved): " . substr($current_row_key, 0, 50) . "...");
                continue;
            }
            Rank_Processor::add_debug_log("Processing row: " . substr($current_row_key, 0, 50) . "...");
            // Extract page URL from the issue data before making the AI fix request
            $api_page_full_url = $issue['page']['path_full'] ?? null;
            $wp_page_url = $api_page_full_url ? esc_url($api_page_full_url) : null;
            
            // Check if the URL exists and doesn't redirect
            if (!empty($wp_page_url)) {
                $url_validation_result = self::validate_url($wp_page_url, $batch_data, $current_row_key, $new_ui_logs);
                if ($url_validation_result !== true) {
                    // Process only one URL per request, then break
                    break;
                }
            }
            
            // Extract page URL and path information before making the API call
            // Page context from the issue data
            $api_page_path = $issue['page']['path'] ?? null;
            $api_page_full_url = $issue['page']['path_full'] ?? null;
            $wp_page_url = $api_page_full_url ? esc_url($api_page_full_url) : null; // Prefer full URL if available
            $wp_page_path_display = $api_page_path ? esc_html(ltrim($api_page_path, '/')) : 'N/A';
            
            // Item identifier from the issue data (e.g. image URL, broken link URL)
            $item_identifier_from_issue = $issue['issue_identifier'] ?? null;
            if ($item_identifier_from_issue === ($issue['validator'] ?? null) && !filter_var($item_identifier_from_issue, FILTER_VALIDATE_URL)) {
                // If issue_identifier is just the validator type (e.g., "missing_meta_description") and not a URL, clear it for item-specific display.
                // The page itself is the item in this case.
                $item_identifier_from_issue = null;
            }
            
            // Check if this issue has already been fixed before making AI fix request (to save credits)
            $batch_api_issue_type = $batch_data['issue_type'] ?? null;
            if (!empty($wp_page_url) && !empty($batch_api_issue_type)) {
                $already_fixed = rank_is_issue_already_fixed($batch_api_issue_type, $wp_page_url, $item_identifier_from_issue);
                
                if ($already_fixed) {
                    // Issue already fixed - remove from batch instead of adding decision
                    $log_message = "✅ Skipping issue on: " . $wp_page_url . " - already fixed: " . ($item_identifier_from_issue ? basename($item_identifier_from_issue) : 'page itself');
                    
                    unset($batch_data['issues'][$current_row_key]);
                    $batch_data['skipped_count'] = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] + 1 : 1;
                    
                    // Don't persist logs to batch_data - just send in response via new_ui_logs
                    $new_ui_logs[] = $log_message;
                    
                    // Continue to next issue without using credits
                    continue;
                }
            }
            
            // Get AI fix for this issue
            $ai_fix = rank_get_ai_fix( $current_row_key ); // domain_identifier is handled within rank_get_ai_fix

            if ( is_wp_error( $ai_fix ) ) {
                $error_code = $ai_fix->get_error_code();
                $error_message = $ai_fix->get_error_message();
                $error_data = $ai_fix->get_error_data();
                $http_status_code = null;

                if ( is_array( $error_data ) && isset( $error_data['status_code'] ) ) {
                    $http_status_code = (int) $error_data['status_code'];
                }
                
                // Get item name and page path for logging
                // Make sure we have the item identifier and page path before logging
                $item_name = isset($item_identifier_from_issue) && !empty($item_identifier_from_issue) ?
                    basename($item_identifier_from_issue) :
                    (isset($issue['issue_identifier']) ? basename($issue['issue_identifier']) : 'Unknown');
                
                $page_path = isset($wp_page_path_display) && !empty($wp_page_path_display) ?
                    $wp_page_path_display :
                    (isset($issue['page']['path']) ? ltrim($issue['page']['path'], '/') : 'Unknown page');
                
                if ( 'out_of_credits' === $error_code ) {
                    // AI credits exhausted
                    $log_message = 'AI Credits Exhausted while processing Image: ' . esc_html($item_name) . ' on /' . esc_html($page_path) . '. Cannot fetch further suggestions.';
                    self::log_error('ajax_get_batch_state', $log_message, ['error_code' => $error_code]);
                    
                    // Keep UI logs for user feedback
                    $ui_log_message = '⚠️ ' . $log_message;
                    $new_ui_logs[] = $ui_log_message;
                    // Remove this issue from batch to prevent reprocessing
                    unset($batch_data['issues'][$current_row_key]);
                    $batch_data['skipped_count'] = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] + 1 : 1;
                    $credits_exhausted_flag = true;
                    $batch_status_message = 'AI credits exhausted. Processing stopped.';
                    // Do not prepare $next_row, and break the loop as we can't proceed.
                    break;
                } elseif ( $http_status_code === 400 && $error_code === 'api_error' ) {
                    // Handle generic 400 API errors (that are not 'out_of_credits')
                    // Make sure we're using the correct item name and page path
                    $log_message = 'API Error (400) for ' . esc_html($item_name) . ' on /' . esc_html($page_path) . ': ' . esc_html($error_message) . '. Processing halted.';
                    self::log_error('ajax_get_batch_state', $log_message, ['error_code' => $error_code, 'status_code' => $http_status_code]);
                    
                    // Keep UI logs for user feedback
                    $ui_log_message = '🐞 Error: ' . esc_html($item_name) . ' on /' . esc_html($page_path) . ': ' . esc_html($error_message);
                    $new_ui_logs[] = $ui_log_message;
                    // Remove this issue from batch to prevent reprocessing
                    unset($batch_data['issues'][$current_row_key]);
                    $batch_data['skipped_count'] = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] + 1 : 1;
                    $api_error_400_halt_flag = true;
                    $batch_status_message = 'A critical API error (HTTP 400) occurred. Processing stopped.';
                    break; // Halt batch processing for this request
                } else {
                    // Handle other errors from rank_get_ai_fix (e.g., 5xx, connection issues, non-400 'api_error')
                    $log_message = 'Error: ' . esc_html($item_name) . ' on /' . esc_html($page_path) . ': ' . esc_html($error_message);
                    self::log_error('ajax_get_batch_state', $log_message, ['error_code' => $error_code, 'error_data' => $error_data]);
                    
                    // Keep UI logs for user feedback - format consistently for the JS parser
                    $ui_log_message = '🐞 Error: ' . esc_html($item_name) . ' on /' . esc_html($page_path) . ': ' . esc_html($error_message);
                    $new_ui_logs[] = $ui_log_message;
                    // Instead of storing decision (which bloats the batch data),
                    // remove this issue from the batch to prevent reprocessing
                    // This keeps batch data small and prevents options table overflow
                    unset($batch_data['issues'][$current_row_key]);
                    
                    // Increment skipped counter
                    $batch_data['skipped_count'] = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] + 1 : 1;
                    
                    Rank_Processor::add_debug_log("Issue removed from batch due to error - key: " . $current_row_key . ", message: " . $error_message);
                    Rank_Processor::add_debug_log("Remaining issues in batch: " . count($batch_data['issues']));
                    
                    // For other errors, break the loop after processing one URL
                    break;
                }
            }
            
            // If $ai_fix is not a WP_Error, it should contain the 'content' key with the solution(s)
            // The structure of $ai_fix (successful response) is assumed to be:
            // { "content": [ { "solution_type": "...", "solution_value": "..." }, ... ] }
            // Or simply { "content": "single solution string" } - adapt as needed.
            
            $ai_solutions_for_current_row = null;
            
            // Process the AI fix content
            if ( isset( $ai_fix['content'] ) ) {
                if ( is_array( $ai_fix['content'] ) ) {
                    // Content is already an array of solutions
                    $ai_solutions_for_current_row = $ai_fix['content'];
                } elseif ( is_string( $ai_fix['content'] ) ) {
                    // If content is a string, wrap it in an array to match structure of multiple solutions
                    $ai_solutions_for_current_row = array(
                        array( 'solution_type' => 'text', 'solution_value' => $ai_fix['content'] )
                    );
                } else {
                    // Unexpected format for 'content' - try to convert to string
                    $content_string = is_object($ai_fix['content']) || is_array($ai_fix['content'])
                        ? json_encode($ai_fix['content'])
                        : (string)$ai_fix['content'];
                    
                    $ai_solutions_for_current_row = array(
                        array( 'solution_type' => 'text', 'solution_value' => $content_string )
                    );
                }
            } else {
                // This shouldn't happen with our updated rank_get_ai_fix function,
                // but let's handle it just in case
                
                // Try to extract any useful text from the response
                $fallback_content = "Image description";
                
                // Check for common fields that might contain useful text
                foreach (['alt_text', 'suggestion', 'text', 'message', 'description'] as $field) {
                    if (isset($ai_fix[$field]) && is_string($ai_fix[$field])) {
                        $fallback_content = $ai_fix[$field];
                        break;
                    }
                }
                
                $ai_solutions_for_current_row = array(
                    array( 'solution_type' => 'text', 'solution_value' => $fallback_content )
                );
                
                // Log this issue but continue processing
                $item_name = isset($item_identifier_from_issue) ? basename($item_identifier_from_issue) : 'Unknown';
                $page_path = isset($wp_page_path_display) ? $wp_page_path_display : 'Unknown page';
                $log_message = 'Warning for Image: ' . esc_html($item_name) . ' on /' . esc_html($page_path) . ': Using fallback content.';
                self::log_error('ajax_get_batch_state', $log_message);
                
                // Keep UI logs for user feedback
                $ui_log_message = '⚠️ ' . $log_message;
                $new_ui_logs[] = $ui_log_message;
            }


            // If we are here, we have a valid $ai_solutions_for_current_row
            // Prepare the $next_row for the UI
            // This logic is similar to what's in ajax_record_decision for finding the next row,
            // but here we are populating the *current* row that was just fetched.

            $ui_url_display = 'N/A';
            $ui_issue_data = [ /* ... populate with page_url, image_url etc. from $issue ... */ ];
            
            // Extract page_url, image_url etc. from $issue (which is $batch_data['issues'][$current_row_key])
            $batch_api_issue_type = $batch_data['issue_type'] ?? null; // The overall type for this batch
            $current_item_api_validator = $issue['validator'] ?? $batch_api_issue_type; // More specific if available

            // Page context from the issue data
            $api_page_path = $issue['page']['path'] ?? null;
            $api_page_full_url = $issue['page']['path_full'] ?? null;
            $wp_page_url = $api_page_full_url ? esc_url($api_page_full_url) : null; // Prefer full URL if available
            $wp_page_path_display = $api_page_path ? esc_html(ltrim($api_page_path, '/')) : 'N/A';
            
            // Extract URL fragments from the API


            $ui_issue_data['page_url'] = $wp_page_url;
            $ui_issue_data['page_path'] = $wp_page_path_display; // Relative path for display

            // Item identifier from the issue data (e.g. image URL, broken link URL)
            $item_identifier_from_issue = $issue['issue_identifier'] ?? null;
            if ($item_identifier_from_issue === $current_item_api_validator && !filter_var($item_identifier_from_issue, FILTER_VALIDATE_URL)) {
                // If issue_identifier is just the validator type (e.g., "missing_meta_description") and not a URL, clear it for item-specific display.
                // The page itself is the item in this case.
                $item_identifier_from_issue = null; 
            }


            // Get the site URL to construct full URLs
            $site_url = site_url();
            
            // Ensure site URL ends with a slash
            if (substr($site_url, -1) !== '/') {
                $site_url .= '/';
            }
            
            // Construct a full URL from the path fragment
            $full_page_url = $wp_page_url;
            if (empty($full_page_url) && !empty($api_page_path)) {
                // Remove leading slash if present
                $path_for_url = ltrim($api_page_path, '/');
                $full_page_url = $site_url . $path_for_url;
            }
            
            switch ($current_item_api_validator) {
                case 'missing_alt_tags':
                    $ui_url_display = 'Image: ' . ($item_identifier_from_issue ? basename($item_identifier_from_issue) : 'Unknown') . ' on <a href="' . esc_url($full_page_url) . '" target="_blank">' . esc_html($wp_page_path_display) . '</a>';
                    $ui_issue_data['image_url'] = $item_identifier_from_issue;
                    $ui_issue_data['page_url'] = $full_page_url; // Store the full URL
                    break;
                case 'missing_meta_description':
                case 'short_meta_description':
                case 'long_meta_description':
                    $desc = '';
                    if ($current_item_api_validator === 'missing_meta_description') $desc = 'Missing meta description';
                    if ($current_item_api_validator === 'short_meta_description') $desc = 'Short meta description';
                    if ($current_item_api_validator === 'long_meta_description') $desc = 'Long meta description';
                    $ui_url_display = '<a href="' . esc_url($full_page_url) . '" target="_blank">' . esc_html($wp_page_path_display) . '</a> - ' . $desc;
                    $ui_issue_data['page_url'] = $full_page_url; // Store the full URL
                    break;
                case 'missing_title_tag':
                case 'short_title_tag':
                case 'long_title_tag':
                    $desc = '';
                    if ($current_item_api_validator === 'missing_title_tag') $desc = 'Missing title';
                    if ($current_item_api_validator === 'short_title_tag') $desc = 'Short title';
                    if ($current_item_api_validator === 'long_title_tag') $desc = 'Long title';
                    $ui_url_display = '<a href="' . esc_url($full_page_url) . '" target="_blank">' . esc_html($wp_page_path_display) . '</a> - ' . $desc;
                    $ui_issue_data['page_url'] = $full_page_url; // Store the full URL
                    break;
                case 'broken_links_external':
                case 'broken_links_internal':
                    $link_type = ($current_item_api_validator === 'broken_links_external') ? 'External' : 'Internal';
                    $ui_url_display = 'Broken ' . $link_type . ' Link: ' . ($item_identifier_from_issue ? esc_html($item_identifier_from_issue) : 'N/A') . ' on <a href="' . esc_url($full_page_url) . '" target="_blank">' . esc_html($wp_page_path_display) . '</a>';
                    if ($item_identifier_from_issue && filter_var($item_identifier_from_issue, FILTER_VALIDATE_URL)) {
                       $ui_issue_data['item_specific_url_generic'] = $item_identifier_from_issue; // The broken URL
                    }
                    $ui_issue_data['page_url'] = $full_page_url; // Store the full URL
                    break;
                default:
                    $page_display_part = '<a href="' . esc_url($full_page_url) . '" target="_blank">' . esc_html($wp_page_path_display) . '</a>';
                    $item_display_part = $item_identifier_from_issue ? esc_html($item_identifier_from_issue) : "Details for " . $current_row_key;
                    $ui_url_display = $page_display_part . ($item_identifier_from_issue ? ': ' . $item_display_part : '');
                    if ($item_identifier_from_issue && filter_var($item_identifier_from_issue, FILTER_VALIDATE_URL)) {
                       $ui_issue_data['item_specific_url_generic'] = $item_identifier_from_issue;
                    }
                    $ui_issue_data['page_url'] = $full_page_url; // Store the full URL
                    break;
            }

            // Prepare the next row data for the frontend
            
            $next_row = array(
                'rowKey'        => $current_row_key,
                'url'           => $ui_url_display,
                'solution'      => $ai_solutions_for_current_row, // This is the array of solutions
                'issue_data'    => $ui_issue_data
            );
            
            // Explicitly set skip_auto_approve to false for normal rows
            // This ensures the frontend knows this is not a continuation row
            if (!isset($next_row['issue_data']['skip_auto_approve'])) {
                $next_row['issue_data']['skip_auto_approve'] = false;
            }
            
            // We found an item to process.
            // The decision for this item will be made by the user via ajax_record_decision.
            
            // We've processed one URL (valid or invalid), so break the loop
            break;
        } // End foreach loop

        // Save batch data - now we remove processed issues instead of adding decisions
        // This keeps the data smaller and avoids options table size limits
        wp_cache_delete($batch_id, 'options');
        
        $save_result = update_option( $batch_id, $batch_data, false );
        
        // If update fails, try delete+add
        if (!$save_result) {
            delete_option($batch_id);
            $save_result = add_option($batch_id, $batch_data, '', 'no');
        }
        
        $remaining_issues = isset($batch_data['issues']) ? count($batch_data['issues']) : 0;
        $skipped_count = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] : 0;
        
        Rank_Processor::add_debug_log("Batch saved. Result: " . ($save_result ? 'SUCCESS' : 'FAILED') . ", Remaining issues: " . $remaining_issues . ", Skipped: " . $skipped_count);
        Rank_Processor::add_debug_log("=== END BATCH STATE DEBUG ===");

        // Calculate counts - remaining issues plus skipped plus approved = original total
        $remaining_issues = isset($batch_data['issues']) && is_array($batch_data['issues']) ? count($batch_data['issues']) : 0;
        $skipped_count = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] : 0;
        $approved_count = isset($batch_data['decisions']) ? count($batch_data['decisions']) : 0;
        
        // Total issues is what we started with (remaining + processed)
        $total_issues = $remaining_issues + $skipped_count + $approved_count;
        $processed_count = $skipped_count + $approved_count;

        // Fetch current credits information
        $credits_data = rank_get_ai_credits();
        $credits_info = array( 'remaining' => 0, 'total' => 0 );
        
        if ( !is_wp_error( $credits_data ) && is_array( $credits_data ) ) {
            if ( isset( $credits_data['limit'] ) && isset( $credits_data['usage'] ) ) {
                $credits_info['total'] = intval( $credits_data['limit'] );
                $credits_info['remaining'] = max( 0, intval( $credits_data['limit'] ) - intval( $credits_data['usage'] ) );
            }
        }

        // No need for dummy next_row logic since we're processing one URL at a time

        // Check if there are more URLs to process and find the next URL
        $more_to_process = false;
        $next_unprocessed_key = null;
        
        if ($processed_count < $total_issues) {
            foreach ($batch_data['issues'] as $key => $issue_data) {
                if (!isset($batch_data['decisions'][$key])) {
                    $more_to_process = true;
                    $next_unprocessed_key = $key;
                    break;
                }
            }
        }
        
        // If we have more to process but no next_row is set, create one for the next unprocessed URL
        if ($more_to_process && !$next_row && $next_unprocessed_key) {
            $next_issue = $batch_data['issues'][$next_unprocessed_key];
            
            // Extract basic information for the next row
            $api_page_full_url = $next_issue['page']['path_full'] ?? null;
            $wp_page_url = $api_page_full_url ? esc_url($api_page_full_url) : null;
            $api_page_path = $next_issue['page']['path'] ?? null;
            $wp_page_path_display = $api_page_path ? esc_html(ltrim($api_page_path, '/')) : 'N/A';
            
            // Create a special next_row that tells the frontend this is just for continuation
            // and should not be auto-approved or logged as a fix
            $next_row = array(
                'rowKey'        => $next_unprocessed_key,
                'url'           => 'Continue processing...',
                'solution'      => array('Continue processing...'),
                'issue_data'    => array(
                    'page_url' => $wp_page_url,
                    'page_path' => $wp_page_path_display,
                    'continue_processing' => true,
                    'skip_auto_approve' => true
                )
            );
        }

        $response_payload = array(
            'nextRow'        => $next_row,
            'ui_logs'        => $new_ui_logs, // Only send new logs generated during this request
            'totalCount'     => $total_issues,
            'processedCount' => $processed_count,
            'status'         => $more_to_process ? 'processing' : 'batch_complete', // Set status based on whether there are more URLs to process
            'message'        => $batch_status_message ?: ($next_row ? 'Next item fetched.' : ($more_to_process ? 'More items to process.' : 'No more items to process or batch complete.')),
            'credits'        => $credits_info, // Add credits information to the response
            'more_to_process' => $more_to_process, // Explicitly indicate if there are more URLs to process
            'force_continue' => $more_to_process, // Additional flag to force frontend to continue
            'is_continuation' => $next_row && isset($next_row['issue_data']['skip_auto_approve']) ? true : false // Flag to indicate this is a continuation request
        );

        if ( $api_error_400_halt_flag ) {
            $response_payload['status'] = 'api_error_400_halt'; // Specific status for JS
            // $response_payload['message'] is already set by $batch_status_message
        } elseif ( $credits_exhausted_flag ) {
            $response_payload['status'] = 'out_of_credits';
            // $response_payload['message'] is already set by $batch_status_message
        } elseif ( $total_issues === 0 ) {
            $response_payload['status'] = 'batch_empty';
            $response_payload['message'] = 'No issues found in this batch to process.';
        }


        
        wp_send_json_success( $response_payload );
    }

    /**
     * Record user decision (approve/decline)
     */
    public static function ajax_record_decision() {
        // Set up AJAX request parameters
        self::setup_ajax_request(10, false);
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_record_decision');
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batchId']) ? sanitize_key($_POST['batchId']) : null;
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'), [], 'ajax_record_decision');
            return;
        }
        
        // Get row key and approval status
        $row_key = isset($_POST['rowKey']) ? sanitize_text_field($_POST['rowKey']) : null;
        $approved = isset($_POST['approved']) ? filter_var($_POST['approved'], FILTER_VALIDATE_BOOLEAN) : false;
        
        if (!$row_key) {
            self::send_error('Missing row key.', 400, [], 'ajax_record_decision');
            return;
        }

        // Retrieve new POST variables expected from the frontend
        $page_url_from_post = isset($_POST['page_url']) ? esc_url_raw(trim(wp_unslash($_POST['page_url']))) : null;
        // Store item URLs as-is to preserve special characters
        $item_url_from_post = isset($_POST['item_url']) ? trim(wp_unslash($_POST['item_url'])) : null;
        $solution_from_post = isset($_POST['solution']) ? wp_unslash($_POST['solution']) : null;
        
        // Simple solution extraction
        if (is_string($solution_from_post)) {
            // Check if it's a serialized array
            if (is_serialized($solution_from_post)) {
                $unserialized = maybe_unserialize($solution_from_post);
                if (is_array($unserialized) && isset($unserialized['solution_value'])) {
                    $solution_from_post = $unserialized['solution_value'];
                }
            }
            $solution_from_post = trim($solution_from_post);
        } elseif (is_array($solution_from_post)) {
            // If it's an array with a single object (common case from logs)
            if (count($solution_from_post) === 1 && isset($solution_from_post[0]) && is_array($solution_from_post[0])) {
                if (isset($solution_from_post[0]['solution_value'])) {
                    $solution_from_post = $solution_from_post[0]['solution_value'];
                }
            }
            // If it's a direct array with solution_value
            elseif (isset($solution_from_post['solution_value'])) {
                $solution_from_post = $solution_from_post['solution_value'];
            }
            // If it's a simple array, use the first element
            elseif (isset($solution_from_post[0])) {
                $solution_from_post = $solution_from_post[0];
            }
            
            // Ensure it's a string and trim it
            if (is_string($solution_from_post)) {
                $solution_from_post = trim($solution_from_post);
            }
        }
        // $api_issue_type_from_post will be derived after fetching $current_issue_data or from POST

        $batch_data = get_option( $batch_id );

        if ( ! $batch_data ) {
            self::log_error('ajax_record_decision', 'Batch data not found', [
                'batch_id' => $batch_id,
                'row_key' => $row_key
            ]);
            self::send_error('Batch data not found.', 404, [], 'ajax_record_decision');
            return;
        }

        // Ensure arrays are initialized
        if ( ! isset( $batch_data['decisions'] ) || ! is_array( $batch_data['decisions'] ) ) {
            $batch_data['decisions'] = array();
        }
        if ( ! isset( $batch_data['ui_logs'] ) || ! is_array( $batch_data['ui_logs'] ) ) {
            $batch_data['ui_logs'] = array();
        }
        // Initialize skipped_count if not set
        if ( ! isset( $batch_data['skipped_count'] ) ) {
            $batch_data['skipped_count'] = 0;
        }

        // Check if item exists in issues - if not, it was already processed (skipped or completed)
        if ( ! isset( $batch_data['issues'][$row_key] ) ) {
            // Item not in issues - either already skipped or doesn't exist
            // Return success and move to next item
            $next_row_for_ui = self::find_next_unprocessed_row($batch_data);
            
            $remaining_issues = isset($batch_data['issues']) && is_array($batch_data['issues']) ? count($batch_data['issues']) : 0;
            $skipped_count = $batch_data['skipped_count'];
            $approved_count = isset($batch_data['decisions']) ? count($batch_data['decisions']) : 0;
            $total_issues = $remaining_issues + $skipped_count + $approved_count;
            $processed_count = $skipped_count + $approved_count;
            
            self::send_success('Item already processed, moving to next.', array(
                'nextRow'        => $next_row_for_ui,
                'totalCount'     => $total_issues,
                'processedCount' => $processed_count,
            ));
            return;
        }

        // Handle decline/skip: remove from issues, increment counter
        if (!$approved) {
            unset($batch_data['issues'][$row_key]);
            $batch_data['skipped_count']++;
            
            // Save and return with next row
            update_option($batch_id, $batch_data, false);
            
            $next_row_for_ui = self::find_next_unprocessed_row($batch_data);
            
            $remaining_issues = isset($batch_data['issues']) && is_array($batch_data['issues']) ? count($batch_data['issues']) : 0;
            $skipped_count = $batch_data['skipped_count'];
            $approved_count = isset($batch_data['decisions']) ? count($batch_data['decisions']) : 0;
            $total_issues = $remaining_issues + $skipped_count + $approved_count;
            $processed_count = $skipped_count + $approved_count;
            
            wp_send_json_success( array(
                'message'        => '❌ Fix declined.',
                'nextRow'        => $next_row_for_ui,
                'totalCount'     => $total_issues,
                'processedCount' => $processed_count,
            ) );
            return;
        }

        // Handle approval: add to decisions (item stays in issues for task execution)
        $batch_data['decisions'][$row_key] = ['approved' => true];
        if ($solution_from_post !== null) {
            $batch_data['decisions'][$row_key]['solution'] = $solution_from_post;
        }


        $current_issue_data = $batch_data['issues'][$row_key];
        $api_issue_type_from_post = isset($_POST['api_issue_type']) ? sanitize_key($_POST['api_issue_type']) : ($current_issue_data['validator'] ?? $batch_data['issue_type'] ?? null);


        $action_text = $approved ? 'approved' : 'declined';
        
        // Correctly access nested data for logging and task arguments
        $log_identifier_url = $page_url_from_post ?? $item_url_from_post ?? $row_key;
        $actual_issue_type_for_log = $api_issue_type_from_post ?? 'N/A';
        $actual_solution_for_log = $solution_from_post ?? 'N/A (Solution not provided in POST)';
        
        // Ensure solution is a string, trim it, and provide a default if empty after trimming.
        $actual_solution_for_log = is_string($actual_solution_for_log) ? trim($actual_solution_for_log) : 'N/A';
        if (empty($actual_solution_for_log)) {
            $actual_solution_for_log = 'N/A';
        }

        // Create detailed log message for server logs only (not for UI)
        $detailed_log_message = sprintf(
            'Item for %s (%s) %s. Solution: "%s"',
            $log_identifier_url,
            $actual_issue_type_for_log,
            $action_text,
            $actual_solution_for_log
        );
        
        // Log to server error log
        self::log_error('ajax_record_decision', $detailed_log_message);
        
        // No longer adding this message to UI logs
        if ( count( $batch_data['ui_logs'] ) > 500 ) { // Limit log size
            $batch_data['ui_logs'] = array_slice( $batch_data['ui_logs'], 0, 500 );
        }

        if ( $approved ) {
            if ( ! function_exists('as_enqueue_async_action') ) {
                $error_message = 'Error: Action Scheduler function as_enqueue_async_action() not available. Task not scheduled.';
                $batch_data['ui_logs'][] = $error_message; // Add to UI logs as well
                update_option( $batch_id, $batch_data, false ); // Save updated batch data
                self::log_error('ajax_record_decision', $error_message);
                self::send_error($error_message, 500, ['log' => $batch_data['ui_logs']], 'ajax_record_decision');
                return; // Ensure execution stops
            }

            // Check if the URL exists in WordPress before scheduling the task
            $wp_post_id = 0;
            $url_exists = false;
            $skip_reason = '';
            
            // Check if page URL is missing
            if (empty($page_url_from_post)) {
                $skip_reason = 'missing_page_url';
                $log_message = "Skipping item due to missing page URL: " . ($item_url_from_post ?? $row_key);
                array_unshift($batch_data['ui_logs'], $log_message);
                self::log_error('ajax_record_decision', $log_message);
                
                // Remove skipped item from issues to shrink data and prevent reprocessing
                unset($batch_data['issues'][$row_key]);
                $batch_data['skipped_count'] = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] + 1 : 1;
                
                // Save batch data with the new log message
                update_option($batch_id, $batch_data, false);
                
                // Find next row to process instead of stopping
                $next_row_for_ui = self::find_next_unprocessed_row($batch_data);
                
                // Return success but indicate the URL was skipped
                self::send_success('⚠️ Item skipped (missing page URL)', array(
                    'nextRow'        => $next_row_for_ui, // Include next row to continue processing
                    'totalCount'     => isset($batch_data['issues']) && is_array($batch_data['issues']) ? count($batch_data['issues']) : 0,
                    'processedCount' => isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] : 0,
                ));
                return;
            }
            
            // URL validation already done in ajax_get_batch_state() - no need to validate again
            // Prepare arguments for the scheduled task
            $task_args = array(
                'page_context_url' => $page_url_from_post, // URL of the page/post
                'item_specific_url'=> $item_url_from_post, // URL of the specific item (e.g., image)
                'api_issue_type'   => $api_issue_type_from_post, // e.g., 'missing_meta_description'
                'solution'         => $solution_from_post,   // The actual solution text
                'row_key'          => $row_key,
                'batch_id'         => $batch_id,
            );


            $action_id = as_enqueue_async_action(
                'rank_process_single_item_hook', // Ensure this hook matches what Rank_Processor expects
                array( $task_args ), // Pass $task_args directly as the single argument in the array
                'rank-ai-seo'
            );

            if ( $action_id ) {
                // Success - no action needed
            } else {
                
                // Log the error
                $error_message = sprintf('Failed to schedule task for item: %s', $row_key);
                self::log_error('ajax_record_decision', $error_message);
                
                // We still need to show errors in the UI
                $ui_error_message = sprintf(
                    'Error: Failed to schedule task for item: %s',
                    esc_html( $row_key )
                );
                array_unshift( $batch_data['ui_logs'], $ui_error_message );
            }
        }

        // Save batch data (decisions, logs)
        update_option( $batch_id, $batch_data, false );

        // Find next unprocessed row for the UI
        $next_row_for_ui = null;
        if (isset($batch_data['issues']) && is_array($batch_data['issues'])) {
            $batch_api_issue_type = $batch_data['issue_type'] ?? null; // Get the overall issue type for the batch

            foreach ( $batch_data['issues'] as $key => $issue_item_data ) { // $key is rank_id, $issue_item_data is the issue's own data
                if ( ! isset( $batch_data['decisions'][$key] ) ) { // Found an unprocessed item
                    
                    $next_ui_url_display = 'N/A'; // For the main display string in the UI
                    $next_ui_issue_data = [
                        'page_path' => null, // For display in UI
                        'page_url'  => null, // Full URL for processing
                        'image_url' => null  // Full image URL if applicable
                    ];

                    // Get page details using helper method
                    $page_details = self::get_page_details_from_issue($issue_item_data);
                    $next_ui_issue_data['page_url'] = $page_details['page_url']; // This is the crucial full URL
                    $next_ui_issue_data['page_path'] = $page_details['page_path']; // For UI text

                    // Get item identifier (e.g., image URL for alt tags, or specific element identifier)
                    $item_identifier_from_issue = $issue_item_data['issue_identifier'] ?? ($issue_item_data['parameters']['url'] ?? null);

                    $ai_solutions_for_next_row = $issue_item_data['solution'] ?? ['Solution not available'];
                    if (is_string($ai_solutions_for_next_row)) { // Ensure it's an array for the UI
                        $ai_solutions_for_next_row = [$ai_solutions_for_next_row];
                    }

                    // Build UI display string using helper method
                    $next_ui_url_display = self::build_ui_display_string($batch_api_issue_type, $page_details['page_path'], $item_identifier_from_issue, $key);
                    
                    // Set type-specific issue data
                    if ($batch_api_issue_type === 'missing_alt_tags') {
                        $next_ui_issue_data['image_url'] = $item_identifier_from_issue; // This is the item_url for alt tags
                    } elseif ($item_identifier_from_issue && filter_var($item_identifier_from_issue, FILTER_VALIDATE_URL) && $batch_api_issue_type !== 'missing_alt_tags') {
                        $next_ui_issue_data['item_specific_url_generic'] = $item_identifier_from_issue;
                    }

                    $next_row_for_ui = array(
                        'rowKey'        => $key,                         // The unique rank_id
                        'url'           => $next_ui_url_display,         // For UI display string
                        'solution'      => $ai_solutions_for_next_row,   // Array of solutions
                        'issue_data'    => $next_ui_issue_data           // Contains page_url, image_url etc. for JS
                    );
                    break; // Found the next row, exit loop
                }
            }
        }
        
        // Calculate counts using new pattern (remaining + skipped + approved = original total)
        $remaining_issues = isset($batch_data['issues']) && is_array($batch_data['issues']) ? count($batch_data['issues']) : 0;
        $skipped_count = isset($batch_data['skipped_count']) ? $batch_data['skipped_count'] : 0;
        $approved_count = isset($batch_data['decisions']) ? count($batch_data['decisions']) : 0;
        $total_issues = $remaining_issues + $skipped_count + $approved_count;
        $processed_count = $skipped_count + $approved_count;

        // Fetch current credits information
        $credits_data = rank_get_ai_credits();
        $credits_info = array( 'remaining' => 0, 'total' => 0 );
        
        if ( !is_wp_error( $credits_data ) && is_array( $credits_data ) ) {
            if ( isset( $credits_data['limit'] ) && isset( $credits_data['usage'] ) ) {
                $credits_info['total'] = intval( $credits_data['limit'] );
                $credits_info['remaining'] = max( 0, intval( $credits_data['limit'] ) - intval( $credits_data['usage'] ) );
            }
        }

        wp_send_json_success( array(
            'message'        => '✅ Fixed',
            'nextRow'        => $next_row_for_ui, // Send the fully prepared next row for UI
            'totalCount'     => $total_issues,
            'processedCount' => $processed_count,
            'credits'        => $credits_info // Add credits information to the response
        ) );
        
    }

    

    /**
     * AJAX handler for optimized health issues overview using the new /overall endpoint.
     * This replaces the sequential processing with a single API call.
     */
    public static function ajax_get_health_issues_overview_optimized() {
        // Set up AJAX request parameters
        self::setup_ajax_request(30, false); // Increased timeout for the API call
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_get_health_issues_overview_optimized');
            return;
        }
        
        // Get optimized issue counts using the new endpoint
        $overview_data = rank_get_health_issues_overview_counts();
        
        if (is_wp_error($overview_data)) {
            $error_data = $overview_data->get_error_data();
            $error_code = $overview_data->get_error_code();
            
            self::log_error('ajax_get_health_issues_overview_optimized', 'Error fetching health issues overview: ' . $overview_data->get_error_message(), [
                'error_code' => $error_code,
                'error_data' => $error_data
            ]);
            
            // Build debug info for frontend display
            $debug_info = array(
                'error_code' => $error_code,
                'error_message' => $overview_data->get_error_message(),
                'timestamp' => current_time('mysql'),
                'site_url' => home_url(),
                'wp_version' => get_bloginfo('version'),
                'php_version' => phpversion()
            );
            
            // Include response body if available (for API errors)
            if (is_array($error_data)) {
                if (isset($error_data['response_body'])) {
                    $debug_info['api_response'] = substr($error_data['response_body'], 0, 1000);
                }
                if (isset($error_data['status_code'])) {
                    $debug_info['http_status'] = $error_data['status_code'];
                }
                if (isset($error_data['original_error'])) {
                    $debug_info['original_error'] = $error_data['original_error'];
                }
            }
            
            self::send_error(
                'Error fetching health issues overview: ' . $overview_data->get_error_message(),
                500,
                array('debug_info' => $debug_info),
                'ajax_get_health_issues_overview_optimized'
            );
            return;
        }
        
        // Extract issues, crawled_at, and crawl_status from the response
        $issues_data = isset($overview_data['issues']) ? $overview_data['issues'] : $overview_data;
        $crawled_at = isset($overview_data['crawled_at']) ? $overview_data['crawled_at'] : null;
        $crawl_status = isset($overview_data['crawl_status']) ? $overview_data['crawl_status'] : 'ready';
        
        // Check if the crawl data is too old
        $is_data_too_old = rank_is_crawl_data_too_old($crawled_at);
        
        // Fetch AI credits information
        $credits_data = rank_get_ai_credits();
        
        if (is_wp_error($credits_data)) {
            $error_data = $credits_data->get_error_data();
            $error_code = $credits_data->get_error_code();
            
            self::log_error('ajax_get_health_issues_overview_optimized', 'Error fetching AI credits data: ' . $credits_data->get_error_message(), [
                'error_code' => $error_code,
                'error_data' => $error_data
            ]);
            
            // Build debug info for frontend display
            $debug_info = array(
                'error_code' => $error_code,
                'error_message' => $credits_data->get_error_message(),
                'timestamp' => current_time('mysql'),
                'site_url' => home_url(),
                'wp_version' => get_bloginfo('version'),
                'php_version' => phpversion()
            );
            
            // Include response body if available (for API errors)
            if (is_array($error_data)) {
                if (isset($error_data['response_body'])) {
                    $debug_info['api_response'] = substr($error_data['response_body'], 0, 1000);
                }
                if (isset($error_data['status_code'])) {
                    $debug_info['http_status'] = $error_data['status_code'];
                }
            }
            
            self::send_error(
                'Error fetching AI credits data: ' . $credits_data->get_error_message(),
                500,
                array('debug_info' => $debug_info),
                'ajax_get_health_issues_overview_optimized'
            );
            return;
        }
        
        if (!is_array($credits_data)) {
            $credits_data = array('remaining' => 0, 'total' => 0, 'limit' => 0, 'usage' => 0);
        }
        
        // Log credits fetch success
        Rank_Processor::add_debug_log("=== CREDITS DATA FETCHED SUCCESSFULLY ===");
        Rank_Processor::add_debug_log("Credits limit: " . ($credits_data['limit'] ?? 'N/A'));
        Rank_Processor::add_debug_log("Credits usage: " . ($credits_data['usage'] ?? 'N/A'));
        
        $response_data = array(
            'issues' => $issues_data,
            'credits' => $credits_data,
            'issue_types' => array_keys($issues_data), // Send list of issue types from the response
            'crawled_at' => $crawled_at,
            'is_data_too_old' => $is_data_too_old,
            'crawl_status' => $crawl_status
        );
        
        // === COMPREHENSIVE PRE-RESPONSE LOGGING ===
        // This helps debug HTTP 500 errors that occur after successful API calls
        Rank_Processor::add_debug_log("=== PREPARING JSON RESPONSE ===");
        Rank_Processor::add_debug_log("Issue types in response: " . count($response_data['issues'] ?? []));
        Rank_Processor::add_debug_log("Memory usage: " . number_format(memory_get_usage(true)) . " bytes");
        Rank_Processor::add_debug_log("Memory peak: " . number_format(memory_get_peak_usage(true)) . " bytes");
        
        // Check for output buffer issues (plugin/theme conflicts)
        $ob_level = ob_get_level();
        $ob_contents = '';
        if ($ob_level > 0) {
            $ob_contents = ob_get_contents();
        }
        if (!empty($ob_contents)) {
            Rank_Processor::add_debug_log("!!! WARNING: Output buffer has content before JSON response !!!");
            Rank_Processor::add_debug_log("Output buffer level: " . $ob_level);
            Rank_Processor::add_debug_log("Output buffer content (first 500 chars): " . substr($ob_contents, 0, 500));
        }
        
        // Test JSON encoding before sending
        $json_test = json_encode($response_data);
        $json_error = json_last_error();
        $json_error_msg = json_last_error_msg();
        
        Rank_Processor::add_debug_log("JSON encode test - Error code: " . $json_error);
        Rank_Processor::add_debug_log("JSON encode test - Error msg: " . $json_error_msg);
        
        if ($json_test !== false) {
            $response_size = strlen($json_test);
            Rank_Processor::add_debug_log("Response size: " . number_format($response_size) . " bytes");
        } else {
            Rank_Processor::add_debug_log("!!! JSON ENCODING FAILED !!!");
        }
        
        // If JSON encoding failed, return a safe error response with debug info
        if ($json_error !== JSON_ERROR_NONE) {
            Rank_Processor::add_debug_log("!!! RETURNING ERROR DUE TO JSON ENCODING FAILURE !!!");
            
            $debug_info = array(
                'json_error_code' => $json_error,
                'json_error_msg' => $json_error_msg,
                'response_keys' => array_keys($response_data),
                'issue_types_count' => count($response_data['issues'] ?? []),
                'memory_usage' => memory_get_usage(true),
                'memory_peak' => memory_get_peak_usage(true),
                'ob_level' => $ob_level,
                'ob_content_preview' => !empty($ob_contents) ? substr($ob_contents, 0, 200) : 'empty',
                'timestamp' => current_time('mysql'),
                'php_version' => phpversion()
            );
            
            self::send_error(
                'Failed to encode response data: ' . $json_error_msg,
                500,
                array('debug_info' => $debug_info),
                'ajax_get_health_issues_overview_optimized'
            );
            return;
        }
        
        Rank_Processor::add_debug_log("=== SENDING JSON RESPONSE NOW ===");
        
        wp_send_json_success($response_data);
    }

    /**
     * AJAX handler to clear pending decisions for a batch.
     * This is called when a user clicks the Cancel button to reset the state.
     */
    public static function ajax_clear_pending_decisions() {
        // Set up AJAX request parameters
        self::setup_ajax_request(10, false);
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_clear_pending_decisions');
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batchId']) ? sanitize_key($_POST['batchId']) : '';
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'), [], 'ajax_clear_pending_decisions');
            return;
        }
        
        // Get the batch data
        $batch_data = get_option( $batch_id );
        
        if ( ! $batch_data ) {
            // If batch doesn't exist, nothing to clear
            wp_send_json_success( array( 'message' => 'No batch data found to clear.' ) );
            return;
        }
        
        // Reset the batch state
        if (isset($batch_data['decisions'])) {
            // Clear all decisions that haven't been processed yet
            $batch_data['decisions'] = array();
        }
        
        // Reset counters
        $batch_data['pending'] = isset($batch_data['issues']) ? count($batch_data['issues']) : 0;
        $batch_data['complete'] = 0;
        $batch_data['failed'] = 0;
        
        // Add a log entry
        if ( isset( $batch_data['ui_logs'] ) && is_array( $batch_data['ui_logs'] ) ) {
            $batch_data['ui_logs'][] = 'User canceled processing and reset all pending decisions.';
        } else {
            $batch_data['ui_logs'] = array( 'User canceled processing and reset all pending decisions.' );
        }
        
        // Cancel any scheduled tasks for this batch
        if (function_exists('as_unschedule_all_actions')) {
            $action_group = 'rank-tasks-' . $batch_id;
            as_unschedule_all_actions('rank_process_single_item_hook', array(), $action_group);
            
            if (isset($batch_data['ui_logs']) && is_array($batch_data['ui_logs'])) {
                $batch_data['ui_logs'][] = 'All scheduled tasks for this batch have been canceled.';
            }
        }
        
        // Save the updated batch data
        update_option( $batch_id, $batch_data, false );
        
        // Log the action
        self::log_error('ajax_clear_pending_decisions', 'User cleared pending decisions for batch: ' . $batch_id);
        
        wp_send_json_success( array(
            'message' => 'Pending decisions cleared successfully. All processing has been reset.',
            'batch_data' => $batch_data // Return the updated batch data for the frontend
        ) );
    }

    /**
     * AJAX handler for updating fix values
     */
    public static function ajax_update_fix_value() {
        // Set up AJAX request parameters
        self::setup_ajax_request(10, false);
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_update_fix_value');
            return;
        }
        
        // Get and sanitize parameters
        $category = isset($_POST['category']) ? sanitize_key($_POST['category']) : '';
        $value = isset($_POST['value']) ? sanitize_text_field($_POST['value']) : '';
        $is_option = isset($_POST['is_option']) && $_POST['is_option'] === 'true';
        
        if (empty($category) || $value === '') {
            self::send_error('Missing required parameters', 400, [], 'ajax_update_fix_value');
        }
        
        // Handle different types of fixes
        if ($is_option) {
            // Handle option-based fixes (broken links)
            $old_url = isset($_POST['old_url']) ? sanitize_text_field($_POST['old_url']) : '';
            if (empty($old_url)) {
                self::send_error('Missing old URL parameter', 400, [], 'ajax_update_fix_value');
            }
            
            // Get option name from mapping
            $option_name_map = self::get_category_option_mapping();
            $option_name = isset($option_name_map[$category]) ? $option_name_map[$category] : '';
            
            if (empty($option_name)) {
                self::send_error('Invalid category for option-based fix', 400, [], 'ajax_update_fix_value');
                return;
            }
            
            // Get current replacements
            $replacements = get_option($option_name, []);
            
            // Find the correct key - it might be a composite key or the broken URL itself
            $key_to_update = null;
            
            // First, try direct match (for legacy data or simple keys)
            if (array_key_exists($old_url, $replacements)) {
                $key_to_update = $old_url;
            } else {
                // Search for composite keys that contain this broken URL
                foreach ($replacements as $key => $data) {
                    // Check if this is a composite key containing the broken URL
                    if (strpos($key, '::context::') !== false) {
                        $broken_url_part = explode('::context::', $key)[0];
                        if ($broken_url_part === $old_url) {
                            $key_to_update = $key;
                            break;
                        }
                    }
                    // Also check the stored broken_url field
                    if (is_array($data) && isset($data['broken_url']) && $data['broken_url'] === $old_url) {
                        $key_to_update = $key;
                        break;
                    }
                }
            }
            
            // Update the value if key was found
            if ($key_to_update !== null && is_array($replacements)) {
                if (is_array($replacements[$key_to_update]) && isset($replacements[$key_to_update]['new_url'])) {
                    $replacements[$key_to_update]['new_url'] = $value;
                } else {
                    $replacements[$key_to_update] = $value;
                }
                
                update_option($option_name, $replacements, false);
                wp_send_json_success();
            } else {
                self::log_error('ajax_update_fix_value', 'Old URL not found in replacements: ' . $old_url);
                self::send_error('Old URL not found in replacements', 404, [], 'ajax_update_fix_value');
                return;
            }
        } else {
            // Handle database-based fixes
            $post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;
            $object_id = isset($_POST['object_id']) ? absint($_POST['object_id']) : 0;
            
            if ($post_id <= 0) {
                self::send_error('Invalid post ID', 400, [], 'ajax_update_fix_value');
                return;
            }
            
            // Get database override type from mapping
            $db_category_map = self::get_category_db_mapping();
            $override_type = isset($db_category_map[$category]) ? $db_category_map[$category] : '';
            
            if (empty($override_type)) {
                self::send_error('Invalid category for database-based fix', 400, [], 'ajax_update_fix_value');
                return;
            }
            
            // First, get the existing record to preserve original_page_url and image_url
            global $wpdb;
            $table_name = $wpdb->prefix . RANK_TABLE_NAME;
            
            $existing_record = $wpdb->get_row($wpdb->prepare(
                "SELECT original_page_url, image_url FROM {$table_name} WHERE post_id = %d AND object_id = %d AND override_type = %s LIMIT 1",
                $post_id,
                $object_id,
                $override_type
            ));
            
            $original_page_url = $existing_record ? $existing_record->original_page_url : null;
            $image_url = $existing_record ? $existing_record->image_url : null;
            
            // Use the existing update_seo_data method with preserved URLs
            $result = Rank_Processor::update_seo_data($post_id, $object_id, $override_type, $value, $category, null, null, $image_url, $original_page_url);
            
            if ($result) {
                wp_send_json_success();
            } else {
                self::log_error('ajax_update_fix_value', 'Failed to update database value', [
                    'post_id' => $post_id,
                    'object_id' => $object_id,
                    'override_type' => $override_type,
                    'category' => $category
                ]);
                self::send_error('Failed to update database value', 500, [], 'ajax_update_fix_value');
                return;
            }
        }
    }

    /**
     * Helper method to find the next unprocessed row in a batch
     *
     * @param array $batch_data The batch data containing issues and decisions
     * @return array|null The next row data for UI, or null if no more rows to process
     */
    private static function find_next_unprocessed_row($batch_data) {
        if (!isset($batch_data['issues']) || !is_array($batch_data['issues'])) {
            return null;
        }
        
        $batch_api_issue_type = $batch_data['issue_type'] ?? null;
        
        foreach ($batch_data['issues'] as $key => $issue_item_data) {
            if (!isset($batch_data['decisions'][$key])) { // Found an unprocessed item
                $next_ui_url_display = 'N/A';
                $next_ui_issue_data = [
                    'page_path' => null,
                    'page_url'  => null,
                    'image_url' => null
                ];
                
                // Get page details using helper method
                $page_details = self::get_page_details_from_issue($issue_item_data);
                $next_ui_issue_data['page_url'] = $page_details['page_url'];
                $next_ui_issue_data['page_path'] = $page_details['page_path'];
                
                // Get item identifier
                $item_identifier_from_issue = $issue_item_data['issue_identifier'] ?? ($issue_item_data['parameters']['url'] ?? null);
                
                $ai_solutions_for_next_row = $issue_item_data['solution'] ?? ['Solution not available'];
                if (is_string($ai_solutions_for_next_row)) {
                    $ai_solutions_for_next_row = [$ai_solutions_for_next_row];
                }
                
                // Build UI display string using helper method
                $next_ui_url_display = self::build_ui_display_string($batch_api_issue_type, $page_details['page_path'], $item_identifier_from_issue, $key);
                
                // Set type-specific issue data
                if ($batch_api_issue_type === 'missing_alt_tags') {
                    $next_ui_issue_data['image_url'] = $item_identifier_from_issue;
                } elseif ($item_identifier_from_issue && filter_var($item_identifier_from_issue, FILTER_VALIDATE_URL) && $batch_api_issue_type !== 'missing_alt_tags') {
                    $next_ui_issue_data['item_specific_url_generic'] = $item_identifier_from_issue;
                }
                
                return array(
                    'rowKey'        => $key,
                    'url'           => $next_ui_url_display,
                    'solution'      => $ai_solutions_for_next_row,
                    'issue_data'    => $next_ui_issue_data
                );
            }
        }
        
        return null; // No more unprocessed rows
    }

    /**
     * AJAX handler to check how many issues in a batch have already been fixed
     * Uses chunked processing to prevent gateway timeouts
     */
    public static function ajax_check_already_fixed() {
        // Set up AJAX request parameters with shorter timeout since we're chunking
        self::setup_ajax_request(10, false);
        
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_check_already_fixed');
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batchId']) ? sanitize_key($_POST['batchId']) : '';
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'), [], 'ajax_check_already_fixed');
            return;
        }

        // Get batch data
        $batch_data = get_option($batch_id);
        if (!$batch_data || !isset($batch_data['issues']) || !is_array($batch_data['issues'])) {
            self::send_error('Batch data not found or invalid.', 404, [], 'ajax_check_already_fixed');
            return;
        }

        // Get offset for chunked processing (default to 0 for first chunk)
        $offset = isset($_POST['offset']) ? max(0, intval($_POST['offset'])) : 0;

        // Use the chunked checker class
        if (!class_exists('Rank_Already_Fixed_Checker')) {
            require_once plugin_dir_path(__FILE__) . 'class-rank-already-fixed-checker.php';
        }

        try {
            $result = Rank_Already_Fixed_Checker::check_chunk($batch_data, $offset);
            wp_send_json_success($result);
        } catch (Exception $e) {
            self::log_error('ajax_check_already_fixed', 'Exception in checker: ' . $e->getMessage(), [
                'trace' => $e->getTraceAsString()
            ]);
            self::send_error('Error checking fixed items: ' . $e->getMessage(), 500, [], 'ajax_check_already_fixed');
        }
    }

    /**
     * AJAX handler for filtering out already-fixed issues from batch
     */
    public static function ajax_filter_already_fixed() {
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_filter_already_fixed');
            return;
        }

        // Get and validate batch ID
        $batch_id = isset($_POST['batch_id']) ? sanitize_key($_POST['batch_id']) : '';
        $batch_validation = self::validate_batch_id($batch_id);
        if (is_wp_error($batch_validation)) {
            self::send_error($batch_validation->get_error_message(), $batch_validation->get_error_data('status'), [], 'ajax_filter_already_fixed');
            return;
        }

        // Get batch data
        $batch_data = get_option($batch_id);
        if (false === $batch_data || !is_array($batch_data)) {
            self::send_error('Batch data not found.', 404, [], 'ajax_filter_already_fixed');
            return;
        }

        if (!isset($batch_data['issues']) || !is_array($batch_data['issues'])) {
            self::send_error('No issues found in batch.', 400, [], 'ajax_filter_already_fixed');
            return;
        }

        // Load the checker class
        if (!class_exists('Rank_Already_Fixed_Checker')) {
            require_once plugin_dir_path(__FILE__) . 'class-rank-already-fixed-checker.php';
        }

        $issues = $batch_data['issues'];
        $issue_type = $batch_data['issue_type'] ?? '';
        $filtered_issues = array();
        $removed_count = 0;

        // Check each issue and keep only unfixed ones
        foreach ($issues as $key => $issue) {
            $is_fixed = Rank_Already_Fixed_Checker::is_issue_fixed($issue, $issue_type);
            if (!$is_fixed) {
                $filtered_issues[$key] = $issue;
            } else {
                $removed_count++;
            }
        }

        // Update batch with filtered issues
        $batch_data['issues'] = $filtered_issues;
        update_option($batch_id, $batch_data);

        self::send_success('Batch filtered successfully.', array(
            'removed_count' => $removed_count,
            'remaining_count' => count($filtered_issues)
        ));
    }

    /**
     * AJAX handler to get debug logs from WordPress debug.log file
     */
    public static function get_debug_logs() {
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'get_debug_logs');
            return;
        }

        // Use the custom debug log from Rank_Processor
        $log_data = Rank_Processor::get_debug_logs(200);
        
        if (!$log_data['file_info']['exists']) {
            wp_send_json_error(array(
                'message' => 'No debug logs found yet. Logs will appear here when RANK AI processing runs.',
                'logs' => ''
            ));
            return;
        }

        wp_send_json_success(array(
            'logs' => $log_data['logs'],
            'timestamp' => current_time('mysql'),
            'file_size' => size_format($log_data['file_info']['size']),
            'lines_shown' => $log_data['file_info']['lines_shown'],
            'total_lines' => $log_data['file_info']['lines']
        ));
    }

    /**
     * AJAX handler to toggle debug logging on/off
     */
    public static function ajax_toggle_debug() {
        // Verify nonce and permissions
        $auth_check = self::verify_ajax_request();
        if (is_wp_error($auth_check)) {
            self::send_error($auth_check->get_error_message(), $auth_check->get_error_data('status'), [], 'ajax_toggle_debug');
            return;
        }

        // Get the enabled state
        $enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
        
        // Update the option
        $result = update_option('rank_debug_enabled', (bool)$enabled, false);
        
        if ($result !== false) {
            wp_send_json_success(array(
                'message' => 'Debug logging ' . ($enabled ? 'enabled' : 'disabled'),
                'enabled' => (bool)$enabled
            ));
        } else {
            self::send_error('Failed to update debug setting', 500, [], 'ajax_toggle_debug');
        }
    }

    /**
     * AJAX handler for exporting category fixes to CSV
     */
    public static function ajax_export_category_csv() {
        // Verify nonce
        if (!isset($_GET['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['nonce'])), 'rank_ajax_nonce')) {
            wp_die('Security check failed', 'Unauthorized', array('response' => 403));
        }
        
        // Check permissions
        if (!current_user_can('manage_options')) {
            wp_die('Permission denied', 'Forbidden', array('response' => 403));
        }
        
        // Get and sanitize parameters
        $category = isset($_GET['category']) ? sanitize_key($_GET['category']) : '';
        $issue_type = isset($_GET['issue_type']) ? sanitize_key($_GET['issue_type']) : $category;
        
        if (empty($issue_type)) {
            wp_die('Missing required parameters', 'Bad Request', array('response' => 400));
        }
        
        global $wpdb;
        
        // Ensure RANK_TABLE_NAME is defined
        if (!defined('RANK_TABLE_NAME')) {
            wp_die('Database table not configured', 'Internal Server Error', array('response' => 500));
        }
        
        $table_name = $wpdb->prefix . RANK_TABLE_NAME;
        
        // Query all rows for this issue type
        $results = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table_name WHERE issue_type = %s ORDER BY created_at DESC",
            $issue_type
        ), ARRAY_A);
        
        // Set headers for CSV download
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="rank-fixes-' . sanitize_file_name($issue_type) . '-' . date('Y-m-d') . '.csv"');
        header('Pragma: no-cache');
        header('Expires: 0');
        
        // Open output stream
        $output = fopen('php://output', 'w');
        
        // Add BOM for Excel UTF-8 compatibility
        fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
        
        if (!empty($results)) {
            // Write column headers
            fputcsv($output, array_keys($results[0]));
            
            // Write data rows
            foreach ($results as $row) {
                fputcsv($output, $row);
            }
        } else {
            // Write headers even if no data
            fputcsv($output, array('override_id', 'post_id', 'object_id', 'override_type', 'issue_type', 'override_value', 'created_at', 'updated_at', 'image_url', 'original_page_url'));
            fputcsv($output, array('No data found for this category'));
        }
        
        fclose($output);
        exit;
    }

}