<?php
/**
 * File State Scanner - Phase 2 Handler
 * 
 * Phase 2: File Alignment
 * Verifies files exist in correct locations (local and bucket) and fixes file issues.
 * 
 * Issue categories handled:
 * - CAT_UPLOAD_TO_BUCKET: File exists local, needs upload to bucket
 * - CAT_DOWNLOAD_TO_LOCAL: File exists bucket, needs download to local
 * - CAT_GENERATE_SIZES: File doesn't exist anywhere, needs generation
 * - CAT_CRITICAL_MISSING: Master missing everywhere - unrecoverable
 * 
 * @package StaticQ
 * @since 3.0.0
 */

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

class SQMedia_File_State_Scanner_Phase2 {
    
    /**
     * Reference to parent scanner
     */
    private SQMedia_File_State_Scanner $scanner;
    
    /**
     * Constructor
     */
    public function __construct(SQMedia_File_State_Scanner $scanner) {
        $this->scanner = $scanner;
    }
    
    // =========================================================================
    // PHASE 2 INITIALIZATION
    // =========================================================================
    
    /**
     * Start Phase 2 scan
     * Requires building file indexes first
     *
     * @deprecated Use start_unified_scan() instead
     */
    public function start_phase2_scan(): array {
        return $this->start_unified_scan(false);
    }

    /**
     * Start unified scan (replaces separate Phase 1 / Phase 2)
     * Requires building file indexes first, then scans both metadata and files per attachment.
     *
     * @param bool $overwrite_existing When true, flags all non-master files for regeneration
     * @return array Scan start result
     */
    public function start_unified_scan(bool $overwrite_existing = false): array {
        $db = $this->scanner->get_db();

        // Check for existing active scan
        $active = $db->get_active_scan('file_state');
        if ($active) {
            return [
                'success' => false,
                'error' => __('A scan is already in progress. Please complete or cancel it first.', 'staticq-media'),
                'scan_id' => $active->scan_id,
            ];
        }

        // Clean up old scan items from ALL previous scans
        $this->scanner->cleanup_old_scan_items();

        // Create scan record with building_index status
        $scan_id = $db->create_scan('file_state');

        if (!$scan_id) {
            return [
                'success' => false,
                'error' => __('Failed to create scan record.', 'staticq-media'),
            ];
        }

        $db->update_scan($scan_id, [
            'status' => 'building_index',
            'scan_started' => current_time('mysql'),
            'results' => [
                'current_phase' => 'unified',
                'overwrite_existing' => $overwrite_existing,
                'index_status' => 'pending',
            ],
        ]);

        // Return immediately - process_batch will trigger the index build
        // This allows the UI to show "Building index..." and poll for progress
        return [
            'success' => true,
            'scan_id' => $scan_id,
            'status' => 'building_index',
            'message' => __('Building file index...', 'staticq-media'),
        ];
    }
    
    /**
     * Called from process_batch when status is building_index
     * Runs LOCAL index only (fast ~2 sec), then schedules cron for bucket
     */
    public function run_local_index_only(string $scan_id): void {
        $db = $this->scanner->get_db();
        $scan = $db->get_scan($scan_id);
        
        if (!$scan || $scan->status !== 'building_index') {
            return;
        }
        
        $results = $scan->results ?? [];
        $index_status = $results['index_status'] ?? 'pending';
        
        // Already running or past local phase
        if ($index_status !== 'pending') {
            return;
        }
        
        // Mark as building local
        $results['index_status'] = 'building_local';
        $results['index_file_count'] = 0;
        $results['index_current_folder'] = 'Starting...';
        $db->update_scan($scan_id, ['results' => $results]);
        
        // Build LOCAL index only (fast ~2 seconds)
        $local_count = $this->build_local_index($scan_id, $db);

        // In local_only mode, skip bucket indexing entirely
        if (SQMedia_Settings::get_storage_mode() === 'local_only') {
            $results['index_status'] = 'complete';
            $results['local_file_count'] = $local_count;
            $results['bucket_file_count'] = 0;
            $results['index_file_count'] = 0;
            $results['index_current_folder'] = '';
            $db->update_scan($scan_id, ['results' => $results]);

            // Save empty bucket manifest so lookups don't fail
            $temp_dir = $this->get_scan_temp_dir();
            $this->save_index_manifest($scan_id, 'bucket', 0, 0, [], $temp_dir);

            // Transition directly to scanning phase
            $this->start_scanning_phase($scan_id, $db, $results);
            return;
        }

        // Update status: ready for bucket
        $results['index_status'] = 'building_bucket';
        $results['local_file_count'] = $local_count;
        $results['index_file_count'] = 0;
        $results['index_current_folder'] = 'Starting...';
        $results['bucket_build_started'] = time();
        $db->update_scan($scan_id, ['results' => $results]);

        // Schedule cron for bucket index (runs in background)
        $this->clear_all_index_build_crons();
        wp_schedule_single_event(time(), 'sqmedia_build_file_index', [$scan_id]);
        spawn_cron();
    }
    
    /**
     * Called via cron or AJAX fallback: sq_build_file_index
     * Processes one page of bucket files per call using list_files_paginated().
     * Saves continuation token in scan state so cron or the next AJAX poll
     * can pick up where it left off.
     */
    public function continue_index_build(string $scan_id): bool {
        $db = $this->scanner->get_db();
        $scan = $db->get_scan($scan_id);

        if (!$scan || $scan->status !== 'building_index') {
            return false;
        }

        $results = $scan->results ?? [];
        $index_status = $results['index_status'] ?? '';

        // Only continue if we're in building_bucket phase
        if ($index_status !== 'building_bucket') {
            return false;
        }

        // Check if paused
        if ($scan->status === 'paused') {
            return false;
        }

        // Atomic lock using MySQL GET_LOCK() to prevent cron + AJAX fallback
        // running simultaneously. GET_LOCK is a true mutex — only one connection
        // can hold it. Auto-released if the process crashes.
        global $wpdb;
        $lock_name = 'sqmedia_bucket_build_' . substr($scan_id, 0, 40);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $lock_acquired = (int) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 0)", $lock_name));
        if ($lock_acquired !== 1) {
            return false;
        }

        // Process one page of bucket files
        $done = $this->build_bucket_index_batch($scan_id, $db);

