<?php
/**
 * Batch Processor Base Class for StaticQ
 * 
 * Abstract base class providing shared functionality for all batch operations:
 * - Health Check (Phase 1 & 2)
 * - Registration
 * - Orphan Scan
 * 
 * Handles: scan lifecycle, progress tracking, cron continuation, AJAX responses.
 * Subclasses implement: item retrieval, item processing, completion handling.
 * 
 * @package suspended-cloud/cloudflare-media-offload
 * @since 3.0.0
 */

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

abstract class SQMedia_Batch_Processor {
    
    /**
     * Scan type identifier (e.g., 'health_phase1', 'health_phase2', 'registration', 'orphan')
     * Must be set by subclass
     */
    protected $scan_type;
    
    /**
     * Cron hook name for background processing
     * Must be set by subclass
     */
    protected $cron_hook;
    
    /**
     * Batch size for processing
     */
    protected $batch_size = 50;
    
    /**
     * Database instance
     */
    protected $db;
    
    /**
     * Constructor
     */
    public function __construct() {
        $this->db = sqmedia()->get_database();
        $this->init_batch_size();
    }
    
    /**
     * Initialize batch size from config
     * Subclasses can override to use different constants
     */
    protected function init_batch_size(): void {
        $const_name = 'SQMEDIA_' . strtoupper($this->scan_type) . '_BATCH_SIZE';
        if (defined($const_name)) {
            $this->batch_size = max(10, min(500, (int) constant($const_name)));
        }
    }
    
    /**
     * Get current batch size
     */
    public function get_batch_size(): int {
        return $this->batch_size;
    }

    /**
     * Get batch size for a specific scan
     * Subclasses can override to return different sizes based on scan context (e.g., phase)
     *
     * @param object $scan The scan record
     * @return int Batch size to use
     */
    protected function get_batch_size_for_scan(object $scan): int {
        return $this->batch_size;
    }

    // =========================================================================
    // ABSTRACT METHODS - Subclasses must implement
    // =========================================================================
    
    /**
     * Get all item IDs to process
     * Called once at scan start, IDs stored in transient
     * 
     * @param array $options Optional filters/options passed from start()
     * @return array Array of item IDs (usually attachment IDs)
     */
    abstract protected function get_items_to_process(array $options = []): array;
    
    /**
     * Process a single item
     * 
     * @param int $item_id The item ID to process
     * @param array $context Shared context (settings, handlers, etc.)
     * @return array Result with 'success', 'issues' (optional), 'error' (optional)
     */
    abstract protected function process_single_item(int $item_id, array $context): array;
    
    /**
     * Build processing context
     * Called once per batch, returns shared data for process_single_item
     * 
     * @param string $scan_id Current scan ID
     * @return array Context array passed to process_single_item
     */
    abstract protected function build_context(string $scan_id): array;
    
    /**
     * Handle scan completion
     * Called after all items processed
     * 
     * @param string $scan_id Scan ID
     * @param array $results Accumulated results
     */
    abstract protected function on_complete(string $scan_id, array $results): void;
    
    /**
     * Recover item IDs if transient was lost
     * Override in child class to provide recovery logic
     * 
     * @param string $scan_id Scan ID
     * @param object $scan Scan record
     * @return array|null Recovered item IDs, or null if recovery failed
     */
    protected function recover_item_ids(string $scan_id, object $scan): ?array {
        // Default: no recovery possible
        // Child classes can override to provide recovery logic
        return null;
    }
    
    /**
     * Get human-readable label for this processor
     * Used in progress messages
     */
    abstract public function get_label(): string;
    
    // =========================================================================
    // OPTIONAL HOOKS - Subclasses can override
    // =========================================================================
    
    /**
     * Called before processing starts
     * Use for initialization (e.g., building file indexes)
     * 
     * @param string $scan_id Scan ID
     * @param array $options Options passed to start()
     * @return array ['ready' => bool, 'status' => string, 'error' => string|null]
     */
    protected function on_before_start(string $scan_id, array $options): array {
        return ['ready' => true, 'status' => 'running', 'error' => null];
    }
    
