<?php
/**
 * Admin class for StaticQ
 * 
 * Handles admin menu registration, asset loading, and AJAX handlers.
 * 
 * @package suspended-cloud/cloudflare-media-offload
 * @since 2.0.0
 */

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

class SQMedia_Admin {
    
    /**
     * Page instances
     */
    private $media_manager_page;
    private $settings_page;
    
    /**
     * Constructor
     */
    public function __construct() {
        $this->init_hooks();
    }
    
    /**
     * Initialize hooks
     */
    private function init_hooks(): void {
        // Ensure database schema is up to date
        SQMedia_Database::maybe_upgrade_tables();

        add_action('admin_menu', [$this, 'register_menu']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);

        // Register custom cron schedule
        add_filter('cron_schedules', [$this, 'add_cron_schedules']);

        // Refresh the AJAX nonce via WordPress's Heartbeat API. A long-lived
        // tab on the Media Manager page would otherwise hit a stale-nonce
        // 403 once the page-load nonce ages out (24h max). The JS side
        // listens for `heartbeat-tick` and updates the cached nonce.
        add_filter('heartbeat_received', [$this, 'refresh_nonce_via_heartbeat'], 10, 3);

        // AJAX handlers
        $this->register_ajax_handlers();
    }

    /**
     * Inject a fresh sqmedia AJAX nonce into the Heartbeat response.
     * Only fires on StaticQ admin screens to avoid touching unrelated pages.
     */
    public function refresh_nonce_via_heartbeat(array $response, array $data, $screen_id): array {
        if (strpos((string) $screen_id, 'sqmedia') !== false) {
            $response['sqmedia_nonce'] = wp_create_nonce('sqmedia_admin_nonce');
        }
        return $response;
    }
    
    /**
     * Add custom cron schedules
     */
    public function add_cron_schedules(array $schedules): array {
        $schedules['sqmedia_one_minute'] = [
            'interval' => 60,
            'display' => __('Every Minute', 'staticq-media'),
        ];
        return $schedules;
    }
    
    /**
     * Register admin menu
     */
    public function register_menu(): void {
        // Main menu - Media Manager
        add_menu_page(
            __('StaticQ', 'staticq-media'),
            __('StaticQ', 'staticq-media'),
            'manage_options',
            'sqmedia-media-manager',
            [$this, 'render_media_manager_page'],
            'dashicons-cloud',
            81
        );
        
        // Media Manager (same as main)
        add_submenu_page(
            'sqmedia-media-manager',
            __('Media Manager', 'staticq-media'),
            __('Media Manager', 'staticq-media'),
            'manage_options',
            'sqmedia-media-manager',
            [$this, 'render_media_manager_page']
        );
        
        // Settings
        add_submenu_page(
            'sqmedia-media-manager',
            __('Settings', 'staticq-media'),
            __('Settings', 'staticq-media'),
            'manage_options',
            'sqmedia-settings',
            [$this, 'render_settings_page']
        );
    }
    
    /**
     * Enqueue admin assets
     */
    public function enqueue_assets(string $hook): void {
        // Only on our pages
        if (strpos($hook, 'sqmedia-') === false && strpos($hook, 'sqmedia_') === false) {
            return;
        }
        
        // Common styles
        wp_enqueue_style(
            'sqmedia-admin',
            SQMEDIA_PLUGIN_URL . 'admin/css/sqmedia-admin.css',
            [],
            SQMEDIA_VERSION
        );
        
        // Common scripts
        wp_enqueue_script(
            'sqmedia-admin',
            SQMEDIA_PLUGIN_URL . 'admin/js/sqmedia-admin.js',
            ['jquery'],
            SQMEDIA_VERSION,
            true
        );
        
        wp_localize_script('sqmedia-admin', 'sqmediaAdmin', [
            'ajaxurl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('sqmedia_admin_nonce'),
            'strings' => [
                'confirm_cancel' => __('Are you sure you want to cancel?', 'staticq-media'),
                'error' => __('An error occurred. Please try again.', 'staticq-media'),
                'processing' => __('Processing...', 'staticq-media'),
                'connection_failed' => __('Connection failed', 'staticq-media'),
                'request_failed' => __('Request failed', 'staticq-media'),
                'session_expired' => __('Your StaticQ session has expired. Please reload the page to continue.', 'staticq-media'),
            ],
        ]);

        // The WordPress Heartbeat API drives the nonce-refresh path in
        // sqmedia-admin.js — make sure it's loaded on our pages.
        wp_enqueue_script('heartbeat');

        // Page-specific styles
        if (strpos($hook, 'sqmedia-media-manager') !== false) {
            wp_enqueue_style('sqmedia-file-state', SQMEDIA_PLUGIN_URL . 'admin/css/sqmedia-file-state.css', ['sqmedia-admin'], SQMEDIA_VERSION);
            wp_enqueue_style('sqmedia-media-manager', SQMEDIA_PLUGIN_URL . 'admin/css/sqmedia-media-manager.css', ['sqmedia-admin'], SQMEDIA_VERSION);
            wp_enqueue_style('sqmedia-post-content-scanner', SQMEDIA_PLUGIN_URL . 'admin/css/sqmedia-post-content-scanner.css', ['sqmedia-admin'], SQMEDIA_VERSION);
        }
        if (strpos($hook, 'sqmedia-settings') !== false) {
            wp_enqueue_style('sqmedia-settings', SQMEDIA_PLUGIN_URL . 'admin/css/sqmedia-settings.css', ['sqmedia-admin'], SQMEDIA_VERSION);
        }
    }
    
    /**
     * Register AJAX handlers
     */
    private function register_ajax_handlers(): void {
        // Registration handlers are in SQMedia_Registration_Ajax (initialized in staticq.php)
        
        // Health Check (formerly File State Scan)
        add_action('wp_ajax_sqmedia_start_health_check', [$this, 'ajax_start_health_check']);
        add_action('wp_ajax_sqmedia_process_health_check_batch', [$this, 'ajax_process_health_check_batch']);
        add_action('wp_ajax_sqmedia_health_check_progress', [$this, 'ajax_health_check_progress']);
        add_action('wp_ajax_sqmedia_cancel_health_check', [$this, 'ajax_cancel_health_check']);
        
        // Note: Cron hooks are registered in main plugin file (staticq.php)
        // for proper wp-cron.php support (non-admin context)
        
        // Repair
        add_action('wp_ajax_sqmedia_repair_attachment', [$this, 'ajax_repair_attachment']);
        add_action('wp_ajax_sqmedia_repair_by_action', [$this, 'ajax_repair_by_action']);
        add_action('wp_ajax_sqmedia_repair_all', [$this, 'ajax_repair_all']);
        add_action('wp_ajax_sqmedia_start_repair', [$this, 'ajax_start_repair']);
        add_action('wp_ajax_sqmedia_process_repair_batch', [$this, 'ajax_process_repair_batch']);
        
        // Attachment Lookup & Integration
        add_action('wp_ajax_sqmedia_analyze_attachment', [$this, 'ajax_analyze_attachment']);
        add_action('wp_ajax_sqmedia_integrate_attachment', [$this, 'ajax_integrate_attachment']);
        
        // Orphan Detection
        add_action('wp_ajax_sqmedia_start_orphan_scan', [$this, 'ajax_start_orphan_scan']);
        add_action('wp_ajax_sqmedia_process_orphan_scan_batch', [$this, 'ajax_process_orphan_batch']);
        add_action('wp_ajax_sqmedia_orphan_scan_progress', [$this, 'ajax_orphan_scan_progress']);
        add_action('wp_ajax_sqmedia_cancel_orphan_scan', [$this, 'ajax_cancel_orphan_scan']);
        add_action('wp_ajax_sqmedia_quarantine_orphan_files', [$this, 'ajax_quarantine_orphans']);
        add_action('wp_ajax_sqmedia_get_orphans_paginated', [$this, 'ajax_get_orphans_paginated']);
        add_action('wp_ajax_sqmedia_quarantine_all_orphans', [$this, 'ajax_quarantine_all_orphans']);
        add_action('wp_ajax_sqmedia_quarantine_progress', [$this, 'ajax_quarantine_progress']);
        add_action('wp_ajax_sqmedia_cancel_quarantine', [$this, 'ajax_cancel_quarantine']);
        add_action('wp_ajax_sqmedia_clear_orphan_results', [$this, 'ajax_clear_orphan_results']);

        // Quarantine Management
        add_action('wp_ajax_sqmedia_get_quarantine_list', [$this, 'ajax_get_quarantine_list']);
        add_action('wp_ajax_sqmedia_purge_quarantine', [$this, 'ajax_purge_quarantine']);
        add_action('wp_ajax_sqmedia_restore_quarantine', [$this, 'ajax_restore_quarantine']);
        add_action('wp_ajax_sqmedia_restore_progress', [$this, 'ajax_restore_progress']);
        add_action('wp_ajax_sqmedia_cancel_restore', [$this, 'ajax_cancel_restore']);
        
        // Settings
        add_action('wp_ajax_sqmedia_test_r2_connection', [$this, 'ajax_test_r2_connection']);
        add_action('wp_ajax_sqmedia_test_cloudflare_connection', [$this, 'ajax_test_cloudflare_connection']);
        add_action('wp_ajax_sqmedia_check_cloudflare_proxy', [$this, 'ajax_check_cloudflare_proxy']);
        
        // Legacy support - redirect old actions (registration legacy aliases in SQMedia_Registration_Ajax)
        add_action('wp_ajax_sqmedia_start_scan', [$this, 'ajax_start_health_check']);
        add_action('wp_ajax_sqmedia_process_scan_batch', [$this, 'ajax_process_health_check_batch']);
    }
    
    // =========================================================================
    // PAGE RENDERERS
    // =========================================================================
    
    public function render_media_manager_page(): void {
        if (!$this->media_manager_page) {
            $this->media_manager_page = new SQMedia_Media_Manager_Page($this);
        }
        $this->media_manager_page->render();
    }
    
    public function render_settings_page(): void {
        if (!$this->settings_page) {
            $this->settings_page = new SQMedia_Settings_Page();
        }
        $this->settings_page->render();
    }
    
    // =========================================================================
    // HEALTH CHECK AJAX HANDLERS (New 2-Phase System)
    // =========================================================================

    /**
     * AJAX: Start health check scan
     * 
     * Accepts 'phase' parameter: 1 (database alignment) or 2 (file alignment)
     * Default is Phase 1
     */
    public function ajax_start_health_check(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $phase = intval($_POST['phase'] ?? 1);
        
        $scanner = new SQMedia_Health_Scanner();
        
        if ($phase === 2) {
            $result = $scanner->start_phase2();
        } else {
            $result = $scanner->start_phase1();
        }
        
        if ($result['success']) {
            // Clear any existing scheduled events for this hook
            wp_clear_scheduled_hook('sqmedia_health_scan_continue');
            
            // Schedule recurring cron for background processing (every minute)
            // Note: For Phase 2, cron starts after index building completes
            if (($result['status'] ?? '') !== 'building_index' && !wp_next_scheduled('sqmedia_health_scan_continue')) {
                wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_health_scan_continue');
            }
            
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error']);
        }
    }

    /**
     * AJAX: Process health check batch
     */
    public function ajax_process_health_check_batch(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }
        