        // Release lock
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));

        if (!$done) {
            // More pages to process — reschedule cron as fallback
            if (!wp_next_scheduled('sqmedia_build_file_index', [$scan_id])) {
                wp_schedule_single_event(time() + 1, 'sqmedia_build_file_index', [$scan_id]);
            }
            return false;
        }

        // Re-fetch scan to check if it was paused during indexing
        $scan = $db->get_scan($scan_id);
        if ($scan->status === 'paused') {
            return false;
        }

        // All pages processed — finalize
        $results = $scan->results;
        $bucket_count = $results['bucket_index_count'] ?? 0;
        $results['index_status'] = 'complete';
        $results['bucket_file_count'] = $bucket_count;
        $results['index_file_count'] = 0;
        $results['index_current_folder'] = '';
        unset($results['bucket_index_partial']);
        unset($results['bucket_page_token']);
        unset($results['bucket_index_count']);
        unset($results['bucket_chunk_number']);
        unset($results['bucket_chunks_meta']);
        unset($results['bucket_index_retries']);
        $db->update_scan($scan_id, ['results' => $results]);

        return $this->start_scanning_phase($scan_id, $db, $results);
    }

    /**
     * Transition from index-building to scanning phase.
     * Loads all registered attachment IDs and sets up batch processing.
     */
    private function start_scanning_phase(string $scan_id, SQMedia_Database $db, array $results): bool {
        // Get items to process
        $item_ids = $db->get_all_registered_ids_sorted_by_path();
        $total = count($item_ids);

        if ($total === 0) {
            $db->update_scan($scan_id, [
                'status' => 'completed',
                'total_items' => 0,
                'processed_items' => 0,
                'scan_started' => current_time('mysql'),
                'scan_completed' => current_time('mysql'),
                'results' => array_merge($results, [
                    'summary' => ['total' => 0, 'clean' => 0, 'issues' => 0],
                ]),
            ]);
            return true;
        }

        // Store IDs for batch processing
        set_transient('sqmedia_scan_ids_' . $scan_id, $item_ids, DAY_IN_SECONDS);

        if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
            error_log(sprintf(
                '[StaticQ Scanner] SCANNING START — %d attachments to scan, bucket_index=%s files, local_index=%s files',
                $total,
                $results['bucket_file_count'] ?? 'unknown',
                $results['local_file_count'] ?? 'unknown'
            ));
        }

        // Update results with initial summary
        $results['summary'] = [
            'total' => $total,
            'processed' => 0,
            'clean' => 0,
            'with_issues' => 0,
            'files_checked' => 0,
            'files_ok' => 0,
            'files_with_issues' => 0,
        ];

        $db->update_scan($scan_id, [
            'status' => 'scanning',
            'total_items' => $total,
            'processed_items' => 0,
            'results' => $results,
        ]);

        // Schedule recurring cron for scanning phase (matches AJAX schedule_cron pattern)
        wp_clear_scheduled_hook('sqmedia_file_state_scan_continue');
        if (!wp_next_scheduled('sqmedia_file_state_scan_continue')) {
            wp_schedule_event(time() + 5, 'sqmedia_one_minute', 'sqmedia_file_state_scan_continue');
        }

        return true;
    }

    /**
     * Clear all scheduled sq_build_file_index cron events
     * 
     * wp_clear_scheduled_hook() without args only clears hooks scheduled without args.
     * This method clears ALL events for the hook, regardless of arguments.
     */
    public function clear_all_index_build_crons(): void {
        $crons = _get_cron_array();
        if (!is_array($crons)) {
            return;
        }
        
        $modified = false;
        foreach ($crons as $timestamp => $cron) {
            if (isset($cron['sqmedia_build_file_index'])) {
                unset($crons[$timestamp]['sqmedia_build_file_index']);
                $modified = true;
                
                // Clean up empty timestamp entries
                if (empty($crons[$timestamp])) {
                    unset($crons[$timestamp]);
                }
            }
        }
        
        if ($modified) {
            _set_cron_array($crons);
        }
    }
    
    // =========================================================================
    // SCANNING
    // =========================================================================
    
    /**
     * Scan a single attachment for Phase 2 issues
     * 
     * @param int $attachment_id Attachment ID to scan
     * @param array $context Scan context with settings and file indexes
     * @return array Scan results with issues found
     */
    public function scan_attachment(int $attachment_id, array $context): array {
        $issues = [];
        $files_checked = 0;
        $files_ok = 0;
        $files_with_issues = 0;
        $scan_id = $context['scan_id'];
        
        // Track if master is critically missing (unrecoverable)
        // If true, subsizes cannot be generated and should not be marked as such
        $master_critically_missing = false;
        
        $record = SQMedia_Attachment_Record::find($attachment_id);
        
        if (!$record) {
            return ['success' => false, 'error' => 'Record not found', 'issues' => [], 'files_checked' => 0, 'files_ok' => 0, 'files_with_issues' => 0];
        }
        
        $wp_metadata = $record->get_wp_metadata();

        // Storage destination is the current global setting — the diagnosis target.
        // The record's storage_location is a snapshot of past truth and may lag
        // behind settings; using it as the target hides exactly the mismatches
        // the scanner is supposed to surface (e.g., settings switched to 'both'
        // but files still only in the bucket → must flag CAT_DOWNLOAD_TO_LOCAL).
        $storage_dest = SQMedia_Settings::get_storage_destination();
        $delete_local = SQMedia_Settings::should_delete_local_files();

        // Determine what files should exist and where
        $files_to_check = $this->get_expected_files($record, $wp_metadata);
        
        foreach ($files_to_check as $file_info) {
            $rel_path = $file_info['path'];
            $is_master = $file_info['is_master'] ?? false;
            
            // Use new chunked lookup - much more memory efficient
            $in_local = $this->file_exists_in_index($scan_id, 'local', $rel_path);
            $in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $rel_path);
            
            $files_checked++;
            
            // Track if we've already logged an issue for this file
            $issue_logged = false;
            
            // Case 1: File missing from BOTH locations
            if (!$in_local && !$in_bucket) {
                if ($is_master) {
                    // Master missing - check if we can recover from original_image
                    $original_image = $wp_metadata['original_image'] ?? null;
                    $can_recover = false;

                    if ($original_image) {
                        // Build original file path (same directory as master)
                        $master_dir = dirname($rel_path);
                        $original_rel_path = ($master_dir === '.' ? '' : $master_dir . '/') . $original_image;

                        // Check if original exists anywhere
                        $original_in_local = $this->file_exists_in_index($scan_id, 'local', $original_rel_path);
                        $original_in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $original_rel_path);

                        $can_recover = ($original_in_local || $original_in_bucket);
                    }

                    if ($can_recover) {
                        // Recoverable - can regenerate master from original
                        $this->scanner->store_issue($context['scan_id'], SQMedia_File_State_Scanner::CAT_GENERATE_SIZES, $attachment_id, [
                            'file' => $rel_path,
                            'size' => 'master',
                            'target' => $storage_dest,
                            'is_master_recovery' => true,
                            'original_image' => $original_image,
                            'original_path' => $original_rel_path,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_GENERATE_SIZES;
                    } else {
                        // Truly critical - master missing and no original to recover from
                        $master_critically_missing = true;
                        $this->scanner->store_issue($context['scan_id'], SQMedia_File_State_Scanner::CAT_CRITICAL_MISSING, $attachment_id, [
                            'file' => $rel_path,
                            'filename' => basename($rel_path),
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_CRITICAL_MISSING;
                    }
                } elseif ($file_info['is_original'] ?? false) {
                    // Original-image diagnostics are owned by the post-loop
                    // policy check, which can distinguish "missing from both"
                    // (lost) vs "found at legacy location" (misplaced). Skip
                    // here entirely so we don't double-count or emit
                    // MISSING in parallel with MISPLACED.
                    continue;
                } else {
                    // Derivative missing from both locations
                    // Only mark as "generate" if master is available (not critically missing)
                    if (!$master_critically_missing) {
                        $this->scanner->store_issue($context['scan_id'], SQMedia_File_State_Scanner::CAT_GENERATE_SIZES, $attachment_id, [
                            'file' => $rel_path,
                            'size' => $file_info['size'] ?? 'unknown',
                            'target' => $storage_dest,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_GENERATE_SIZES;
                    }
                    // If master is critically missing, we don't log a separate issue for subsizes
                    // They're implicitly unrecoverable due to the master being missing
                }
                $issue_logged = true;
                $files_with_issues++;
            }

            // Case 2: File exists locally but missing from bucket (needs upload)
            if (!$issue_logged && $in_local && !$in_bucket) {
                if ($storage_dest === 'bucket' || $storage_dest === 'both') {
                    $this->scanner->store_issue($context['scan_id'], SQMedia_File_State_Scanner::CAT_UPLOAD_TO_BUCKET, $attachment_id, [
                        'file' => $rel_path,
                        'is_master' => $is_master,
                        'size' => $file_info['size'] ?? null,
                    ]);
                    $issues[] = SQMedia_File_State_Scanner::CAT_UPLOAD_TO_BUCKET;
                    $issue_logged = true;
                    $files_with_issues++;
                }
            }
            
            // Case 3: File exists in bucket but missing locally (needs download)
            if (!$issue_logged && !$in_local && $in_bucket) {
                if ($storage_dest === 'local' || ($storage_dest === 'both' && !$delete_local)) {
                    $this->scanner->store_issue($context['scan_id'], SQMedia_File_State_Scanner::CAT_DOWNLOAD_TO_LOCAL, $attachment_id, [
                        'file' => $rel_path,
                        'is_master' => $is_master,
                        'size' => $file_info['size'] ?? null,
                    ]);
                    $issues[] = SQMedia_File_State_Scanner::CAT_DOWNLOAD_TO_LOCAL;
                    $issue_logged = true;
                    $files_with_issues++;
                }
            }
            
            // Case 4: File exists where expected - OK
            if (!$issue_logged) {
                $files_ok++;
            }
        }

        // Post-loop: Original image policy checks
        if (!empty($wp_metadata['original_image'])) {
            $original_handling = SQMedia_Settings::get_original_file_handling();

            // A. Discard policy violation — original exists in metadata but policy says discard
            if ($original_handling === 'discard') {
                $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_ORIGINAL_POLICY, $attachment_id, [
                    'action' => 'discard',
                    'size' => 'original',
                    'original_image' => $wp_metadata['original_image'],
                    'master_file' => $record->get_master_path(),
                ]);
                $issues[] = SQMedia_File_State_Scanner::CAT_ORIGINAL_POLICY;
                $files_with_issues++;
            }

            // B. Location mismatch — original exists but in wrong location per current settings
            if ($original_handling !== 'discard') {
                $original_file = $wp_metadata['original_image'];
                $master_base_dir = dirname($record->get_master_path());

                if ($original_handling === 'preserve') {
                    $expected_original_path = ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                    $alt_path = 'originals/' . ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                } else {
                    // move_to_originals
                    $expected_original_path = 'originals/' . ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                    $alt_path = ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                }

                $expected_in_local = $this->file_exists_in_index($scan_id, 'local', $expected_original_path);
                $expected_in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $expected_original_path);

                if (!$expected_in_local && !$expected_in_bucket) {
                    // Check alternate location
                    $alt_in_local = $this->file_exists_in_index($scan_id, 'local', $alt_path);
                    $alt_in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $alt_path);

                    if ($alt_in_local || $alt_in_bucket) {
                        // Found in wrong location — flag for move
                        $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_ORIGINAL_MISPLACED, $attachment_id, [
                            'size' => 'original',
                            'original_image' => $original_file,
                            'current_path' => $alt_path,
                            'expected_path' => $expected_original_path,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_ORIGINAL_MISPLACED;
                        $files_with_issues++;
                    } else {
                        // Not found anywhere — info-only flag, no fix available
                        $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_ORIGINAL_MISSING, $attachment_id, [
                            'size' => 'original',
                            'original_image' => $original_file,
                            'expected_path' => $expected_original_path,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_ORIGINAL_MISSING;
                        $files_with_issues++;
                    }
                }
            }
        }

        return [
            'success' => true,
            'issues' => array_unique($issues),
            'files_checked' => $files_checked,
            'files_ok' => $files_ok,
            'files_with_issues' => $files_with_issues,
        ];
    }

    /**
     * Scan a single attachment for file issues using TARGET sizes (from compute_target_sizes)
     * instead of current metadata. Used by unified scan.
     *
     * @param int $attachment_id Attachment ID to scan
     * @param array $context Scan context with settings and file indexes
     * @param array $target_sizes Target sizes from Phase1::compute_target_sizes()
     * @return array Scan results with issues found
     */
    public function scan_attachment_with_target(int $attachment_id, array $context, array $target_sizes): array {
        $issues = [];
        $files_checked = 0;
        $files_ok = 0;
        $files_with_issues = 0;
        $scan_id = $context['scan_id'];
        $overwrite_existing = $context['overwrite_existing'] ?? false;

        // Track if master is critically missing (unrecoverable)
        $master_critically_missing = false;

        $record = SQMedia_Attachment_Record::find($attachment_id);

        if (!$record) {
            return ['success' => false, 'error' => 'Record not found', 'issues' => [], 'files_checked' => 0, 'files_ok' => 0, 'files_with_issues' => 0];
        }

        $wp_metadata = $record->get_wp_metadata();

        // Storage destination is the current global setting — the diagnosis target.
        // See scan_attachment() above for the full reasoning; same principle here.
        $storage_dest = SQMedia_Settings::get_storage_destination();
        $delete_local = SQMedia_Settings::should_delete_local_files();

        // Build file list from target sizes instead of current metadata
        $files_to_check = $this->get_expected_files_from_target($record, $wp_metadata, $target_sizes);

        foreach ($files_to_check as $file_info) {
            $rel_path = $file_info['path'];
            $is_master = $file_info['is_master'] ?? false;

            $in_local = $this->file_exists_in_index($scan_id, 'local', $rel_path);
            $in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $rel_path);

            $files_checked++;

            $issue_logged = false;

            // In overwrite mode, flag all non-master, non-original files for regeneration regardless of existence
            if ($overwrite_existing && !$is_master && !($file_info['is_original'] ?? false)) {
                $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_GENERATE_SIZES, $attachment_id, [
                    'file' => $rel_path,
                    'size' => $file_info['size'] ?? 'unknown',
                    'target' => $storage_dest,
                    'overwrite' => true,
                ]);
                $issues[] = SQMedia_File_State_Scanner::CAT_GENERATE_SIZES;
                $issue_logged = true;
                $files_with_issues++;
            }

            if (!$issue_logged) {
                // Case 1: File missing from BOTH locations
                if (!$in_local && !$in_bucket) {
                    if ($is_master) {
                        $original_image = $wp_metadata['original_image'] ?? null;
                        $can_recover = false;

                        if ($original_image) {
                            $master_dir = dirname($rel_path);
                            $original_rel_path = ($master_dir === '.' ? '' : $master_dir . '/') . $original_image;
                            $original_in_local = $this->file_exists_in_index($scan_id, 'local', $original_rel_path);
                            $original_in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $original_rel_path);
                            $can_recover = ($original_in_local || $original_in_bucket);
                        }

                        if ($can_recover) {
                            $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_GENERATE_SIZES, $attachment_id, [
                                'file' => $rel_path,
                                'size' => 'master',
                                'target' => $storage_dest,
                                'is_master_recovery' => true,
                                'original_image' => $original_image,
                                'original_path' => $original_rel_path,
                            ]);
                            $issues[] = SQMedia_File_State_Scanner::CAT_GENERATE_SIZES;
                        } else {
                            $master_critically_missing = true;
                            $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_CRITICAL_MISSING, $attachment_id, [
                                'file' => $rel_path,
                                'filename' => basename($rel_path),
                            ]);
                            $issues[] = SQMedia_File_State_Scanner::CAT_CRITICAL_MISSING;
                        }
                    } elseif ($file_info['is_original'] ?? false) {
                        // Original-image diagnostics are owned by the post-loop
                        // policy check, which probes both intended and legacy
                        // locations and emits MISPLACED or MISSING with full
                        // context. Skipping here prevents the contradictory
                        // pair of badges (e.g., "found at legacy" + "not
                        // found anywhere") on the same file row.
                        continue;
                    } else {
                        if (!$master_critically_missing) {
                            $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_GENERATE_SIZES, $attachment_id, [
                                'file' => $rel_path,
                                'size' => $file_info['size'] ?? 'unknown',
                                'target' => $storage_dest,
                            ]);
                            $issues[] = SQMedia_File_State_Scanner::CAT_GENERATE_SIZES;
                        }
                    }
                    $issue_logged = true;
                    $files_with_issues++;
                }
            }

            if (!$issue_logged) {
                // Case 2: File exists locally but missing from bucket (needs upload)
                if ($in_local && !$in_bucket) {
                    if ($storage_dest === 'bucket' || $storage_dest === 'both') {
                        $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_UPLOAD_TO_BUCKET, $attachment_id, [
                            'file' => $rel_path,
                            'is_master' => $is_master,
                            'size' => $file_info['size'] ?? null,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_UPLOAD_TO_BUCKET;
                        $issue_logged = true;
                        $files_with_issues++;
                    }
                }
            }

            if (!$issue_logged) {
                // Case 3: File exists in bucket but missing locally (needs download)
                if (!$in_local && $in_bucket) {
                    if ($storage_dest === 'local' || ($storage_dest === 'both' && !$delete_local)) {
                        $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_DOWNLOAD_TO_LOCAL, $attachment_id, [
                            'file' => $rel_path,
                            'is_master' => $is_master,
                            'size' => $file_info['size'] ?? null,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_DOWNLOAD_TO_LOCAL;
                        $issue_logged = true;
                        $files_with_issues++;
                    }
                }
            }

            // Case 4: File exists where expected - OK
            if (!$issue_logged) {
                $files_ok++;
            }
        }

        // Post-loop: Original image policy checks
        if (!empty($wp_metadata['original_image'])) {
            $original_handling = SQMedia_Settings::get_original_file_handling();

            // A. Discard policy violation — original exists in metadata but policy says discard
            if ($original_handling === 'discard') {
                $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_ORIGINAL_POLICY, $attachment_id, [
                    'action' => 'discard',
                    'size' => 'original',
                    'original_image' => $wp_metadata['original_image'],
                    'master_file' => $record->get_master_path(),
                ]);
                $issues[] = SQMedia_File_State_Scanner::CAT_ORIGINAL_POLICY;
                $files_with_issues++;
            }

            // B. Location mismatch — original exists but in wrong location per current settings
            if ($original_handling !== 'discard') {
                $original_file = $wp_metadata['original_image'];
                $master_base_dir = dirname($record->get_master_path());

                if ($original_handling === 'preserve') {
                    $expected_original_path = ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                    $alt_path = 'originals/' . ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                } else {
                    // move_to_originals
                    $expected_original_path = 'originals/' . ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                    $alt_path = ($master_base_dir === '.' ? '' : $master_base_dir . '/') . $original_file;
                }

                $expected_in_local = $this->file_exists_in_index($scan_id, 'local', $expected_original_path);
                $expected_in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $expected_original_path);

                if (!$expected_in_local && !$expected_in_bucket) {
                    $alt_in_local = $this->file_exists_in_index($scan_id, 'local', $alt_path);
                    $alt_in_bucket = $this->file_exists_in_index($scan_id, 'bucket', $alt_path);

                    if ($alt_in_local || $alt_in_bucket) {
                        $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_ORIGINAL_MISPLACED, $attachment_id, [
                            'size' => 'original',
                            'original_image' => $original_file,
                            'current_path' => $alt_path,
                            'expected_path' => $expected_original_path,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_ORIGINAL_MISPLACED;
                        $files_with_issues++;
                    } else {
                        // Not found anywhere — info-only flag, no fix available
                        $this->scanner->store_issue($scan_id, SQMedia_File_State_Scanner::CAT_ORIGINAL_MISSING, $attachment_id, [
                            'size' => 'original',
                            'original_image' => $original_file,
                            'expected_path' => $expected_original_path,
                        ]);
                        $issues[] = SQMedia_File_State_Scanner::CAT_ORIGINAL_MISSING;
                        $files_with_issues++;
                    }
                }
            }
        }

        return [
            'success' => true,
            'issues' => array_unique($issues),
            'files_checked' => $files_checked,
            'files_ok' => $files_ok,
            'files_with_issues' => $files_with_issues,
        ];
    }

    /**
     * Get list of expected files for an attachment based on TARGET sizes
     * (from compute_target_sizes) instead of current metadata.
     *
     * @param SQMedia_Attachment_Record $record Attachment record
     * @param array $wp_metadata Current WordPress metadata
     * @param array $target_sizes Target sizes [size_name => ['file' => ..., 'width' => ..., 'height' => ...]]
     * @return array List of expected files
     */
    private function get_expected_files_from_target(SQMedia_Attachment_Record $record, array $wp_metadata, array $target_sizes): array {
        $files = [];
        $master_path = $record->get_master_path();
        $master_path = str_replace('\\', '/', $master_path);
        $base_dir = dirname($master_path);

        $include_webp = SQMedia_Settings::is_webp_enabled() && $record->supports_webp();

        // Master file is always expected
        $files[] = [
            'path' => $master_path,
            'is_master' => true,
        ];

        // Master WebP
        if ($include_webp) {
            $webp_path = SQMedia_Attachment_Record::get_webp_filename($master_path);
            if ($webp_path !== $master_path) {
                $files[] = [
                    'path' => $webp_path,
                    'size' => 'master-webp',
                    'is_master' => false,
                ];
            }
        }

        // Original image file (archival copy, no WebP)
        if (!empty($wp_metadata['original_image'])) {
            $original_file = $wp_metadata['original_image'];
            $original_handling = SQMedia_Settings::get_original_file_handling();

            if ($original_handling === 'preserve') {
                $original_path = ($base_dir === '.' ? '' : $base_dir . '/') . $original_file;
            } elseif ($original_handling === 'move_to_originals') {
                $original_path = 'originals/' . ($base_dir === '.' ? '' : $base_dir . '/') . $original_file;
            } else {
                // 'discard' mode — don't add to expected files (it shouldn't exist)
                $original_path = null;
            }

            if ($original_path) {
                $files[] = [
                    'path' => $original_path,
                    'size' => 'original',
                    'is_master' => false,
                    'is_original' => true,
                ];
            }
        }

        // Add files from target sizes (what SHOULD exist based on settings)
        foreach ($target_sizes as $size_name => $size_data) {
            if (!empty($size_data['file'])) {
                $size_path = ($base_dir === '.' ? '' : $base_dir . '/') . $size_data['file'];
                $files[] = [
                    'path' => $size_path,
                    'size' => $size_name,
                    'is_master' => false,
                ];

                // Size WebP
                if ($include_webp) {
                    $webp_file = SQMedia_Attachment_Record::get_webp_filename($size_data['file']);
                    if ($webp_file !== $size_data['file']) {
                        $webp_path = ($base_dir === '.' ? '' : $base_dir . '/') . $webp_file;
                        $files[] = [
                            'path' => $webp_path,
                            'size' => $size_name . '-webp',
                            'is_master' => false,
                        ];
                    }
                }
            }
        }

        return $files;
    }

    /**
     * Get list of expected files for an attachment
     */
    private function get_expected_files(SQMedia_Attachment_Record $record, array $wp_metadata): array {
        $files = [];
        $master_path = $record->get_master_path();
        // Normalize to forward slashes
        $master_path = str_replace('\\', '/', $master_path);
        $base_dir = dirname($master_path);

        $include_webp = SQMedia_Settings::is_webp_enabled() && $record->supports_webp();

        // Master file
        $files[] = [
            'path' => $master_path,
            'is_master' => true,
        ];

        // Master WebP
        if ($include_webp) {
            $webp_path = SQMedia_Attachment_Record::get_webp_filename($master_path);
            if ($webp_path !== $master_path) {
                $files[] = [
                    'path' => $webp_path,
                    'size' => 'master-webp',
                    'is_master' => false,
                ];
            }
        }

        // Original image file (archival copy, no WebP)
        if (!empty($wp_metadata['original_image'])) {
            $original_file = $wp_metadata['original_image'];
            $original_handling = SQMedia_Settings::get_original_file_handling();

            if ($original_handling === 'preserve') {
                $original_path = ($base_dir === '.' ? '' : $base_dir . '/') . $original_file;
            } elseif ($original_handling === 'move_to_originals') {
                $original_path = 'originals/' . ($base_dir === '.' ? '' : $base_dir . '/') . $original_file;
            } else {
                // 'discard' mode — don't add to expected files (it shouldn't exist)
                $original_path = null;
            }

            if ($original_path) {
                $files[] = [
                    'path' => $original_path,
                    'size' => 'original',
                    'is_master' => false,
                    'is_original' => true,
                ];
            }
        }

        // All sizes from metadata
        $sizes = $wp_metadata['sizes'] ?? [];

        foreach ($sizes as $size_name => $size_data) {
            if (!empty($size_data['file'])) {
                $size_path = ($base_dir === '.' ? '' : $base_dir . '/') . $size_data['file'];
                $files[] = [
                    'path' => $size_path,
                    'size' => $size_name,
                    'is_master' => false,
                ];

                // Size WebP
                if ($include_webp) {
                    $webp_file = SQMedia_Attachment_Record::get_webp_filename($size_data['file']);
                    if ($webp_file !== $size_data['file']) {
                        $webp_path = ($base_dir === '.' ? '' : $base_dir . '/') . $webp_file;
                        $files[] = [
                            'path' => $webp_path,
                            'size' => $size_name . '-webp',
                            'is_master' => false,
                        ];
                    }
                }
            }
        }

        return $files;
    }
    
    // =========================================================================
    // FILE INDEX MANAGEMENT - Chunked Approach
    // =========================================================================
    
    /**
     * Chunk size for file indexes
     * 5000 files per chunk = ~300KB per file, good balance for RAM and file count
     */
    const INDEX_CHUNK_SIZE = 5000;
    
    /**
     * Cache for loaded chunks (avoid reloading same chunk repeatedly)
     */
    private array $chunk_cache = [];
    private int $chunk_cache_max = 3; // Keep up to 3 chunks in memory
    
    /**
     * Loaded manifests
     */
    private array $manifests = [];
    
    /**
     * Build file indexes for Phase 2
     * Called via cron: sq_build_file_index
     */
    public function build_file_indexes(string $scan_id): bool {
        $db = $this->scanner->get_db();
        $scan = $db->get_scan($scan_id);
        
        if (!$scan) {
            return false;
        }
        
        if ($scan->status !== 'building_index') {
            return false;
        }
        
        $results = $scan->results;
        $index_status = $results['index_status'] ?? 'pending';

        // Skip local index if already built (run_local_index_only may have done it)
        if ($index_status === 'pending' || $index_status === 'building_local') {
            $results['index_status'] = 'building_local';
            $results['index_file_count'] = 0;
            $results['index_current_folder'] = 'Starting...';
            $db->update_scan($scan_id, ['results' => $results]);

            // Build local file index (writes chunks progressively)
            $local_count = $this->build_local_index($scan_id, $db);

            $results['local_file_count'] = $local_count;
        }

        // Build bucket index — run all pages in a loop (cron context, no UI to update)
        // Acquire atomic lock to prevent AJAX fallback (continue_index_build) from
        // running concurrently — both paths share the same buffer file and DB state.
        global $wpdb;
        $lock_name = 'sqmedia_bucket_build_' . substr($scan_id, 0, 40);
        // Wait up to 10s for AJAX fallback to finish its current page, then take over.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $lock_acquired = (int) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 10)", $lock_name));
        if ($lock_acquired !== 1) {
            // Still locked after 10s — very unusual, let AJAX continue
            return false;
        }

        $results['index_status'] = 'building_bucket';
        $results['index_file_count'] = 0;
        $results['index_current_folder'] = 'Starting...';
        $db->update_scan($scan_id, ['results' => $results]);

        while (!$this->build_bucket_index_batch($scan_id, $db)) {
            // Check for pause between batches
            $scan = $db->get_scan($scan_id);
            if ($scan->status === 'paused') {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));
                return false;
            }
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));

        // Re-fetch results after batching updated them
        $scan = $db->get_scan($scan_id);
        $results = $scan->results;
        $bucket_count = $results['bucket_index_count'] ?? 0;

        // Mark index complete and clean up batch state
        $results['index_status'] = 'complete';
        $results['bucket_file_count'] = $bucket_count;
        $results['index_file_count'] = 0;
        $results['index_current_folder'] = '';
        unset($results['bucket_page_token']);
        unset($results['bucket_index_count']);
        unset($results['bucket_chunk_number']);
        unset($results['bucket_chunks_meta']);
        $db->update_scan($scan_id, ['results' => $results]);
        
        // Get items to process - sorted by file path for optimal chunk loading
        // (files from the same chunk will be processed together, minimizing chunk loads)
        $item_ids = $db->get_all_registered_ids_sorted_by_path();
        $total = count($item_ids);
        
        if ($total === 0) {
            $db->update_scan($scan_id, [
                'status' => 'completed',
                'total_items' => 0,
                'processed_items' => 0,
                'scan_completed' => current_time('mysql'),
                'results' => array_merge($results, [
                    'summary' => [
                        'total' => 0,
                        'processed' => 0,
                        'clean' => 0,
                        'with_issues' => 0,
                        'files_checked' => 0,
                        'files_ok' => 0,
                        'files_with_issues' => 0,
                    ],
                ]),
            ]);
            return true;
        }
        
        // Store item IDs for processing
        set_transient('sqmedia_scan_ids_' . $scan_id, $item_ids, HOUR_IN_SECONDS * 4);
        
        // Prepare for scanning phase
        $results['summary'] = [
            'total' => $total,
            'processed' => 0,
            'clean' => 0,
            'with_issues' => 0,
            'files_checked' => 0,
            'files_ok' => 0,
            'files_with_issues' => 0,
        ];
        
        $db->update_scan($scan_id, [
            'status' => 'scanning',
            'total_items' => $total,
            'processed_items' => 0,
            'results' => $results,
        ]);
        
        // Schedule recurring cron for scanning phase (matches AJAX schedule_cron pattern)
        wp_clear_scheduled_hook('sqmedia_file_state_scan_continue');
        if (!wp_next_scheduled('sqmedia_file_state_scan_continue')) {
            wp_schedule_event(time() + 5, 'sqmedia_one_minute', 'sqmedia_file_state_scan_continue');
        }

        return true;
    }

    /**
     * Build a chunked index from a file source
     * Unified method for both local and bucket indexing
     * 
     * @param string $scan_id Scan ID
     * @param string $type 'local' or 'bucket'
     * @param iterable $file_source Generator or iterable yielding file paths in alphabetical order
     * @param SQMedia_Database $db Database instance
     * @return int Total file count
     */
    private function build_chunked_index(string $scan_id, string $type, iterable $file_source, SQMedia_Database $db): int {
        // This method is no longer used - kept for reference
        // Use build_local_index() and build_bucket_index() directly instead
        return 0;
    }
    
    /**
     * Build local file index using pass-by-reference for immediate chunk writing
     * 
     * @param string $scan_id Scan ID
     * @param SQMedia_Database $db Database instance
     * @return int Total file count
     */
    private function build_local_index(string $scan_id, SQMedia_Database $db): int {
        $upload_dir = $this->scanner->get_upload_dir();
        $base_dir = str_replace('\\', '/', $upload_dir['basedir']);
        $temp_dir = $this->get_scan_temp_dir();
        
        // Get skip directories from constant or use empty array
        $skip_dirs = defined('SQMEDIA_SKIP_DIRS') ? SQMEDIA_SKIP_DIRS : [];
        // Always skip our own folder
        $skip_dirs[] = 'staticq';
        $skip_dirs = array_map('strtolower', $skip_dirs);
        
        // Initialize state variables (passed by reference through recursion)
        $buffer = [];
        $chunk_number = 0;
        $total_count = 0;
        $chunks_meta = [];
        $last_update = 0;
        
        // Start recursive scan
        $this->scan_directory_recursive(
            $base_dir,
            '',
            $skip_dirs,
            $buffer,
            $chunk_number,
            $total_count,
            $chunks_meta,
            $last_update,
            $scan_id,
            'local',
            $temp_dir,
            $db
        );
        
        // Save final chunk if there are remaining files
        if (!empty($buffer)) {
            $chunk_number++;
            $chunk_file = sprintf('chunk-%s-local-%04d.json', $scan_id, $chunk_number);
            
            $chunks_meta[] = [
                'file' => $chunk_file,
                'first' => $buffer[0],
                'last' => $buffer[count($buffer) - 1],
                'count' => count($buffer),
            ];
            
            file_put_contents($temp_dir . '/' . $chunk_file, json_encode($buffer));
        }
        
        // Save manifest
        $this->save_index_manifest($scan_id, 'local', $total_count, $chunk_number, $chunks_meta, $temp_dir);
        
        // Final progress update
        $this->update_index_progress($scan_id, $total_count, 'Complete', $db);
        
        
        return $total_count;
    }
    
    /**
     * Recursive directory scanner with pass-by-reference for immediate chunk writing
     * Processes files and directories in true alphabetical order (interleaved)
     */
    private function scan_directory_recursive(
        string $base_dir,
        string $prefix,
        array $skip_dirs,
        array &$buffer,
        int &$chunk_number,
        int &$total_count,
        array &$chunks_meta,
        int &$last_update,
        string $scan_id,
        string $type,
        string $temp_dir,
        SQMedia_Database $db
    ): void {
        $current_dir = $prefix ? $base_dir . '/' . $prefix : $base_dir;
        
        if (!is_dir($current_dir)) {
            return;
        }
        
        // Update progress when entering directory
        if (time() - $last_update >= 1) {
            $this->update_index_progress($scan_id, $total_count, $prefix ?: '/', $db);
            $last_update = time();
        }
        
        $raw_items = @scandir($current_dir);
        if ($raw_items === false) {
            return;
        }
        
        // Collect all items with their type
        $items = [];
        
        foreach ($raw_items as $name) {
            // Skip . and .. and hidden files
            if ($name === '.' || $name === '..' || $name[0] === '.') {
                continue;
            }
            
            $full_path = $current_dir . '/' . $name;
            
            if (is_file($full_path)) {
                $items[] = ['type' => 'file', 'name' => $name];
            } elseif (is_dir($full_path)) {
                // Check skip list
                if (!in_array(strtolower($name), $skip_dirs)) {
                    $items[] = ['type' => 'dir', 'name' => $name];
                }
            }
        }
        
        // ONE sort by name - files and dirs together
        usort($items, fn($a, $b) => strcmp($a['name'], $b['name']));
        
        // ONE loop - process in alphabetical order
        foreach ($items as $item) {
            if ($item['type'] === 'file') {
                // Add file to buffer
                $rel_path = $prefix ? $prefix . '/' . $item['name'] : $item['name'];
                $buffer[] = $rel_path;
                $total_count++;
                
                // Log every 20,000 files
                if ($total_count % 20000 === 0) {
                }
                
                // Write chunk when buffer is full
                if (count($buffer) >= self::INDEX_CHUNK_SIZE) {
                    $chunk_number++;
                    $chunk_file = sprintf('chunk-%s-%s-%04d.json', $scan_id, $type, $chunk_number);
                    
                    $chunks_meta[] = [
                        'file' => $chunk_file,
                        'first' => $buffer[0],
                        'last' => $buffer[count($buffer) - 1],
                        'count' => count($buffer),
                    ];
                    
                    file_put_contents($temp_dir . '/' . $chunk_file, json_encode($buffer));
                    
                    // Clear buffer immediately - free memory
                    $buffer = [];
                    
                    
                    // Update progress
                    $this->update_index_progress($scan_id, $total_count, $prefix ?: '/', $db);
                    $last_update = time();
                }
            } else {
                // Recurse into directory
                $sub_prefix = $prefix ? $prefix . '/' . $item['name'] : $item['name'];
                $this->scan_directory_recursive(
                    $base_dir,
                    $sub_prefix,
                    $skip_dirs,
                    $buffer,
                    $chunk_number,
                    $total_count,
                    $chunks_meta,
                    $last_update,
                    $scan_id,
                    $type,
                    $temp_dir,
                    $db
                );
            }
        }
    }
    
    /**
     * Process one page of bucket files using list_files_paginated().
     * State (continuation token, chunk number, buffer) is stored in scan results
     * so each call picks up where the previous one left off.
     *
     * @param string $scan_id Scan ID
     * @param SQMedia_Database $db Database instance
     * @return bool True if all pages are done, false if more pages remain
     */
    private function build_bucket_index_batch(string $scan_id, SQMedia_Database $db): bool {
        if (!SQMedia_Settings::is_cloud_storage_configured()) {
            $temp_dir = $this->get_scan_temp_dir();
            $this->save_index_manifest($scan_id, 'bucket', 0, 0, [], $temp_dir);
            return true;
        }

        $handler = sqmedia()->get_storage_handler();
        if (!$handler) {
            $temp_dir = $this->get_scan_temp_dir();
            $this->save_index_manifest($scan_id, 'bucket', 0, 0, [], $temp_dir);
            return true;
        }

        $prefix = SQMedia_Settings::get_current_prefix();
        $prefix_len = $prefix ? strlen($prefix) + 1 : 0;
        $temp_dir = $this->get_scan_temp_dir();

        // Restore state from previous batch (or initialize)
        $scan = $db->get_scan($scan_id);
        $results = $scan->results ?? [];
        $page_token = $results['bucket_page_token'] ?? null;
        $total_count = $results['bucket_index_count'] ?? 0;
        $chunk_number = $results['bucket_chunk_number'] ?? 0;
        $chunks_meta = $results['bucket_chunks_meta'] ?? [];
        $buffer = [];

        // Load leftover buffer from previous batch (partial chunk)
        $buffer_file = $temp_dir . '/bucket-buffer-' . $scan_id . '.json';
        if (file_exists($buffer_file)) {
            $buffer = json_decode(file_get_contents($buffer_file), true) ?: [];
        }

        // Check for pause
        if ($scan->status === 'paused') {
            return false;
        }

        // Fetch one page of files (tunable via wp-config.php)
        $page_size = defined('SQMEDIA_BUCKET_INDEX_PAGE_SIZE') ? (int) SQMEDIA_BUCKET_INDEX_PAGE_SIZE : 1000;
        try {
            $page = $handler->list_files_paginated($prefix, $page_size, $page_token);
        } catch (\Exception $e) {
            $retries = ($results['bucket_index_retries'] ?? 0) + 1;
            $results['bucket_index_retries'] = $retries;
            $db->update_scan($scan_id, ['results' => $results]);

            if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log(sprintf(
                    '[StaticQ Scanner] Bucket index page failed (attempt %d/3) — %d files indexed so far. Error: %s',
                    $retries,
                    $total_count,
                    $e->getMessage()
                ));
            }

            if ($retries >= 3) {
                // Give up after 3 consecutive failures — save what we have
                $this->save_index_manifest($scan_id, 'bucket', $total_count, $chunk_number, $chunks_meta, $temp_dir);
                return true;
            }

            // Retry — reschedule cron with same page token
            if (!wp_next_scheduled('sqmedia_build_file_index', [$scan_id])) {
                wp_schedule_single_event(time() + 5, 'sqmedia_build_file_index', [$scan_id]);
            }
            return false;
        }

        $files = $page['files'] ?? [];
        $next_token = $page['next_page_token'] ?? null;

        // Process files from this page
        foreach ($files as $file) {
            $name = $file['name'] ?? '';
            if (!$name) {
                continue;
            }

            $rel_path = $prefix_len > 0 ? substr($name, $prefix_len) : $name;
            if (!$rel_path) {
                continue;
            }

            $buffer[] = $rel_path;
            $total_count++;

            // Write chunk when buffer is full
            if (count($buffer) >= self::INDEX_CHUNK_SIZE) {
                $chunk_number++;
                $chunk_file = sprintf('chunk-%s-bucket-%04d.json', $scan_id, $chunk_number);

                // Sort to guarantee binary search correctness
                sort($buffer, SORT_STRING);

                $chunks_meta[] = [
                    'file' => $chunk_file,
                    'first' => $buffer[0],
                    'last' => $buffer[count($buffer) - 1],
                    'count' => count($buffer),
                ];

                file_put_contents($temp_dir . '/' . $chunk_file, json_encode($buffer));
                $buffer = [];

            }
        }

        $is_done = empty($next_token);

        if ($is_done) {
            // Final chunk: flush remaining buffer
            if (!empty($buffer)) {
                $chunk_number++;
                $chunk_file = sprintf('chunk-%s-bucket-%04d.json', $scan_id, $chunk_number);

                // Sort to guarantee binary search correctness
                sort($buffer, SORT_STRING);

                $chunks_meta[] = [
                    'file' => $chunk_file,
                    'first' => $buffer[0],
                    'last' => $buffer[count($buffer) - 1],
                    'count' => count($buffer),
                ];

                file_put_contents($temp_dir . '/' . $chunk_file, json_encode($buffer));
            }

            // Save manifest
            $this->save_index_manifest($scan_id, 'bucket', $total_count, $chunk_number, $chunks_meta, $temp_dir);

            // Clean up buffer file
            if (file_exists($buffer_file)) {
                wp_delete_file($buffer_file);
            }

            if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log(sprintf(
                    '[StaticQ Scanner] Bucket index complete — %d files indexed in %d chunks',
                    $total_count,
                    $chunk_number
                ));
            }

        } else {
            // Save partial buffer to disk for next batch
            file_put_contents($buffer_file, json_encode($buffer));
        }

        // Save all batch state in a single write to avoid stale read-modify-write races.
        // Previously update_index_progress() did a separate DB read-write that could
        // overwrite chunks_meta with stale data if the final save below failed.
        $last_rel = !empty($files) ? substr(end($files)['name'] ?? '', $prefix_len) : '';
        $results['bucket_page_token'] = $next_token;
        $results['bucket_index_count'] = $total_count;
        $results['bucket_chunk_number'] = $chunk_number;
        $results['bucket_chunks_meta'] = $chunks_meta;
        $results['index_file_count'] = $total_count;
        $results['index_current_folder'] = $is_done ? 'Complete' : (dirname($last_rel) ?: '/');
        $results['bucket_index_retries'] = 0;
        $db->update_scan($scan_id, ['results' => $results]);

        return $is_done;
    }
    
    /**
     * Helper: Update index progress in database
     */
    private function update_index_progress(string $scan_id, int $file_count, string $current_folder, SQMedia_Database $db): void {
        $scan = $db->get_scan($scan_id);
        if ($scan) {
            $results = $scan->results;
            $results['index_file_count'] = $file_count;
            $results['index_current_folder'] = $current_folder;
            $db->update_scan($scan_id, ['results' => $results]);
        }
    }
    
    /**
     * Helper: Save index manifest
     */
    private function save_index_manifest(string $scan_id, string $type, int $total_count, int $chunk_count, array $chunks_meta, string $temp_dir): void {
        $manifest = [
            'type' => $type,
            'scan_id' => $scan_id,
            'total_files' => $total_count,
            'chunk_size' => self::INDEX_CHUNK_SIZE,
            'chunk_count' => $chunk_count,
            'chunks' => $chunks_meta,
            'created' => current_time('mysql'),
        ];
        
        $manifest_file = $temp_dir . '/manifest-' . $scan_id . '-' . $type . '.json';
        file_put_contents($manifest_file, json_encode($manifest, JSON_PRETTY_PRINT));
    }
    
    /**
     * Get the directory for scan temporary files
     */
    private function get_scan_temp_dir(): string {
        $upload_dir = $this->scanner->get_upload_dir();
        $dir = $upload_dir['basedir'] . '/staticq/scans';
        
        if (!file_exists($dir)) {
            wp_mkdir_p($dir);
            
            // Add .htaccess to prevent direct access
            $htaccess = $dir . '/.htaccess';
            if (!file_exists($htaccess)) {
                file_put_contents($htaccess, "Deny from all\n");
            }
            
            // Add index.php for extra protection
            $index = $dir . '/index.php';
            if (!file_exists($index)) {
                file_put_contents($index, "<?php // Silence is golden\n");
            }
        }
        
        return $dir;
    }
    
    /**
     * Load manifest for an index
     */
    public function load_manifest(string $scan_id, string $type): ?array {
        $cache_key = $scan_id . '-' . $type;
        
        if (isset($this->manifests[$cache_key])) {
            return $this->manifests[$cache_key];
        }
        
        $dir = $this->get_scan_temp_dir();
        $manifest_file = $dir . '/manifest-' . $scan_id . '-' . $type . '.json';
        
        if (!file_exists($manifest_file)) {
            return null;
        }
        
        $manifest = json_decode(file_get_contents($manifest_file), true);
        
        if ($manifest) {
            $this->manifests[$cache_key] = $manifest;
        }
        
        return $manifest;
    }
    
    /**
     * Check if a file exists in the index
     * Uses binary search on manifest to find correct chunk, then searches within chunk
     * 
     * @param string $scan_id Scan ID
     * @param string $type 'local' or 'bucket'
     * @param string $file_path File path relative to uploads
     * @return bool
     */
    public function file_exists_in_index(string $scan_id, string $type, string $file_path): bool {
        $manifest = $this->load_manifest($scan_id, $type);

        if (!$manifest || empty($manifest['chunks'])) {
            return false;
        }

        // Binary search to find the right chunk
        $chunk_meta = $this->find_chunk_for_file($manifest['chunks'], $file_path);

        if (!$chunk_meta) {
            return false;
        }

        // Load chunk (with caching)
        $chunk_data = $this->load_chunk($chunk_meta['file']);

        if (!$chunk_data) {
            return false;
        }

        // Binary search within chunk
        return $this->binary_search_in_array($chunk_data, $file_path);
    }

    /**
     * Binary search to find which chunk contains a file
     * 
     * @param array $chunks Array of chunk metadata with 'first' and 'last' keys
     * @param string $file_path File path to find
     * @return array|null Chunk metadata or null if not found
     */
    private function find_chunk_for_file(array $chunks, string $file_path): ?array {
        $left = 0;
        $right = count($chunks) - 1;
        
        while ($left <= $right) {
            $mid = (int) (($left + $right) / 2);
            $chunk = $chunks[$mid];
            
            if ($file_path < $chunk['first']) {
                // File comes before this chunk
                $right = $mid - 1;
            } elseif ($file_path > $chunk['last']) {
                // File comes after this chunk
                $left = $mid + 1;
            } else {
                // File is within this chunk's range
                return $chunk;
            }
        }
        
        return null;
    }
    
    /**
     * Load a chunk file with LRU caching
     */
    private function load_chunk(string $chunk_file): ?array {
        // Check cache
        if (isset($this->chunk_cache[$chunk_file])) {
            // Move to end (most recently used)
            $data = $this->chunk_cache[$chunk_file];
            unset($this->chunk_cache[$chunk_file]);
            $this->chunk_cache[$chunk_file] = $data;
            return $data;
        }
        
        // Load from disk
        $dir = $this->get_scan_temp_dir();
        $path = $dir . '/' . $chunk_file;
        
        if (!file_exists($path)) {
            return null;
        }
        
        $data = json_decode(file_get_contents($path), true);
        
        if (!$data) {
            return null;
        }
        
        // Add to cache
        $this->chunk_cache[$chunk_file] = $data;
        
        // Evict oldest if cache is full
        while (count($this->chunk_cache) > $this->chunk_cache_max) {
            // Remove first item (oldest)
            array_shift($this->chunk_cache);
        }
        
        return $data;
    }
    
    /**
     * Binary search within a sorted array
     */
    private function binary_search_in_array(array $arr, string $needle): bool {
        $left = 0;
        $right = count($arr) - 1;
        
        while ($left <= $right) {
            $mid = (int) (($left + $right) / 2);
            $cmp = strcmp($arr[$mid], $needle);
            
            if ($cmp === 0) {
                return true;
            } elseif ($cmp < 0) {
                $left = $mid + 1;
            } else {
                $right = $mid - 1;
            }
        }
        
        return false;
    }
    
    /**
     * Clear chunk cache (call between scans or when memory is tight)
     */
    public function clear_chunk_cache(): void {
        $this->chunk_cache = [];
        $this->manifests = [];
    }
    
    /**
     * Legacy method for backward compatibility
     * Now returns a lookup object instead of full array
     * 
     * @deprecated Use file_exists_in_index() instead
     */
    public function load_file_index(string $scan_id, string $type): array {
        // Return empty array - callers should use file_exists_in_index() instead
        // This maintains backward compatibility but forces migration to new approach
        return [];
    }
    
    /**
     * Clean up index files for a specific scan
     */
    public function cleanup_indexes(string $scan_id): void {
        $dir = $this->get_scan_temp_dir();
        
        // Clean up all files for this scan
        $patterns = [
            $dir . '/chunk-' . $scan_id . '-*.json',
            $dir . '/manifest-' . $scan_id . '-*.json',
            // Old format cleanup
            $dir . '/index-' . $scan_id . '-*.json',
        ];
        
        foreach ($patterns as $pattern) {
            $files = glob($pattern);
            foreach ($files as $file) {
                wp_delete_file($file);
            }
        }

        // Clear cache
        $this->clear_chunk_cache();
    }
    
    /**
     * Clean up all old index files (called periodically)
     */
    public function cleanup_all_old_indexes(): void {
        $dir = $this->get_scan_temp_dir();
        
        if (!is_dir($dir)) {
            return;
        }
        
        $files = glob($dir . '/*.json');
        $now = time();
        $max_age = HOUR_IN_SECONDS * 24; // 24 hours
        
        foreach ($files as $file) {
            if ($now - filemtime($file) > $max_age) {
                wp_delete_file($file);
            }
        }
    }
    
    // =========================================================================
    // FIXING
    // =========================================================================
    
    /**
     * Fix all Phase 2 issues from a scan
     * 
     * TODO: Implement file operations:
     * - Upload files to bucket
     * - Download files from bucket
     * - Generate missing sizes
     */
    public function fix_all(string $scan_id, object $scan): array {
        $db = $this->scanner->get_db();
        
        // Get all unique attachment IDs with issues
        global $wpdb;
        $table = SQMedia_Database::get_table(SQMedia_Database::TABLE_SCAN_ITEMS);
        
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $attachment_ids = $wpdb->get_col($wpdb->prepare(
            "SELECT DISTINCT attachment_id FROM {$table} WHERE scan_id = %s ORDER BY attachment_id ASC",
            $scan_id
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (empty($attachment_ids)) {
            $db->update_scan($scan_id, ['status' => 'completed']);
            return ['success' => true, 'message' => __('No issues to fix.', 'staticq-media'), 'fixed' => 0, 'total' => 0, 'failed' => 0];
        }

        $total = count($attachment_ids);
        $fixed = 0;
        $failed = 0;
        $errors = [];

        foreach ($attachment_ids as $attachment_id) {
            $result = $this->fix_attachment((int) $attachment_id, $scan_id);

            if ($result['success']) {
                $fixed++;
            } else {
                $failed++;
                if (!empty($result['error'])) {
                    $errors[] = sprintf('#%d: %s', $attachment_id, $result['error']);
                }
            }
        }
        
        // Update scan results
        $results = $scan->results ?? [];
        $results['fix_completed'] = [
            'total' => $total,
            'fixed' => $fixed,
            'failed' => $failed,
        ];
        
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $remaining = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE scan_id = %s",
            $scan_id
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $db->update_scan($scan_id, [
            'status' => 'completed',
            'results' => $results,
        ]);

        // Clean up index files
        $this->cleanup_indexes($scan_id);
        
        return [
            'success' => true,
            'total' => $total,
            'fixed' => $fixed,
            'failed' => $failed,
            'errors' => array_slice($errors, 0, 10),
            'remaining' => $remaining,
        ];
    }
    
    /**
     * Fix a single attachment's Phase 2 issues
     * 
     * Handles:
     * - UPLOAD_TO_BUCKET: Copy local file to bucket
     * - DOWNLOAD_TO_LOCAL: Copy bucket file to local
     * - GENERATE_SIZES: Regenerate missing sizes from master
     * - CRITICAL_MISSING: Cannot fix - master is missing everywhere
     */
    public function fix_attachment(int $attachment_id, string $scan_id): array {
        $db = $this->scanner->get_db();
        $record = SQMedia_Attachment_Record::find($attachment_id);
        
        if (!$record) {
            return ['success' => false, 'fixed' => 0, 'errors' => [__('Attachment not found.', 'staticq-media')]];
        }

        // Get issues for this attachment
        $issues = $db->get_attachment_scan_items($scan_id, $attachment_id);

        if (empty($issues)) {
            // Issues already resolved between scan and fix — nothing to do
            return ['success' => true, 'fixed' => 0, 'errors' => []];
        }
        
        $fixed_count = 0;
        $errors = [];
        $chunked_pending = false;

        // Get handlers
        $s3 = sqmedia()->get_storage_handler();
        $cloudflare = sqmedia()->get_cloudflare_handler();
        $upload_dir = wp_upload_dir();
        
        // Track which issue rows had at least one successful fix
        $fixed_action_types = [];

        foreach ($issues as $issue) {
            $action_type = $issue->action_type;
            $raw_details = $issue->details ? json_decode($issue->details, true) : [];

            // Handle new aggregated format (items array) or old single-item format
            $items_list = $raw_details['items'] ?? [$raw_details];

            foreach ($items_list as $details) {
                $file_path = $details['file'] ?? '';
            
                try {
                    switch ($action_type) {
                        case SQMedia_File_State_Scanner::CAT_UPLOAD_TO_BUCKET:
                        $result = $this->fix_upload_to_bucket($file_path, $s3, $upload_dir);
                        if ($result['success']) {
                            $fixed_count++;
                            $fixed_action_types[$action_type] = true;
                        } elseif (!empty($result['chunked_upload_pending'])) {
                            $chunked_pending = true;
                        } else {
                            /* translators: 1: file name, 2: error message */
                            $errors[] = sprintf(__('Upload failed for %1$s: %2$s', 'staticq-media'), basename($file_path), $result['error']);
                        }
                        break;

                    case SQMedia_File_State_Scanner::CAT_DOWNLOAD_TO_LOCAL:
                        $result = $this->fix_download_to_local($file_path, $s3, $upload_dir);
                        if ($result['success']) {
                            $fixed_count++;
                            $fixed_action_types[$action_type] = true;
                        } else {
                            /* translators: 1: file name, 2: error message */
                            $errors[] = sprintf(__('Download failed for %1$s: %2$s', 'staticq-media'), basename($file_path), $result['error']);
                        }
                        break;

                    case SQMedia_File_State_Scanner::CAT_GENERATE_SIZES:
                        $result = $this->fix_generate_size($record, $file_path, $details, $s3, $cloudflare, $upload_dir);
                        if ($result['success']) {
                            $fixed_count++;
                            $fixed_action_types[$action_type] = true;
                        } else {
                            /* translators: 1: file name, 2: error message */
                            $errors[] = sprintf(__('Generate failed for %1$s: %2$s', 'staticq-media'), basename($file_path), $result['error']);
                        }
                        break;

                    case SQMedia_File_State_Scanner::CAT_CRITICAL_MISSING:
                        // Cannot fix - master is missing
                        /* translators: %s: file name */
                        $errors[] = sprintf(__('Master file missing: %s', 'staticq-media'), $details['filename'] ?? 'unknown');
                        break;

                    case SQMedia_File_State_Scanner::CAT_ORIGINAL_MISPLACED:
                        $result = $this->fix_original_misplaced($record, $details, $s3, $upload_dir);
                        if ($result['success']) {
                            $fixed_count++;
                            $fixed_action_types[$action_type] = true;
                        } else {
                            /* translators: %s: error message */
                            $errors[] = sprintf(__('Move original failed: %s', 'staticq-media'), $result['error']);
                        }
                        break;

                    case SQMedia_File_State_Scanner::CAT_ORIGINAL_MISSING:
                        // Info-only — original file not found anywhere, no automated fix
                        break;

                    case SQMedia_File_State_Scanner::CAT_ORIGINAL_POLICY:
                        $action = $details['action'] ?? '';
                        if ($action === 'discard') {
                            $result = $this->fix_original_discard($record, $details, $s3, $upload_dir);
                        } elseif ($action === 'scale_down') {
                            $result = $this->fix_scale_down($record, $details, $s3, $cloudflare, $upload_dir);
                        } else {
                            $result = ['success' => false, 'error' => __('Unknown policy action', 'staticq-media')];
                        }
                        if ($result['success']) {
                            $fixed_count++;
                            $fixed_action_types[$action_type] = true;
                        } else {
                            /* translators: %s: error message */
                            $errors[] = sprintf(__('Policy fix failed: %s', 'staticq-media'), $result['error']);
                        }
                        break;
                }

                } catch (Exception $e) {
                    /* translators: 1: file name or action type, 2: error message */
                    $errors[] = sprintf(__('%1$s: %2$s', 'staticq-media'), basename($file_path), $e->getMessage());
                }
            } // end foreach items_list
        } // end foreach issues
        
        // Remove scan items for action types that had successful fixes
        foreach (array_keys($fixed_action_types) as $action_type) {
            $db->remove_scan_item($scan_id, $action_type, $attachment_id);
        }

        // Clean up: remove master from local if it was only needed temporarily
        if (SQMedia_Settings::get_storage_destination() === 'bucket' && SQMedia_Settings::should_delete_local_files()) {
            $master_local = $upload_dir['basedir'] . '/' . $record->get_master_path();
            if (file_exists($master_local)) {
                wp_delete_file($master_local);
            }
        }

        // Note: state flag alignment (storage_location, has_webp) is handled by the
        // parent scanner after ALL fix phases complete, not here.

        return [
            'success' => $fixed_count > 0 && empty($errors),
            'fixed' => $fixed_count,
            'errors' => $errors,
            'attachment_id' => $attachment_id,
            'chunked_upload_pending' => $chunked_pending,
        ];
    }
    
    /**
     * Fix: Upload local file to bucket
     * 
     * @param string $rel_path Relative path to file
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_upload_to_bucket(string $rel_path, $s3, array $upload_dir): array {
        $local_path = $upload_dir['basedir'] . '/' . $rel_path;
        $bucket_path = SQMedia_Settings::get_current_prefix() . '/' . $rel_path;

        if (!file_exists($local_path)) {
            return ['success' => false, 'error' => __('Local file not found', 'staticq-media')];
        }

        $uploaded = $s3->upload_file($local_path, $bucket_path);

        if (!$uploaded) {
            // Check if this is a chunked upload in progress (large file)
            if (method_exists($s3, 'has_pending_chunked_upload') && $s3->has_pending_chunked_upload($local_path, $bucket_path)) {
                return ['success' => false, 'chunked_upload_pending' => true, 'error' => __('Large file upload in progress', 'staticq-media')];
            }
            return ['success' => false, 'error' => $s3->get_last_error()];
        }

        return ['success' => true];
    }
    
    /**
     * Fix: Download file from bucket to local
     * 
     * @param string $rel_path Relative path to file
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_download_to_local(string $rel_path, $s3, array $upload_dir): array {
        $local_path = $upload_dir['basedir'] . '/' . $rel_path;
        $bucket_path = SQMedia_Settings::get_current_prefix() . '/' . $rel_path;
        
        // Ensure directory exists
        $dir = dirname($local_path);
        if (!file_exists($dir)) {
            wp_mkdir_p($dir);
        }
        
        $downloaded = $s3->download_file($bucket_path, $local_path);
        
        if (!$downloaded) {
            return ['success' => false, 'error' => $s3->get_last_error()];
        }
        
        return ['success' => true];
    }

    /**
     * Fix: Copy original image to correct location per settings
     *
     * Copies (not moves) the file — the old copy stays in place and becomes
     * a legitimate orphan, detectable by the orphan scanner for safe cleanup.
     * No file should ever disappear during alignment.
     *
     * @param SQMedia_Attachment_Record $record Attachment record
     * @param array $details Issue details (current_path, expected_path, original_image)
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_original_misplaced(SQMedia_Attachment_Record $record, array $details, $s3, array $upload_dir): array {
        $current_path = $details['current_path'] ?? '';
        $expected_path = $details['expected_path'] ?? '';

        if (empty($current_path) || empty($expected_path)) {
            return ['success' => false, 'error' => __('Missing path information', 'staticq-media')];
        }

        $base_dir = $upload_dir['basedir'];
        $bucket_prefix = SQMedia_Settings::get_current_prefix();
        $copied = false;

        // Copy on local filesystem (old copy stays as orphan)
        $current_local = $base_dir . '/' . $current_path;
        $expected_local = $base_dir . '/' . $expected_path;

        if (file_exists($current_local)) {
            $dest_dir = dirname($expected_local);
            if (!file_exists($dest_dir)) {
                wp_mkdir_p($dest_dir);
            }
            if (copy($current_local, $expected_local)) {
                $copied = true;
            } else {
                return ['success' => false, 'error' => __('Failed to copy local file', 'staticq-media')];
            }
        }

        // Copy in bucket (old copy stays as orphan)
        if ($s3 && SQMedia_Settings::is_cloud_storage_configured()) {
            $current_bucket = $bucket_prefix . '/' . $current_path;
            $expected_bucket = $bucket_prefix . '/' . $expected_path;

            if ($s3->file_exists($current_bucket)) {
                if ($s3->copy_file($current_bucket, $expected_bucket)) {
                    $copied = true;
                } else {
                    return ['success' => false, 'error' => __('Failed to copy bucket file', 'staticq-media')];
                }
            }
        }

        if (!$copied) {
            return ['success' => false, 'error' => __('Original file not found in either location', 'staticq-media')];
        }

        // Update per-attachment original_location to reflect the new position
        $new_location = (strpos($expected_path, 'originals/') === 0) ? 'originals' : 'alongside';
        $db = $this->scanner->get_db();
        $db->update_record($record->get_attachment_id(), [
            'original_location' => $new_location,
        ]);

        return ['success' => true];
    }

    /**
     * Fix: Discard original image — deprecate original + remove -scaled from master
     *
     * Steps:
     * 1. Deprecate original on all storages (rename to .sqmedia-deprecated)
     * 2. If master has -scaled suffix with original_image metadata, rename master to remove -scaled
     * 3. Update WordPress metadata and sq_attachments record
     *
     * @param SQMedia_Attachment_Record $record Attachment record
     * @param array $details Issue details (original_image, master_file)
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_original_discard(SQMedia_Attachment_Record $record, array $details, $s3, array $upload_dir): array {
        $original_image = $details['original_image'] ?? '';
        $master_rel_path = $details['master_file'] ?? $record->get_master_path();
        $attachment_id = $record->get_attachment_id();

        if (empty($original_image)) {
            return ['success' => false, 'error' => __('No original_image in details', 'staticq-media')];
        }

        $base_dir = $upload_dir['basedir'];
        $bucket_prefix = SQMedia_Settings::get_current_prefix();
        $master_dir = dirname($master_rel_path);
        $original_handling = SQMedia_Settings::get_original_file_handling();

        // Determine where the original might be (check both locations)
        $original_alongside = ($master_dir === '.' ? '' : $master_dir . '/') . $original_image;
        $original_in_originals = 'originals/' . ($master_dir === '.' ? '' : $master_dir . '/') . $original_image;

        // Step 1: Deprecate original on all storages
        foreach ([$original_alongside, $original_in_originals] as $original_path) {
            $local_path = $base_dir . '/' . $original_path;
            if (file_exists($local_path)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
                rename($local_path, $local_path . '.sqmedia-deprecated');
            }

            if ($s3 && SQMedia_Settings::is_cloud_storage_configured()) {
                $bucket_path = $bucket_prefix . '/' . $original_path;
                if ($s3->file_exists($bucket_path)) {
                    $s3->rename_file($bucket_path, $bucket_path . '.sqmedia-deprecated');
                }
            }
        }

        // Step 2: Check if master has -scaled suffix AND original_image metadata exists
        $master_filename = basename($master_rel_path);
        $has_scaled = (bool) preg_match('/-scaled\.(jpe?g|png|gif|webp)$/i', $master_filename);

        if ($has_scaled) {
            // Remove -scaled from master filename
            $unscaled_filename = preg_replace('/-scaled\.(jpe?g|png|gif|webp)$/i', '.$1', $master_filename);
            $unscaled_rel_path = ($master_dir === '.' ? '' : $master_dir . '/') . $unscaled_filename;

            // Rename master locally
            $master_local = $base_dir . '/' . $master_rel_path;
            $unscaled_local = $base_dir . '/' . $unscaled_rel_path;
            if (file_exists($master_local)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
                rename($master_local, $unscaled_local);
            }

            // Rename master in bucket
            if ($s3 && SQMedia_Settings::is_cloud_storage_configured()) {
                $master_bucket = $bucket_prefix . '/' . $master_rel_path;
                $unscaled_bucket = $bucket_prefix . '/' . $unscaled_rel_path;
                if ($s3->file_exists($master_bucket)) {
                    $s3->rename_file($master_bucket, $unscaled_bucket);
                }
            }

            // Rename master WebP too if it exists
            $master_webp = SQMedia_Attachment_Record::get_webp_filename($master_rel_path);
            if ($master_webp !== $master_rel_path) {
                $unscaled_webp = SQMedia_Attachment_Record::get_webp_filename($unscaled_rel_path);
                $master_webp_local = $base_dir . '/' . $master_webp;
                if (file_exists($master_webp_local)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
                    rename($master_webp_local, $base_dir . '/' . $unscaled_webp);
                }
                if ($s3 && SQMedia_Settings::is_cloud_storage_configured()) {
                    $master_webp_bucket = $bucket_prefix . '/' . $master_webp;
                    if ($s3->file_exists($master_webp_bucket)) {
                        $s3->rename_file($master_webp_bucket, $bucket_prefix . '/' . $unscaled_webp);
                    }
                }
            }

            // Step 3: Update metadata
            $wp_metadata = get_post_meta($attachment_id, '_wp_attachment_metadata', true);
            if (is_array($wp_metadata)) {
                unset($wp_metadata['original_image']);
                // Update file path to remove -scaled
                if (!empty($wp_metadata['file'])) {
                    $wp_metadata['file'] = str_replace($master_filename, $unscaled_filename, $wp_metadata['file']);
                }
                update_post_meta($attachment_id, '_wp_attachment_metadata', $wp_metadata);
            }

            // Update _wp_attached_file
            $attached_file = get_post_meta($attachment_id, '_wp_attached_file', true);
            if ($attached_file) {
                $new_attached = str_replace($master_filename, $unscaled_filename, $attached_file);
                update_post_meta($attachment_id, '_wp_attached_file', $new_attached);
            }

            // Step 4: Update sq_attachments record
            $db = $this->scanner->get_db();
            $db->update_record($attachment_id, [
                'master_path' => $unscaled_rel_path,
                'master_filename' => $unscaled_filename,
                'original_image' => null,
                'original_location' => null,
            ]);
        } else {
            // No -scaled suffix — just remove original_image from metadata
            $wp_metadata = get_post_meta($attachment_id, '_wp_attachment_metadata', true);
            if (is_array($wp_metadata)) {
                unset($wp_metadata['original_image']);
                update_post_meta($attachment_id, '_wp_attachment_metadata', $wp_metadata);
            }

            $db = $this->scanner->get_db();
            $db->update_record($attachment_id, [
                'original_image' => null,
                'original_location' => null,
            ]);
        }

        return ['success' => true];
    }

    /**
     * Fix: Re-scale master image to match current threshold
     *
     * If original exists, use it as source for re-scaling.
     * If no original, current master becomes the original and a new scaled master is generated.
     *
     * @param SQMedia_Attachment_Record $record Attachment record
     * @param array $details Issue details
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param SQMedia_Cloudflare_Handler $cloudflare Cloudflare handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_scale_down(SQMedia_Attachment_Record $record, array $details, $s3, SQMedia_Cloudflare_Handler $cloudflare, array $upload_dir): array {
        $attachment_id = $record->get_attachment_id();
        $has_original = $details['has_original'] ?? false;
        $threshold_w = $details['threshold_width'] ?? 2560;
        $threshold_h = $details['threshold_height'] ?? 2560;
        $master_rel_path = $record->get_master_path();
        $master_dir = dirname($master_rel_path);
        $base_dir = $upload_dir['basedir'];
        $bucket_prefix = SQMedia_Settings::get_current_prefix();
        $storage_dest = SQMedia_Settings::get_storage_destination(); // Settings are reference of intent, not the record
        $wp_metadata = get_post_meta($attachment_id, '_wp_attachment_metadata', true);

        if (!is_array($wp_metadata)) {
            return ['success' => false, 'error' => __('No metadata found', 'staticq-media')];
        }

        $master_w = $wp_metadata['width'] ?? 0;
        $master_h = $wp_metadata['height'] ?? 0;

        if ($master_w <= 0 || $master_h <= 0) {
            return ['success' => false, 'error' => __('Invalid master dimensions', 'staticq-media')];
        }

        if ($has_original) {
            // Original exists — use it as source for re-scaling
            $original_image = $wp_metadata['original_image'] ?? '';
            if (empty($original_image)) {
                return ['success' => false, 'error' => __('original_image missing from metadata', 'staticq-media')];
            }

            // Find the original file (check both locations)
            $original_alongside = ($master_dir === '.' ? '' : $master_dir . '/') . $original_image;
            $original_in_originals = 'originals/' . ($master_dir === '.' ? '' : $master_dir . '/') . $original_image;

            $source_rel_path = null;
            foreach ([$original_alongside, $original_in_originals] as $candidate) {
                $source_local = $this->ensure_source_local($candidate, $s3, $upload_dir);
                if ($source_local) {
                    $source_rel_path = $candidate;
                    break;
                }
            }

            if (!$source_local) {
                return ['success' => false, 'error' => __('Original file not found', 'staticq-media')];
            }
        } else {
            // No original — current master becomes the original
            // Ensure master is available locally
            $source_local = $this->ensure_source_local($master_rel_path, $s3, $upload_dir);
            if (!$source_local) {
                return ['success' => false, 'error' => __('Master file not found', 'staticq-media')];
            }
            $source_rel_path = $master_rel_path;
        }

        // Calculate new scaled dimensions (maintain aspect ratio)
        $ratio = min($threshold_w / $master_w, $threshold_h / $master_h);
        if ($ratio >= 1) {
            return ['success' => true]; // No scaling needed after all
        }
        $new_w = (int) round($master_w * $ratio);
        $new_h = (int) round($master_h * $ratio);

        // Determine the scaled filename
        $master_ext = pathinfo(basename($master_rel_path), PATHINFO_EXTENSION);
        if ($has_original) {
            // original_image is set, master already has -scaled (or should)
            // Re-generate the master in place
            $scaled_filename = basename($master_rel_path);
            $scaled_rel_path = $master_rel_path;
        } else {
            // Current master becomes original, new master gets -scaled suffix
            $master_basename = pathinfo(basename($master_rel_path), PATHINFO_FILENAME);
            $scaled_filename = $master_basename . '-scaled.' . $master_ext;
            $scaled_rel_path = ($master_dir === '.' ? '' : $master_dir . '/') . $scaled_filename;
        }

        $dest_local = $base_dir . '/' . $scaled_rel_path;
        $dest_bucket = $bucket_prefix . '/' . $scaled_rel_path;

        // Ensure destination directory exists
        $dest_dir = dirname($dest_local);
        if (!file_exists($dest_dir)) {
            wp_mkdir_p($dest_dir);
        }

        // Generate the scaled image
        $result = $this->generate_size_file(
            $source_local,
            $source_rel_path,
            $dest_local,
            $dest_bucket,
            $threshold_w,
            $threshold_h,
            false, // no crop
            $cloudflare,
            $s3,
            $storage_dest,
            $upload_dir
        );

        if (!$result['success']) {
            return $result;
        }

        // Get actual dimensions of the generated file
        $image_size = @getimagesize($dest_local);
        if ($image_size) {
            $new_w = $image_size[0];
            $new_h = $image_size[1];
        }

        if (!$has_original) {
            // Current master becomes the original — handle per original_file_handling setting
            $original_image_basename = basename($master_rel_path);
            $original_handling = SQMedia_Settings::get_original_file_handling();

            if ($original_handling === 'move_to_originals') {
                $original_dest = 'originals/' . ($master_dir === '.' ? '' : $master_dir . '/') . $original_image_basename;
                $original_dest_local = $base_dir . '/' . $original_dest;
                $original_dest_dir = dirname($original_dest_local);
                if (!file_exists($original_dest_dir)) {
                    wp_mkdir_p($original_dest_dir);
                }
                // Copy master to originals (don't move yet — we might need it)
                copy($source_local, $original_dest_local);
                if ($s3 && SQMedia_Settings::is_cloud_storage_configured()) {
                    $s3->upload_file($original_dest_local, $bucket_prefix . '/' . $original_dest);
                }
            }
            // For 'preserve' mode, original stays in the same directory (master_rel_path still points to it conceptually
            // but we'll update _wp_attached_file to point to the scaled version)
            // For 'discard' mode, we don't keep the original at all — it gets deprecated
            if ($original_handling === 'discard') {
                // Deprecate the old master
                if (file_exists($source_local)) {
                    // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
                    rename($source_local, $source_local . '.sqmedia-deprecated');
                }
                if ($s3 && SQMedia_Settings::is_cloud_storage_configured()) {
                    $source_bucket = $bucket_prefix . '/' . $master_rel_path;
                    if ($s3->file_exists($source_bucket)) {
                        $s3->rename_file($source_bucket, $source_bucket . '.sqmedia-deprecated');
                    }
                }
            }

            // Update metadata
            $wp_metadata['original_image'] = $original_image_basename;
            $wp_metadata['file'] = $scaled_rel_path;
            $wp_metadata['width'] = $new_w;
            $wp_metadata['height'] = $new_h;
            if (file_exists($dest_local)) {
                $wp_metadata['filesize'] = filesize($dest_local);
            }
            update_post_meta($attachment_id, '_wp_attachment_metadata', $wp_metadata);
            update_post_meta($attachment_id, '_wp_attached_file', $scaled_rel_path);

            // Update sq_attachments record
            $db = $this->scanner->get_db();
            // Determine original_location based on what was done
            if ($original_handling === 'discard') {
                $new_original_location = null;
            } elseif ($original_handling === 'move_to_originals') {
                $new_original_location = 'originals';
            } else {
                $new_original_location = 'alongside';
            }

            $db->update_record($attachment_id, [
                'master_path' => $scaled_rel_path,
                'master_filename' => $scaled_filename,
                'original_image' => ($original_handling === 'discard') ? null : $original_image_basename,
                'original_location' => $new_original_location,
            ]);
        } else {
            // Has original — just update master dimensions
            $wp_metadata['width'] = $new_w;
            $wp_metadata['height'] = $new_h;
            if (file_exists($dest_local)) {
                $wp_metadata['filesize'] = filesize($dest_local);
            }
            update_post_meta($attachment_id, '_wp_attachment_metadata', $wp_metadata);
        }

        // Generate master WebP if enabled
        if (SQMedia_Settings::is_webp_enabled() && $record->supports_webp()) {
            $webp_rel_path = SQMedia_Attachment_Record::get_webp_filename($scaled_rel_path);
            if ($webp_rel_path !== $scaled_rel_path) {
                $webp_local = $base_dir . '/' . $webp_rel_path;
                $webp_bucket = $bucket_prefix . '/' . $webp_rel_path;

                $webp_result = $this->generate_webp_file(
                    $dest_local,
                    $scaled_rel_path,
                    $webp_local,
                    $webp_bucket,
                    0, 0, // full-size WebP of the new master
                    false,
                    $cloudflare,
                    $s3,
                    $storage_dest,
                    $upload_dir
                );

                if (!$webp_result['success']) {
                    sqmedia()->log('Scale-down WebP generation failed: ' . ($webp_result['error'] ?? 'unknown'), 'warning');
                    // Non-fatal — master was scaled successfully, WebP can be generated on next scan
                }
            }
        }

        return ['success' => true];
    }

    /**
     * Fix: Generate missing size file
     *
     * Handles both regular sizes and WebP variants.
     * Also handles master recovery from original_image when master is missing.
     * 
     * @param SQMedia_Attachment_Record $record Attachment record
     * @param string $rel_path Relative path
     * @param array $details Issue details
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param SQMedia_Cloudflare_Handler $cloudflare Cloudflare handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_generate_size(
        SQMedia_Attachment_Record $record,
        string $rel_path,
        array $details,
        $s3,
        SQMedia_Cloudflare_Handler $cloudflare,
        array $upload_dir
    ): array {
        // Route non-images to separate handler (e.g., PDF preview regeneration)
        if (!$record->is_image()) {
            return $this->fix_generate_non_image_previews($record, $s3, $upload_dir);
        }

        // Non-resizable images (SVG, ICO) don't need size generation
        if (!$record->supports_resizing()) {
            return ['success' => true, 'skipped' => true, 'reason' => 'non_resizable_image'];
        }

        $size_name = $details['size'] ?? '';
        $is_webp = (strpos($size_name, '-webp') !== false);
        $base_size = $is_webp ? str_replace('-webp', '', $size_name) : $size_name;
        $is_master_recovery = $details['is_master_recovery'] ?? false;
        $wp_metadata = $record->get_wp_metadata();
        $storage_dest = SQMedia_Settings::get_storage_destination(); // Settings are reference of intent, not the record

        // Check if Cloudflare can handle this without needing the master locally
        $can_use_cloudflare = !$is_master_recovery
            && $cloudflare->is_configured()
            && ($storage_dest === 'bucket' || $storage_dest === 'both');

        // Determine source file — only download master when actually needed
        $source_local = null;
        $source_rel_path = null;

        if ($is_master_recovery) {
            // Master recovery mode: use original_image as source
            $original_rel_path = $details['original_path'] ?? '';
            if (empty($original_rel_path)) {
                return ['success' => false, 'error' => __('Original image path not provided', 'staticq-media')];
            }

            $source_rel_path = $original_rel_path;
            $source_local = $this->ensure_source_local($original_rel_path, $s3, $upload_dir);

            if (!$source_local) {
                return ['success' => false, 'error' => __('Original image not found', 'staticq-media')];
            }
        } else {
            // Normal mode: use master as source
            $source_rel_path = $record->get_master_path();

            if ($can_use_cloudflare) {
                // Cloudflare fetches from origin — just verify master exists somewhere
                $master_local = $upload_dir['basedir'] . '/' . $source_rel_path;
                $master_bucket = SQMedia_Settings::get_current_prefix() . '/' . $source_rel_path;

                if (!file_exists($master_local) && !$s3->file_exists($master_bucket)) {
                    return ['success' => false, 'error' => __('Master file not found', 'staticq-media')];
                }
                // $source_local stays null — Cloudflare doesn't need it
            } else {
                // Local processing — need the file on disk
                $source_local = $this->ensure_source_local($source_rel_path, $s3, $upload_dir);

                if (!$source_local) {
                    return ['success' => false, 'error' => __('Master file not found', 'staticq-media')];
                }
            }
        }

        // Get target dimensions
        $target_width = 0;
        $target_height = 0;
        $crop = false;

        if ($base_size === 'master') {
            // Master or Master WebP - use dimensions from metadata
            $target_width = $wp_metadata['width'] ?? 0;
            $target_height = $wp_metadata['height'] ?? 0;
        } else {
            // Get size config from WordPress or theme settings
            $size_config = $this->get_size_config($base_size);
            if (!$size_config) {
                // Try from metadata
                $meta_size = $wp_metadata['sizes'][$base_size] ?? null;
                if ($meta_size) {
                    $target_width = $meta_size['width'] ?? 0;
                    $target_height = $meta_size['height'] ?? 0;
                } else {
                    /* translators: %s: image size name */
                    return ['success' => false, 'error' => sprintf(__('Unknown size: %s', 'staticq-media'), $base_size)];
                }
            } else {
                $target_width = $size_config['width'] ?? 0;
                $target_height = $size_config['height'] ?? 0;
                $crop = $size_config['crop'] ?? false;
            }
        }

        // Determine destination paths
        $dest_local = $upload_dir['basedir'] . '/' . $rel_path;
        $dest_bucket = SQMedia_Settings::get_current_prefix() . '/' . $rel_path;

        // Ensure local directory exists
        $dest_dir = dirname($dest_local);
        if (!file_exists($dest_dir)) {
            wp_mkdir_p($dest_dir);
        }

        if ($is_webp) {
            $result = $this->generate_webp_file($source_local, $source_rel_path, $dest_local, $dest_bucket, $target_width, $target_height, $crop, $cloudflare, $s3, $storage_dest, $upload_dir);
        } else {
            $result = $this->generate_size_file($source_local, $source_rel_path, $dest_local, $dest_bucket, $target_width, $target_height, $crop, $cloudflare, $s3, $storage_dest, $upload_dir);
        }

        return $result;
    }

    /**
     * Ensure a source file exists locally, downloading from bucket if needed
     *
     * @param string $rel_path Relative path to file
     * @param mixed $s3 Storage handler
     * @param array $upload_dir Upload directory info
     * @return string|null Local path if available, null if not found anywhere
     */
    private function ensure_source_local(string $rel_path, $s3, array $upload_dir): ?string {
        $local_path = $upload_dir['basedir'] . '/' . $rel_path;
        $bucket_path = SQMedia_Settings::get_current_prefix() . '/' . $rel_path;

        if (file_exists($local_path)) {
            return $local_path;
        }

        if ($s3->file_exists($bucket_path)) {
            $dir = dirname($local_path);
            if (!file_exists($dir)) {
                wp_mkdir_p($dir);
            }

            if ($s3->download_file($bucket_path, $local_path)) {
                return $local_path;
            }
        }

        return null;
    }
    
    /**
     * Fix missing preview thumbnails for non-image attachments (PDFs, etc.)
     * 
     * Uses WordPress to regenerate preview images, then uploads to bucket.
     * This is separate from image size generation to keep that code untouched.
     * 
     * @param SQMedia_Attachment_Record $record Attachment record
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param array $upload_dir Upload directory info
     * @return array Result with success/error
     */
    private function fix_generate_non_image_previews(
        SQMedia_Attachment_Record $record,
        $s3,
        array $upload_dir
    ): array {
        $attachment_id = $record->get_attachment_id();
        $master_rel_path = $record->get_master_path();
        $master_dir = $record->get_master_dir();
        $master_local = $upload_dir['basedir'] . '/' . $master_rel_path;
        $bucket_prefix = SQMedia_Settings::get_current_prefix();
        $master_bucket = $bucket_prefix . '/' . $master_rel_path;
        $storage_dest = SQMedia_Settings::get_storage_destination(); // Settings are reference of intent, not the record
        $delete_local = SQMedia_Settings::should_delete_local_files();
        
        // Step 1: Ensure master file exists locally (download from bucket if needed)
        if (!file_exists($master_local)) {
            if (!$s3->file_exists($master_bucket)) {
                return ['success' => false, 'error' => __('Master file not found in local or bucket', 'staticq-media')];
            }
            
            $dir = dirname($master_local);
            if (!file_exists($dir)) {
                wp_mkdir_p($dir);
            }
            
            if (!$s3->download_file($master_bucket, $master_local)) {
                return ['success' => false, 'error' => __('Could not download master file from bucket', 'staticq-media')];
            }
            
        }
        
        // Step 2: Use WordPress to regenerate attachment metadata (creates PDF previews)
        // This calls the same code WordPress uses during upload
        $new_metadata = wp_generate_attachment_metadata($attachment_id, $master_local);
        
        if (empty($new_metadata) || is_wp_error($new_metadata)) {
            $error = is_wp_error($new_metadata) ? $new_metadata->get_error_message() : __('Failed to generate metadata', 'staticq-media');
            return ['success' => false, 'error' => $error];
        }
        
        // Step 3: Update WordPress metadata
        wp_update_attachment_metadata($attachment_id, $new_metadata);
        
        // Step 4: Upload generated preview files to bucket
        $preview_count = 0;
        
        if (!empty($new_metadata['sizes']) && is_array($new_metadata['sizes'])) {
            foreach ($new_metadata['sizes'] as $size_name => $size_data) {
                if (empty($size_data['file'])) {
                    continue;
                }
                
                $preview_local = $upload_dir['basedir'] . '/' . $master_dir . '/' . $size_data['file'];
                
                if (file_exists($preview_local)) {
                    if ($storage_dest === 'bucket' || $storage_dest === 'both') {
                        $preview_bucket = $bucket_prefix . '/' . $master_dir . '/' . $size_data['file'];
                        
                        if ($s3->upload_file($preview_local, $preview_bucket)) {
                            $preview_count++;
                        } else {
                        }
                    } else {
                        // Local only - just count it
                        $preview_count++;
                    }
                    
                    // Delete local preview if configured
                    if ($storage_dest === 'bucket' && $delete_local) {
                        wp_delete_file($preview_local);
                    }
                }
            }
        }
        
        // Step 5: Clean up - delete master local copy if we downloaded it and should delete local
        if ($storage_dest === 'bucket' && $delete_local) {
            wp_delete_file($master_local);
        }
        
        if ($preview_count === 0 && !empty($new_metadata['sizes'])) {
            return ['success' => false, 'error' => __('Preview files were not generated (ImageMagick/Ghostscript may not be available)', 'staticq-media')];
        }
        
        
        return ['success' => true, 'preview_count' => $preview_count];
    }
    
    /**
     * Generate a regular size file
     * 
     * @param string $master_local Path to master file
     * @param string $dest_local Local destination path
     * @param string $dest_bucket Bucket destination path
     * @param int $width Target width
     * @param int $height Target height
     * @param bool $crop Whether to crop
     * @param SQMedia_S3_Handler|SQMedia_R2_Handler $s3 Storage handler
     * @param string $storage_dest Storage destination (local/bucket/both)
     * @return array Result with success/error
     */
    /**
     * Generate a resized image via Cloudflare (preferred) or local fallback
     *
     * @param string|null $master_local Local path to master (null if not downloaded)
     * @param string $master_rel_path Relative path to master (for Cloudflare origin fetch)
     * @param string $dest_local Local destination path
     * @param string $dest_bucket Bucket destination path
     * @param int $width Target width
     * @param int $height Target height
     * @param bool $crop Whether to crop
     * @param SQMedia_Cloudflare_Handler $cloudflare Cloudflare handler
     * @param mixed $s3 Storage handler
     * @param string $storage_dest Storage destination (local/bucket/both)
     * @param array $upload_dir Upload directory info (for local fallback download)
     */
    private function generate_size_file(
        ?string $master_local,
        string $master_rel_path,
        string $dest_local,
        string $dest_bucket,
        int $width,
        int $height,
        bool $crop,
        SQMedia_Cloudflare_Handler $cloudflare,
        $s3,
        string $storage_dest,
        array $upload_dir
    ): array {
        // Try Cloudflare first — no local master needed
        if ($cloudflare->is_configured() && ($storage_dest === 'bucket' || $storage_dest === 'both')) {
            $quality = SQMedia_Settings::get_quality_value();

            $result = $cloudflare->resize_and_save(
                $master_rel_path,
                $dest_bucket,
                null, // null = keep original format
                $quality,
                $width > 0 ? $width : null,
                $height > 0 ? $height : null,
                $crop
            );

            if ($result['success']) {
                // For 'both' mode, also download to local
                if ($storage_dest === 'both') {
                    $dest_dir = dirname($dest_local);
                    if (!file_exists($dest_dir)) {
                        wp_mkdir_p($dest_dir);
                    }

                    $downloaded = $s3->download_file($dest_bucket, $dest_local);
                    if (!$downloaded) {
                        sqmedia()->log('Resized to bucket but local download failed: ' . $s3->get_last_error(), 'warning');
                    }
                }
                return ['success' => true];
            }

            // Fall through to local generation if Cloudflare fails
            sqmedia()->log('Cloudflare resize failed, falling back to local: ' . ($result['error'] ?? 'unknown'), 'warning');
        }

        // Local fallback — need master on disk
        if (!$master_local || !file_exists($master_local)) {
            $master_local = $this->ensure_source_local($master_rel_path, $s3, $upload_dir);
            if (!$master_local) {
                return ['success' => false, 'error' => __('Master file not found for local resize', 'staticq-media')];
            }
        }

        $editor = wp_get_image_editor($master_local);

        if (is_wp_error($editor)) {
            return ['success' => false, 'error' => $editor->get_error_message()];
        }

        $resized = $editor->resize($width, $height, $crop);

        if (is_wp_error($resized)) {
            return ['success' => false, 'error' => $resized->get_error_message()];
        }

        // Pass explicit mime type to prevent WordPress output format filters
        // from silently converting JPEG to WebP.
        $source_mime = wp_check_filetype($master_local)['type'] ?: 'image/jpeg';
        $saved = $editor->save($dest_local, $source_mime);

        if (is_wp_error($saved)) {
            return ['success' => false, 'error' => $saved->get_error_message()];
        }

        // Use actual saved path — WordPress may alter filename/extension
        $actual_path = $saved['path'] ?? $dest_local;
        $actual_bucket = $dest_bucket;
        if ($actual_path !== $dest_local) {
            // WordPress changed the filename — update bucket path to match
            $actual_bucket = dirname($dest_bucket) . '/' . wp_basename($actual_path);
        }

        // Upload to bucket if needed
        if ($storage_dest === 'bucket' || $storage_dest === 'both') {
            $uploaded = $s3->upload_file($actual_path, $actual_bucket);

            if (!$uploaded) {
                return ['success' => false, 'error' => __('Generated but upload failed: ', 'staticq-media') . $s3->get_last_error()];
            }
        }

        // Delete local if not needed
        if ($storage_dest === 'bucket' && SQMedia_Settings::should_delete_local_files()) {
            wp_delete_file($actual_path);
        }

        return ['success' => true];
    }

    /**
     * Generate a WebP file via Cloudflare (preferred) or local fallback
     *
     * @param string|null $master_local Local path to master (null if not downloaded)
     * @param string $master_rel_path Relative path to master (for Cloudflare origin fetch)
     * @param string $dest_local Local destination path
     * @param string $dest_bucket Bucket destination path
     * @param int $width Target width
     * @param int $height Target height
     * @param bool $crop Whether to crop
     * @param SQMedia_Cloudflare_Handler $cloudflare Cloudflare handler
     * @param mixed $s3 Storage handler
     * @param string $storage_dest Storage destination (local/bucket/both)
     * @param array $upload_dir Upload directory info (for local fallback download)
     */
    private function generate_webp_file(
        ?string $master_local,
        string $master_rel_path,
        string $dest_local,
        string $dest_bucket,
        int $width,
        int $height,
        bool $crop,
        SQMedia_Cloudflare_Handler $cloudflare,
        $s3,
        string $storage_dest,
        array $upload_dir
    ): array {
        // Try Cloudflare first — no local master needed
        if ($cloudflare->is_configured() && ($storage_dest === 'bucket' || $storage_dest === 'both')) {
            $quality = SQMedia_Settings::get_quality_value();

            $result = $cloudflare->generate_webp_and_save(
                $master_rel_path,
                $dest_bucket,
                $quality,
                $width > 0 ? $width : null,
                $height > 0 ? $height : null,
                $crop
            );

            if ($result['success']) {
                // For 'both' mode, also download to local
                if ($storage_dest === 'both') {
                    $dest_dir = dirname($dest_local);
                    if (!file_exists($dest_dir)) {
                        wp_mkdir_p($dest_dir);
                    }

                    $downloaded = $s3->download_file($dest_bucket, $dest_local);
                    if (!$downloaded) {
                        sqmedia()->log('WebP generated to bucket but local download failed: ' . $s3->get_last_error(), 'warning');
                    }
                }
                return ['success' => true];
            }

            // Fall through to local generation if Cloudflare fails
        }

        // Local fallback — need master on disk
        if (!$master_local || !file_exists($master_local)) {
            // Download master on demand for local fallback
            $master_local = $this->ensure_source_local($master_rel_path, $s3, $upload_dir);
            if (!$master_local) {
                return ['success' => false, 'error' => __('Master file not found for local WebP generation', 'staticq-media')];
            }
        }

        // Track the actual local file path (editor may change filename/extension)
        $actual_local = $dest_local;

        // If we need a specific size, resize first
        if ($width > 0 && $height > 0) {
            $editor = wp_get_image_editor($master_local);

            if (is_wp_error($editor)) {
                return ['success' => false, 'error' => $editor->get_error_message()];
            }

            $editor->resize($width, $height, $crop);

            // Save as WebP directly
            $editor->set_quality(SQMedia_Settings::get_quality_value());
            $saved = $editor->save($dest_local, 'image/webp');

            if (is_wp_error($saved)) {
                return ['success' => false, 'error' => $saved->get_error_message()];
            }

            $actual_local = $saved['path'] ?? $dest_local;
        } else {
            // Full-size WebP
            $webp_path = SQMedia_Image_Processor::generate_webp($master_local, $dest_local);

            if (!$webp_path) {
                return ['success' => false, 'error' => __('WebP generation failed', 'staticq-media')];
            }
            $actual_local = $webp_path;
        }

        // Upload to bucket if needed
        if ($storage_dest === 'bucket' || $storage_dest === 'both') {
            $uploaded = $s3->upload_file($actual_local, $dest_bucket, ['mime_type' => 'image/webp']);

            if (!$uploaded) {
                return ['success' => false, 'error' => __('Generated but upload failed: ', 'staticq-media') . $s3->get_last_error()];
            }
        }

        // Delete local if not needed
        if ($storage_dest === 'bucket' && SQMedia_Settings::should_delete_local_files()) {
            wp_delete_file($actual_local);
        }

        return ['success' => true];
    }

    /**
     * Get size configuration from WordPress
     */
    private function get_size_config(string $size_name): ?array {
        // Check WordPress registered sizes
        $sizes = wp_get_registered_image_subsizes();
        
        if (isset($sizes[$size_name])) {
            return $sizes[$size_name];
        }
        
        // Check plugin enabled sizes
        $enabled = SQMedia_Settings::get_enabled_sizes();
        
        if (isset($enabled[$size_name])) {
            return $enabled[$size_name];
        }
        
        return null;
    }
}