    /**
     * Called after each batch completes
     * Use for intermediate saves, progress updates, etc.
     * 
     * @param string $scan_id Scan ID
     * @param array $batch_results Results from this batch
     * @param array $cumulative_results All results so far
     */
    protected function on_batch_complete(string $scan_id, array $batch_results, array $cumulative_results): void {
        // Default: no-op
    }
    
    /**
     * Called when scan is cancelled
     * Use for cleanup
     * 
     * @param string $scan_id Scan ID
     */
    protected function on_cancel(string $scan_id): void {
        // Default: no-op
    }
    
    // =========================================================================
    // PUBLIC METHODS - Main API
    // =========================================================================
    
    /**
     * Start a new scan/batch process
     * 
     * @param array $options Optional filters and options
     * @return array ['success' => bool, 'scan_id' => string|null, 'error' => string|null, ...]
     */
    public function start(array $options = []): array {
        // Check for existing active scan
        $active = $this->db->get_active_scan($this->scan_type);
        if ($active) {
            return [
                'success' => false,
                'error' => sprintf(
                    /* translators: %s: processor label (e.g., "File State Scanner") */
                    __('A %s is already in progress. Please complete or cancel it first.', 'staticq-media'),
                    $this->get_label()
                ),
                'scan_id' => $active->scan_id,
            ];
        }
        
        // Create scan record
        $scan_id = $this->db->create_scan($this->scan_type);
        
        if (!$scan_id) {
            return [
                'success' => false,
                'error' => __('Failed to create scan record.', 'staticq-media'),
            ];
        }
        
        // Run pre-start hook (e.g., build file index)
        $pre_start = $this->on_before_start($scan_id, $options);
        
        if (!$pre_start['ready']) {
            // Not ready yet (e.g., building index in progress)
            $this->db->update_scan($scan_id, [
                'status' => $pre_start['status'] ?? 'building_index',
            ]);
            
            return [
                'success' => true,
                'scan_id' => $scan_id,
                'status' => $pre_start['status'] ?? 'building_index',
                'message' => $pre_start['message'] ?? __('Initializing...', 'staticq-media'),
            ];
        }
        
        // Get items to process
        $item_ids = $this->get_items_to_process($options);
        $total = count($item_ids);
        
        if ($total === 0) {
            $this->db->update_scan($scan_id, [
                'status' => 'completed',
                'total_items' => 0,
                'processed_items' => 0,
                'scan_started' => current_time('mysql'),
                'scan_completed' => current_time('mysql'),
                'results' => ['summary' => ['total' => 0, 'clean' => 0, 'issues' => 0]],
            ]);
            
            return [
                'success' => true,
                'scan_id' => $scan_id,
                'status' => 'completed',
                'total' => 0,
                'message' => __('No items to process.', 'staticq-media'),
            ];
        }
        
        // Store item IDs in transient for batch processing
        set_transient('sqmedia_scan_ids_' . $scan_id, $item_ids, HOUR_IN_SECONDS * 4);
        
        // Get current results (may have been set by on_before_start)
        $scan = $this->db->get_scan($scan_id);
        $existing_results = $scan->results ?? [];
        
        // Merge with initial results structure
        $initial_results = array_merge($existing_results, [
            'summary' => [
                'total' => $total,
                'processed' => 0,
                'clean' => 0,
                'with_issues' => 0,
            ],
            'by_category' => [],
            'issues' => [],
        ]);
        
        // Update scan record
        $this->db->update_scan($scan_id, [
            'status' => 'running',
            'total_items' => $total,
            'processed_items' => 0,
            'scan_started' => current_time('mysql'),
            'results' => $initial_results,
        ]);
        
        // Schedule cron for background processing
        $this->schedule_cron();
        
        return [
            'success' => true,
            'scan_id' => $scan_id,
            'status' => 'running',
            'total' => $total,
            'processed' => 0,
        ];
    }
    