        $scanner = new SQMedia_Health_Scanner();
        $result = $scanner->process_batch($scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error'] ?? __('Scan failed.', 'staticq-media'));
        }
    }

    /**
     * AJAX: Cancel health check
     */
    public function ajax_cancel_health_check(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        $scanner = new SQMedia_Health_Scanner();
        $result = $scanner->cancel($scan_id);
        
        // Clean up index files if any
        if (!empty($scan_id)) {
            $scanner->cleanup_index_files($scan_id);
        }
        
        // Clear the cron job
        wp_clear_scheduled_hook('sqmedia_health_scan_continue');
        
        
        wp_send_json_success($result);
    }

    /**
     * AJAX: Get health check progress
     */
    public function ajax_health_check_progress(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }
        
        $scanner = new SQMedia_Health_Scanner();
        $result = $scanner->get_progress($scan_id);
        
        if (!$result['success']) {
            wp_send_json_error($result['error'] ?? __('Scan not found.', 'staticq-media'));
        }
        
        // Add summary data for UI when complete
        if (!empty($result['done'])) {
            $result['summary'] = $scanner->get_scan_summary($scan_id);
        }
        
        // Add phase message for building_index
        if (($result['status'] ?? '') === 'building_index') {
            $db = sqmedia()->get_database();
            $scan = $db->get_scan($scan_id);
            $index_status = $scan->results['index_status'] ?? 'starting';
            
            switch ($index_status) {
                case 'building_local':
                    $result['phase_message'] = __('Building local file index...', 'staticq-media');
                    break;
                case 'building_bucket':
                    $result['phase_message'] = __('Building bucket file index...', 'staticq-media');
                    $result['local_file_count'] = $scan->results['local_file_count'] ?? 0;
                    break;
                default:
                    $result['phase_message'] = __('Initializing...', 'staticq-media');
            }
            $result['progress'] = 0; // Progress starts after index building
        }
        
        wp_send_json_success($result);
    }

    /**
     * Cron: Continue health scan in background
     */
    public function cron_continue_health_scan(): void {
        
        $scanner = new SQMedia_Health_Scanner();
        $running = $scanner->get_running();
        
        if (!$running) {
            wp_clear_scheduled_hook('sqmedia_health_scan_continue');
            wp_clear_scheduled_hook('sqmedia_continue_health_scan'); // Legacy hook
            return;
        }
        
        
        // Process a batch
        $result = $scanner->process_batch($running->scan_id);
        
        
        // If completed or failed, clear the scheduled event
        if (!empty($result['done'])) {
            wp_clear_scheduled_hook('sqmedia_health_scan_continue');
            wp_clear_scheduled_hook('sqmedia_continue_health_scan'); // Legacy hook
        }
    }

    /**
     * Cron: Build file index for Phase 2
     * Called via wp_schedule_single_event from start_phase2()
     */
    public function cron_build_file_index(string $scan_id): void {
        
        $scanner = new SQMedia_Health_Scanner();
        $scanner->build_file_indexes($scan_id);
        
        // After index building, the scanner will update status to 'running'
        // Schedule the main cron to continue processing
        if (!wp_next_scheduled('sqmedia_health_scan_continue')) {
            wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_health_scan_continue');
        }
        
    }

    /**
     * Cron: Continue orphan scan in background
     */
    public function cron_continue_orphan_scan(): void {
        
        $db = sqmedia()->get_database();
        $running_scan = $db->get_running_scan('orphan');
        
        if (!$running_scan || $running_scan->status !== 'running') {
            wp_clear_scheduled_hook('sqmedia_continue_orphan_scan');
            return;
        }
        
        // Check if state file exists (file-based storage)
        $state = $this->load_orphan_state($running_scan->scan_id);
        if (!$state) {
            $db->update_scan($running_scan->scan_id, [
                'status' => 'failed',
                'scan_completed' => current_time('mysql'),
            ]);
            wp_clear_scheduled_hook('sqmedia_continue_orphan_scan');
            return;
        }
        
        
        // Process a batch
        $result = $this->process_orphan_scan_batch($running_scan->scan_id);
        
        
        // If completed or failed, clear the scheduled event
        if (!$result['success'] || $result['status'] !== 'running') {
            wp_clear_scheduled_hook('sqmedia_continue_orphan_scan');
        }
    }
    
    // =========================================================================
    // ATTACHMENT LOOKUP & INTEGRATION AJAX HANDLERS
    // =========================================================================
    
    public function ajax_analyze_attachment(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $attachment_id = intval($_POST['attachment_id'] ?? 0);
        
        if (!$attachment_id) {
            wp_send_json_error(__('No attachment ID provided.', 'staticq-media'));
        }
        
        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->analyze_any_attachment($attachment_id);
        
        if (!$result['success']) {
            wp_send_json_error($result['error']);
        }
        
        // Render HTML for the result
        $html = $this->render_attachment_analysis_html($result);
        
        wp_send_json_success(['html' => $html, 'data' => $result]);
    }
    
    public function ajax_integrate_attachment(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $attachment_id = intval($_POST['attachment_id'] ?? 0);
        
        if (!$attachment_id) {
            wp_send_json_error(__('No attachment ID provided.', 'staticq-media'));
        }
        
        $repair = new SQMedia_File_State_Repair();
        $result = $repair->integrate_attachment($attachment_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error'] ?? __('Integration failed.', 'staticq-media'));
        }
    }
    
    /**
     * Render attachment analysis HTML
     */
    private function render_attachment_analysis_html(array $result): string {
        $status_class = 'success';
        $status_icon = 'Ã¢Å“â€œ';
        $status_text = __('Fully synced', 'staticq-media');
        
        if (!$result['is_registered']) {
            $status_class = 'warning';
            $status_icon = 'Ã°Å¸â€œÂ¥';
            $status_text = __('Not registered with StaticQ', 'staticq-media');
        } elseif ($result['status'] === 'needs_attention') {
            $status_class = 'warning';
            $status_icon = 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¢ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¡ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¯ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â';
            $count = count($result['actions_needed'] ?? []);
            // translators: %d is the number of actions needed
            $status_text = $count > 0 ? sprintf(__('%d actions needed', 'staticq-media'), $count) : __('Needs attention', 'staticq-media');
        } elseif ($result['status'] === 'error') {
            $status_class = 'error';
            $status_icon = 'Ã¢Å¡Â ';
            $status_text = __('Critical issues found', 'staticq-media');
        }
        
        $html = '<div class="sqmedia-lookup-result-card">';
        
        // Header
        $html .= '<h4>Ã°Å¸â€œÂ· ' . esc_html($result['title'] ?: ($result['filename'] ?? 'Unknown')) . ' <small>(ID: ' . $result['attachment_id'] . ')</small></h4>';
        
        // Info - handle missing dimensions gracefully
        $width = $result['dimensions']['width'] ?? 0;
        $height = $result['dimensions']['height'] ?? 0;
        $dimensions_text = ($width && $height) ? $width . 'Ãƒâ€”' . $height : __('N/A', 'staticq-media');
        
        $html .= '<p>';
        $html .= '<strong>' . __('Dimensions:', 'staticq-media') . '</strong> ' . esc_html($dimensions_text) . ' &nbsp;|&nbsp; ';
        $html .= '<strong>' . __('Registered:', 'staticq-media') . '</strong> ' . ($result['is_registered'] ? '<span style="color: #00a32a;">Ã¢Å“â€œ ' . __('Yes', 'staticq-media') . '</span>' : '<span style="color: #d63638;">Ã¢Å“â€” ' . __('No', 'staticq-media') . '</span>') . ' &nbsp;|&nbsp; ';
        $html .= '<strong>' . __('Storage:', 'staticq-media') . '</strong> ' . esc_html($result['storage_location']);
        $html .= '</p>';
        
        // Status
        $html .= '<div class="sqmedia-lookup-status ' . $status_class . '" style="padding: 10px; border-radius: 4px; margin: 10px 0; ';
        if ($status_class === 'success') {
            $html .= 'background: #d4edda; border: 1px solid #c3e6cb; color: #155724;';
        } elseif ($status_class === 'warning') {
            $html .= 'background: #fff3cd; border: 1px solid #ffc107; color: #856404;';
        } else {
            $html .= 'background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;';
        }
        $html .= '">';
        $html .= '<strong>' . $status_icon . ' ' . __('Status:', 'staticq-media') . '</strong> ' . esc_html($status_text);
        $html .= '</div>';
        
        // Actions needed
        if (!empty($result['issues'])) {
            $html .= '<h5 style="margin-top: 15px;">' . __('Actions needed:', 'staticq-media') . '</h5>';
            $html .= '<ol class="sqmedia-actions-list" style="margin: 10px 0; padding-left: 20px;">';
            
            foreach ($result['issues'] as $issue) {
                $html .= '<li style="margin-bottom: 8px;">';
                $html .= '<strong>' . $this->format_issue_icon($issue['type']) . ' ' . $this->format_issue_title($issue) . '</strong>';
                if (!empty($issue['description'])) {
                    $html .= '<div class="sqmedia-action-description" style="color: #646970; font-size: 13px; margin-top: 3px;">' . esc_html($issue['description']) . '</div>';
                } elseif (!empty($issue['file'])) {
                    $html .= '<div class="sqmedia-action-description" style="margin-top: 3px;"><code style="font-size: 11px;">' . esc_html($issue['file']) . '</code></div>';
                }
                $html .= '</li>';
            }
            
            $html .= '</ol>';
            
            // Show appropriate button based on registration status
            if (!$result['is_registered']) {
                $html .= '<button type="button" class="button button-primary" id="sqmedia-register-single-attachment" data-attachment-id="' . esc_attr($result['attachment_id']) . '">';
                $html .= __('Register This Attachment', 'staticq-media');
                $html .= '</button>';
            } else {
                $html .= '<button type="button" class="button button-primary" id="sqmedia-integrate-attachment" data-attachment-id="' . esc_attr($result['attachment_id']) . '">';
                $html .= __('Fix Issues', 'staticq-media');
                $html .= '</button>';
            }
        }
        
        // Files table (expandable)
        if (!empty($result['files'])) {
            $bucket_enabled = $result['bucket_enabled'] ?? true;
            
            $html .= '<details style="margin-top: 20px;" open>';
            $html .= '<summary style="cursor: pointer; font-weight: 500;">' . __('File Details', 'staticq-media') . ' (' . count($result['files']) . ' ' . __('files', 'staticq-media') . ')</summary>';
            $html .= '<table class="widefat striped" style="margin-top: 10px; font-size: 12px;">';
            $html .= '<thead><tr>';
            $html .= '<th>' . __('Type', 'staticq-media') . '</th>';
            $html .= '<th>' . __('Path', 'staticq-media') . '</th>';
            $html .= '<th style="text-align:center; width: 60px;">' . __('Local', 'staticq-media') . '</th>';
            $html .= '<th style="text-align:center; width: 60px;">' . __('Bucket', 'staticq-media') . '</th>';
            $html .= '<th>' . __('Status', 'staticq-media') . '</th>';
            $html .= '</tr></thead><tbody>';
            
            foreach ($result['files'] as $file) {
                // Use relative_path if available, fallback to file
                $display_path = $file['relative_path'] ?? $file['file'] ?? '';
                
                // Bucket column: show N/A if bucket not configured, otherwise show check/cross
                if (!$bucket_enabled) {
                    $bucket_cell = '<span style="color: #646970;">N/A</span>';
                } else {
                    $bucket_cell = $file['bucket'] ? '<span style="color: #00a32a;">Ã¢Å“â€œ</span>' : '<span style="color: #d63638;">Ã¢Å“â€”</span>';
                }
                
                $html .= '<tr>';
                $html .= '<td><strong>' . esc_html($file['name']) . '</strong></td>';
                $html .= '<td><code style="font-size: 11px;">' . esc_html($display_path) . '</code></td>';
                $html .= '<td style="text-align:center;">' . ($file['local'] ? '<span style="color: #00a32a;">Ã¢Å“â€œ</span>' : '<span style="color: #d63638;">Ã¢Å“â€”</span>') . '</td>';
                $html .= '<td style="text-align:center;">' . $bucket_cell . '</td>';
                $html .= '<td>' . esc_html($file['status'] ?? '') . '</td>';
                $html .= '</tr>';
            }
            
            $html .= '</tbody></table>';
            $html .= '</details>';
        }
        
        $html .= '</div>';
        
        return $html;
    }
    
    /**
     * Get icon for issue type
     */
    private function format_issue_icon(string $type): string {
        $icons = [
            'not_registered' => 'Ã°Å¸â€œÂ¥',
            'master_needs_upload' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¢ÃƒÆ’Ã¢â‚¬Â¹Ãƒâ€¦Ã¢â‚¬Å“ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚ÂÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¯ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â',
            'size_needs_upload' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¢ÃƒÆ’Ã¢â‚¬Â¹Ãƒâ€¦Ã¢â‚¬Å“ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚ÂÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¯ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â',
            'master_needs_download' => 'Ã°Å¸â€™Â¾',
            'size_needs_download' => 'Ã°Å¸â€™Â¾',
            'size_needs_generation' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€¦Ã¢â‚¬Å“ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â',
            'webp_needs_generation' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬ÃƒÂ¢Ã¢â€šÂ¬Ã…â€œÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¼ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¯ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â',
            'webp_flags_wrong' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€šÃ‚ÂÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€¦Ã‚Â¾',
            'webp_not_registered' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€šÃ‚ÂÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€¦Ã‚Â¾',
            'size_table_mismatch' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€šÃ‚ÂÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€¦Ã‚Â¾',
            'local_should_delete' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬ÃƒÂ¢Ã¢â€šÂ¬Ã‚ÂÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€¹Ã…â€œÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¯ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â',
            'bucket_should_delete' => 'ÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â°ÃƒÆ’Ã¢â‚¬Â¦Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬ÃƒÂ¢Ã¢â€šÂ¬Ã‚ÂÃƒÆ’Ã‚Â¢ÃƒÂ¢Ã¢â‚¬Å¡Ã‚Â¬Ãƒâ€¹Ã…â€œÃƒÆ’Ã†â€™Ãƒâ€šÃ‚Â¯ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â¸ÃƒÆ’Ã¢â‚¬Å¡Ãƒâ€šÃ‚Â',
            'master_missing_both' => 'Ã¢Å¡Â ',
            'managed_by_other_plugin' => 'Ã¢â€žÂ¹Ã¯Â¸Â',
        ];
        return $icons[$type] ?? 'Ã¢â‚¬Â¢';
    }
    
    /**
     * Get title for issue
     */
    private function format_issue_title(array $issue): string {
        $type = $issue['type'];
        $size = $issue['size'] ?? '';
        
        switch ($type) {
            case 'not_registered':
                return __('Register attachment', 'staticq-media');
            case 'master_needs_upload':
                return __('Upload master to bucket', 'staticq-media');
            case 'size_needs_upload':
                // translators: %s is the size name
                return sprintf(__('Upload %s to bucket', 'staticq-media'), $size);
            case 'master_needs_download':
                return __('Download master to local', 'staticq-media');
            case 'size_needs_download':
                // translators: %s is the size name
                return sprintf(__('Download %s to local', 'staticq-media'), $size);
            case 'size_needs_generation':
                // translators: %s is the size name
                return sprintf(__('Generate missing size: %s', 'staticq-media'), $size);
            case 'webp_needs_generation':
                // translators: %s is the size name
                return sprintf(__('Generate WebP for %s', 'staticq-media'), $size);
            case 'webp_flags_wrong':
            case 'webp_not_registered':
                // translators: %s is the size name
                return sprintf(__('Update WebP tracking for %s', 'staticq-media'), $size);
            case 'size_table_mismatch':
                // translators: %s is the size name
                return sprintf(__('Update database tracking for %s', 'staticq-media'), $size);
            case 'local_should_delete':
                // translators: %s is the size name
                return sprintf(__('Delete local copy of %s', 'staticq-media'), $size);
            case 'bucket_should_delete':
                // translators: %s is the size name
                return sprintf(__('Delete bucket copy of %s', 'staticq-media'), $size);
            case 'master_missing_both':
                return __('Critical: Master file missing everywhere', 'staticq-media');
            case 'managed_by_other_plugin':
                return __('Corrupted filename in database', 'staticq-media');
            default:
                return str_replace('_', ' ', ucfirst($type)) . ($size ? " ($size)" : '');
        }
    }
    // =========================================================================
    // REPAIR AJAX HANDLERS
    // =========================================================================
    
    public function ajax_repair_attachment(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $attachment_id = intval($_POST['attachment_id'] ?? 0);
        $actions = isset($_POST['actions']) ? array_map('sanitize_text_field', wp_unslash($_POST['actions'])) : [];
        
        if (!$attachment_id) {
            wp_send_json_error(__('No attachment ID provided.', 'staticq-media'));
        }
        
        $repair = new SQMedia_File_State_Repair();
        $result = $repair->repair_attachment($attachment_id, $actions);
        
        // Return error if repair failed
        if (empty($result['success'])) {
            wp_send_json_error($result['message'] ?? __('Repair failed', 'staticq-media'));
        }
        
        wp_send_json_success($result);
    }
    
    public function ajax_repair_by_action(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $action_type = sanitize_text_field(wp_unslash($_POST['action_type'] ?? ''));
        $limit = intval($_POST['limit'] ?? 50);
        
        
        if (empty($action_type)) {
            wp_send_json_error(__('No action type provided.', 'staticq-media'));
        }
        
        $repair = new SQMedia_File_State_Repair();
        $result = $repair->repair_by_action($action_type, $limit);
        
        
        // Return error if repair failed
        if (empty($result['success'])) {
            wp_send_json_error($result['message'] ?? __('Repair failed', 'staticq-media'));
        }
        
        wp_send_json_success($result);
    }
    
    /**
     * Repair all issues at once
     */
    public function ajax_repair_all(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $db = sqmedia()->get_database();
        $last_scan = $db->get_last_completed_scan('file_state');
        
        if (!$last_scan) {
            wp_send_json_error(__('No scan results found. Please run a Health Check first.', 'staticq-media'));
        }
        
        $results = json_decode($last_scan->results, true);
        if (empty($results['by_action'])) {
            wp_send_json_success(['message' => __('No issues to fix.', 'staticq-media'), 'fixed' => 0]);
        }
        
        $repair = new SQMedia_File_State_Repair();
        $total_fixed = 0;
        $errors = [];
        
        foreach ($results['by_action'] as $action => $action_data) {
            if (($action_data['count'] ?? 0) === 0) {
                continue;
            }
            
            $result = $repair->repair_by_action($action, 1000);
            if (!empty($result['success'])) {
                $total_fixed += $result['fixed'] ?? 0;
            } else {
                $errors[] = $action . ': ' . ($result['message'] ?? 'Failed');
            }
        }
        
        if (!empty($errors)) {
            wp_send_json_success([
                'success' => true,
                'fixed' => $total_fixed,
                'errors' => $errors,
                // translators: %d is the number of fixed issues
                'message' => sprintf(__('Fixed %d issues with some errors.', 'staticq-media'), $total_fixed),
            ]);
        }
        
        wp_send_json_success([
            'success' => true,
            'fixed' => $total_fixed,
            // translators: %d is the number of fixed issues
            'message' => sprintf(__('Fixed %d issues.', 'staticq-media'), $total_fixed),
        ]);
    }
    
    /**
     * Start a persistent repair operation
     */
    public function ajax_start_repair(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $action_type = sanitize_text_field(wp_unslash($_POST['action_type'] ?? ''));
        
        
        if (empty($action_type)) {
            wp_send_json_error(__('No action type provided.', 'staticq-media'));
        }
        
        $db = sqmedia()->get_database();
        
        // Check for active repair
        $active = $db->get_active_scan('file_repair');
        if ($active) {
            wp_send_json_error(__('A repair is already in progress.', 'staticq-media'));
        }
        
        // Check for active scan
        $scanner = new SQMedia_File_State_Scanner();
        $active_scan = $scanner->get_active_scan();
        if ($active_scan) {
            wp_send_json_error(__('A scan is in progress. Please wait for it to complete.', 'staticq-media'));
        }
        
        // Get attachment IDs from latest scan results
        $latest_results = $scanner->get_latest_results();
        
        
        if (!$latest_results) {
            wp_send_json_error(__('No scan results found. Run a scan first.', 'staticq-media'));
        }
        
        $scan_id = $latest_results['scan_id'] ?? '';
        
        // Get attachment IDs from the scan_items table (contains ALL items, not just first 100)
        $attachments = $db->get_scan_item_ids($scan_id, $action_type);
        $total = count($attachments);
        
        
        if (empty($attachments)) {
            // Fallback to JSON results if table is empty (for backwards compatibility)
            $by_action = $latest_results['results']['by_action'] ?? [];
            $attachments = $by_action[$action_type]['attachments'] ?? [];
            $total = count($attachments);
            
            if (empty($attachments)) {
                wp_send_json_error(__('No attachments need this repair. Run a scan first.', 'staticq-media'));
            }
            
        }
        
        
        // SAFETY CHECK: For delete_local action, verify all files exist in bucket first
        if ($action_type === 'delete_local') {
            $s3 = sqmedia()->get_storage_handler();
            $unsafe_attachments = [];
            
            foreach ($attachments as $attachment_id) {
                $record = SQMedia_Attachment_Record::find($attachment_id);
                if (!$record) {
                    $unsafe_attachments[] = $attachment_id;
                    continue;
                }
                
                // Check master exists in bucket
                if (!$record->master_exists_bucket()) {
                    $unsafe_attachments[] = $attachment_id;
                    continue;
                }
                
                // Check original exists in bucket (if applicable)
                if ($record->has_original_image()) {
                    $original_bucket = $record->get_original_bucket_path();
                    if (!$s3->file_exists($original_bucket)) {
                        $unsafe_attachments[] = $attachment_id;
                    }
                }
            }
            
            // Remove unsafe attachments from the list
            if (!empty($unsafe_attachments)) {
                $attachments = array_diff($attachments, $unsafe_attachments);
                $total = count($attachments);
                
                if ($total === 0) {
                    wp_send_json_error(sprintf(
                        // translators: %d is the number of attachments without bucket backups
                        __('Cannot delete local files: %d attachment(s) do not have bucket backups. Upload files to bucket first.', 'staticq-media'),
                        count($unsafe_attachments)
                    ));
                }
                
                // Warn user but proceed with safe attachments
            }
        }
        
        // Create repair record
        $repair_id = $db->create_scan('file_repair');
        
        
        $db->update_scan($repair_id, [
            'status' => 'running',
            'total_items' => $total,
            'scan_started' => current_time('mysql'),
            'results' => [
                'action_type' => $action_type,
                'attachments' => $attachments,
                'successful' => 0,
                'failed' => 0,
                'errors' => [],
            ],
        ]);
        
        wp_send_json_success([
            'repair_id' => $repair_id,
            'total' => $total,
            'action_type' => $action_type,
        ]);
    }
    
    /**
     * Process a batch of the repair operation
     */
    public function ajax_process_repair_batch(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $repair_id = sanitize_text_field(wp_unslash($_POST['repair_id'] ?? ''));
        
        if (empty($repair_id)) {
            wp_send_json_error(__('No repair ID provided.', 'staticq-media'));
        }
        
        $db = sqmedia()->get_database();
        $repair = $db->get_scan($repair_id);
        
        if (!$repair) {
            wp_send_json_error(__('Repair not found.', 'staticq-media'));
        }
        
        if ($repair->status === 'completed') {
            wp_send_json_success([
                'status' => 'completed',
                'message' => __('Repair completed.', 'staticq-media'),
            ]);
        }
        
        if ($repair->status === 'cancelled') {
            wp_send_json_error(__('Repair was cancelled.', 'staticq-media'));
        }
        
        $results = $repair->results ?: [];
        $action_type = $results['action_type'] ?? '';
        $attachments = $results['attachments'] ?? [];
        $successful = $results['successful'] ?? 0;
        $failed = $results['failed'] ?? 0;
        $errors = $results['errors'] ?? [];
        
        $processed = $repair->processed_items;
        
        // Use batch size of 1 for bucket operations (can be very slow with retries)
        // This ensures progress bar updates after each attachment
        $slow_actions = ['download_to_local', 'upload_to_bucket', 'delete_bucket'];
        $batch_size = in_array($action_type, $slow_actions) ? 1 : 5;
        
        // Get next batch
        $to_process = array_slice($attachments, $processed, $batch_size);
        
        if (empty($to_process)) {
            // Complete
            $db->update_scan($repair_id, [
                'status' => 'completed',
                'scan_completed' => current_time('mysql'),
            ]);
            
            // Note: We don't update scan result counters here because:
            // 1. One attachment can have multiple issues, so fixing one action doesn't mean it's "complete"
            // 2. Users should re-run scan to get accurate counts after repairs
            
            wp_send_json_success([
                'status' => 'completed',
                'processed' => $repair->total_items,
                'total' => $repair->total_items,
                'successful' => $successful,
                'failed' => $failed,
                'message' => sprintf(
                    // translators: %1$d is the number of successful repairs, %2$d is the number of failed repairs
                    __('Repair completed: %1$d successful, %2$d failed. Run a new scan to see updated results.', 'staticq-media'),
                    $successful,
                    $failed
                ),
            ]);
            return; // Important: stop execution after sending response
        }
        
        // Process batch
        $repair_handler = new SQMedia_File_State_Repair();
        $batch_results = [];
        
        foreach ($to_process as $attachment_id) {
            $record = SQMedia_Attachment_Record::find($attachment_id);
            
            if (!$record) {
                $failed++;
                // translators: %d is the number of attachments
                $errors[] = sprintf(__('Attachment #%d not found', 'staticq-media'), $attachment_id);
                $batch_results[] = [
                    'id' => $attachment_id,
                    'success' => false,
                    'message' => 'Not found',
                ];
                continue;
            }
            
            $result = $repair_handler->execute_action($attachment_id, $action_type, $record);
            
            if ($result['success']) {
                $successful++;
                $repair_handler->refresh_record($attachment_id);
                $batch_results[] = [
                    'id' => $attachment_id,
                    'success' => true,
                    'message' => $this->format_repair_result($result),
                ];
            } else {
                $failed++;
                $error_msg = $result['error'] ?? 'Unknown error';
                $errors[] = sprintf('#%d: %s', $attachment_id, $error_msg);
                $batch_results[] = [
                    'id' => $attachment_id,
                    'success' => false,
                    'message' => $error_msg,
                ];
            }
        }
        
        // Update repair record
        $new_processed = $processed + count($to_process);
        $results['successful'] = $successful;
        $results['failed'] = $failed;
        $results['errors'] = array_slice($errors, -50); // Keep last 50 errors
        
        $db->update_scan($repair_id, [
            'processed_items' => $new_processed,
            'results' => $results,
        ]);
        
        wp_send_json_success([
            'status' => 'running',
            'processed' => $new_processed,
            'total' => $repair->total_items,
            'successful' => $successful,
            'failed' => $failed,
            'batch_results' => $batch_results,
            'action_type' => $action_type,
        ]);
    }
    
    /**
     * Format repair result for display
     */
    private function format_repair_result(array $result): string {
        $action = $result['action'] ?? '';
        $parts = [];
        
        if (!empty($result['downloaded'])) {
            $parts[] = sprintf('%d downloaded', count($result['downloaded']));
        }
        if (!empty($result['uploaded'])) {
            $parts[] = sprintf('%d uploaded', count($result['uploaded']));
        }
        if (!empty($result['deleted'])) {
            $parts[] = sprintf('%d deleted', count($result['deleted']));
        }
        if (!empty($result['deleted_from_bucket'])) {
            $parts[] = sprintf('%d removed from bucket', count($result['deleted_from_bucket']));
        }
        if (!empty($result['generated'])) {
            $parts[] = sprintf('%d generated', count($result['generated']));
        }
        
        return !empty($parts) ? implode(', ', $parts) : 'OK';
    }
    
    /**
     * Cancel a repair operation
     */
    public function ajax_cancel_repair(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $repair_id = sanitize_text_field(wp_unslash($_POST['repair_id'] ?? ''));
        
        if (empty($repair_id)) {
            wp_send_json_error(__('No repair ID provided.', 'staticq-media'));
        }
        
        $db = sqmedia()->get_database();
        $db->update_scan($repair_id, [
            'status' => 'cancelled',
            'scan_completed' => current_time('mysql'),
        ]);
        
        wp_send_json_success(['message' => __('Repair cancelled.', 'staticq-media')]);
    }
    
    // =========================================================================
    // RESET/RETRY AJAX HANDLERS
    // =========================================================================
    
    public function ajax_reset_attachment(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $attachment_id = intval($_POST['attachment_id'] ?? 0);
        
        if (!$attachment_id) {
            wp_send_json_error(__('No attachment ID provided.', 'staticq-media'));
        }
        
        $db = sqmedia()->get_database();
        $result = $db->reset_attachment($attachment_id);
        
        if ($result) {
            wp_send_json_success([
                // translators: %d is the number of attachments
                'message' => sprintf(__('Attachment #%d reset to pending.', 'staticq-media'), $attachment_id),
            ]);
        } else {
            wp_send_json_error(__('Failed to reset attachment.', 'staticq-media'));
        }
    }
    
    public function ajax_bulk_retry_failed(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $db = sqmedia()->get_database();
        $max_retries = SQMedia_Settings::get_max_retries();
        $count = $db->bulk_retry_failed($max_retries);
        
        wp_send_json_success([
            // translators: %d is the number of attachments
            'message' => sprintf(__('%d attachments queued for retry.', 'staticq-media'), $count),
            'count' => $count,
        ]);
    }
    
    // =========================================================================
    // ORPHAN DETECTION AJAX HANDLERS
    // =========================================================================
    
    public function ajax_start_orphan_scan(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_type = sanitize_text_field(wp_unslash($_POST['scan_type'] ?? 'both'));
        $exclude_folders = sanitize_text_field(wp_unslash($_POST['exclude_folders'] ?? ''));
        
        // Save exclude folders for future use
        update_option('sqmedia_orphan_exclude_folders', $exclude_folders);
        
        // Parse exclude folders into array
        $exclude_list = array_filter(array_map('trim', explode(',', $exclude_folders)));
        
        // Create scan in database
        $db = sqmedia()->get_database();
        $scan_id = $db->create_scan('orphan');
        
        // Update with scan type
        $db->update_scan($scan_id, [
            'status' => 'running',
            'scan_started' => current_time('mysql'),
        ]);
        
        // Create scan folder
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        if (!wp_mkdir_p($scan_dir)) {
            wp_send_json_error(__('Failed to create scan directory.', 'staticq-media'));
            return;
        }
        
        // Initialize state file (lightweight - no orphan data)
        $state = [
            'status' => 'running',
            'scan_type' => $scan_type,
            'exclude_folders' => $exclude_list,
            'phase' => 'build_index',
            'progress' => 0,
            'processed_files' => 0,
            'total_files' => 0,
            'index_offset' => 0,
            'index_total' => 0,
            'local_offset' => 0,
            'bucket_offset' => 0,
            'local_orphan_count' => 0,
            'bucket_orphan_count' => 0,
        ];
        $this->save_orphan_state($scan_id, $state);
        
        // Create empty orphan result files
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        file_put_contents($scan_dir . '/local_orphans.jsonl', '');
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        file_put_contents($scan_dir . '/bucket_orphans.jsonl', '');
        
        // Schedule cron for background processing
        wp_clear_scheduled_hook('sqmedia_continue_orphan_scan');
        if (!wp_next_scheduled('sqmedia_continue_orphan_scan')) {
            wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_continue_orphan_scan');
        }
        
        
        wp_send_json_success([
            'scan_id' => $scan_id,
            'message' => __('Orphan scan started', 'staticq-media'),
        ]);
    }
    
    public function ajax_process_orphan_batch(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }
        
        $result = $this->process_orphan_scan_batch($scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error'] ?? __('Unknown error', 'staticq-media'));
        }
    }
    
    /**
     * Process a single batch of the orphan scan
     * Uses file-based storage for orphan results (JSONL format)
     * 
     * Phases: 'build_index' -> 'local' -> 'bucket' -> 'completed'
     * 
     * @param string $scan_id The scan ID
     * @return array Result array with success, status, progress, etc.
     */
    private function process_orphan_scan_batch(string $scan_id): array {
        $state = $this->load_orphan_state($scan_id);
        
        if (!$state) {
            return ['success' => false, 'error' => __('Scan not found or expired.', 'staticq-media')];
        }
        
        $batch_size = defined('SQMEDIA_ORPHAN_SCAN_BATCH_SIZE') ? (int) SQMEDIA_ORPHAN_SCAN_BATCH_SIZE : 1000;
        $batch_size = max(50, min(5000, $batch_size));
        $scan_type = $state['scan_type'];
        $phase = $state['phase'];
        $exclude_folders = $state['exclude_folders'] ?? [];
        
        // Current scan's folder - always exclude this
        $current_scan_folder = 'staticq_scans/orphans/' . $scan_id;
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        
        // Helper function to check if path should be excluded
        $should_exclude = function($path) use ($exclude_folders, $current_scan_folder) {
            // Always exclude current scan's folder
            if (strpos($path, $current_scan_folder) === 0) {
                return true;
            }

            // Always exclude quarantine folder
            if (strpos($path, 'staticq_quarantine/') === 0) {
                return true;
            }

            if (empty($exclude_folders)) {
                return false;
            }
            $path_lower = strtolower($path);
            foreach ($exclude_folders as $folder) {
                $folder_lower = strtolower(trim($folder));
                if (empty($folder_lower)) continue;
                if (strpos($path_lower, '/' . $folder_lower . '/') !== false ||
                    strpos($path_lower, '/' . $folder_lower) === strlen($path_lower) - strlen('/' . $folder_lower) ||
                    strpos($path_lower, $folder_lower . '/') === 0) {
                    return true;
                }
            }
            return false;
        };
        
        try {
            // Phase 1: Build the index from WordPress metadata
            if ($phase === 'build_index') {
                global $wpdb;
                
                // Get total attachments count on first run
                if (empty($state['index_total'])) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                    $state['index_total'] = (int) $wpdb->get_var(
                        "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment'"
                    );
                }
                
                // Query attachments in batches (join sq_attachments for original_location)
                $sq_table = SQMedia_Database::get_table(SQMedia_Database::TABLE_RECORDS);
                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
                $attachments = $wpdb->get_results($wpdb->prepare(
                    "SELECT p.ID,
                            pm1.meta_value as attached_file,
                            pm2.meta_value as attachment_metadata,
                            sq.original_location as sq_original_location,
                            sq.has_webp as sq_has_webp
                     FROM {$wpdb->posts} p
                     LEFT JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = '_wp_attached_file'
                     LEFT JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = '_wp_attachment_metadata'
                     LEFT JOIN {$sq_table} sq ON p.ID = sq.attachment_id
                     WHERE p.post_type = 'attachment'
                     ORDER BY p.ID ASC
                     LIMIT %d OFFSET %d",
                    $batch_size,
                    $state['index_offset']
                ));
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
                
                // Process each attachment and add files to index
                foreach ($attachments as $attachment) {
                    $this->add_attachment_to_index($scan_id, $attachment);
                }
                
                $state['index_offset'] += count($attachments);
                
                // Check if indexing is complete
                if (count($attachments) < $batch_size || $state['index_offset'] >= $state['index_total']) {
                    $state['phase'] = ($scan_type === 'bucket') ? 'bucket' : 'local';
                }
                
                // Calculate progress (index building is 0-20%)
                $index_progress = $state['index_total'] > 0
                    ? ($state['index_offset'] / $state['index_total']) * 20
                    : 20;
                $state['progress'] = min(20, round($index_progress));
                $state['processed_files'] = $state['index_offset'];

                $this->save_orphan_state($scan_id, $state);

                $phase_label = 'building index (' . $state['index_offset'] . ' / ' . $state['index_total'] . ' attachments)';

                return [
                    'success' => true,
                    'status' => 'running',
                    'phase' => $phase_label,
                    'progress' => $state['progress'],
                    'processed_files' => $state['index_offset'],
                    'local_orphans' => 0,
                    'bucket_orphans' => 0,
                ];
            }
            
            // Phase 2: Local files
            if ($phase === 'local' && ($scan_type === 'both' || $scan_type === 'local')) {
                $upload_dir = wp_upload_dir();
                $basedir = $upload_dir['basedir'];
                
                // Get local files list if not yet scanned (filtered by exclude_folders)
                if (empty($state['local_files_ready'])) {
                    $local_files = $this->get_local_media_files($basedir, $exclude_folders, $current_scan_folder);
                    $cache_file = $scan_dir . '/local_files_cache.txt';
                    
                    // Write as line-based file (one path per line) for efficient reading
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    file_put_contents($cache_file, implode("\n", $local_files));
                    
                    $state['local_files_ready'] = true;
                    $state['local_files_count'] = count($local_files);
                    $state['total_files'] = count($local_files);
                    if ($scan_type === 'both') {
                        $state['total_files'] += 1000; // Estimate for bucket
                    }
                }
                
                // Read only the lines we need (efficient - no full file load)
                $cache_file = $scan_dir . '/local_files_cache.txt';
                $local_files = [];
                if (file_exists($cache_file)) {
                    $local_files = $this->read_lines_from_file($cache_file, $state['local_offset'], $batch_size);
                }
                
                // File handle for appending orphans
                $orphan_file = $scan_dir . '/local_orphans.jsonl';
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $handle = fopen($orphan_file, 'a');

                // Load existing folder tree for incremental building
                $tree_file = $scan_dir . '/local_folder_tree.json';
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $folder_tree = file_exists($tree_file) ? json_decode(file_get_contents($tree_file), true) : [];
                if (!is_array($folder_tree)) $folder_tree = [];

                // In cloud_only mode, ALL local files are orphans (they shouldn't be on disk)
                $storage_mode = SQMedia_Settings::get_storage_mode();
                $skip_index = ($storage_mode === 'cloud_only');

                foreach ($local_files as $file_path) {
                    if (empty($file_path)) continue;

                    // Normalize path separators (Windows uses backslashes)
                    $file_path_normalized = str_replace('\\', '/', $file_path);
                    $basedir_normalized = str_replace('\\', '/', $basedir);
                    $relative_path = str_replace($basedir_normalized . '/', '', $file_path_normalized);

                    // In cloud_only mode skip index check — no local file is legitimate
                    if ($skip_index || !$this->is_in_orphan_index($scan_id, $relative_path)) {
                        $file_size = filesize($file_path) ?: 0;
                        // Append orphan to JSONL file (one JSON object per line)
                        $orphan = [
                            'path' => $file_path,
                            'relative_path' => $relative_path,
                            'size' => $file_size,
                            'modified' => gmdate('Y-m-d H:i:s', filemtime($file_path) ?: 0),
                        ];
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        fwrite($handle, json_encode($orphan) . "\n");
                        $state['local_orphan_count']++;

                        // Accumulate folder tree
                        $dir = dirname($relative_path);
                        if ($dir === '.') $dir = '';
                        if (!isset($folder_tree[$dir])) {
                            $folder_tree[$dir] = ['count' => 0, 'size' => 0];
                        }
                        $folder_tree[$dir]['count']++;
                        $folder_tree[$dir]['size'] += $file_size;
                    }
                    $state['processed_files']++;
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fclose($handle);
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                file_put_contents($tree_file, json_encode($folder_tree));
                $state['local_offset'] += $batch_size;
                
                // Check if local phase is done
                if ($state['local_offset'] >= ($state['local_files_count'] ?? 0)) {
                    // Clean up cache file only (not orphan results)
                    if (file_exists($cache_file)) {
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        wp_delete_file($cache_file);
                    }
                    
                    if ($scan_type === 'both') {
                        $state['phase'] = 'bucket';
                    } else {
                        $state['phase'] = 'completed';
                    }
                }
                
                // Calculate progress (local is 20-60% or 20-100%)
                $local_progress = ($state['local_files_count'] ?? 1) > 0 
                    ? ($state['local_offset'] / ($state['local_files_count'] ?? 1)) 
                    : 1;
                $range = ($scan_type === 'both') ? 40 : 80;
                $state['progress'] = min(20 + $range, round(20 + ($local_progress * $range)));
                
            // Phase 3: Bucket files — fetch one page per AJAX call, compare immediately
            } elseif ($phase === 'bucket' && ($scan_type === 'both' || $scan_type === 'bucket')) {
                $s3 = sqmedia()->get_storage_handler();
                $prefix = SQMedia_Settings::get_current_prefix();

                $page_token = $state['bucket_listing_token'] ?? null;
                $bucket_page_size = defined('SQMEDIA_ORPHAN_BUCKET_PAGE_SIZE') ? (int) SQMEDIA_ORPHAN_BUCKET_PAGE_SIZE : 5000;
                $bucket_page_size = max(500, min(10000, $bucket_page_size));
                $result = $s3->list_files_paginated($prefix, $bucket_page_size, $page_token);
                $page_files = $result['files'] ?? [];
                $next_token = $result['next_page_token'] ?? null;

                // Compare each file against index immediately
                $orphan_file = $scan_dir . '/bucket_orphans.jsonl';
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $handle = fopen($orphan_file, 'a');

                // Load existing folder tree for incremental building
                $tree_file = $scan_dir . '/bucket_folder_tree.json';
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $folder_tree = file_exists($tree_file) ? json_decode(file_get_contents($tree_file), true) : [];
                if (!is_array($folder_tree)) $folder_tree = [];

                // In local_only mode, ALL bucket files are orphans (they shouldn't be in the bucket)
                $storage_mode = SQMedia_Settings::get_storage_mode();
                $skip_index = ($storage_mode === 'local_only');

                foreach ($page_files as $file) {
                    $relative_path = preg_replace('#^' . preg_quote($prefix . '/', '#') . '#', '', $file['name']);

                    if ($should_exclude($relative_path)) {
                        continue;
                    }

                    $state['bucket_files_count'] = ($state['bucket_files_count'] ?? 0) + 1;

                    if ($skip_index || !$this->is_in_orphan_index($scan_id, $relative_path)) {
                        $file_size = (int) ($file['size'] ?? 0);
                        $orphan = [
                            'path' => $file['name'],
                            'relative_path' => $relative_path,
                            'size' => $file_size,
                            'modified' => $file['updated'] ?? '',
                        ];
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        fwrite($handle, json_encode($orphan) . "\n");
                        $state['bucket_orphan_count']++;

                        // Accumulate folder tree
                        $dir = dirname($relative_path);
                        if ($dir === '.') $dir = '';
                        if (!isset($folder_tree[$dir])) {
                            $folder_tree[$dir] = ['count' => 0, 'size' => 0];
                        }
                        $folder_tree[$dir]['count']++;
                        $folder_tree[$dir]['size'] += $file_size;
                    }
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fclose($handle);
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                file_put_contents($tree_file, json_encode($folder_tree));
                $state['bucket_listing_total'] = ($state['bucket_listing_total'] ?? 0) + count($page_files);
                $state['processed_files'] = $state['bucket_listing_total'];

                if ($next_token) {
                    $state['bucket_listing_token'] = $next_token;
                } else {
                    $state['phase'] = 'completed';
                }

                // Progress: log scale for unknown total
                $start = ($scan_type === 'both') ? 60 : 20;
                $full_range = ($scan_type === 'both') ? 40 : 80;
                $files_scanned = $state['bucket_listing_total'] ?? 0;
                $bucket_progress = $files_scanned > 0 ? min(0.95, log10($files_scanned) / 6) : 0;
                $state['progress'] = round($start + ($bucket_progress * $full_range));

            } elseif ($phase === 'local' && $scan_type === 'bucket') {
                // Skip local, go to bucket
                $state['phase'] = 'bucket';
            }
            
            // Check if completed
            if ($state['phase'] === 'completed') {
                $state['status'] = 'completed';
                $state['progress'] = 100;
                
                // Clean up index files (keep orphan results)
                $this->cleanup_orphan_index($scan_id);
                
                // Save final state
                $this->save_orphan_state($scan_id, $state);
                
                // Save to database - only counts and folder reference (no orphan data)
                $db = sqmedia()->get_database();
                $db->update_scan($scan_id, [
                    'status' => 'completed',
                    'results' => json_encode([
                        'local_orphan_count' => $state['local_orphan_count'],
                        'bucket_orphan_count' => $state['bucket_orphan_count'],
                        'local_files_scanned' => $state['local_files_count'] ?? 0,
                        'bucket_files_scanned' => $state['bucket_listing_total'] ?? 0,
                        'scan_folder' => $scan_dir,
                    ]),
                    'total_items' => $state['processed_files'],
                    'processed_items' => $state['processed_files'],
                    'scan_completed' => current_time('mysql'),
                ]);
                
                // Clear the cron since we're done
                wp_clear_scheduled_hook('sqmedia_continue_orphan_scan');
                
                
                return [
                    'success' => true,
                    'status' => 'completed',
                    'progress' => 100,
                    'processed_files' => $state['processed_files'],
                    'local_orphans' => $state['local_orphan_count'],
                    'bucket_orphans' => $state['bucket_orphan_count'],
                ];
            } else {
                // Still running - save state
                $this->save_orphan_state($scan_id, $state);

                // Build descriptive phase label
                if ($state['phase'] === 'build_index') {
                    $phase_label = 'building index (' . ($state['index_offset'] ?? 0) . ' / ' . ($state['index_total'] ?? '?') . ' attachments)';
                } elseif ($state['phase'] === 'bucket') {
                    $phase_label = 'scanning bucket (' . ($state['bucket_listing_total'] ?? 0) . ' files checked)';
                } elseif ($state['phase'] === 'local' && empty($state['local_files_ready'])) {
                    $phase_label = 'listing local files';
                } elseif ($state['phase'] === 'local') {
                    $phase_label = 'scanning local';
                } else {
                    $phase_label = $state['phase'];
                }

                return [
                    'success' => true,
                    'status' => 'running',
                    'phase' => $phase_label,
                    'progress' => $state['progress'],
                    'processed_files' => $state['processed_files'],
                    'local_orphans' => $state['local_orphan_count'],
                    'bucket_orphans' => $state['bucket_orphan_count'],
                ];
            }
            
        } catch (Exception $e) {
            return ['success' => false, 'error' => 'Scan error: ' . $e->getMessage()];
        } catch (Error $e) {
            return ['success' => false, 'error' => 'Scan error: ' . $e->getMessage()];
        }
    }
    
    // =========================================================================
    // ORPHAN INDEX HELPER METHODS
    // =========================================================================
    
    /**
     * Validate that a relative path does not escape the uploads directory.
     * Returns the sanitized path or false if traversal is detected.
     */
    private function validate_upload_path(string $relative_path): string|false {
        $upload_dir = wp_upload_dir();
        $basedir = realpath($upload_dir['basedir']);
        if (!$basedir) {
            return false;
        }
        $full = realpath($basedir . '/' . $relative_path);
        if ($full === false) {
            // File doesn't exist yet — normalize without realpath
            $full = $basedir . '/' . str_replace(['../', '..\\'], '', $relative_path);
            $full = wp_normalize_path($full);
            $basedir = wp_normalize_path($basedir);
            if (strpos($full, $basedir . '/') !== 0) {
                return false;
            }
            return $relative_path;
        }
        $basedir_normalized = wp_normalize_path($basedir);
        $full_normalized = wp_normalize_path($full);
        if (strpos($full_normalized, $basedir_normalized . '/') !== 0) {
            return false;
        }
        return $relative_path;
    }

    /**
     * Get the index directory path for an orphan scan
     * Structure: /uploads/staticq_scans/orphans/{scan_id}/
     */
    private function get_orphan_index_dir(string $scan_id): string {
        $upload_dir = wp_upload_dir();
        return $upload_dir['basedir'] . '/staticq_scans/orphans/' . $scan_id;
    }
    
    /**
     * Read specific lines from a file efficiently (without loading entire file)
     * Uses SplFileObject to seek to specific line offset
     */
    private function read_lines_from_file(string $filepath, int $offset, int $count): array {
        if (!file_exists($filepath)) {
            return [];
        }
        
        $lines = [];
        try {
            $file = new SplFileObject($filepath, 'r');
            $file->seek($offset);  // Seek to the starting line
            
            $read = 0;
            while (!$file->eof() && $read < $count) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $line = $file->fgets();
                if ($line !== false) {
                    $lines[] = rtrim($line, "\r\n");
                }
                $read++;
            }
        } catch (Exception $e) {
        }
        
        return $lines;
    }
    
    /**
     * Convert a directory path to an index filename
     * e.g., "2010/06" -> "2010_06.txt", "" -> "_root.txt"
     */
    private function get_index_filename(string $dir): string {
        if (empty($dir) || $dir === '.') {
            return '_root.txt';
        }
        // Replace directory separators with underscores
        $filename = str_replace(['/', '\\'], '_', trim($dir, '/\\'));
        // Sanitize: only allow alphanumeric, underscore, hyphen
        $filename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $filename);
        return $filename . '.txt';
    }
    
    /**
     * Add a file path to the orphan index
     */
    private function add_to_orphan_index(string $scan_id, string $relative_path): void {
        // Normalize path separators
        $relative_path = str_replace('\\', '/', $relative_path);
        
        $dir = dirname($relative_path);
        $filename = basename($relative_path);
        
        if ($dir === '.') {
            $dir = '';
        }
        
        $index_dir = $this->get_orphan_index_dir($scan_id);
        $index_file = $index_dir . '/' . $this->get_index_filename($dir);
        
        // Append filename to index file
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        file_put_contents($index_file, $filename . "\n", FILE_APPEND | LOCK_EX);
    }
    
    /**
     * Add all files from an attachment to the index
     */
    private function add_attachment_to_index(string $scan_id, object $attachment): void {
        // Per-record WebP claim. The File State Scanner aligns this flag to current
        // settings (see class-sqmedia-file-state-scanner.php:440), so an attachment
        // whose record has has_webp=0 contributes no .webp paths to the expected
        // index — and any .webp file in the bucket for it will be flagged as orphan.
        $webp_expected = (int) ($attachment->sq_has_webp ?? 0) === 1;

        // The attached file is the master/main file
        if (!empty($attachment->attached_file)) {
            $attached_file = $attachment->attached_file;

            // Handle WP Offload Media cloud URLs - extract relative path
            if (preg_match('#^(gs|s3|gsus|do)://#', $attached_file)) {
                if (preg_match('#(\d{4}/\d{2}/[^/]+)$#', $attached_file, $matches)) {
                    $attached_file = $matches[1];
                } else {
                    $attached_file = basename($attached_file);
                }
            }

            $this->add_to_orphan_index($scan_id, $attached_file);

            // Also track WebP version of master — only when the record claims it
            if ($webp_expected) {
                $master_webp = preg_replace('/\.(jpg|jpeg|png|gif)$/i', '.webp', $attached_file);
                if ($master_webp !== $attached_file) {
                    $this->add_to_orphan_index($scan_id, $master_webp);
                }
            }
        }
        
        // Parse the serialized metadata (only images have sizes)
        if (!empty($attachment->attachment_metadata)) {
            $metadata = @unserialize($attachment->attachment_metadata);
            
            if (!is_array($metadata)) {
                return;
            }
            
            // Get directory from the main file path or metadata
            $dir = '';
            if (!empty($metadata['file'])) {
                $dir = dirname($metadata['file']);
                // Also add the main file from metadata
                $this->add_to_orphan_index($scan_id, $metadata['file']);
                // And its WebP version — only when the record claims it
                if ($webp_expected) {
                    $file_webp = preg_replace('/\.(jpg|jpeg|png|gif)$/i', '.webp', $metadata['file']);
                    if ($file_webp !== $metadata['file']) {
                        $this->add_to_orphan_index($scan_id, $file_webp);
                    }
                }
            } elseif (!empty($attachment->attached_file)) {
                $attached_file = $attachment->attached_file;
                if (preg_match('#^(gs|s3|gsus|do)://#', $attached_file)) {
                    if (preg_match('#(\d{4}/\d{2})/[^/]+$#', $attached_file, $matches)) {
                        $dir = $matches[1];
                    }
                } else {
                    $dir = dirname($attached_file);
                }
            }
            if ($dir === '.') {
                $dir = '';
            }
            
            // Original image (for big images that were scaled)
            // Use per-attachment original_location from sq_attachments to know
            // where the original actually IS — not just the global setting.
            // After alignment (copy to new location), the old copy becomes an orphan.
            if (!empty($metadata['original_image'])) {
                $original_location = $attachment->sq_original_location ?? null;

                if ($original_location === 'originals') {
                    $original_path = 'originals/' . ($dir ? $dir . '/' : '') . $metadata['original_image'];
                } elseif ($original_location === 'alongside') {
                    $original_path = ($dir ? $dir . '/' : '') . $metadata['original_image'];
                } else {
                    // NULL = pre-migration record (no tracking yet) — index both locations
                    // to avoid false orphan flags until the user runs alignment
                    $path_alongside = ($dir ? $dir . '/' : '') . $metadata['original_image'];
                    $path_originals = 'originals/' . ($dir ? $dir . '/' : '') . $metadata['original_image'];
                    $this->add_to_orphan_index($scan_id, $path_alongside);
                    $this->add_to_orphan_index($scan_id, $path_originals);
                    $original_path = null; // already indexed both
                }

                if ($original_path) {
                    $this->add_to_orphan_index($scan_id, $original_path);

                    // WebP version — only when the record claims it
                    if ($webp_expected) {
                        $original_webp = preg_replace('/\.(jpg|jpeg|png|gif)$/i', '.webp', $original_path);
                        if ($original_webp !== $original_path) {
                            $this->add_to_orphan_index($scan_id, $original_webp);
                        }
                    }
                }
            }
            
            // All sizes
            if (!empty($metadata['sizes']) && is_array($metadata['sizes'])) {
                foreach ($metadata['sizes'] as $size_name => $size_data) {
                    if (!empty($size_data['file'])) {
                        $path = ($dir ? $dir . '/' : '') . $size_data['file'];
                        $this->add_to_orphan_index($scan_id, $path);

                        // WebP version of each size — only when the record claims it
                        if ($webp_expected) {
                            $size_webp = preg_replace('/\.(jpg|jpeg|png|gif)$/i', '.webp', $path);
                            if ($size_webp !== $path) {
                                $this->add_to_orphan_index($scan_id, $size_webp);
                            }
                        }
                    }
                }
            }
        }
    }
    
    /**
     * Check if a file is in the orphan index
     * Returns true if tracked (not an orphan), false if orphan
     */
    private function is_in_orphan_index(string $scan_id, string $relative_path): bool {
        // Normalize path separators
        $relative_path = str_replace('\\', '/', $relative_path);
        
        $dir = dirname($relative_path);
        $filename = basename($relative_path);
        
        if ($dir === '.') {
            $dir = '';
        }
        
        $index_dir = $this->get_orphan_index_dir($scan_id);
        $index_file = $index_dir . '/' . $this->get_index_filename($dir);
        
        // If index file doesn't exist, this directory has no tracked files.
        // WebP files are explicitly added to the index when the record's
        // has_webp flag is set; absent from the index when not. No fallback
        // to the non-webp original — that would mask the very condition the
        // orphan scanner is meant to detect.
        if (!file_exists($index_file)) {
            return false;
        }
        
        // Read index file and check for filename
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $handle = fopen($index_file, 'r');
        if ($handle) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            while (($line = fgets($handle)) !== false) {
                if (trim($line) === $filename) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    fclose($handle);
                    return true;
                }
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($handle);
        }

        // No fallback to the non-webp original. WebP membership in the index is
        // controlled by the per-record has_webp flag in add_attachment_to_index().
        return false;
    }
    
    /**
     * Clean up orphan scan folder and all its contents
     * Folder structure: /uploads/staticq_scans/orphans/{scan_id}/
     */
    private function cleanup_orphan_scan(string $scan_id): void {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        
        if (!is_dir($scan_dir)) {
            return;
        }
        
        // List of known file patterns we create (meticulous cleanup)
        $known_files = [
            'state.json',
            'local_orphans.jsonl',
            'bucket_orphans.jsonl',
            'local_folder_tree.json',
            'bucket_folder_tree.json',
            'local_files_cache.txt',
            'bucket_files_cache.txt',
        ];
        
        // Delete known files first
        foreach ($known_files as $filename) {
            $filepath = $scan_dir . '/' . $filename;
            if (file_exists($filepath) && is_file($filepath)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                wp_delete_file($filepath);
            }
        }
        
        // Delete index files (pattern: *.txt in index subdirectory or root)
        // Index files are named like: 2010_01.txt, _root.txt
        $index_files = glob($scan_dir . '/*.txt');
        if ($index_files) {
            foreach ($index_files as $file) {
                if (is_file($file)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    wp_delete_file($file);
                }
            }
        }
        
        // Check if directory is now empty before removing
        $remaining = glob($scan_dir . '/*');
        if (empty($remaining)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            rmdir($scan_dir);
            
            // Try to remove orphans/ folder if empty
            $parent = dirname($scan_dir);
            $parent_contents = glob($parent . '/*');
            if (empty($parent_contents)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                rmdir($parent);
            }
        } else {
        }
    }
    
    /**
     * Clean up only index files (keep orphan results for user actions)
     */
    private function cleanup_orphan_index(string $scan_id): void {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        
        if (!is_dir($scan_dir)) {
            return;
        }
        
        // Only delete index files (*.txt) - keep state.json and orphan JSONL files
        $index_files = glob($scan_dir . '/*.txt');
        if ($index_files) {
            foreach ($index_files as $file) {
                if (is_file($file)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    wp_delete_file($file);
                }
            }
        }
    }

    // =========================================================================
    // QUARANTINE SYSTEM
    // =========================================================================

    /**
     * Get the base quarantine directory path
     */
    private function get_quarantine_base_dir(): string {
        $upload_dir = wp_upload_dir();
        return $upload_dir['basedir'] . '/staticq_quarantine';
    }

    /**
     * Get the quarantine directory for a specific quarantine ID
     */
    private function get_quarantine_dir(string $id): string {
        return $this->get_quarantine_base_dir() . '/' . $id;
    }

    /**
     * Get the bucket prefix for a specific quarantine ID
     */
    private function get_bucket_quarantine_prefix(string $id): string {
        return 'staticq_quarantine/' . $id . '/';
    }

    /**
     * Move a local file to quarantine
     */
    private function quarantine_local_file(string $relative_path, string $quarantine_id): bool {
        $upload_dir = wp_upload_dir();
        $basedir = $upload_dir['basedir'];
        $source = $basedir . '/' . $relative_path;

        // Security check - make sure path is within uploads
        $real_path = realpath($source);
        $real_basedir = realpath($basedir);

        if (!$real_path || strpos($real_path, $real_basedir) !== 0) {
            return false;
        }

        $dest = $this->get_quarantine_dir($quarantine_id) . '/files/' . $relative_path;

        // Create destination directory
        $dest_dir = dirname($dest);
        if (!is_dir($dest_dir)) {
            wp_mkdir_p($dest_dir);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        // Try rename (atomic on same filesystem)
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        if (@rename($real_path, $dest)) {
            return true;
        }

        // Fallback: copy + unlink (cross-mount)
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        if (@copy($real_path, $dest) && @unlink($real_path)) {
            return true;
        }

        return false;
    }

    /**
     * Move a bucket file to quarantine
     */
    private function quarantine_bucket_file(string $bucket_path, string $quarantine_id, $s3): bool {
        $dest = $this->get_bucket_quarantine_prefix($quarantine_id) . 'files/' . $bucket_path;

        if ($s3->rename_file($bucket_path, $dest)) {
            return true;
        }

        return false;
    }

    /**
     * Save quarantine manifest
     */
    private function save_quarantine_manifest(string $id, string $type, int $file_count, int $total_size, string $status = 'completed'): bool {
        $dir = $this->get_quarantine_dir($id);
        if (!is_dir($dir)) {
            wp_mkdir_p($dir);
        }

        $manifest_file = $dir . '/manifest.json';

        // Preserve the original created timestamp if manifest already exists
        $created = current_time('Y-m-d H:i:s');
        if (file_exists($manifest_file)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $existing = json_decode(file_get_contents($manifest_file), true);
            if (is_array($existing) && !empty($existing['created'])) {
                $created = $existing['created'];
            }
        }

        $manifest = [
            'quarantine_id' => $id,
            'created' => $created,
            'type' => $type,
            'file_count' => $file_count,
            'total_size' => $total_size,
            'status' => $status,
        ];

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $result = file_put_contents(
            $manifest_file,
            json_encode($manifest, JSON_PRETTY_PRINT),
            LOCK_EX
        );

        return $result !== false;
    }

    /**
     * Save quarantine process state to file
     */
    private function save_quarantine_process_state(string $quarantine_id, array $state): bool {
        $dir = $this->get_quarantine_dir($quarantine_id);
        if (!is_dir($dir)) {
            wp_mkdir_p($dir);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $result = file_put_contents(
            $dir . '/process_state.json',
            json_encode($state, JSON_PRETTY_PRINT),
            LOCK_EX
        );
        return $result !== false;
    }

    /**
     * Load quarantine process state from file
     */
    private function load_quarantine_process_state(string $quarantine_id): ?array {
        $dir = $this->get_quarantine_dir($quarantine_id);
        $file = $dir . '/process_state.json';

        if (!file_exists($file)) {
            return null;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $content = file_get_contents($file);
        if ($content === false) {
            return null;
        }

        $state = json_decode($content, true);
        return is_array($state) ? $state : null;
    }

    /**
     * Find any running quarantine process.
     *
     * If a quarantine has status=running but no process is active and no cron
     * is scheduled, re-schedules the cron so it resumes automatically.
     * Only explicit user cancellation finalizes a quarantine as a separate entry.
     *
     * @return string|null The quarantine_id of a running process, or null
     */
    public function find_running_quarantine(): ?string {
        $base_dir = $this->get_quarantine_base_dir();
        if (!is_dir($base_dir)) {
            return null;
        }

        $dirs = glob($base_dir . '/q_*', GLOB_ONLYDIR);
        if (!$dirs) {
            return null;
        }

        foreach ($dirs as $dir) {
            $state_file = $dir . '/process_state.json';
            if (!file_exists($state_file)) {
                continue;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $content = file_get_contents($state_file);
            if ($content === false) {
                continue;
            }

            $state = json_decode($content, true);
            if (!is_array($state) || ($state['status'] ?? '') !== 'running') {
                continue;
            }

            $quarantine_id = basename($dir);

            // Re-schedule cron if it's not running — ensures resume after
            // session expiry, browser close, or server restart
            if (!wp_next_scheduled('sqmedia_continue_quarantine')) {
                wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_continue_quarantine');
            }

            return $quarantine_id;
        }

        return null;
    }

    // =========================================================================
    // BATCHED RESTORE — state helpers
    // =========================================================================

    /**
     * Save restore process state to file
     */
    private function save_restore_state(string $quarantine_id, array $state): bool {
        $dir = $this->get_quarantine_dir($quarantine_id);
        if (!is_dir($dir)) {
            wp_mkdir_p($dir);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $result = file_put_contents(
            $dir . '/restore_state.json',
            json_encode($state, JSON_PRETTY_PRINT),
            LOCK_EX
        );
        return $result !== false;
    }

    /**
     * Load restore process state from file
     */
    private function load_restore_state(string $quarantine_id): ?array {
        $dir = $this->get_quarantine_dir($quarantine_id);
        $file = $dir . '/restore_state.json';

        if (!file_exists($file)) {
            return null;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $content = file_get_contents($file);
        if ($content === false) {
            return null;
        }

        $state = json_decode($content, true);
        return is_array($state) ? $state : null;
    }

    /**
     * Find any running restore process.
     *
     * Scans quarantine directories for restore_state.json with active status.
     * Re-schedules cron if needed to ensure resume after browser close.
     *
     * @return string|null The quarantine_id of a running restore, or null
     */
    public function find_running_restore(): ?string {
        $base_dir = $this->get_quarantine_base_dir();
        if (!is_dir($base_dir)) {
            return null;
        }

        $dirs = glob($base_dir . '/q_*', GLOB_ONLYDIR);
        if (!$dirs) {
            return null;
        }

        foreach ($dirs as $dir) {
            $state_file = $dir . '/restore_state.json';
            if (!file_exists($state_file)) {
                continue;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $content = file_get_contents($state_file);
            if ($content === false) {
                continue;
            }

            $state = json_decode($content, true);
            if (!is_array($state)) {
                continue;
            }

            $status = $state['status'] ?? '';
            if ($status !== 'enumerating' && $status !== 'restoring') {
                continue;
            }

            $quarantine_id = basename($dir);

            // Re-schedule cron if it's not running
            if (!wp_next_scheduled('sqmedia_continue_restore')) {
                wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_continue_restore');
            }

            return $quarantine_id;
        }

        return null;
    }

    /**
     * Read items from restore JSONL file at a given offset.
     *
     // phpcs:ignore WordPress.WP.AlternativeFunctions
     * When $byte_offset > 0, uses fseek() for O(1) seeking instead of
     * line-by-line iteration. Pass $known_total to skip re-counting lines.
     *
     * @param int $byte_offset Byte position to fseek to (0 = use line-based $offset)
     * @param int|null $known_total If provided, skip line counting (use stored total)
     * @return array ['items' => [...], 'total' => int, 'next_byte_offset' => int]
     */
    private function read_restore_jsonl(string $quarantine_id, int $offset, int $limit, int $byte_offset = 0, ?int $known_total = null): array {
        $dir = $this->get_quarantine_dir($quarantine_id);
        $file = $dir . '/restore_files.jsonl';

        if (!file_exists($file)) {
            return ['items' => [], 'total' => 0, 'next_byte_offset' => 0];
        }

        $items = [];
        $total = $known_total ?? 0;
        $next_byte_offset = 0;

        try {
            if ($byte_offset > 0) {
                // Fast path: O(1) byte-level seek
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $fp = fopen($file, 'r');
                if (!$fp) {
                    return ['items' => [], 'total' => $total, 'next_byte_offset' => 0];
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fseek($fp, $byte_offset);

                if ($known_total === null) {
                    // Count total lines (only needed on first call without known total)
                    $total = 0;
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $count_fp = fopen($file, 'r');
                    if ($count_fp) {
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        while (fgets($count_fp) !== false) {
                            $total++;
                        }
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        fclose($count_fp);
                    }
                }

                $read = 0;
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                while (!feof($fp) && $read < $limit) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $line = fgets($fp);
                    if ($line === false) break;
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $items[] = $item;
                        }
                    }
                    $read++;
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $next_byte_offset = ftell($fp);
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fclose($fp);
            } else {
                // Legacy path: line-based seek (for callers without byte tracking)
                $handle = new SplFileObject($file, 'r');

                if ($known_total === null) {
                    $handle->seek(PHP_INT_MAX);
                    $total = $handle->key();
                }

                $handle->seek($offset);
                $read = 0;

                while (!$handle->eof() && $read < $limit) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $line = $handle->fgets();
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $items[] = $item;
                        }
                    }
                    $read++;
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $next_byte_offset = $handle->ftell();
            }
        } catch (Exception $e) {
        }

        return ['items' => $items, 'total' => $total, 'next_byte_offset' => $next_byte_offset];
    }

    /**
     * Trim processed entries from the JSONL orphan list.
     *
     * Removes the first $offset lines (already processed by a quarantine)
     * so subsequent quarantine-all runs don't re-attempt moved files.
     */
    private function trim_orphan_jsonl(array $state): void {
        $type = $state['type'] ?? '';
        $scan_id = $state['scan_id'] ?? '';
        $offset = $state['offset'] ?? 0;

        if (empty($scan_id) || empty($type) || $offset <= 0) {
            return;
        }

        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $jsonl_file = $scan_dir . '/' . $type . '_orphans.jsonl';

        if (!file_exists($jsonl_file)) {
            return;
        }

        $remaining_lines = [];
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $handle = fopen($jsonl_file, 'r');
        if (!$handle) {
            return;
        }

        $line_num = 0;
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        while (($line = fgets($handle)) !== false) {
            if ($line_num >= $offset) {
                $remaining_lines[] = $line;
            }
            $line_num++;
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        fclose($handle);

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        file_put_contents($jsonl_file, implode('', $remaining_lines), LOCK_EX);
    }

    // =========================================================================
    // BATCHED RESTORE — enumeration + batch processing
    // =========================================================================

    /**
     * Enumerate files to restore from a quarantine into restore_files.jsonl.
     *
     * Local quarantines: single-pass RecursiveIteratorIterator (fast).
     * Bucket quarantines: paginated listing, 5 pages (5000 files) per call.
     *
     * @return array Updated restore state
     */
    private function enumerate_restore_batch(string $quarantine_id): array {
        $state = $this->load_restore_state($quarantine_id);
        if (!$state || $state['status'] !== 'enumerating') {
            return $state ?? ['status' => 'cancelled'];
        }

        $dir = $this->get_quarantine_dir($quarantine_id);
        $jsonl_file = $dir . '/restore_files.jsonl';
        $type = $state['type'];

        if ($type === 'local') {
            // Single-pass: enumerate all files at once (just reading filenames, fast)
            $files_dir = $dir . '/files';
            if (!is_dir($files_dir)) {
                $state['status'] = 'completed';
                $state['total'] = 0;
                $this->save_restore_state($quarantine_id, $state);
                return $state;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $handle = fopen($jsonl_file, 'w');
            if (!$handle) {
                $state['status'] = 'stalled';
                $state['stall_reason'] = 'Failed to create restore JSONL file';
                $this->save_restore_state($quarantine_id, $state);
                return $state;
            }

            $count = 0;
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST
            );

            foreach ($iterator as $file) {
                if (!$file->isFile()) continue;
                $quarantine_path = str_replace('\\', '/', $file->getPathname());
                $files_dir_normalized = str_replace('\\', '/', $files_dir);
                $relative_path = str_replace($files_dir_normalized . '/', '', $quarantine_path);
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fwrite($handle, json_encode(['path' => $relative_path]) . "\n");
                $count++;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($handle);

            $state['status'] = 'restoring';
            $state['total'] = $count;
            $this->save_restore_state($quarantine_id, $state);
            return $state;

        } elseif ($type === 'bucket') {
            // Paginated: process 5 pages (5000 files) per call
            $s3 = sqmedia()->get_storage_handler();
            $prefix = $this->get_bucket_quarantine_prefix($quarantine_id) . 'files/';
            $page_token = $state['page_token'] ?? null;

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $handle = fopen($jsonl_file, 'a');
            if (!$handle) {
                $state['status'] = 'stalled';
                $state['stall_reason'] = 'Failed to open restore JSONL file';
                $this->save_restore_state($quarantine_id, $state);
                return $state;
            }

            $pages_processed = 0;
            $count = $state['total'] ?? 0;

            do {
                $result = $s3->list_files_paginated($prefix, 1000, $page_token);

                foreach ($result['files'] as $file) {
                    $quarantine_path = $file['name'];
                    $original_path = preg_replace('#^' . preg_quote($prefix, '#') . '#', '', $quarantine_path);
                    if (empty($original_path)) continue;

                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    fwrite($handle, json_encode([
                        'path' => $original_path,
                        'quarantine_path' => $quarantine_path,
                        'size' => $file['size'] ?? 0,
                    ]) . "\n");
                    $count++;
                }

                $page_token = $result['next_page_token'] ?? null;
                $pages_processed++;
            } while ($page_token && $pages_processed < 5);

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($handle);

            if ($page_token === null) {
                // Enumeration complete
                $state['status'] = 'restoring';
                $state['total'] = $count;
                $state['page_token'] = null;
            } else {
                // More pages to enumerate
                $state['total'] = $count;
                $state['page_token'] = $page_token;
            }

            $this->save_restore_state($quarantine_id, $state);
            return $state;
        }

        return $state;
    }

    /**
     * Process a single restore batch.
     *
     * Reads items from restore_files.jsonl at the current offset, restores them
     * to their original locations, updates and saves state.
     *
     * @return array Updated state after processing
     */
    private function process_restore_batch(string $quarantine_id): array {
        $dir = $this->get_quarantine_dir($quarantine_id);

        // Acquire exclusive lock to prevent overlapping batch processing
        $lock_file = $dir . '/restore_batch.lock';
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $lock_fp = fopen($lock_file, 'c');
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        if (!$lock_fp || !flock($lock_fp, LOCK_EX | LOCK_NB)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            if ($lock_fp) fclose($lock_fp);
            $state = $this->load_restore_state($quarantine_id);
            return array_merge($state ?? [], ['busy' => true]);
        }

        // Re-read state inside lock
        $state = $this->load_restore_state($quarantine_id);
        if (!$state || $state['status'] !== 'restoring') {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            flock($lock_fp, LOCK_UN);
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($lock_fp);
            return $state ?? ['status' => 'cancelled'];
        }

        $type = $state['type'];
        $batch_size = ($type === 'bucket') ? 50 : 200;
        $byte_offset = $state['byte_offset'] ?? 0;

        // Read batch from JSONL at current offset (O(1) seek via byte_offset)
        $result = $this->read_restore_jsonl($quarantine_id, $state['offset'], $batch_size, $byte_offset, $state['total']);

        if (empty($result['items'])) {
            // No more items — mark completed
            $state['status'] = 'completed';
            $this->save_restore_state($quarantine_id, $state);
            $this->restore_completion_cleanup($quarantine_id, $state);
            wp_clear_scheduled_hook('sqmedia_continue_restore');
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            flock($lock_fp, LOCK_UN);
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($lock_fp);
            return $state;
        }

        // Track byte position for next batch
        $state['byte_offset'] = $result['next_byte_offset'];

        $batch_errors = 0;

        if ($type === 'local') {
            $upload_dir = wp_upload_dir();
            $basedir = $upload_dir['basedir'];
            $files_dir = $dir . '/files';

            foreach ($result['items'] as $item) {
                $relative_path = $item['path'] ?? '';
                if (empty($relative_path)) {
                    $state['skipped']++;
                    $state['offset']++;
                    continue;
                }

                $quarantine_path = $files_dir . '/' . $relative_path;
                $original_path = $basedir . '/' . $relative_path;

                if (file_exists($original_path)) {
                    $state['skipped']++;
                    $state['offset']++;
                    continue;
                }

                if (!file_exists($quarantine_path)) {
                    // File already gone from quarantine (already restored or deleted)
                    $state['skipped']++;
                    $state['offset']++;
                    continue;
                }

                $original_dir = dirname($original_path);
                if (!is_dir($original_dir)) {
                    wp_mkdir_p($original_dir);
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                if (@rename($quarantine_path, $original_path) || (@copy($quarantine_path, $original_path) && @unlink($quarantine_path))) {
                    $state['restored']++;
                    $state['offset']++;
                } else {
                    $state['errors']++;
                    $batch_errors++;
                }
            }
        } else {
            // Bucket restore
            $s3 = sqmedia()->get_storage_handler();
            $prefix = $this->get_bucket_quarantine_prefix($quarantine_id) . 'files/';

            foreach ($result['items'] as $item) {
                $original_path = $item['path'] ?? '';
                if (empty($original_path)) {
                    $state['skipped']++;
                    $state['offset']++;
                    continue;
                }

                $quarantine_path = $item['quarantine_path'] ?? ($prefix . $original_path);

                if ($s3->file_exists($original_path)) {
                    $state['skipped']++;
                    $state['offset']++;
                    continue;
                }

                if ($s3->rename_file($quarantine_path, $original_path)) {
                    $state['restored']++;
                    $state['offset']++;
                } else {
                    $state['errors']++;
                    $batch_errors++;
                }
            }
        }

        // If entire batch failed with no progress, stall to avoid infinite loop
        if ($batch_errors > 0 && $batch_errors === count($result['items'])) {
            $state['status'] = 'stalled';
            $state['stall_reason'] = sprintf('%d files failed to restore — check error log for details', $batch_errors);
            $this->save_restore_state($quarantine_id, $state);
            wp_clear_scheduled_hook('sqmedia_continue_restore');
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            flock($lock_fp, LOCK_UN);
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($lock_fp);
            return $state;
        }

        // Check if completed
        if ($state['offset'] >= $state['total']) {
            $state['status'] = 'completed';
            $this->save_restore_state($quarantine_id, $state);
            $this->restore_completion_cleanup($quarantine_id, $state);
            wp_clear_scheduled_hook('sqmedia_continue_restore');
        } else {
            $this->save_restore_state($quarantine_id, $state);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        flock($lock_fp, LOCK_UN);
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        fclose($lock_fp);
        return $state;
    }

    /**
     * Cleanup after restore completes.
     *
     * Counts remaining files, auto-cleans quarantine if empty,
     * or updates manifest with reduced count.
     */
    private function restore_completion_cleanup(string $quarantine_id, array $state): void {
        $dir = $this->get_quarantine_dir($quarantine_id);
        $manifest_file = $dir . '/manifest.json';
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $manifest = file_exists($manifest_file) ? json_decode(file_get_contents($manifest_file), true) : null;
        $type = $state['type'];

        // Count remaining files in quarantine
        $remaining = 0;
        $remaining_size = 0;

        if ($type === 'local') {
            $files_dir = $dir . '/files';
            if (is_dir($files_dir)) {
                $iterator = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS)
                );
                foreach ($iterator as $file) {
                    if ($file->isFile()) {
                        $remaining++;
                        $remaining_size += $file->getSize();
                    }
                }
            }
        } elseif ($type === 'bucket') {
            $prefix = $this->get_bucket_quarantine_prefix($quarantine_id) . 'files/';
            $bucket_remaining = sqmedia()->get_storage_handler()->list_files($prefix, 1);
            $remaining = count($bucket_remaining);
        }

        if ($remaining === 0) {
            // Auto-cleanup: remove quarantine folder entirely
            if ($type === 'bucket') {
                $s3 = sqmedia()->get_storage_handler();
                $all_prefix = $this->get_bucket_quarantine_prefix($quarantine_id);
                $leftover = $s3->list_files($all_prefix, 1000);
                if (!empty($leftover)) {
                    $paths = array_column($leftover, 'name');
                    $s3->delete_files($paths);
                }
            }

            if (is_dir($dir)) {
                $this->recursive_rmdir($dir);
            }

            // Clean up parent if empty
            $base_dir = $this->get_quarantine_base_dir();
            $dirs_remaining = glob($base_dir . '/*');
            if (empty($dirs_remaining) && is_dir($base_dir)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                @rmdir($base_dir);
            }
        } else {
            // Update manifest with reduced count
            $this->save_quarantine_manifest($quarantine_id, $type, $remaining, $remaining_size);

            // Clean up restore state/JSONL files
            $this->cleanup_restore_files($quarantine_id);
        }
    }

    /**
     * Clean up restore state and JSONL files
     */
    private function cleanup_restore_files(string $quarantine_id): void {
        $dir = $this->get_quarantine_dir($quarantine_id);
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        wp_delete_file($dir . '/restore_state.json');
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        wp_delete_file($dir . '/restore_files.jsonl');
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        wp_delete_file($dir . '/restore_batch.lock');
    }

    /**
     * Cron: Process restore batches within time budget
     */
    public function cron_process_restore(): void {
        $quarantine_id = $this->find_running_restore();

        if (!$quarantine_id) {
            wp_clear_scheduled_hook('sqmedia_continue_restore');
            return;
        }


        $max_execution = (int) ini_get('max_execution_time');
        if ($max_execution <= 0) {
            $time_limit = 55;
        } else {
            $time_limit = max(10, $max_execution - 10);
        }

        $start = time();
        $batches = 0;

        do {
            $state = $this->load_restore_state($quarantine_id);
            if (!$state) break;

            if ($state['status'] === 'enumerating') {
                $state = $this->enumerate_restore_batch($quarantine_id);
            } elseif ($state['status'] === 'restoring') {
                $state = $this->process_restore_batch($quarantine_id);
            } else {
                break;
            }

            $batches++;

            if (!in_array($state['status'] ?? '', ['enumerating', 'restoring'], true)) {
                break;
            }
        } while ((time() - $start) < $time_limit);

        $elapsed = time() - $start;

        if (!in_array($state['status'] ?? '', ['enumerating', 'restoring'])) {
            wp_clear_scheduled_hook('sqmedia_continue_restore');
        }
    }

    /**
     * Process a single quarantine batch
     *
     * Reads items from JSONL at the current offset, processes them,
     * updates and saves state. Used by both AJAX and cron.
     *
     * @return array Updated state after processing
     */
    private function process_quarantine_batch(string $quarantine_id): array {
        // Acquire exclusive lock to prevent overlapping batch processing
        $lock_file = $this->get_quarantine_dir($quarantine_id) . '/batch.lock';
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $lock_fp = fopen($lock_file, 'c');
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        if (!$lock_fp || !flock($lock_fp, LOCK_EX | LOCK_NB)) {
            // Another process is already processing this quarantine
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            if ($lock_fp) fclose($lock_fp);
            $state = $this->load_quarantine_process_state($quarantine_id);
            return array_merge($state ?? [], ['busy' => true]);
        }

        // Re-read state inside lock to get the latest offset
        $state = $this->load_quarantine_process_state($quarantine_id);
        if (!$state || $state['status'] !== 'running') {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            flock($lock_fp, LOCK_UN);
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($lock_fp);
            return $state ?? ['status' => 'cancelled'];
        }

        $type = $state['type'];
        $scan_id = $state['scan_id'];
        $batch_size = ($type === 'bucket') ? 50 : 200;
        $byte_offset = $state['byte_offset'] ?? 0;

        // Read batch from JSONL — use custom source if set (quarantine selected), else scan JSONL
        if (!empty($state['source_jsonl']) && file_exists($state['source_jsonl'])) {
            $result = $this->read_jsonl_paginated($state['source_jsonl'], $state['offset'], $batch_size, $byte_offset, $state['total']);
        } else {
            $result = $this->read_orphans_paginated($scan_id, $type, $state['offset'], $batch_size, $byte_offset, $state['total']);
        }

        if (empty($result['items'])) {
            // No more items — mark completed
            $state['status'] = 'completed';
            $this->save_quarantine_process_state($quarantine_id, $state);

            // Save manifest
            if ($state['quarantined'] > 0) {
                $this->save_quarantine_manifest($quarantine_id, $type, $state['quarantined'], $state['total_size']);
            }

            // Clear scan JSONL — if this was a selected-files quarantine, remove
            // only the quarantined entries; otherwise clear the entire file.
            if (!empty($state['source_jsonl'])) {
                // Selected files: remove quarantined entries from scan JSONL
                $quarantined_paths = $state['quarantined_paths'] ?? [];
                if (!empty($quarantined_paths)) {
                    $this->remove_orphans_from_jsonl($scan_id, $type, $quarantined_paths);
                }
                // Clean up the selected files JSONL
                if (file_exists($state['source_jsonl'])) {
                    wp_delete_file($state['source_jsonl']);
                }
            } else {
                // All files: clear the entire JSONL
                $scan_dir = $this->get_orphan_index_dir($scan_id);
                $jsonl_file = $scan_dir . '/' . $type . '_orphans.jsonl';
                if (file_exists($jsonl_file)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    file_put_contents($jsonl_file, '', LOCK_EX);
                }
            }

            wp_clear_scheduled_hook('sqmedia_continue_quarantine');
            return $state;
        }

        // Track byte position for next batch
        $state['byte_offset'] = $result['next_byte_offset'];

        $batch_errors = 0;
        $is_selected = !empty($state['source_jsonl']);

        if ($type === 'local') {
            $upload_dir = wp_upload_dir();

            foreach ($result['items'] as $item) {
                $path = $item['relative_path'] ?? $item['path'] ?? '';
                if (empty($path)) continue;

                $file_size = 0;
                $full_path = $upload_dir['basedir'] . '/' . $path;
                if (file_exists($full_path)) {
                    $file_size = filesize($full_path);
                }

                if ($this->quarantine_local_file($path, $quarantine_id)) {
                    $state['quarantined']++;
                    $state['total_size'] += $file_size;
                    $state['offset']++;
                    if ($is_selected) {
                        $state['quarantined_paths'][] = $path;
                    }
                } else {
                    $state['errors']++;
                    $batch_errors++;
                }
            }
        } else {
            $s3 = sqmedia()->get_storage_handler();

            foreach ($result['items'] as $item) {
                $path = $item['path'] ?? '';
                if (empty($path)) continue;

                $file_size = intval($item['size'] ?? 0);

                if ($this->quarantine_bucket_file($path, $quarantine_id, $s3)) {
                    $state['quarantined']++;
                    $state['total_size'] += $file_size;
                    $state['offset']++;
                    if ($is_selected) {
                        $state['quarantined_paths'][] = $path;
                    }
                } else {
                    $state['errors']++;
                    $batch_errors++;
                }
            }
        }

        // Write manifest once per batch (not per file)
        $this->save_quarantine_manifest($quarantine_id, $type, $state['quarantined'], $state['total_size'], 'running');

        // If entire batch failed with no progress, stop to avoid infinite loop
        if ($batch_errors > 0 && $batch_errors === count($result['items'])) {
            $state['status'] = 'stalled';
            $state['stall_reason'] = sprintf('%d files failed to move — check error log for details', $batch_errors);
            $this->save_quarantine_process_state($quarantine_id, $state);
            $this->save_quarantine_manifest($quarantine_id, $type, $state['quarantined'], $state['total_size'], 'stalled');
            wp_clear_scheduled_hook('sqmedia_continue_quarantine');
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            flock($lock_fp, LOCK_UN);
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($lock_fp);
            return $state;
        }

        // Check if completed
        if ($state['offset'] >= $state['total']) {
            $state['status'] = 'completed';

            if (!empty($state['source_jsonl'])) {
                // Selected files: remove only quarantined entries from scan JSONL
                $quarantined_paths = $state['quarantined_paths'] ?? [];
                if (!empty($quarantined_paths)) {
                    $this->remove_orphans_from_jsonl($scan_id, $type, $quarantined_paths);
                }
                // Clean up the selected files JSONL
                if (file_exists($state['source_jsonl'])) {
                    wp_delete_file($state['source_jsonl']);
                }
            } else {
                // All files: clear the entire JSONL
                $scan_dir = $this->get_orphan_index_dir($scan_id);
                $jsonl_file = $scan_dir . '/' . $type . '_orphans.jsonl';
                if (file_exists($jsonl_file)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    file_put_contents($jsonl_file, '', LOCK_EX);
                }
            }

            wp_clear_scheduled_hook('sqmedia_continue_quarantine');
            $this->save_quarantine_manifest($quarantine_id, $type, $state['quarantined'], $state['total_size'], 'completed');
        }

        $this->save_quarantine_process_state($quarantine_id, $state);

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        flock($lock_fp, LOCK_UN);
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        fclose($lock_fp);
        return $state;
    }

    /**
     * Get all quarantines sorted by date descending
     */
    private function get_all_quarantines(): array {
        $base_dir = $this->get_quarantine_base_dir();

        if (!is_dir($base_dir)) {
            return [];
        }

        $quarantines = [];
        $dirs = glob($base_dir . '/q_*', GLOB_ONLYDIR);

        if (!$dirs) {
            return [];
        }

        foreach ($dirs as $dir) {
            $manifest_file = $dir . '/manifest.json';
            if (!file_exists($manifest_file)) {
                continue;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $content = file_get_contents($manifest_file);
            if ($content === false) {
                continue;
            }

            $manifest = json_decode($content, true);
            if (is_array($manifest)) {
                // Check for running restore
                $restore_state_file = $dir . '/restore_state.json';
                if (file_exists($restore_state_file)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $restore_content = file_get_contents($restore_state_file);
                    if ($restore_content !== false) {
                        $restore_state = json_decode($restore_content, true);
                        if (is_array($restore_state) && in_array($restore_state['status'] ?? '', ['enumerating', 'restoring'])) {
                            $manifest['restore_status'] = $restore_state['status'];
                        }
                    }
                }
                $quarantines[] = $manifest;
            }
        }

        // Sort by created date descending
        usort($quarantines, function($a, $b) {
            return strcmp($b['created'] ?? '', $a['created'] ?? '');
        });

        return $quarantines;
    }

    /**
     * Purge a quarantine (permanently delete all quarantined files)
     */
    private function purge_quarantine(string $id): array {
        $dir = $this->get_quarantine_dir($id);
        $manifest_file = $dir . '/manifest.json';

        if (!file_exists($manifest_file)) {
            return ['deleted' => 0, 'errors' => ['Quarantine not found']];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $manifest = json_decode(file_get_contents($manifest_file), true);
        if (!$manifest) {
            return ['deleted' => 0, 'errors' => ['Invalid manifest']];
        }

        $deleted = 0;
        $errors = [];

        // Delete bucket files if type is bucket
        if ($manifest['type'] === 'bucket') {
            $s3 = sqmedia()->get_storage_handler();
            $prefix = $this->get_bucket_quarantine_prefix($id) . 'files/';

            // List and delete in batches
            $page_token = null;
            do {
                $result = $s3->list_files_paginated($prefix, 1000, $page_token);
                $files_to_delete = [];

                foreach ($result['files'] as $file) {
                    $files_to_delete[] = $file['name'];
                }

                if (!empty($files_to_delete)) {
                    $delete_result = $s3->delete_files($files_to_delete);
                    $deleted += count($delete_result['deleted'] ?? $files_to_delete);
                    if (!empty($delete_result['errors'])) {
                        $errors = array_merge($errors, $delete_result['errors']);
                    }
                }

                $page_token = $result['next_page_token'] ?? null;
            } while ($page_token);
        }

        // Delete local quarantine directory (files + manifest)
        if (is_dir($dir)) {
            $this->recursive_rmdir($dir);
            if (!is_dir($dir)) {
                // Count local files deleted (for local type quarantines)
                if ($manifest['type'] === 'local') {
                    $deleted = $manifest['file_count'];
                }
            } else {
                $errors[] = 'Failed to remove quarantine directory';
            }
        }

        // Clean up parent if empty
        $base_dir = $this->get_quarantine_base_dir();
        $remaining = glob($base_dir . '/*');
        if (empty($remaining) && is_dir($base_dir)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            @rmdir($base_dir);
        }

        return ['deleted' => $deleted, 'errors' => $errors];
    }

    /**
     * Restore quarantined files to their original locations
     */
    private function restore_quarantine(string $id): array {
        $dir = $this->get_quarantine_dir($id);
        $manifest_file = $dir . '/manifest.json';

        if (!file_exists($manifest_file)) {
            return ['restored' => 0, 'skipped' => 0, 'remaining' => 0, 'auto_cleaned' => false];
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $manifest = json_decode(file_get_contents($manifest_file), true);
        if (!$manifest) {
            return ['restored' => 0, 'skipped' => 0, 'remaining' => 0, 'auto_cleaned' => false];
        }

        $restored = 0;
        $skipped = 0;

        if ($manifest['type'] === 'local') {
            $upload_dir = wp_upload_dir();
            $basedir = $upload_dir['basedir'];
            $files_dir = $dir . '/files';

            if (is_dir($files_dir)) {
                $iterator = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS),
                    RecursiveIteratorIterator::SELF_FIRST
                );

                foreach ($iterator as $file) {
                    if (!$file->isFile()) continue;

                    $quarantine_path = str_replace('\\', '/', $file->getPathname());
                    $files_dir_normalized = str_replace('\\', '/', $files_dir);
                    $relative_path = str_replace($files_dir_normalized . '/', '', $quarantine_path);
                    $original_path = $basedir . '/' . $relative_path;

                    if (file_exists($original_path)) {
                        $skipped++;
                        continue;
                    }

                    $original_dir = dirname($original_path);
                    if (!is_dir($original_dir)) {
                        wp_mkdir_p($original_dir);
                    }

                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    if (@rename($quarantine_path, $original_path) || (@copy($quarantine_path, $original_path) && @unlink($quarantine_path))) {
                        $restored++;
                    } else {
                        $skipped++;
                    }
                }
            }
        } elseif ($manifest['type'] === 'bucket') {
            $s3 = sqmedia()->get_storage_handler();
            $prefix = $this->get_bucket_quarantine_prefix($id) . 'files/';

            $page_token = null;
            do {
                $result = $s3->list_files_paginated($prefix, 1000, $page_token);

                foreach ($result['files'] as $file) {
                    $quarantine_path = $file['name'];
                    // Strip quarantine prefix to get original path
                    $original_path = preg_replace('#^' . preg_quote($prefix, '#') . '#', '', $quarantine_path);

                    if (empty($original_path)) continue;

                    if ($s3->file_exists($original_path)) {
                        $skipped++;
                        continue;
                    }

                    if ($s3->rename_file($quarantine_path, $original_path)) {
                        $restored++;
                    } else {
                        $skipped++;
                    }
                }

                $page_token = $result['next_page_token'] ?? null;
            } while ($page_token);
        }

        // Check remaining files in quarantine
        $remaining = 0;
        $auto_cleaned = false;

        if ($manifest['type'] === 'local') {
            $files_dir = $dir . '/files';
            if (is_dir($files_dir)) {
                $iterator = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS)
                );
                foreach ($iterator as $file) {
                    if ($file->isFile()) $remaining++;
                }
            }
        } elseif ($manifest['type'] === 'bucket') {
            $prefix = $this->get_bucket_quarantine_prefix($id) . 'files/';
            $bucket_remaining = sqmedia()->get_storage_handler()->list_files($prefix, 1);
            $remaining = count($bucket_remaining);
        }

        if ($remaining === 0) {
            // Auto-cleanup: remove quarantine folder entirely
            if ($manifest['type'] === 'bucket') {
                // Delete any remaining bucket quarantine metadata
                $s3 = sqmedia()->get_storage_handler();
                $all_prefix = $this->get_bucket_quarantine_prefix($id);
                $leftover = $s3->list_files($all_prefix, 1000);
                if (!empty($leftover)) {
                    $paths = array_column($leftover, 'name');
                    $s3->delete_files($paths);
                }
            }

            // Remove local quarantine directory
            if (is_dir($dir)) {
                $this->recursive_rmdir($dir);
            }

            $auto_cleaned = true;

            // Clean up parent if empty
            $base_dir = $this->get_quarantine_base_dir();
            $dirs_remaining = glob($base_dir . '/*');
            if (empty($dirs_remaining) && is_dir($base_dir)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                @rmdir($base_dir);
            }
        } else {
            // Update manifest with reduced counts
            $remaining_size = 0;
            if ($manifest['type'] === 'local') {
                $files_dir = $dir . '/files';
                if (is_dir($files_dir)) {
                    $iterator = new RecursiveIteratorIterator(
                        new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS)
                    );
                    foreach ($iterator as $file) {
                        if ($file->isFile()) $remaining_size += $file->getSize();
                    }
                }
            }
            $this->save_quarantine_manifest($id, $manifest['type'], $remaining, $remaining_size);
        }

        return [
            'restored' => $restored,
            'skipped' => $skipped,
            'remaining' => $remaining,
            'auto_cleaned' => $auto_cleaned,
        ];
    }

    /**
     * Recursively remove a directory and its contents
     */
    private function recursive_rmdir(string $dir): void {
        if (!is_dir($dir)) return;

        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );

        foreach ($items as $item) {
            if ($item->isDir()) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                @rmdir($item->getPathname());
            } else {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                @unlink($item->getPathname());
            }
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions
        @rmdir($dir);
    }

    /**
     * Save orphan scan state to file
     */
    private function save_orphan_state(string $scan_id, array $state): bool {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $state_file = $scan_dir . '/state.json';
        
        if (!is_dir($scan_dir)) {
            wp_mkdir_p($scan_dir);
        }
        
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $result = file_put_contents($state_file, json_encode($state, JSON_PRETTY_PRINT), LOCK_EX);
        return $result !== false;
    }
    
    /**
     * Load orphan scan state from file
     */
    private function load_orphan_state(string $scan_id): ?array {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $state_file = $scan_dir . '/state.json';
        
        if (!file_exists($state_file)) {
            return null;
        }
        
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $content = file_get_contents($state_file);
        if ($content === false) {
            return null;
        }
        
        $state = json_decode($content, true);
        return is_array($state) ? $state : null;
    }
    
    /**
     * Read orphans from JSONL file with pagination.
     *
     // phpcs:ignore WordPress.WP.AlternativeFunctions
     * When $byte_offset > 0, uses fseek() for O(1) seeking instead of
     * line-by-line iteration. Pass $known_total to skip re-counting lines.
     *
     * @param string $scan_id The scan ID
     * @param string $type 'local' or 'bucket'
     * @param int $offset Starting line (0-indexed)
     * @param int $limit Number of items to read
     * @param int $byte_offset Byte position to fseek to (0 = use line-based $offset)
     * @param int|null $known_total If provided, skip line counting (use stored total)
     * @return array ['items' => [...], 'total' => int, 'next_byte_offset' => int]
     */
    private function read_orphans_paginated(string $scan_id, string $type, int $offset, int $limit, int $byte_offset = 0, ?int $known_total = null): array {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $file = $scan_dir . '/' . $type . '_orphans.jsonl';

        if (!file_exists($file)) {
            return ['items' => [], 'total' => 0, 'next_byte_offset' => 0];
        }

        $items = [];
        $total = $known_total ?? 0;
        $next_byte_offset = 0;

        try {
            if ($byte_offset > 0) {
                // Fast path: O(1) byte-level seek
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $fp = fopen($file, 'r');
                if (!$fp) {
                    return ['items' => [], 'total' => $total, 'next_byte_offset' => 0];
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fseek($fp, $byte_offset);

                if ($known_total === null) {
                    $total = 0;
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $count_fp = fopen($file, 'r');
                    if ($count_fp) {
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        while (fgets($count_fp) !== false) {
                            $total++;
                        }
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        fclose($count_fp);
                    }
                }

                $read = 0;
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                while (!feof($fp) && $read < $limit) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $line = fgets($fp);
                    if ($line === false) break;
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $item['size_formatted'] = size_format($item['size'] ?? 0, 2);
                            $items[] = $item;
                        }
                    }
                    $read++;
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $next_byte_offset = ftell($fp);
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fclose($fp);
            } else {
                // Legacy path: line-based seek (for UI pagination callers)
                $handle = new SplFileObject($file, 'r');

                if ($known_total === null) {
                    $handle->seek(PHP_INT_MAX);
                    $total = $handle->key();
                }

                $handle->seek($offset);
                $read = 0;

                while (!$handle->eof() && $read < $limit) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $line = $handle->fgets();
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $item['size_formatted'] = size_format($item['size'] ?? 0, 2);
                            $items[] = $item;
                        }
                    }
                    $read++;
                }

                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $next_byte_offset = $handle->ftell();
            }
        } catch (Exception $e) {
        }

        return ['items' => $items, 'total' => $total, 'next_byte_offset' => $next_byte_offset];
    }
    
    /**
     * Read items from an arbitrary JSONL file with byte-offset pagination.
     * Used by process_quarantine_batch when processing a custom source file (quarantine selected).
     */
    private function read_jsonl_paginated(string $file, int $offset, int $limit, int $byte_offset = 0, ?int $known_total = null): array {
        if (!file_exists($file)) {
            return ['items' => [], 'total' => 0, 'next_byte_offset' => 0];
        }

        $items = [];
        $total = $known_total ?? 0;
        $next_byte_offset = 0;

        try {
            if ($byte_offset > 0) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $fp = fopen($file, 'r');
                if (!$fp) {
                    return ['items' => [], 'total' => $total, 'next_byte_offset' => 0];
                }
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fseek($fp, $byte_offset);

                $read = 0;
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                while (!feof($fp) && $read < $limit) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $line = fgets($fp);
                    if ($line === false) break;
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $items[] = $item;
                        }
                    }
                    $read++;
                }
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $next_byte_offset = ftell($fp);
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fclose($fp);
            } else {
                $handle = new SplFileObject($file, 'r');
                if ($known_total === null) {
                    $handle->seek(PHP_INT_MAX);
                    $total = $handle->key();
                }
                $handle->seek($offset);
                $read = 0;
                while (!$handle->eof() && $read < $limit) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    $line = $handle->fgets();
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $items[] = $item;
                        }
                    }
                    $read++;
                }
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $next_byte_offset = $handle->ftell();
            }
        } catch (Exception $e) {
            // Silently continue
        }

        return ['items' => $items, 'total' => $total, 'next_byte_offset' => $next_byte_offset];
    }

    /**
     * Read ALL orphans from JSONL file (no limit)
     * 
     * @param string $scan_id The scan ID
     * @param string $type 'local' or 'bucket'
     * @return array Array with 'items' and 'total'
     */
    private function read_all_orphans(string $scan_id, string $type): array {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $file = $scan_dir . '/' . $type . '_orphans.jsonl';
        
        if (!file_exists($file)) {
            return ['items' => [], 'total' => 0, 'total_size' => 0];
        }
        
        $items = [];
        $total_size = 0;
        
        try {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $handle = fopen($file, 'r');
            if ($handle) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                while (($line = fgets($handle)) !== false) {
                    if (!empty(trim($line))) {
                        $item = json_decode($line, true);
                        if ($item) {
                            $item['size_formatted'] = size_format($item['size'] ?? 0, 2);
                            $items[] = $item;
                            $total_size += (int)($item['size'] ?? 0);
                        }
                    }
                }
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                fclose($handle);
            }
        } catch (Exception $e) {
        }
        
        return ['items' => $items, 'total' => count($items), 'total_size' => $total_size];
    }
    
    /**
     * Get all orphan paths from JSONL file (for deletion operations)
     * 
     * @param string $scan_id The scan ID
     * @param string $type 'local' or 'bucket'
     * @return array Array of paths
     */
    private function get_all_orphan_paths(string $scan_id, string $type): array {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $file = $scan_dir . '/' . $type . '_orphans.jsonl';
        
        if (!file_exists($file)) {
            return [];
        }
        
        $paths = [];
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $handle = fopen($file, 'r');
        if ($handle) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            while (($line = fgets($handle)) !== false) {
                if (!empty(trim($line))) {
                    $item = json_decode($line, true);
                    if ($item && !empty($item['path'])) {
                        $paths[] = $item['path'];
                    }
                }
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($handle);
        }
        
        return $paths;
    }

    /**
     * Remove specific paths from orphan JSONL file
     */
    private function remove_orphans_from_jsonl(string $scan_id, string $type, array $paths_to_remove): void {
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $file = $scan_dir . '/' . $type . '_orphans.jsonl';

        if (!file_exists($file) || empty($paths_to_remove)) {
            return;
        }

        // Build lookup set for fast matching
        $remove_set = array_flip($paths_to_remove);

        $lines_kept = [];
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        $handle = fopen($file, 'r');
        if ($handle) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            while (($line = fgets($handle)) !== false) {
                $trimmed = trim($line);
                if (empty($trimmed)) continue;

                $item = json_decode($trimmed, true);
                if (!$item) continue;

                // Check both path and relative_path against remove set
                $path = $item['path'] ?? '';
                $rel = $item['relative_path'] ?? '';

                if (isset($remove_set[$path]) || isset($remove_set[$rel])) {
                    continue; // Skip this entry
                }

                $lines_kept[] = $trimmed;
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            fclose($handle);
        }

        // Rewrite file
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        file_put_contents($file, implode("\n", $lines_kept) . (empty($lines_kept) ? '' : "\n"), LOCK_EX);

        // Rebuild folder tree from remaining items
        $folder_tree = [];
        foreach ($lines_kept as $line) {
            $item = json_decode($line, true);
            if (!$item) continue;
            $rel = $item['relative_path'] ?? '';
            $dir = dirname($rel);
            if ($dir === '.') $dir = '';
            if (!isset($folder_tree[$dir])) {
                $folder_tree[$dir] = ['count' => 0, 'size' => 0];
            }
            $folder_tree[$dir]['count']++;
            $folder_tree[$dir]['size'] += (int) ($item['size'] ?? 0);
        }
        $tree_file = $scan_dir . '/' . $type . '_folder_tree.json';
        // phpcs:ignore WordPress.WP.AlternativeFunctions
        file_put_contents($tree_file, json_encode($folder_tree));
    }

    /**
     * Get all local media files (for incremental scanning)
     */
    private function get_local_media_files(string $basedir, array $exclude_folders = [], string $scan_folder = ''): array {
        $files = [];
        $extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp4', 'mp3', 'swf', 'doc', 'docx', 'xls', 'xlsx'];
        
        // Helper to check if path should be excluded
        $should_exclude = function($relative_path) use ($exclude_folders, $scan_folder) {
            // Always exclude scan folder
            if ($scan_folder && strpos($relative_path, $scan_folder) === 0) {
                return true;
            }

            // Always exclude quarantine folder
            if (strpos($relative_path, 'staticq_quarantine/') === 0) {
                return true;
            }

            if (empty($exclude_folders)) {
                return false;
            }
            
            $path_lower = strtolower($relative_path);
            foreach ($exclude_folders as $folder) {
                $folder_lower = strtolower(trim($folder));
                if (empty($folder_lower)) continue;
                // Check if folder appears in path
                if (strpos($path_lower, '/' . $folder_lower . '/') !== false ||
                    strpos($path_lower, '/' . $folder_lower) === strlen($path_lower) - strlen('/' . $folder_lower) ||
                    strpos($path_lower, $folder_lower . '/') === 0) {
                    return true;
                }
            }
            return false;
        };
        
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($basedir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );
        
        foreach ($iterator as $file) {
            if ($file->isFile()) {
                $ext = strtolower(pathinfo($file->getPathname(), PATHINFO_EXTENSION));
                if (in_array($ext, $extensions)) {
                    // Normalize path separators (Windows compatibility)
                    $file_path = str_replace('\\', '/', $file->getPathname());
                    $basedir_normalized = str_replace('\\', '/', $basedir);
                    
                    // Check exclusion before adding
                    $relative_path = str_replace($basedir_normalized . '/', '', $file_path);
                    if (!$should_exclude($relative_path)) {
                        // Store normalized path for consistent comparison later
                        $files[] = $file_path;
                    }
                }
            }
        }
        
        return $files;
    }

    /**
     * AJAX: Get orphan scan progress
     */
    public function ajax_orphan_scan_progress(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }
        
        $state = $this->load_orphan_state($scan_id);
        
        if (!$state) {
            // Check if scan was cancelled or failed in database
            $db = sqmedia()->get_database();
            $scan_record = $db->get_scan($scan_id);
            
            if ($scan_record && in_array($scan_record->status, ['cancelled', 'failed', 'completed'])) {
                wp_send_json_success([
                    'status' => $scan_record->status,
                    'progress' => 100,
                    'message' => $scan_record->status === 'cancelled' 
                        ? __('Scan was cancelled', 'staticq-media') 
                        : __('Scan ended', 'staticq-media'),
                ]);
            }
            
            wp_send_json_error(__('Scan not found or expired.', 'staticq-media'));
        }
        
        // Read sample orphans from JSONL files for live preview (first 200 of each)
        $local_sample = $this->read_orphans_paginated($scan_id, 'local', 0, 200);
        $bucket_sample = $this->read_orphans_paginated($scan_id, 'bucket', 0, 200);

        // Load complete folder trees (built incrementally during scan)
        $scan_dir = $this->get_orphan_index_dir($scan_id);
        $local_tree = null;
        $local_tree_file = $scan_dir . '/local_folder_tree.json';
        if (file_exists($local_tree_file)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $local_tree = json_decode(file_get_contents($local_tree_file), true);
        }
        $bucket_tree = null;
        $bucket_tree_file = $scan_dir . '/bucket_folder_tree.json';
        if (file_exists($bucket_tree_file)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $bucket_tree = json_decode(file_get_contents($bucket_tree_file), true);
        }

        // Build descriptive phase label
        $raw_phase = $state['phase'] ?? 'unknown';
        if ($raw_phase === 'build_index') {
            $phase_label = 'building index (' . ($state['index_offset'] ?? 0) . ' / ' . ($state['index_total'] ?? '?') . ' attachments)';
        } elseif ($raw_phase === 'bucket') {
            $phase_label = 'scanning bucket (' . ($state['bucket_listing_total'] ?? 0) . ' files checked)';
        } elseif ($raw_phase === 'local' && empty($state['local_files_ready'])) {
            $phase_label = 'listing local files';
        } elseif ($raw_phase === 'local') {
            $phase_label = 'scanning local';
        } else {
            $phase_label = $raw_phase;
        }

        wp_send_json_success([
            'status' => $state['status'],
            'phase' => $phase_label,
            'progress' => $state['progress'] ?? 0,
            'processed_files' => $state['processed_files'] ?? 0,
            'orphans_found' => ($state['local_orphan_count'] ?? 0) + ($state['bucket_orphan_count'] ?? 0),
            'local_orphans' => $state['local_orphan_count'] ?? 0,
            'bucket_orphans' => $state['bucket_orphan_count'] ?? 0,
            'results' => [
                'local_orphans' => $local_sample['items'],
                'bucket_orphans' => $bucket_sample['items'],
                'total_local_orphans' => $state['local_orphan_count'] ?? 0,
                'total_bucket_orphans' => $state['bucket_orphan_count'] ?? 0,
                'local_folder_tree' => $local_tree,
                'bucket_folder_tree' => $bucket_tree,
            ],
        ]);
    }
    
    /**
     * AJAX: Cancel orphan scan
     */
    public function ajax_cancel_orphan_scan(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        if (!empty($scan_id)) {
            // Clean up all scan files
            $this->cleanup_orphan_scan($scan_id);
            
            // Update database record
            $db = sqmedia()->get_database();
            $db->update_scan($scan_id, [
                'status' => 'cancelled',
                'scan_completed' => current_time('mysql'),
            ]);
        }
        
        // Clear the cron job
        wp_clear_scheduled_hook('sqmedia_continue_orphan_scan');
        
        
        wp_send_json_success([
            'message' => __('Scan cancelled', 'staticq-media'),
        ]);
    }
    
    /**
     * AJAX: Quarantine selected orphan files
     */
    public function ajax_quarantine_orphans(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $local_files = [];
        $bucket_files = [];

        // New format from Media Manager: files (JSON) + quarantine_type
        if (isset($_POST['files']) && isset($_POST['quarantine_type'])) {
            $quarantine_type = sanitize_text_field(wp_unslash($_POST['quarantine_type']));
            if (!in_array($quarantine_type, ['local', 'bucket'], true)) {
                wp_send_json_error(__('Invalid quarantine type.', 'staticq-media'));
            }

            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON decoded and individual paths sanitized below
            $files = json_decode(wp_unslash($_POST['files']), true);
            if (!is_array($files)) {
                $files = [];
            }

            $paths = [];
            foreach ($files as $file) {
                if (!is_array($file)) {
                    continue;
                }
                $raw_path = $quarantine_type === 'bucket'
                    ? ($file['path'] ?? $file['relative_path'] ?? '')
                    : ($file['relative_path'] ?? $file['path'] ?? '');
                $clean_path = sanitize_text_field((string) $raw_path);
                if ($clean_path !== '') {
                    $paths[] = $clean_path;
                }
            }

            if ($quarantine_type === 'local') {
                $local_files = $paths;
            } else {
                $bucket_files = $paths;
            }
        }

        if (empty($local_files) && empty($bucket_files)) {
            wp_send_json_error(__('No files selected.', 'staticq-media'));
        }

        // Generate quarantine ID
        $quarantine_id = 'q_' . time() . '_' . wp_generate_password(6, false, false);
        $type = !empty($local_files) ? 'local' : 'bucket';

        $local_quarantined = 0;
        $bucket_quarantined = 0;
        $errors = [];
        $quarantined_paths = [
            'local' => [],
            'bucket' => [],
        ];
        $total_size = 0;

        // Quarantine local files
        if (!empty($local_files)) {
            foreach ($local_files as $path) {
                $path = sanitize_text_field($path);

                if ($this->validate_upload_path($path) === false) {
                    $errors[] = 'local: ' . $path . ' (invalid path)';
                    continue;
                }

                // Get file size before moving
                $upload_dir = wp_upload_dir();
                $full_path = $upload_dir['basedir'] . '/' . $path;
                if (file_exists($full_path)) {
                    $total_size += filesize($full_path);
                }

                if ($this->quarantine_local_file($path, $quarantine_id)) {
                    $local_quarantined++;
                    $quarantined_paths['local'][] = $path;
                } else {
                    $errors[] = 'local: ' . $path;
                }
            }
        }

        // Quarantine bucket files
        if (!empty($bucket_files)) {
            $s3 = sqmedia()->get_storage_handler();

            foreach ($bucket_files as $path) {
                $path = sanitize_text_field($path);
                if (strpos($path, '..') !== false) {
                    $errors[] = 'bucket: ' . $path . ' (invalid path)';
                    continue;
                }
                if ($this->quarantine_bucket_file($path, $quarantine_id, $s3)) {
                    $bucket_quarantined++;
                    $quarantined_paths['bucket'][] = $path;
                } else {
                    $errors[] = 'bucket: ' . $path;
                }
            }
        }

        $total_quarantined = $local_quarantined + $bucket_quarantined;
        $total_requested = count($local_files) + count($bucket_files);

        // Save manifest
        if ($total_quarantined > 0) {
            $this->save_quarantine_manifest($quarantine_id, $type, $total_quarantined, $total_size);
        }

        // Remove quarantined entries from scan results JSONL
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        if (!empty($scan_id)) {
            if (!empty($quarantined_paths['local'])) {
                $this->remove_orphans_from_jsonl($scan_id, 'local', $quarantined_paths['local']);
            }
            if (!empty($quarantined_paths['bucket'])) {
                $this->remove_orphans_from_jsonl($scan_id, 'bucket', $quarantined_paths['bucket']);
            }
        }

        wp_send_json_success([
            'local_quarantined' => $local_quarantined,
            'bucket_quarantined' => $bucket_quarantined,
            'quarantined' => $total_quarantined,
            'quarantined_paths' => $quarantined_paths,
            'quarantine_id' => $quarantine_id,
            'errors' => $errors,
            'message' => sprintf(
                // translators: %1$d is the number of files moved, %2$d is the total number of files
                __('Moved %1$d of %2$d files to quarantine.', 'staticq-media'),
                $total_quarantined,
                $total_requested
            ),
        ]);
    }
    
    // =========================================================================
    // ORPHAN RETRIEVAL AJAX HANDLERS
    // =========================================================================
    
    /**
     * AJAX: Get orphans with real pagination
     * Streams total_size without loading all items into memory
     */
    public function ajax_get_orphans_paginated(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        $type = sanitize_text_field(wp_unslash($_POST['type'] ?? 'local'));
        $offset = max(0, intval($_POST['offset'] ?? 0));
        $limit = max(10, min(500, intval($_POST['limit'] ?? 500)));

        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }

        if (!in_array($type, ['local', 'bucket'])) {
            wp_send_json_error(__('Invalid type. Must be "local" or "bucket".', 'staticq-media'));
        }

        // Get paginated items
        $result = $this->read_orphans_paginated($scan_id, $type, $offset, $limit);

        $scan_dir = $this->get_orphan_index_dir($scan_id);

        // Stream total_size from file without loading all items (only on first page)
        $total_size = 0;
        if ($offset === 0) {
            $file = $scan_dir . '/' . $type . '_orphans.jsonl';
            if (file_exists($file)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $fh = fopen($file, 'r');
                if ($fh) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    while (($line = fgets($fh)) !== false) {
                        if (!empty(trim($line))) {
                            $item = json_decode($line, true);
                            if ($item) {
                                $total_size += (int)($item['size'] ?? 0);
                            }
                        }
                    }
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    fclose($fh);
                }
            }
        }

        // Load the complete folder tree (built during scan)
        $folder_tree = null;
        $tree_file = $scan_dir . '/' . $type . '_folder_tree.json';
        if (file_exists($tree_file)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $folder_tree = json_decode(file_get_contents($tree_file), true);
        }

        // Fallback: build tree from JSONL if tree file is missing (e.g. older scan)
        if (empty($folder_tree)) {
            $jsonl_file = $scan_dir . '/' . $type . '_orphans.jsonl';
            if (file_exists($jsonl_file)) {
                $folder_tree = [];
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                $fh = fopen($jsonl_file, 'r');
                if ($fh) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    while (($line = fgets($fh)) !== false) {
                        $line = trim($line);
                        if (empty($line)) continue;
                        $item = json_decode($line, true);
                        if (!$item) continue;
                        $rel = $item['relative_path'] ?? '';
                        $dir = dirname($rel);
                        if ($dir === '.') $dir = '';
                        if (!isset($folder_tree[$dir])) {
                            $folder_tree[$dir] = ['count' => 0, 'size' => 0];
                        }
                        $folder_tree[$dir]['count']++;
                        $folder_tree[$dir]['size'] += (int) ($item['size'] ?? 0);
                    }
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    fclose($fh);
                }
                // Cache for future requests
                if (!empty($folder_tree)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions
                    file_put_contents($tree_file, json_encode($folder_tree));
                }
            }
        }

        wp_send_json_success([
            'items' => $result['items'],
            'total' => $result['total'],
            'total_size' => $total_size,
            'folder_tree' => $folder_tree,
            'offset' => $offset,
            'limit' => $limit,
            'has_more' => ($offset + $limit) < $result['total'],
        ]);
    }
    
    /**
     * AJAX: Quarantine all orphans of a type (batch processing)
     */
    public function ajax_quarantine_all_orphans(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));

        // First call — create state and schedule cron
        if (empty($quarantine_id)) {
            $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
            $type = sanitize_text_field(wp_unslash($_POST['type'] ?? ''));
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON decoded and used internally, not output
            $selected_files_json = wp_unslash($_POST['files'] ?? '');

            if (empty($scan_id)) {
                wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
            }

            if (!in_array($type, ['local', 'bucket'])) {
                wp_send_json_error(__('Invalid type. Must be "local" or "bucket".', 'staticq-media'));
            }

            $quarantine_id = 'q_' . time() . '_' . wp_generate_password(6, false, false);

            // If selected files are provided, write them to a dedicated JSONL
            $source_jsonl = '';
            if (!empty($selected_files_json)) {
                $selected_files = json_decode($selected_files_json, true);
                if (is_array($selected_files) && !empty($selected_files)) {
                    $sanitized = [];
                    foreach ($selected_files as $file_item) {
                        if (!is_array($file_item)) {
                            continue;
                        }
                        $relative_path = sanitize_text_field((string) ($file_item['relative_path'] ?? ''));
                        $path          = sanitize_text_field((string) ($file_item['path'] ?? ''));
                        if ($relative_path === '' && $path === '') {
                            continue;
                        }
                        $sanitized[] = [
                            'relative_path' => $relative_path,
                            'path'          => $path,
                            'size'          => absint($file_item['size'] ?? 0),
                            'type'          => sanitize_key($file_item['type'] ?? ''),
                        ];
                    }

                    if (!empty($sanitized)) {
                        $q_dir = $this->get_quarantine_dir($quarantine_id);
                        wp_mkdir_p($q_dir);
                        $source_jsonl = $q_dir . '/selected_files.jsonl';
                        $lines = '';
                        foreach ($sanitized as $file_item) {
                            $lines .= wp_json_encode($file_item) . "\n";
                        }
                        // phpcs:ignore WordPress.WP.AlternativeFunctions
                        file_put_contents($source_jsonl, $lines, LOCK_EX);
                        $total = count($sanitized);
                    }
                }
            }

            // Count total orphans from scan JSONL if no selected files
            if (empty($source_jsonl)) {
                $scan_dir = $this->get_orphan_index_dir($scan_id);
                $jsonl_file = $scan_dir . '/' . $type . '_orphans.jsonl';
                $total = 0;
                if (file_exists($jsonl_file)) {
                    try {
                        $handle = new SplFileObject($jsonl_file, 'r');
                        $handle->seek(PHP_INT_MAX);
                        $total = $handle->key();
                    } catch (Exception $e) {
                        // Fallback
                    }
                }
            }

            if ($total === 0) {
                wp_send_json_success([
                    'status' => 'completed',
                    'quarantine_id' => $quarantine_id,
                    'total' => 0,
                    'quarantined' => 0,
                    'errors' => 0,
                    'message' => __('No orphan files to quarantine.', 'staticq-media'),
                ]);
                return;
            }

            $state = [
                'status'      => 'running',
                'type'        => $type,
                'scan_id'     => $scan_id,
                'offset'      => 0,
                'byte_offset' => 0,
                'total'       => $total,
                'quarantined' => 0,
                'errors'      => 0,
                'total_size'  => 0,
                'started'    => current_time('Y-m-d H:i:s'),
            ];

            if (!empty($source_jsonl)) {
                $state['source_jsonl'] = $source_jsonl;
            }

            $this->save_quarantine_process_state($quarantine_id, $state);

            // Schedule cron fallback
            if (!wp_next_scheduled('sqmedia_continue_quarantine')) {
                wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_continue_quarantine');
            }
        }

        // Process one batch (state file is the source of truth for offset)
        $state = $this->process_quarantine_batch($quarantine_id);

        $progress_pct = $state['total'] > 0
            ? round(($state['offset'] / $state['total']) * 100, 1)
            : 100;

        wp_send_json_success([
            'status'        => $state['status'],
            'type'          => $state['type'] ?? '',
            'quarantine_id' => $quarantine_id,
            'total'         => $state['total'],
            'offset'        => $state['offset'],
            'quarantined'   => $state['quarantined'],
            'errors'        => $state['errors'],
            'total_size'    => $state['total_size'],
            'progress_pct'  => $progress_pct,
        ]);
    }

    /**
     * AJAX: Get quarantine-all progress
     */
    public function ajax_quarantine_progress(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));
        if (empty($quarantine_id)) {
            wp_send_json_error(__('No quarantine ID provided.', 'staticq-media'));
        }

        $state = $this->load_quarantine_process_state($quarantine_id);
        if (!$state) {
            wp_send_json_error(__('Quarantine process not found.', 'staticq-media'));
        }

        $progress_pct = $state['total'] > 0
            ? round(($state['offset'] / $state['total']) * 100, 1)
            : 100;

        wp_send_json_success([
            'status'       => $state['status'],
            'type'         => $state['type'] ?? '',
            'offset'       => $state['offset'],
            'total'        => $state['total'],
            'quarantined'  => $state['quarantined'],
            'errors'       => $state['errors'],
            'total_size'   => $state['total_size'],
            'progress_pct' => $progress_pct,
        ]);
    }

    /**
     * AJAX: Cancel a running quarantine-all process
     */
    public function ajax_cancel_quarantine(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));
        if (empty($quarantine_id)) {
            wp_send_json_error(__('No quarantine ID provided.', 'staticq-media'));
        }

        $state = $this->load_quarantine_process_state($quarantine_id);
        if (!$state) {
            wp_send_json_error(__('Quarantine process not found.', 'staticq-media'));
        }

        $state['status'] = 'cancelled';
        $this->save_quarantine_process_state($quarantine_id, $state);

        wp_clear_scheduled_hook('sqmedia_continue_quarantine');

        // Trim processed entries from JSONL so next quarantine-all starts fresh
        $this->trim_orphan_jsonl($state);

        // Save partial manifest so quarantine management shows what was moved
        if ($state['quarantined'] > 0) {
            $this->save_quarantine_manifest(
                $quarantine_id,
                $state['type'],
                $state['quarantined'],
                $state['total_size']
            );
        }

        wp_send_json_success([
            'message'     => sprintf(
                // translators: %s is the formatted number of files already quarantined
                __('Quarantine cancelled. %s files were already moved.', 'staticq-media'),
                number_format($state['quarantined'])
            ),
            'quarantined' => $state['quarantined'],
        ]);
    }

    /**
     * Process quarantine batches from cron (called by SQMedia::cron_continue_quarantine)
     */
    public function cron_process_quarantine(): void {
        $quarantine_id = $this->find_running_quarantine();

        if (!$quarantine_id) {
            wp_clear_scheduled_hook('sqmedia_continue_quarantine');
            return;
        }


        // Time budget — fill available execution time
        $max_execution = (int) ini_get('max_execution_time');
        if ($max_execution <= 0) {
            $time_limit = 55;
        } else {
            $time_limit = max(10, $max_execution - 10);
        }

        $start = time();
        $batches = 0;

        do {
            $state = $this->process_quarantine_batch($quarantine_id);
            $batches++;

            if ($state['status'] !== 'running') {
                break;
            }
        } while ((time() - $start) < $time_limit);

        $elapsed = time() - $start;

        if ($state['status'] !== 'running') {
            wp_clear_scheduled_hook('sqmedia_continue_quarantine');
        }
    }

    /**
     * AJAX: Get list of all quarantines
     */
    public function ajax_get_quarantine_list(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantines = $this->get_all_quarantines();

        wp_send_json_success([
            'quarantines' => $quarantines,
        ]);
    }

    /**
     * AJAX: Purge a quarantine (permanently delete)
     */
    public function ajax_purge_quarantine(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));

        if (empty($quarantine_id) || !preg_match('/^q_[a-zA-Z0-9_-]+$/', $quarantine_id)) {
            wp_send_json_error(__('Invalid quarantine ID.', 'staticq-media'));
        }

        $result = $this->purge_quarantine($quarantine_id);

        wp_send_json_success([
            'deleted' => $result['deleted'],
            'errors' => $result['errors'],
            'message' => sprintf(
                // translators: %d is the number of permanently deleted files
                __('Permanently deleted %d quarantined files.', 'staticq-media'),
                $result['deleted']
            ),
        ]);
    }

    /**
     * AJAX: Restore quarantined files to original locations (batched)
     *
     * First call: creates restore state, schedules cron, starts enumeration.
     * Subsequent calls: processes next enumeration or restore batch.
     */
    public function ajax_restore_quarantine(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));

        if (empty($quarantine_id) || !preg_match('/^q_[a-zA-Z0-9_-]+$/', $quarantine_id)) {
            wp_send_json_error(__('Invalid quarantine ID.', 'staticq-media'));
        }

        // Check for existing restore state
        $state = $this->load_restore_state($quarantine_id);

        if (!$state || in_array($state['status'] ?? '', ['completed', 'cancelled', 'stalled'])) {
            // First call — initialize restore
            $dir = $this->get_quarantine_dir($quarantine_id);
            $manifest_file = $dir . '/manifest.json';

            if (!file_exists($manifest_file)) {
                wp_send_json_error(__('Quarantine not found.', 'staticq-media'));
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $manifest = json_decode(file_get_contents($manifest_file), true);
            if (!$manifest) {
                wp_send_json_error(__('Invalid quarantine manifest.', 'staticq-media'));
            }

            $state = [
                'status'        => 'enumerating',
                'type'          => $manifest['type'],
                'quarantine_id' => $quarantine_id,
                'offset'        => 0,
                'byte_offset'   => 0,
                'total'         => 0,
                'restored'      => 0,
                'skipped'       => 0,
                'errors'        => 0,
                'page_token'    => null,
                'started'       => current_time('Y-m-d H:i:s'),
            ];
            $this->save_restore_state($quarantine_id, $state);

            // Clean up any previous JSONL
            $jsonl_file = $dir . '/restore_files.jsonl';
            if (file_exists($jsonl_file)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions
                wp_delete_file($jsonl_file);
            }

            // Schedule cron fallback
            if (!wp_next_scheduled('sqmedia_continue_restore')) {
                wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_continue_restore');
            }
        }

        // Process one step based on current status
        if ($state['status'] === 'enumerating') {
            $state = $this->enumerate_restore_batch($quarantine_id);
        }

        // If enumeration completed and moved to restoring, process first batch
        if ($state['status'] === 'restoring') {
            $state = $this->process_restore_batch($quarantine_id);
        }

        $progress_pct = $state['total'] > 0
            ? round(($state['offset'] / $state['total']) * 100, 1)
            : ($state['status'] === 'completed' ? 100 : 0);

        wp_send_json_success([
            'status'        => $state['status'],
            'type'          => $state['type'] ?? '',
            'quarantine_id' => $quarantine_id,
            'total'         => $state['total'] ?? 0,
            'offset'        => $state['offset'] ?? 0,
            'restored'      => $state['restored'] ?? 0,
            'skipped'       => $state['skipped'] ?? 0,
            'errors'        => $state['errors'] ?? 0,
            'progress_pct'  => $progress_pct,
        ]);
    }

    /**
     * AJAX: Get restore progress (polling endpoint)
     */
    public function ajax_restore_progress(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));
        if (empty($quarantine_id)) {
            wp_send_json_error(__('No quarantine ID provided.', 'staticq-media'));
        }

        $state = $this->load_restore_state($quarantine_id);
        if (!$state) {
            // State file was already cleaned up — restore completed successfully
            wp_send_json_success([
                'status'       => 'completed',
                'type'         => '',
                'offset'       => 0,
                'total'        => 0,
                'restored'     => 0,
                'skipped'      => 0,
                'errors'       => 0,
                'progress_pct' => 100,
            ]);
            return;
        }

        $progress_pct = $state['total'] > 0
            ? round(($state['offset'] / $state['total']) * 100, 1)
            : ($state['status'] === 'completed' ? 100 : 0);

        wp_send_json_success([
            'status'       => $state['status'],
            'type'         => $state['type'] ?? '',
            'offset'       => $state['offset'] ?? 0,
            'total'        => $state['total'] ?? 0,
            'restored'     => $state['restored'] ?? 0,
            'skipped'      => $state['skipped'] ?? 0,
            'errors'       => $state['errors'] ?? 0,
            'progress_pct' => $progress_pct,
        ]);
    }

    /**
     * AJAX: Cancel a running restore
     */
    public function ajax_cancel_restore(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }

        $quarantine_id = sanitize_text_field(wp_unslash($_POST['quarantine_id'] ?? ''));
        if (empty($quarantine_id)) {
            wp_send_json_error(__('No quarantine ID provided.', 'staticq-media'));
        }

        $state = $this->load_restore_state($quarantine_id);
        if (!$state) {
            wp_send_json_error(__('Restore process not found.', 'staticq-media'));
        }

        $state['status'] = 'cancelled';
        $this->save_restore_state($quarantine_id, $state);

        wp_clear_scheduled_hook('sqmedia_continue_restore');

        // Update manifest with reduced count if any files were restored
        if ($state['restored'] > 0) {
            $dir = $this->get_quarantine_dir($quarantine_id);
            $manifest_file = $dir . '/manifest.json';
            // phpcs:ignore WordPress.WP.AlternativeFunctions
            $manifest = file_exists($manifest_file) ? json_decode(file_get_contents($manifest_file), true) : null;

            if ($manifest) {
                $type = $state['type'];
                $remaining = 0;
                $remaining_size = 0;

                if ($type === 'local') {
                    $files_dir = $dir . '/files';
                    if (is_dir($files_dir)) {
                        $iterator = new RecursiveIteratorIterator(
                            new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS)
                        );
                        foreach ($iterator as $file) {
                            if ($file->isFile()) {
                                $remaining++;
                                $remaining_size += $file->getSize();
                            }
                        }
                    }
                } elseif ($type === 'bucket') {
                    $prefix = $this->get_bucket_quarantine_prefix($quarantine_id) . 'files/';
                    $bucket_remaining = sqmedia()->get_storage_handler()->list_files($prefix, 1);
                    $remaining = count($bucket_remaining);
                    // Keep existing size as approximation for bucket
                    $remaining_size = $manifest['total_size'] ?? 0;
                }

                if ($remaining > 0) {
                    $this->save_quarantine_manifest($quarantine_id, $type, $remaining, $remaining_size);
                }
            }
        }

        // Clean up restore files
        $this->cleanup_restore_files($quarantine_id);

        wp_send_json_success([
            'message'  => sprintf(
                // translators: %s is the formatted number of files already restored
                __('Restore cancelled. %s files were already restored.', 'staticq-media'),
                number_format($state['restored'])
            ),
            'restored' => $state['restored'],
        ]);
    }

    /**
     * AJAX: Clear orphan scan results (clean up files)
     */
    public function ajax_clear_orphan_results(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        
        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }
        
        // Clean up all scan files
        $this->cleanup_orphan_scan($scan_id);
        
        wp_send_json_success([
            'message' => __('Orphan scan results cleared.', 'staticq-media'),
        ]);
    }
    
    // =========================================================================
    // CONNECTION TEST AJAX HANDLERS
    // =========================================================================
    
    public function ajax_test_r2_connection(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $r2 = sqmedia()->get_r2_handler();
        $result = $r2->test_connection();
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error']);
        }
    }
    
    public function ajax_test_cloudflare_connection(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $cloudflare = sqmedia()->get_cloudflare_handler();
        $result = $cloudflare->test_connection();
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error']);
        }
    }
    
    /**
     * AJAX: Check if site is proxied through Cloudflare
     */
    public function ajax_check_cloudflare_proxy(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        // Get the domain from the request (from the Cloudflare Domain input field)
        $domain = sanitize_text_field(wp_unslash($_POST['domain'] ?? ''));
        
        $result = SQMedia_Cloudflare_Handler::check_site_cloudflare_proxy($domain ?: null);
        
        // Add the tested domain to the response for clarity
        if (!empty($domain)) {
            $result['tested_domain'] = $domain;
        }
        
        // Always return success with data (let frontend interpret proxied status)
        wp_send_json_success($result);
    }
}