    /**
     * Process next batch of items
     * Called via AJAX or cron
     * 
     * @param string $scan_id Scan ID to continue
     * @return array Progress and results
     */
    public function process_batch(string $scan_id): array {
        // Get scan record
        $scan = $this->db->get_scan($scan_id);
        
        if (!$scan) {
            return [
                'success' => false,
                'error' => __('Scan not found.', 'staticq-media'),
            ];
        }
        
        // Check if already completed or cancelled
        if (in_array($scan->status, ['completed', 'cancelled', 'failed'], true)) {
            return [
                'success' => true,
                'done' => true,
                'status' => $scan->status,
                'total' => (int) $scan->total_items,
                'processed' => (int) $scan->processed_items,
                'progress' => 100,
                'results' => $scan->results,
            ];
        }
        
        // Check if paused - return current state without processing
        if ($scan->status === 'paused') {
            $processed = (int) $scan->processed_items;
            $total = (int) $scan->total_items;
            return [
                'success' => true,
                'done' => false,
                'status' => 'paused',
                'paused' => true,
                'total' => $total,
                'processed' => $processed,
                'progress' => $total > 0 ? round(($processed / $total) * 100) : 0,
                'results' => $scan->results,
            ];
        }
        
        // Check if still building indexes (Phase 2)
        if ($scan->status === 'building_index') {
            $results = $scan->results;
            $index_status = $results['index_status'] ?? 'pending';
            
            // If pending, run LOCAL index now (fast ~2 seconds), then schedule bucket via cron
            if ($index_status === 'pending') {
                // Use lock to prevent multiple triggers
                $lock_key = 'sqmedia_index_build_lock_' . $scan_id;
                if (!get_transient($lock_key)) {
                    set_transient($lock_key, time(), 300); // 5 min lock

                    // Build LOCAL index only (fast) then schedule bucket for cron
                    if (method_exists($this, 'get_phase2_handler')) {
                        $this->get_phase2_handler()->run_local_index_only($scan_id);
                    }

                    delete_transient($lock_key);

                    // Re-fetch scan to get updated status
                    $scan = $this->db->get_scan($scan_id);
                    $results = $scan->results;
                    $index_status = $results['index_status'] ?? 'building_bucket';
                }

                // Return immediately — let cron handle bucket build
                $file_count = $results['index_file_count'] ?? 0;
                return [
                    'success' => true,
                    'done' => false,
                    'status' => 'building_index',
                    'index_status' => $index_status,
                    'phase_message' => __('Local index complete, starting bucket index...', 'staticq-media'),
                    'index_file_count' => $file_count,
                    'total' => (int) $scan->total_items,
                    'processed' => 0,
                    'progress' => 0,
                ];
            }

            // If bucket index hasn't started or stalled, build it inline (fallback for unreliable cron)
            // The atomic lock inside continue_index_build() prevents concurrent execution with cron.
            if ($index_status === 'building_bucket') {
                if (method_exists($this, 'get_phase2_handler')) {
                    $this->get_phase2_handler()->continue_index_build($scan_id);
                }

                // Re-fetch scan — may have transitioned to scanning
                $scan = $this->db->get_scan($scan_id);
                if ($scan->status === 'scanning') {
                    $processed = (int) $scan->processed_items;
                    $total = (int) $scan->total_items;
                    return [
                        'success' => true,
                        'done' => false,
                        'status' => 'scanning',
                        'total' => $total,
                        'processed' => $processed,
                        'progress' => $total > 0 ? round(($processed / $total) * 100) : 0,
                        'results' => $scan->results,
                    ];
                }

                $results = $scan->results;
                $index_status = $results['index_status'] ?? 'complete';
            }
            
            $file_count = $results['index_file_count'] ?? 0;
            $current_folder = $results['index_current_folder'] ?? '';
            
            // Build detailed message
            if ($index_status === 'building_bucket') {
                $message = sprintf(
                    /* translators: %s: number of files found so far */
                    __('Indexing bucket: %s files found...', 'staticq-media'),
                    number_format_i18n($file_count)
                );
                if ($current_folder) {
                    /* translators: %s: current folder path */
                    $message .= "\n" . sprintf(__('Current: %s', 'staticq-media'), $current_folder);
                }
            } elseif ($index_status === 'pending') {
                $message = __('Starting index build...', 'staticq-media');
            } else {
                $message = sprintf(
                    /* translators: %s: number of local files found so far */
                    __('Indexing local files: %s files found...', 'staticq-media'),
                    number_format_i18n($file_count)
                );
                if ($current_folder) {
                    /* translators: %s: folder path being scanned */
                    $message .= "\n" . sprintf(__('Scanning: %s', 'staticq-media'), $current_folder);
                }
            }
            
            return [
                'success' => true,
                'done' => false,
                'status' => 'building_index',
                'index_status' => $index_status,
                'phase_message' => $message,
                'index_file_count' => $file_count,
                'index_current_folder' => $current_folder,
                'total' => (int) $scan->total_items,
                'processed' => 0,
                'progress' => 0,
            ];
        }
        
        // Lock mechanism to prevent concurrent processing (cron + AJAX racing)
        $lock_key = 'sqmedia_scan_lock_' . $scan_id;
        $lock = get_transient($lock_key);
        
        if ($lock) {
            // Another process is working - return current progress without processing
            $processed = (int) $scan->processed_items;
            $total = (int) $scan->total_items;
            return [
                'success' => true,
                'done' => false,
                'status' => $scan->status,
                'total' => $total,
                'processed' => $processed,
                'progress' => $total > 0 ? round(($processed / $total) * 100) : 0,
                'results' => $scan->results,
                'locked' => true,
            ];
        }
        
        // Acquire lock (30 seconds timeout)
        set_transient($lock_key, time(), 30);
        
        // Get item IDs from transient
        $item_ids = get_transient('sqmedia_scan_ids_' . $scan_id);
        
        if (!$item_ids || !is_array($item_ids)) {
            // Try to recover by rebuilding the ID list
            $item_ids = $this->recover_item_ids($scan_id, $scan);
            
            if (!$item_ids) {
                // Recovery failed - mark as failed
                delete_transient($lock_key);
                $this->db->update_scan($scan_id, [
                    'status' => 'failed',
                    'results' => array_merge($scan->results, ['error' => 'Lost item IDs transient and recovery failed']),
                ]);
                
                return [
                    'success' => false,
                    'error' => __('Scan state was lost and could not be recovered. Please start a new scan.', 'staticq-media'),
                ];
            }
            
            // Recovery succeeded - re-save transient
            set_transient('sqmedia_scan_ids_' . $scan_id, $item_ids, HOUR_IN_SECONDS * 4);
        }
        
        // Calculate batch range
        $processed = (int) $scan->processed_items;
        $total = count($item_ids);
        $batch_start = $processed;
        $current_batch_size = $this->get_batch_size_for_scan($scan);
        $batch_end = min($processed + $current_batch_size, $total);
        $batch_ids = array_slice($item_ids, $batch_start, $batch_end - $batch_start);
        
        if (empty($batch_ids)) {
            // All done
            delete_transient($lock_key);
            return $this->complete_scan($scan_id, $scan->results);
        }
        
        // Build context for this batch
        $context = $this->build_context($scan_id);
        
        // Process batch
        $batch_results = [];
        $results = $scan->results;
        
        // Ensure summary structure exists
        if (!isset($results['summary'])) {
            $results['summary'] = [
                'processed' => 0,
                'clean' => 0,
                'with_issues' => 0,
                'files_checked' => 0,
                'files_ok' => 0,
                'files_with_issues' => 0,
            ];
        }
        // Migration: add file counts if missing
        if (!isset($results['summary']['files_checked'])) {
            $results['summary']['files_checked'] = 0;
            $results['summary']['files_ok'] = 0;
            $results['summary']['files_with_issues'] = 0;
        }
        // Migration: add Phase 1 size counts if missing
        if (!isset($results['summary']['sizes_expected'])) {
            $results['summary']['sizes_expected'] = 0;
            $results['summary']['sizes_found'] = 0;
        }
        if (!isset($results['by_category'])) {
            $results['by_category'] = [];
        }
        // Initialize type counts for extension breakdown
        if (!isset($results['summary']['type_counts'])) {
            $results['summary']['type_counts'] = [];
        }
        
        foreach ($batch_ids as $item_id) {
            $item_result = $this->process_single_item($item_id, $context);
            $batch_results[$item_id] = $item_result;
            
            // Track type counts by extension
            $mime_type = get_post_mime_type($item_id);
            if ($mime_type) {
                $ext = $this->mime_to_extension($mime_type);
                if (!isset($results['summary']['type_counts'][$ext])) {
                    $results['summary']['type_counts'][$ext] = 0;
                }
                $results['summary']['type_counts'][$ext]++;
            }
            
            // Update summary
            $results['summary']['processed']++;
            
            // Track file counts from phase 2 results
            if (isset($item_result['files_checked'])) {
                $results['summary']['files_checked'] += $item_result['files_checked'];
                $results['summary']['files_ok'] += $item_result['files_ok'] ?? 0;
                $results['summary']['files_with_issues'] += $item_result['files_with_issues'] ?? 0;
            }
            
            // Track size counts from phase 1 results
            if (isset($item_result['sizes_expected'])) {
                $results['summary']['sizes_expected'] += $item_result['sizes_expected'];
                $results['summary']['sizes_found'] += $item_result['sizes_found'] ?? 0;
            }
            
            if (empty($item_result['issues'])) {
                $results['summary']['clean']++;
            } else {
                $results['summary']['with_issues']++;
                
                // Categorize issues
                foreach ($item_result['issues'] as $issue) {
                    $category = $issue['category'] ?? 'unknown';
                    
                    if (!isset($results['by_category'][$category])) {
                        $results['by_category'][$category] = 0;
                    }
                    $results['by_category'][$category]++;
                }
            }
        }
        
        // Update scan record
        $new_processed = $batch_end;
        $this->db->update_scan($scan_id, [
            'processed_items' => $new_processed,
            'results' => $results,
        ]);
        
        // Call batch complete hook
        $this->on_batch_complete($scan_id, $batch_results, $results);

        // Check if done
        $done = ($new_processed >= $total);

        if ($done) {
            delete_transient($lock_key);
            // Re-read results from DB — on_batch_complete() may have added data
            // (e.g., registration counters) that isn't in the local $results variable
            $updated_scan = $this->db->get_scan($scan_id);
            $final_results = ($updated_scan && !empty($updated_scan->results)) ? $updated_scan->results : $results;
            return $this->complete_scan($scan_id, $final_results);
        }
        
        // Release lock
        delete_transient($lock_key);
        
        $progress = round(($new_processed / $total) * 100);
        
        return [
            'success' => true,
            'done' => false,
            'status' => 'running',
            'total' => $total,
            'processed' => $new_processed,
            'progress' => $progress,
            'percentage' => $progress, // Legacy support
            'results' => $results,
        ];
    }
    
    /**
     * Get current progress
     * 
     * @param string $scan_id Scan ID
     * @return array Progress info
     */
    public function get_progress(string $scan_id): array {
        $scan = $this->db->get_scan($scan_id);
        
        if (!$scan) {
            return [
                'success' => false,
                'error' => __('Scan not found.', 'staticq-media'),
            ];
        }
        
        $total = (int) $scan->total_items;
        $processed = (int) $scan->processed_items;
        $progress = $total > 0 ? round(($processed / $total) * 100) : 0;
        
        return [
            'success' => true,
            'scan_id' => $scan_id,
            'status' => $scan->status,
            'total' => $total,
            'processed' => $processed,
            'progress' => $progress,
            'percentage' => $progress, // Legacy support
            'results' => $scan->results,
            'done' => in_array($scan->status, ['completed', 'cancelled', 'failed'], true),
        ];
    }
    
    /**
     * Cancel a running scan
     * 
     * @param string $scan_id Scan ID
     * @return array Result
     */
    public function cancel(string $scan_id): array {
        $scan = $this->db->get_scan($scan_id);
        
        if (!$scan) {
            return [
                'success' => false,
                'error' => __('Scan not found.', 'staticq-media'),
            ];
        }
        
        // Update status
        $this->db->update_scan($scan_id, [
            'status' => 'cancelled',
        ]);
        
        // Clean up transient
        delete_transient('sqmedia_scan_ids_' . $scan_id);
        
        // Clear cron
        $this->clear_cron();
        
        // Call cancel hook
        $this->on_cancel($scan_id);
        
        return [
            'success' => true,
            /* translators: %s: processor label (e.g., "File State Scanner") */
            'message' => sprintf(__('%s cancelled.', 'staticq-media'), $this->get_label()),
        ];
    }
    
    /**
     * Pause a running scan (can be resumed)
     * 
     * @param string $scan_id Scan ID
     * @return array Result
     */
    public function pause(string $scan_id): array {
        $scan = $this->db->get_scan($scan_id);
        
        if (!$scan) {
            return [
                'success' => false,
                'error' => __('Scan not found.', 'staticq-media'),
            ];
        }
        
        // Store previous status so we can restore on resume
        $results = $scan->results ?? [];
        $results['status_before_pause'] = $scan->status;
        
        // Update status to paused (keeps progress)
        $this->db->update_scan($scan_id, [
            'status' => 'paused',
            'results' => $results,
        ]);
        
        // Clear cron but keep transient (for resume)
        $this->clear_cron();
        
        return [
            'success' => true,
            /* translators: %s: processor label (e.g., "File State Scanner") */
            'message' => sprintf(__('%s paused. You can resume anytime.', 'staticq-media'), $this->get_label()),
        ];
    }
    
    /**
     * Resume a paused scan
     * 
     * @param string $scan_id Scan ID
     * @return array Result
     */
    public function resume(string $scan_id): array {
        $scan = $this->db->get_scan($scan_id);
        
        if (!$scan) {
            return [
                'success' => false,
                'error' => __('Scan not found.', 'staticq-media'),
            ];
        }
        
        if ($scan->status !== 'paused') {
            return [
                'success' => false,
                'error' => __('Scan is not paused.', 'staticq-media'),
            ];
        }
        
        // Restore previous status (or default to 'scanning')
        $results = $scan->results ?? [];
        $previous_status = $results['status_before_pause'] ?? 'scanning';
        unset($results['status_before_pause']);
        
        // Update status back to what it was
        $this->db->update_scan($scan_id, [
            'status' => $previous_status,
            'results' => $results,
        ]);
        
        return [
            'success' => true,
            'scan_id' => $scan_id,
            'status' => $previous_status,
            /* translators: %s: processor label (e.g., "File State Scanner") */
            'message' => sprintf(__('%s resumed.', 'staticq-media'), $this->get_label()),
        ];
    }
    
    /**
     * Continue processing via cron
     * Called by WordPress cron
     */
    public function cron_continue(): void {
        $scan = $this->db->get_running_scan($this->scan_type);
        if (!$scan) {
            $this->clear_cron();
            return;
        }

        $max_seconds = defined('SQMEDIA_CRON_MAX_SECONDS') ? (int) SQMEDIA_CRON_MAX_SECONDS : 30;
        $deadline = time() + $max_seconds;
        $batches = 0;

        do {
            $result = $this->process_batch($scan->scan_id);
            $batches++;

            // Stop if: scan done, error, locked by another process, or time's up
            if (!empty($result['done']) || empty($result['success']) || !empty($result['locked'])) {
                break;
            }
        } while (time() < $deadline);

        if (!empty($result['done'])) {
            $this->clear_cron();
        }

        if ($batches > 1) {
        }
    }
    
    /**
     * Get the latest completed scan
     * 
     * @return object|null Scan record
     */
    public function get_last_completed(): ?object {
        return $this->db->get_latest_scan($this->scan_type);
    }
    
    /**
     * Get currently running scan
     * 
     * @return object|null Scan record
     */
    public function get_running(): ?object {
        return $this->db->get_running_scan($this->scan_type);
    }
    
    // =========================================================================
    // PROTECTED HELPERS
    // =========================================================================
    
    /**
     * Complete the scan
     */
    protected function complete_scan(string $scan_id, array $results): array {
        // Get final counts before updating
        $scan = $this->db->get_scan($scan_id);
        $total = (int) ($scan->total_items ?? 0);
        $processed = (int) ($scan->processed_items ?? $total);
        
        // Update scan record
        $this->db->update_scan($scan_id, [
            'status' => 'completed',
            'scan_completed' => current_time('mysql'),
            'results' => $results,
        ]);
        
        // Clean up transient
        delete_transient('sqmedia_scan_ids_' . $scan_id);
        
        // Clear cron
        $this->clear_cron();
        
        // Call completion hook
        $this->on_complete($scan_id, $results);
        
        return [
            'success' => true,
            'done' => true,
            'status' => 'completed',
            'total' => $total,
            'processed' => $processed,
            'progress' => 100,
            'results' => $results,
        ];
    }
    
    /**
     * Schedule cron for background processing
     */
    protected function schedule_cron(): void {
        if (!$this->cron_hook) {
            return;
        }
        
        if (!wp_next_scheduled($this->cron_hook)) {
            wp_schedule_event(time() + 10, 'sqmedia_one_minute', $this->cron_hook);
        }
    }
    
    /**
     * Clear cron schedule
     */
    protected function clear_cron(): void {
        if (!$this->cron_hook) {
            return;
        }
        
        wp_clear_scheduled_hook($this->cron_hook);
    }
    
    /**
     * Log message (for debugging)
     */
    protected function log(string $message, array $data = []): void {
        if (!defined('WP_DEBUG_LOG') || !WP_DEBUG_LOG) {
            return;
        }
        
        $log = sprintf('[StaticQ %s] %s', $this->get_label(), $message);
        
        if (!empty($data)) {
            $log .= ' | ' . json_encode($data, JSON_UNESCAPED_SLASHES);
        }
        
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
        error_log($log);
    }
    
    // =========================================================================
    // SCAN ITEMS TABLE HELPERS
    // =========================================================================
    
    /**
     * Store an issue in sq_scan_items table
     * For efficient "Fix All" operations
     * 
     * @param string $scan_id Scan ID
     * @param string $category Issue category
     * @param int $item_id Item (attachment) ID
     * @param array|null $details Additional details
     */
    protected function store_issue(string $scan_id, string $category, int $item_id, ?array $details = null): void {
        $this->db->add_scan_item($scan_id, $category, $item_id, $details);
    }
    
    /**
     * Get issues by category for current scan
     * 
     * @param string $scan_id Scan ID
     * @param string $category Issue category
     * @param int $limit Limit
     * @param int $offset Offset
     * @return array Issues
     */
    protected function get_issues_by_category(string $scan_id, string $category, int $limit = 100, int $offset = 0): array {
        return $this->db->get_scan_items($scan_id, $category, $limit, $offset);
    }
    
    /**
     * Get all issue counts for current scan
     * 
     * @param string $scan_id Scan ID
     * @return array Category => count
     */
    protected function get_issue_counts(string $scan_id): array {
        return $this->db->get_scan_item_counts($scan_id);
    }
    
    /**
     * Remove an issue after it's fixed
     * 
     * @param string $scan_id Scan ID
     * @param string $category Issue category
     * @param int $item_id Item ID
     */
    protected function remove_issue(string $scan_id, string $category, int $item_id): void {
        $this->db->remove_scan_item($scan_id, $category, $item_id);
    }
    
    /**
     * Clear all issues for a scan
     * 
     * @param string $scan_id Scan ID
     */
    protected function clear_issues(string $scan_id): void {
        $this->db->delete_scan_items($scan_id);
    }
    
    /**
     * Convert MIME type to file extension for display
     * 
     * @param string $mime_type MIME type
     * @return string File extension (lowercase)
     */
    protected function mime_to_extension(string $mime_type): string {
        $map = [
            // Images
            'image/jpeg' => 'jpg',
            'image/jpg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            'image/webp' => 'webp',
            'image/svg+xml' => 'svg',
            'image/bmp' => 'bmp',
            'image/x-icon' => 'ico',
            'image/vnd.microsoft.icon' => 'ico',
            'image/tiff' => 'tiff',
            'image/avif' => 'avif',
            
            // Documents
            'application/pdf' => 'pdf',
            'application/msword' => 'doc',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
            'application/vnd.ms-excel' => 'xls',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
            'application/vnd.ms-powerpoint' => 'ppt',
            'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
            'text/plain' => 'txt',
            'text/csv' => 'csv',
            
            // Audio
            'audio/mpeg' => 'mp3',
            'audio/mp3' => 'mp3',
            'audio/wav' => 'wav',
            'audio/ogg' => 'ogg',
            'audio/flac' => 'flac',
            'audio/aac' => 'aac',
            'audio/x-m4a' => 'm4a',
            
            // Video
            'video/mp4' => 'mp4',
            'video/webm' => 'webm',
            'video/ogg' => 'ogv',
            'video/quicktime' => 'mov',
            'video/x-msvideo' => 'avi',
            'video/x-ms-wmv' => 'wmv',
            'video/x-flv' => 'flv',
            
            // Archives
            'application/zip' => 'zip',
            'application/x-rar-compressed' => 'rar',
            'application/x-7z-compressed' => '7z',
        ];
        
        return $map[$mime_type] ?? pathinfo(explode('/', $mime_type)[1] ?? 'unknown', PATHINFO_FILENAME);
    }
}