<?php
/**
 * File State Scanner AJAX Handlers
 * 
 * Handles all AJAX requests for the File State Scanner.
 * Separate from main admin class for cleaner organization.
 * 
 * @package StaticQ
 * @since 3.0.0
 */

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

class SQMedia_File_State_Ajax {
    
    /**
     * Initialize AJAX handlers
     */
    public static function init(): void {
        // Scan operations
        add_action('wp_ajax_sqmedia_start_health_scan', [self::class, 'start_scan']);
        add_action('wp_ajax_sqmedia_health_scan_progress', [self::class, 'get_progress']);
        add_action('wp_ajax_sqmedia_cancel_health_scan', [self::class, 'cancel_scan']);
        add_action('wp_ajax_sqmedia_pause_health_scan', [self::class, 'pause_scan']);
        add_action('wp_ajax_sqmedia_resume_health_scan', [self::class, 'resume_scan']);
        add_action('wp_ajax_sqmedia_process_health_batch', [self::class, 'process_batch']);
        
        // Fix operations
        add_action('wp_ajax_sqmedia_start_health_fix', [self::class, 'start_fix']);
        add_action('wp_ajax_sqmedia_fix_single_attachment', [self::class, 'fix_single']);
        add_action('wp_ajax_sqmedia_get_fix_list', [self::class, 'get_fix_list']);
        add_action('wp_ajax_sqmedia_process_fix_batch', [self::class, 'process_fix_batch']);
        add_action('wp_ajax_sqmedia_pause_fix', [self::class, 'pause_fix']);
        add_action('wp_ajax_sqmedia_resume_fix', [self::class, 'resume_fix']);
        add_action('wp_ajax_sqmedia_stop_fix', [self::class, 'stop_fix']);
        
        // Details/filtering
        add_action('wp_ajax_sqmedia_get_scan_details', [self::class, 'get_details']);
        add_action('wp_ajax_sqmedia_get_attachment_file_details', [self::class, 'get_attachment_file_details']);

        // Table backup
        add_action('wp_ajax_sqmedia_backup_mls_tables', [self::class, 'backup_tables']);
    }
    
    /**
     * Start a new scan
     */
    public static function start_scan(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

        $overwrite = !empty($_POST['overwrite_existing']);
        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->start_scan($overwrite);

        if ($result['success']) {
            self::schedule_cron($result['status'] ?? 'running');
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error']);
        }
    }
    
    /**
     * Get scan progress
     */
    public static function get_progress(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->get_progress($scan_id);
        
        if (!$result['success']) {
            wp_send_json_error($result['error'] ?? __('Scan not found.', 'staticq-media'));
        }
        
        wp_send_json_success($result);
    }
    
    /**
     * Process a batch (called by AJAX polling)
     */
    public static function process_batch(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->process_batch($scan_id);
        
        if ($result['success']) {
            // Check if completed
            if ($result['done'] ?? false) {
                self::clear_cron();
            }
            
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error'] ?? __('Batch processing failed.', 'staticq-media'));
        }
    }
    
    /**
     * Cancel a running scan
     */
    public static function cancel_scan(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->cancel($scan_id);
        
        self::clear_cron();
        
        
        wp_send_json_success($result);
    }
    
    /**
     * Pause a running scan (can be resumed)
     */
    public static function pause_scan(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->pause($scan_id);
        
        self::clear_cron();
        
        
        wp_send_json_success($result);
    }
    
    /**
     * Resume a paused scan
     */
    public static function resume_scan(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->resume($scan_id);
        
        if ($result['success']) {
            // Schedule cron for background processing using actual restored status
            $status = $result['status'] ?? 'scanning';
            
            if ($status === 'building_index') {
                // Reset bucket indexing state so fallback triggers correctly
                // (bucket index restarts from scratch anyway)
                $db = $scanner->get_db();
                $scan = $db->get_scan($scan_id);
                $results = $scan->results ?? [];
                $results['index_file_count'] = 0;
                $results['index_current_folder'] = 'Resuming...';
                $results['bucket_build_started'] = time();
                unset($results['bucket_index_partial']);
                $db->update_scan($scan_id, ['results' => $results]);
                
                // Re-schedule the bucket index cron
                wp_schedule_single_event(time(), 'sqmedia_build_file_index', [$scan_id]);
                spawn_cron();
            } else {
                self::schedule_cron($status);
            }
            
            
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result['error'] ?? __('Failed to resume scan.', 'staticq-media'));
        }
    }
    
    /**
     * Start fixing all issues
     */
    public static function start_fix(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

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

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->start_fix($scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            // Handle both 'error' (string) and 'errors' (array) formats
            $error_message = '';
            if (!empty($result['error'])) {
                $error_message = $result['error'];
            } elseif (!empty($result['errors'])) {
                $error_message = implode(', ', $result['errors']);
            } else {
                $error_message = __('Unknown error occurred.', 'staticq-media');
            }
            wp_send_json_error($error_message);
        }
    }
    
    /**
     * Process a batch of fixes (called via polling)
     */
    public static function process_fix_batch(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

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

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

        // Process a batch AND return progress
        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->process_fix_batch($scan_id);

        wp_send_json_success($result);
    }
    
    /**
     * Pause fix operation
     */
    public static function pause_fix(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

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

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->pause_fix($scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            // Handle both 'error' (string) and 'errors' (array) formats
            $error_message = '';
            if (!empty($result['error'])) {
                $error_message = $result['error'];
            } elseif (!empty($result['errors'])) {
                $error_message = implode(', ', $result['errors']);
            } else {
                $error_message = __('Unknown error occurred.', 'staticq-media');
            }
            wp_send_json_error($error_message);
        }
    }
    
    /**
     * Resume fix operation
     */
    public static function resume_fix(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

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

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->resume_fix($scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            // Handle both 'error' (string) and 'errors' (array) formats
            $error_message = '';
            if (!empty($result['error'])) {
                $error_message = $result['error'];
            } elseif (!empty($result['errors'])) {
                $error_message = implode(', ', $result['errors']);
            } else {
                $error_message = __('Unknown error occurred.', 'staticq-media');
            }
            wp_send_json_error($error_message);
        }
    }
    
    /**
     * Stop fix operation (mark as completed with partial results)
     */
    public static function stop_fix(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

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

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

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->stop_fix($scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            // Handle both 'error' (string) and 'errors' (array) formats
            $error_message = '';
            if (!empty($result['error'])) {
                $error_message = $result['error'];
            } elseif (!empty($result['errors'])) {
                $error_message = implode(', ', $result['errors']);
            } else {
                $error_message = __('Unknown error occurred.', 'staticq-media');
            }
            wp_send_json_error($error_message);
        }
    }
    
    /**
     * Fix a single attachment
     */
    public static function fix_single(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $attachment_id = intval(wp_unslash($_POST['attachment_id'] ?? 0));
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

        if ($attachment_id <= 0) {
            wp_send_json_error(__('Invalid attachment ID.', 'staticq-media'));
        }

        $scanner = new SQMedia_File_State_Scanner();
        $result = $scanner->fix_attachment($attachment_id, $scan_id);
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            // Handle both 'error' (string) and 'errors' (array) formats
            $error_message = '';
            if (!empty($result['error'])) {
                $error_message = $result['error'];
            } elseif (!empty($result['errors'])) {
                $error_message = implode(', ', $result['errors']);
            } else {
                $error_message = __('Unknown error occurred.', 'staticq-media');
            }
            wp_send_json_error($error_message);
        }
    }
    
    /**
     * Get list of attachment IDs that need fixing
     */
    public static function get_fix_list(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

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

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

        global $wpdb;
        $table = SQMedia_Database::get_table(SQMedia_Database::TABLE_SCAN_ITEMS);

        // Get unique attachment IDs with issues (excluding critical missing which can't be fixed)
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $attachment_ids = $wpdb->get_col($wpdb->prepare(
            "SELECT DISTINCT attachment_id
             FROM {$table}
             WHERE scan_id = %s
             AND action_type != %s
             ORDER BY attachment_id ASC",
            $scan_id,
            SQMedia_File_State_Scanner::CAT_CRITICAL_MISSING
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        
        wp_send_json_success([
            'attachment_ids' => array_map('intval', $attachment_ids),
            'total' => count($attachment_ids),
        ]);
    }
    
    /**
     * Get scan details with filtering
     */
    public static function get_details(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Insufficient permissions.', 'staticq-media'));
        }
        
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));
        $filter_type = sanitize_text_field(wp_unslash($_POST['filter_type'] ?? ''));
        $search = sanitize_text_field(wp_unslash($_POST['search'] ?? ''));
        $page = max(1, intval(wp_unslash($_POST['page'] ?? 1)));
        $per_page = 25;
        
        if (empty($scan_id)) {
            wp_send_json_error(__('No scan ID provided.', 'staticq-media'));
        }
        
        global $wpdb;
        $table = SQMedia_Database::get_table(SQMedia_Database::TABLE_SCAN_ITEMS);
        
        $where = $wpdb->prepare("si.scan_id = %s", $scan_id);
        
        if ($filter_type) {
            $where .= $wpdb->prepare(" AND si.action_type = %s", $filter_type);
        }
        
        // Search by attachment ID or filename
        $join = '';
        if ($search) {
            if (is_numeric($search)) {
                $where .= $wpdb->prepare(" AND si.attachment_id = %d", intval($search));
            } else {
                $join = " LEFT JOIN {$wpdb->postmeta} pm ON si.attachment_id = pm.post_id AND pm.meta_key = '_wp_attached_file'";
                $where .= $wpdb->prepare(" AND pm.meta_value LIKE %s", '%' . $wpdb->esc_like($search) . '%');
            }
        }
        
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $total = (int) $wpdb->get_var(
            "SELECT COUNT(DISTINCT si.attachment_id) FROM {$table} si {$join} WHERE {$where}"
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $offset = ($page - 1) * $per_page;

        // Get attachment IDs for this page
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $attachment_ids = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT si.attachment_id
                 FROM {$table} si
                 {$join}
                 WHERE {$where}
                 ORDER BY si.attachment_id DESC
                 LIMIT %d OFFSET %d",
                $per_page,
                $offset
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if (empty($attachment_ids)) {
            wp_send_json_success([
                'items' => [],
                'total' => 0,
                'page' => $page,
                'per_page' => $per_page,
                'total_pages' => 0,
            ]);
            return;
        }
        
        // Get all issues for these attachments with details
        $ids_placeholder = implode(',', array_map('intval', $attachment_ids));
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $all_issues = $wpdb->get_results($wpdb->prepare(
            "SELECT attachment_id, action_type, details
             FROM {$table}
             WHERE scan_id = %s AND attachment_id IN ({$ids_placeholder})
             ORDER BY attachment_id DESC, action_type",
            $scan_id
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
        
        // Group issues by attachment
        $issues_by_attachment = [];
        foreach ($all_issues as $issue) {
            $aid = (int) $issue->attachment_id;
            if (!isset($issues_by_attachment[$aid])) {
                $issues_by_attachment[$aid] = [];
            }
            
            $raw_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 $item_details) {
                $issues_by_attachment[$aid][] = [
                    'type' => $issue->action_type,
                    'details' => $item_details,
                ];
            }
        }
        
        // Build result items
        $result_items = [];
        foreach ($attachment_ids as $attachment_id) {
            $attachment_id = (int) $attachment_id;
            $filename = get_post_meta($attachment_id, '_wp_attached_file', true);
            $raw_issues = $issues_by_attachment[$attachment_id] ?? [];
            
            // Format issues with full details for display
            $issues_formatted = [];
            foreach ($raw_issues as $issue) {
                $type = $issue['type'];
                $details = $issue['details'];
                $size = $details['size'] ?? $details['file'] ?? null;
                
                $formatted = [
                    'type' => $type,
                    'label' => self::get_issue_label($type),
                    'css_class' => self::get_issue_css_class($type),
                    'size' => $size,
                ];
                
                // Add type-specific display info
                switch ($type) {
                    case 'wrong_dimensions':
                        $formatted['current'] = ($details['current_width'] ?? '?') . '×' . ($details['current_height'] ?? '?');
                        $formatted['expected'] = ($details['expected_width'] ?? '?') . '×' . ($details['expected_height'] ?? '?');
                        $formatted['display'] = sprintf(
                            '%s: %s → %s',
                            $size ?: 'unknown',
                            $formatted['current'],
                            $formatted['expected']
                        );
                        break;
                        
                    case 'missing_sizes':
                        $formatted['display'] = sprintf(
                            '%s (%s×%s)',
                            $size ?: 'unknown',
                            $details['expected_width'] ?? '?',
                            $details['expected_height'] ?? '?'
                        );
                        break;
                        
                    case 'deprecated_sizes':
                        $formatted['display'] = $size ?: 'unknown';
                        break;

                    case 'post_content_ref':
                        $source_label = ($details['source'] ?? '') === 'deprecated_sizes'
                            ? __('removed', 'staticq-media')
                            : __('resized', 'staticq-media');
                        $formatted['display'] = sprintf(
                            '%s (%s) → %s',
                            $size ?: 'unknown',
                            $source_label,
                            __('update post content', 'staticq-media')
                        );
                        break;
                        
                    case 'missing_webp':
                        $formatted['display'] = sprintf(
                            // translators: %d is the number of missing WebP files.
                            __('%d WebP files missing', 'staticq-media'),
                            $details['missing'] ?? 1
                        );
                        break;
                        
                    case 'corrupted':
                        $formatted['display'] = $details['reason'] ?? __('Invalid metadata', 'staticq-media');
                        break;
                        
                    case 'upload_to_bucket':
                    case 'download_to_local':
                    case 'generate_sizes':
                        $formatted['display'] = $size ?: $details['file'] ?? 'master';
                        break;
                        
                    case 'critical_missing':
                        $formatted['display'] = __('Master file missing - cannot recover', 'staticq-media');
                        break;
                        
                    default:
                        $formatted['display'] = $size ?: $type;
                }
                
                $issues_formatted[] = $formatted;
            }
            
            // Also provide a summary count by type
            $type_counts = [];
            foreach ($issues_formatted as $issue) {
                $type = $issue['type'];
                if (!isset($type_counts[$type])) {
                    $type_counts[$type] = [
                        'type' => $type,
                        'label' => $issue['label'],
                        'css_class' => $issue['css_class'],
                        'count' => 0,
                    ];
                }
                $type_counts[$type]['count']++;
            }
            
            $result_items[] = [
                'attachment_id' => $attachment_id,
                'filename' => $filename ?: __('Unknown', 'staticq-media'),
                'issues' => $issues_formatted,
                'issue_summary' => array_values($type_counts),
                'edit_link' => admin_url('post.php?post=' . $attachment_id . '&action=edit'),
            ];
        }
        
        wp_send_json_success([
            'items' => $result_items,
            'total' => $total,
            'page' => $page,
            'per_page' => $per_page,
            'total_pages' => ceil($total / $per_page),
        ]);
    }
    
    /**
     * Get human-readable label for issue type
     */
    private static function get_issue_label(string $type): string {
        $labels = [
            'corrupted' => __('Corrupted', 'staticq-media'),
            'wrong_dimensions' => __('Wrong Dimensions', 'staticq-media'),
            'missing_sizes' => __('Missing Sizes', 'staticq-media'),
            'deprecated_sizes' => __('Deprecated Sizes', 'staticq-media'),
            'post_content_ref' => __('Post content reference', 'staticq-media'),
            'missing_webp' => __('Missing WebP', 'staticq-media'),
            'upload_to_bucket' => __('Upload to Bucket', 'staticq-media'),
            'download_to_local' => __('Download to Local', 'staticq-media'),
            'generate_sizes' => __('Generate Sizes', 'staticq-media'),
            'critical_missing' => __('Critical: Missing', 'staticq-media'),
            'original_misplaced' => __('Original: Wrong Location', 'staticq-media'),
            'original_policy' => __('Original & Scaling', 'staticq-media'),
            'original_missing' => __('Original: Missing', 'staticq-media'),
        ];
        return $labels[$type] ?? $type;
    }

    /**
     * Get CSS class for issue type
     */
    private static function get_issue_css_class(string $type): string {
        $classes = [
            'corrupted' => 'error',
            'wrong_dimensions' => 'warning',
            'missing_sizes' => 'warning',
            'deprecated_sizes' => 'warning',
            'post_content_ref' => 'warning',
            'missing_webp' => 'warning',
            'upload_to_bucket' => 'warning',
            'download_to_local' => 'warning',
            'generate_sizes' => 'warning',
            'critical_missing' => 'error',
            'original_misplaced' => 'warning',
            'original_policy' => 'warning',
            'original_missing' => 'info',
        ];
        return $classes[$type] ?? 'default';
    }
    
    /**
     * Schedule cron for background processing
     */
    private static function schedule_cron(string $status = 'running'): void {
        // Don't schedule if building index (separate cron handles that)
        if ($status === 'building_index') {
            return;
        }
        
        wp_clear_scheduled_hook('sqmedia_file_state_scan_continue');
        
        if (!wp_next_scheduled('sqmedia_file_state_scan_continue')) {
            wp_schedule_event(time() + 10, 'sqmedia_one_minute', 'sqmedia_file_state_scan_continue');
        }
    }
    
    /**
     * Clear cron schedule
     */
    private static function clear_cron(): void {
        wp_clear_scheduled_hook('sqmedia_file_state_scan_continue');
    }
    
    /**
     * Get file-level details for an attachment
     */
    public static function get_attachment_file_details(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

        $attachment_id = intval(wp_unslash($_POST['attachment_id'] ?? 0));
        $scan_id = sanitize_text_field(wp_unslash($_POST['scan_id'] ?? ''));

        if ($attachment_id <= 0) {
            wp_send_json_error(__('Invalid attachment ID.', 'staticq-media'));
        }

        // Unified and Phase 2 both use file existence view
        // Phase 1 legacy scans fall back to metadata-only view
        if ($scan_id) {
            $db = new SQMedia_Database();
            $scan = $db->get_scan($scan_id);
            if ($scan && ($scan->results['current_phase'] ?? 'unified') === 1) {
                self::get_phase1_file_details($attachment_id, $scan_id);
                return;
            }
        }

        self::get_phase2_file_details($attachment_id, $scan_id);
    }
    
    /**
     * Get Phase 1 file details (metadata only, no file checks)
     * Instant response - uses scan cache and WordPress metadata only
     */
    private static function get_phase1_file_details(int $attachment_id, string $scan_id): void {
        $wp_metadata = get_post_meta($attachment_id, '_wp_attachment_metadata', true);
        
        if (!$wp_metadata || !is_array($wp_metadata)) {
            wp_send_json_error(__('No metadata found for attachment.', 'staticq-media'));
        }
        
        // Get master dimensions for calculations
        $master_width = $wp_metadata['width'] ?? 0;
        $master_height = $wp_metadata['height'] ?? 0;
        
        // Get issues from scan_items
        $issues_by_size = [];
        if ($scan_id) {
            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, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
            $items = $wpdb->get_results($wpdb->prepare(
                "SELECT action_type, details FROM {$table} WHERE scan_id = %s AND attachment_id = %d",
                $scan_id,
                $attachment_id
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter

            foreach ($items as $item) {
                $details = json_decode($item->details, true) ?: [];

                // Handle new aggregated format (items array)
                $items_list = $details['items'] ?? [];

                // Fallback: handle old single-item format (no 'items' wrapper)
                if (empty($items_list) && !empty($details)) {
                    $items_list = [$details];
                }

                foreach ($items_list as $item_detail) {
                    $size = $item_detail['size'] ?? $item_detail['file'] ?? 'master';

                    if (!isset($issues_by_size[$size])) {
                        $issues_by_size[$size] = [];
                    }

                    // Determine issue type — collapse post_content_ref + add into
                    // a single "update" badge when replacement targets the same size
                    $issue_type = $item->action_type;
                    if ($item->action_type === SQMedia_File_State_Scanner::CAT_POST_CONTENT_REF && !empty($item_detail['replacement_size'])) {
                        $repl = $item_detail['replacement_size'] === 'full' ? 'master' : $item_detail['replacement_size'];
                        if ($repl === $size) {
                            $issue_type = 'post_content_update';
                        }
                    }

                    // Store full issue info
                    $issues_by_size[$size][] = [
                        'type' => $issue_type,
                        'details' => $item_detail,
                    ];

                    // For post_content_ref with a DIFFERENT replacement: add "add to post content" on the replacement
                    if ($item->action_type === SQMedia_File_State_Scanner::CAT_POST_CONTENT_REF && !empty($item_detail['replacement_size'])) {
                        $repl = $item_detail['replacement_size'] === 'full' ? 'master' : $item_detail['replacement_size'];
                        if ($repl !== $size) {
                            if (!isset($issues_by_size[$repl])) {
                                $issues_by_size[$repl] = [];
                            }
                            $issues_by_size[$repl][] = [
                                'type' => 'post_content_add',
                                'details' => $item_detail,
                            ];
                        }
                    }
                }
            }
        }
        
        // Get current theme sizes for comparison
        $current_sizes = SQMedia_Settings::get_enabled_sizes();
        $ignored_sizes = SQMedia_Settings::get_ignored_sizes();
        
        // Build file list from metadata
        $regular_files = [];
        $webp_files = [];
        
        // Master file
        $regular_files[] = [
            'size_name' => 'master',
            'width' => $wp_metadata['width'] ?? 0,
            'height' => $wp_metadata['height'] ?? 0,
            'in_metadata' => true,
            'in_theme' => true,
            'is_preserved' => false,
            'is_webp' => false,
            'issues' => $issues_by_size['master'] ?? [],
        ];
        
        // Sizes from metadata
        $metadata_sizes = $wp_metadata['sizes'] ?? [];
        $seen_sizes = ['master' => true];
        
        foreach ($metadata_sizes as $size_name => $size_data) {
            $is_webp = (strpos($size_name, '-webp') !== false);
            $base_size_name = $is_webp ? str_replace('-webp', '', $size_name) : $size_name;
            
            $seen_sizes[$size_name] = true;
            
            // Check if this is a preserved (custom) size
            $is_preserved = in_array($base_size_name, $ignored_sizes, true);
            
            // For regular sizes, check if in theme
            // For WebP, check if base size is in theme (or is master)
            // Master is always valid - it's not a theme-registered size
            // Preserved sizes are also treated as valid
            if ($base_size_name === 'master' || $is_preserved) {
                $in_theme = true; // Master, master-webp, and preserved sizes always valid
            } else {
                $in_theme = $is_webp ? isset($current_sizes[$base_size_name]) : isset($current_sizes[$size_name]);
            }
            
            $file_entry = [
                'size_name' => $size_name,
                'display_name' => $is_webp ? $base_size_name : $size_name,
                'width' => $size_data['width'] ?? 0,
                'height' => $size_data['height'] ?? 0,
                'in_metadata' => true,
                'in_theme' => $in_theme,
                'is_preserved' => $is_preserved,
                'is_webp' => $is_webp,
                'issues' => $issues_by_size[$size_name] ?? [],
            ];
            
            if ($is_webp) {
                $webp_files[] = $file_entry;
            } else {
                $regular_files[] = $file_entry;
            }
        }
        
        // Add missing sizes (in theme but not in metadata)
        // Only show if scanner actually flagged them (respects master dimension checks)
        foreach ($current_sizes as $size_name => $size_config) {
            if (isset($seen_sizes[$size_name])) continue;
            
            // Only show if scanner flagged it as missing
            // Scanner already checks if master is large enough for this size
            if (!isset($issues_by_size[$size_name])) {
                continue; // Scanner didn't flag this - master too small or other reason
            }
            
            // Calculate actual expected dimensions (not just max config values)
            $expected_dims = self::calculate_output_dimensions(
                $master_width,
                $master_height,
                $size_config['width'] ?? 0,
                $size_config['height'] ?? 0,
                !empty($size_config['crop'])
            );

            // Skip if we can't compute proper dimensions
            if ($expected_dims === null) continue;

            $regular_files[] = [
                'size_name' => $size_name,
                'display_name' => $size_name,
                'width' => null,
                'height' => null,
                'expected_width' => $expected_dims['width'],
                'expected_height' => $expected_dims['height'],
                'in_metadata' => false,
                'in_theme' => true,
                'is_webp' => false,
                'issues' => $issues_by_size[$size_name],
            ];
        }
        
        // Check for missing WebP entries (if WebP is enabled)
        $webp_enabled = SQMedia_Settings::is_webp_enabled();
        if ($webp_enabled) {
            $master_width = $wp_metadata['width'] ?? 0;
            $master_height = $wp_metadata['height'] ?? 0;
            
            // Master WebP
            $master_webp_key = 'master-webp';
            if (!isset($seen_sizes[$master_webp_key])) {
                // Check if scanner flagged this as missing
                $has_issue = isset($issues_by_size[$master_webp_key]);
                
                $webp_files[] = [
                    'size_name' => $master_webp_key,
                    'display_name' => 'master',
                    'width' => $master_width,
                    'height' => $master_height,
                    'in_metadata' => false,
                    'in_theme' => true,
                    'is_webp' => true,
                    'issues' => $has_issue ? $issues_by_size[$master_webp_key] : [],
                ];
            }
            
            // Size WebPs - only show if scanner flagged them (respects master dimension checks)
            foreach ($current_sizes as $size_name => $size_config) {
                $webp_key = $size_name . '-webp';
                if (!isset($seen_sizes[$webp_key])) {
                    // Only show WebP entry if scanner flagged it as missing
                    // Scanner already checks if master is large enough for this size
                    if (!isset($issues_by_size[$webp_key])) {
                        continue; // Scanner didn't flag this - master too small or other reason
                    }
                    
                    // Find the regular size to get dimensions (if it exists)
                    $regular_size = $metadata_sizes[$size_name] ?? null;
                    
                    // Calculate actual expected dimensions
                    $expected_dims = self::calculate_output_dimensions(
                        $master_width,
                        $master_height,
                        $size_config['width'] ?? 0,
                        $size_config['height'] ?? 0,
                        !empty($size_config['crop'])
                    );

                    // Skip if we can't compute proper dimensions
                    if ($expected_dims === null) continue;

                    $webp_files[] = [
                        'size_name' => $webp_key,
                        'display_name' => $size_name,
                        'width' => $regular_size['width'] ?? null,
                        'height' => $regular_size['height'] ?? null,
                        'expected_width' => $expected_dims['width'],
                        'expected_height' => $expected_dims['height'],
                        'in_metadata' => false,
                        'in_theme' => true,
                        'is_webp' => true,
                        'issues' => $issues_by_size[$webp_key],
                    ];
                }
            }
        }
        
        // Combine: regular files first, then WebP files
        $files = array_merge($regular_files, $webp_files);
        
        // Check if master has corrupted dimensions (affects validation of other sizes)
        $master_width = $wp_metadata['width'] ?? 0;
        $master_height = $wp_metadata['height'] ?? 0;
        $master_dimensions_corrupted = ($master_width <= 0 || $master_height <= 0);
        
        wp_send_json_success([
            'attachment_id' => $attachment_id,
            'view' => 'metadata',
            'files' => $files,
            'has_webp_section' => !empty($webp_files),
            'master_dimensions_corrupted' => $master_dimensions_corrupted,
        ]);
    }
    
    /**
     * Get Phase 2 file details (full file existence checks)
     * Slower - checks actual files on disk and bucket
     */
    private static function get_phase2_file_details(int $attachment_id, string $scan_id): void {
        // Get attachment record and metadata
        $record = SQMedia_Attachment_Record::find($attachment_id);
        $wp_metadata = get_post_meta($attachment_id, '_wp_attachment_metadata', true);
        
        if (!$wp_metadata || !is_array($wp_metadata)) {
            wp_send_json_error(__('No metadata found for attachment.', 'staticq-media'));
        }
        
        // Get upload directory info
        $upload_dir = wp_upload_dir();
        $base_dir = $upload_dir['basedir'];
        
        // Get master file path (normalize to forward slashes for consistency with scanner)
        $attached_file = str_replace('\\', '/', get_post_meta($attachment_id, '_wp_attached_file', true));
        $master_dir = dirname($attached_file);
        
        // Initialize Phase 2 scanner for index lookups (uses the scan's indexed file data)
        $phase2_scanner = null;
        if ($scan_id) {
            $scanner = new SQMedia_File_State_Scanner();
            $phase2_scanner = $scanner->get_phase2_handler();
        }
        
        // Get issues from scan_items if we have a scan_id
        $issues_by_file = [];
        if ($scan_id) {
            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, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter
            $items = $wpdb->get_results($wpdb->prepare(
                "SELECT action_type, details FROM {$table} WHERE scan_id = %s AND attachment_id = %d",
                $scan_id,
                $attachment_id
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnescapedDBParameter, PluginCheck.Security.DirectDB.UnescapedDBParameter

            foreach ($items as $item) {
                $details = json_decode($item->details, true) ?: [];

                // Handle new aggregated format (items array)
                $items_list = $details['items'] ?? [];

                // Fallback: handle old single-item format (no 'items' wrapper)
                if (empty($items_list) && !empty($details)) {
                    $items_list = [$details];
                }
                
                foreach ($items_list as $item_detail) {
                    $file_path = $item_detail['file'] ?? '';
                    $size_name = $item_detail['size'] ?? '';

                    // Use action-specific display code for original_policy
                    // so discard vs scale_down get distinct labels in the detail view
                    $display_code = $item->action_type;
                    if ($item->action_type === 'original_policy') {
                        $action = $item_detail['action'] ?? '';
                        if ($action === 'discard') {
                            $display_code = 'original_policy_discard';
                        } elseif ($action === 'scale_down') {
                            $display_code = 'original_policy_scale_down';
                        }
                    }

                    // Use reason-specific display code for corrupted issues
                    // so each corruption type gets a distinct label
                    if ($item->action_type === 'corrupted') {
                        $reason = $item_detail['reason'] ?? '';
                        if ($reason) {
                            $display_code = 'corrupted_' . $reason;
                        }
                    }

                    // Use distinct display code for overwrite regeneration
                    // so the resolution logic (which checks file existence) doesn't suppress the badge
                    if ($item->action_type === 'generate_sizes') {
                        $is_overwrite = $item_detail['overwrite'] ?? false;
                        if ($is_overwrite) {
                            $display_code = 'regenerate_sizes';
                        }
                    }

                    // Collapse post_content_ref + add into a single "update" badge
                    // when replacement targets the same size (dimension update, not file swap)
                    if ($item->action_type === 'post_content_ref' && !empty($item_detail['replacement_size'])) {
                        $repl = $item_detail['replacement_size'] === 'full' ? 'master' : $item_detail['replacement_size'];
                        if ($repl === $size_name) {
                            $display_code = 'post_content_update';
                        }
                    }

                    // Store by multiple keys for flexible lookup
                    $stored = false;

                    // Full path (e.g., '2025/12/file-150x150.jpg')
                    if ($file_path) {
                        if (!isset($issues_by_file[$file_path])) {
                            $issues_by_file[$file_path] = [];
                        }
                        $issues_by_file[$file_path][] = $display_code;

                        // Also store by just filename (e.g., 'file-150x150.jpg')
                        $filename = basename($file_path);
                        if (!isset($issues_by_file[$filename])) {
                            $issues_by_file[$filename] = [];
                        }
                        $issues_by_file[$filename][] = $display_code;
                        $stored = true;
                    }

                    // Size name (e.g., 'thumbnail', 'medium-webp')
                    if ($size_name && $size_name !== 'unknown') {
                        if (!isset($issues_by_file[$size_name])) {
                            $issues_by_file[$size_name] = [];
                        }
                        $issues_by_file[$size_name][] = $display_code;
                        $stored = true;
                    }

                    // Fallback: issues with no file/size key (e.g. corrupted metadata)
                    // index under 'master' since they describe the master file's metadata
                    if (!$stored) {
                        if (!isset($issues_by_file['master'])) {
                            $issues_by_file['master'] = [];
                        }
                        $issues_by_file['master'][] = $display_code;
                    }

                    // For post_content_ref with a DIFFERENT replacement: add "add to post content" on the replacement
                    if ($item->action_type === 'post_content_ref' && !empty($item_detail['replacement_size'])) {
                        $repl = $item_detail['replacement_size'] === 'full' ? 'master' : $item_detail['replacement_size'];
                        if ($repl !== $size_name) {
                            if (!isset($issues_by_file[$repl])) {
                                $issues_by_file[$repl] = [];
                            }
                            $issues_by_file[$repl][] = 'post_content_add';
                        }
                    }
                }
            }
        }
        
        // Human-readable issue labels
        $issue_labels = [
            'upload_to_bucket'  => __('Upload to bucket', 'staticq-media'),
            'download_to_local' => __('Download to local', 'staticq-media'),
            'generate_sizes'    => __('Generate file', 'staticq-media'),
            'regenerate_sizes'  => __('Regenerate file', 'staticq-media'),
            'critical_missing'  => __('Master missing everywhere', 'staticq-media'),
            // Metadata issues (unified scan)
            'missing_sizes'     => __('Add to metadata', 'staticq-media'),
            'wrong_dimensions'  => __('Wrong dimensions', 'staticq-media'),
            'deprecated_sizes'  => __('Remove from metadata', 'staticq-media'),
            'corrupted'                    => __('Corrupted metadata', 'staticq-media'),
            'corrupted_no_metadata'        => __('No metadata', 'staticq-media'),
            'corrupted_invalid_dimensions' => __('Invalid dimensions', 'staticq-media'),
            'corrupted_invalid_filesize'   => __('Invalid filesize', 'staticq-media'),
            'corrupted_missing_file_path'  => __('Missing file path', 'staticq-media'),
            'corrupted_file_path_mismatch' => __('File path mismatch', 'staticq-media'),
            'corrupted_invalid_file_path'  => __('Invalid file path', 'staticq-media'),
            'missing_webp'      => __('Create WebP', 'staticq-media'),
            // Original image issues
            'original_misplaced'          => __('Original: stale copy in old folder', 'staticq-media'),
            'original_policy'             => __('Needs attention', 'staticq-media'),
            'original_policy_discard'     => __('Pending removal', 'staticq-media'),
            'original_policy_scale_down'  => __('Needs scaling down', 'staticq-media'),
            'original_missing'            => __('Original not found (info only)', 'staticq-media'),
            // Post content reference issues
            'post_content_ref'            => __('Remove from post content', 'staticq-media'),
            'post_content_add'            => __('Add to post content', 'staticq-media'),
            'post_content_update'         => __('Update in post content', 'staticq-media'),
        ];
        
        // Build file list
        $files = [];
        
        // Helper to get readable issues
        // $local/$bucket reflect current file state so stale scan issues can be corrected
        // Keys convention: [$size_name, $filename, $rel_path] — file-based keys are indices 1+
        $storage_mode = SQMedia_Settings::get_storage_mode();
        $needs_bucket = ($storage_mode === 'cloud_only' || $storage_mode === 'cloud_and_local');
        $get_issues = function($keys, bool $local = false, bool $bucket = false) use ($issues_by_file, $issue_labels, $storage_mode, $needs_bucket) {
            // Collect issues from file-path-based keys (indices 1+) and size_name key (index 0).
            // File-path keys get priority for resolution logic (generate_sizes can be
            // resolved by checking $local/$bucket). Size_name keys add issues that may
            // only be indexed there (e.g., missing_sizes, original_policy).
            $file_matched_codes = [];
            $matched_via_file = false;

            for ($i = 1, $count = count($keys); $i < $count; $i++) {
                if (!empty($issues_by_file[$keys[$i]])) {
                    $file_matched_codes = $issues_by_file[$keys[$i]];
                    $matched_via_file = true;
                    break;
                }
            }

            // Also collect from size_name key (index 0) — adds codes not already found via file path
            $size_matched_codes = [];
            if (!empty($keys[0]) && !empty($issues_by_file[$keys[0]])) {
                foreach ($issues_by_file[$keys[0]] as $code) {
                    if (!in_array($code, $file_matched_codes, true)) {
                        $size_matched_codes[] = $code;
                    }
                }
            }

            if (empty($file_matched_codes) && empty($size_matched_codes)) {
                return [];
            }

            $issues = [];

            // Process file-path-matched codes (with resolution logic)
            foreach ($file_matched_codes as $code) {
                // Only resolve generate_sizes when matched via file path
                // (ensures we check the same file the scanner checked)
                if ($code === 'generate_sizes' && $matched_via_file) {
                    if ($local && $bucket) {
                        continue; // File exists everywhere — fully resolved
                    }
                    if ($local && !$bucket) {
                        if ($needs_bucket) {
                            $issues[] = $issue_labels['upload_to_bucket'] ?? $code;
                        }
                        continue; // local_only: file exists locally = resolved
                    }
                    if (!$local && $bucket) {
                        if ($storage_mode === 'cloud_only') {
                            continue; // Bucket-only: file in bucket = resolved
                        }
                        if ($storage_mode === 'cloud_and_local') {
                            $issues[] = $issue_labels['download_to_local'] ?? $code;
                            continue;
                        }
                        // local_only: bucket is irrelevant, still need to generate locally
                    }
                }
                $issues[] = $issue_labels[$code] ?? $code;
            }

            // Process size_name-only codes (no resolution logic — these are metadata/policy issues)
            foreach ($size_matched_codes as $code) {
                $issues[] = $issue_labels[$code] ?? $code;
            }

            return $issues;
        };
        
        // Helper to check file existence using scan index (consistent with scanner findings)
        // Falls back to live bucket check when index says "not found" (handles files uploaded after scan)
        $check_exists = function($rel_path, $type) use ($phase2_scanner, $scan_id, $base_dir) {
            if ($type === 'local') {
                // For local, use filesystem check (fast and always current)
                return file_exists($base_dir . '/' . $rel_path);
            }

            // For bucket, use scan's indexed data if available
            if ($phase2_scanner && $scan_id) {
                if ($phase2_scanner->file_exists_in_index($scan_id, 'bucket', $rel_path)) {
                    return true;
                }
                // Index says not found — fall through to live check
                // (file may have been uploaded after the index was built, e.g. by Fix)
            }

            // Live bucket check (fallback or no scan index)
            $storage = sqmedia()->get_storage_handler();
            if ($storage && SQMedia_Settings::is_cloud_storage_configured()) {
                $prefix = SQMedia_Settings::get_current_prefix();
                $bucket_path = ($prefix ? $prefix . '/' : '') . $rel_path;
                return $storage->file_exists($bucket_path);
            }

            return false;
        };
        
        // Master file
        $master_exists_local = $check_exists($attached_file, 'local');
        $master_exists_bucket = $check_exists($attached_file, 'bucket');
        
        $files[] = [
            'file' => basename($attached_file),
            'size_name' => 'master',
            'local' => $master_exists_local,
            'bucket' => $master_exists_bucket,
            'issues' => $get_issues(['master', $attached_file, basename($attached_file)], $master_exists_local, $master_exists_bucket),
        ];
        
        // Original image file (if metadata has original_image)
        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_rel_path = $master_dir . '/' . $original_file;
            } elseif ($original_handling === 'move_to_originals') {
                $original_rel_path = 'originals/' . $master_dir . '/' . $original_file;
            } else {
                // 'discard' — check alongside master (legacy location)
                $original_rel_path = $master_dir . '/' . $original_file;
            }

            // Location column reflects presence at the intended folder only.
            // A file at the legacy location is reported as ABSENT here so the
            // location column never contradicts the issues column. The legacy
            // copy is surfaced separately via the MISPLACED issue badge,
            // which carries current_path/expected_path in its details.
            $original_exists_local  = $check_exists($original_rel_path, 'local');
            $original_exists_bucket = $check_exists($original_rel_path, 'bucket');

            // Probe the alt path purely to attach a "legacy_path" hint to the
            // row. Does not influence the location pills.
            $legacy_path = null;
            if (!$original_exists_local && !$original_exists_bucket) {
                if ($original_handling === 'preserve') {
                    $alt_path = 'originals/' . $master_dir . '/' . $original_file;
                } else {
                    $alt_path = $master_dir . '/' . $original_file;
                }
                if ($check_exists($alt_path, 'local') || $check_exists($alt_path, 'bucket')) {
                    $legacy_path = $alt_path;
                }
            }

            $original_row = [
                'file' => $original_file,
                'size_name' => 'original_image',
                'local' => $original_exists_local,
                'bucket' => $original_exists_bucket,
                'issues' => $get_issues(['original', 'original_image', $original_file, $original_rel_path], $original_exists_local, $original_exists_bucket),
            ];
            if ($legacy_path !== null) {
                $original_row['legacy_path'] = $legacy_path;
                $original_row['expected_path'] = $original_rel_path;
            }
            $files[] = $original_row;
        }

        // Size files
        $seen_sizes = [];
        $sizes = $wp_metadata['sizes'] ?? [];
        foreach ($sizes as $size_name => $size_data) {
            $seen_sizes[$size_name] = true;
            if (empty($size_data['file'])) continue;
            
            $size_file = $size_data['file'];
            $size_rel_path = $master_dir . '/' . $size_file;
            $size_exists_local = $check_exists($size_rel_path, 'local');
            $size_exists_bucket = $check_exists($size_rel_path, 'bucket');
            
            $files[] = [
                'file' => $size_file,
                'size_name' => $size_name,
                'width' => $size_data['width'] ?? 0,
                'height' => $size_data['height'] ?? 0,
                'local' => $size_exists_local,
                'bucket' => $size_exists_bucket,
                'issues' => $get_issues([$size_name, $size_file, $size_rel_path], $size_exists_local, $size_exists_bucket),
            ];
        }

        // Add missing sizes (registered in WP but not in metadata)
        // Only show if the scanner flagged them as issues
        if ($record && $record->is_image() && $record->supports_resizing()) {
            $current_sizes = SQMedia_Settings::get_enabled_sizes();
            $master_width = $wp_metadata['width'] ?? 0;
            $master_height = $wp_metadata['height'] ?? 0;
            // Use original_image basename if present (for scaled images)
            if (!empty($wp_metadata['original_image'])) {
                $master_basename = pathinfo($wp_metadata['original_image'], PATHINFO_FILENAME);
            } else {
                $master_basename = pathinfo(basename($attached_file), PATHINFO_FILENAME);
            }
            $master_ext = pathinfo(basename($attached_file), PATHINFO_EXTENSION);

            foreach ($current_sizes as $size_name => $size_config) {
                if (isset($seen_sizes[$size_name])) continue;

                // Only show if scanner flagged it
                if (empty($issues_by_file[$size_name])) continue;

                // Compute expected filename and dimensions
                $expected_dims = self::calculate_output_dimensions(
                    $master_width, $master_height,
                    $size_config['width'] ?? 0,
                    $size_config['height'] ?? 0,
                    !empty($size_config['crop'])
                );

                // Skip if we can't compute proper dimensions (e.g. unknown master dims + unconstrained size)
                if ($expected_dims === null) continue;

                $expected_file = "{$master_basename}-{$expected_dims['width']}x{$expected_dims['height']}.{$master_ext}";
                $expected_rel_path = $master_dir . '/' . $expected_file;
                $size_exists_local = $check_exists($expected_rel_path, 'local');
                $size_exists_bucket = $check_exists($expected_rel_path, 'bucket');

                $files[] = [
                    'file' => $expected_file,
                    'size_name' => $size_name,
                    'width' => $expected_dims['width'],
                    'height' => $expected_dims['height'],
                    'local' => $size_exists_local,
                    'bucket' => $size_exists_bucket,
                    'issues' => $get_issues([$size_name, $expected_file, $expected_rel_path], $size_exists_local, $size_exists_bucket),
                ];
            }
        }

        // Append WebP counterparts if WebP generation is enabled
        if (SQMedia_Settings::is_webp_enabled() && $record && $record->is_image()) {
            $webp_files = [];
            foreach ($files as $file_entry) {
                $base_size = $file_entry['size_name'] ?? '';

                // Skip entries that are already -webp (deprecated metadata entries)
                if (substr($base_size, -5) === '-webp' || substr($base_size, -7) === ' (webp)') continue;

                // Skip original_image — it's an archival copy, no WebP is generated for it
                if ($base_size === 'original_image') continue;

                // Skip if the deprecated -webp entry already exists in metadata
                // (it will be shown from the metadata loop with its own issues)
                if (isset($seen_sizes[$base_size . '-webp'])) continue;

                $webp_name = SQMedia_Attachment_Record::get_webp_filename($file_entry['file']);
                // Skip if extension didn't change (not a convertible type, e.g. SVG)
                if ($webp_name === $file_entry['file']) continue;

                // Scanner stores WebP issues with size name "{size}-webp"
                $webp_size_key = $base_size . '-webp';
                $webp_rel_path = $master_dir . '/' . $webp_name;
                $webp_local = $check_exists($webp_rel_path, 'local');
                $webp_bucket = $check_exists($webp_rel_path, 'bucket');

                $webp_files[] = [
                    'file' => $webp_name,
                    'size_name' => $base_size . ' (webp)',
                    'width' => $file_entry['width'] ?? 0,
                    'height' => $file_entry['height'] ?? 0,
                    'local' => $webp_local,
                    'bucket' => $webp_bucket,
                    'issues' => $get_issues([$webp_size_key, $webp_name, $webp_rel_path], $webp_local, $webp_bucket),
                ];
            }
            $files = array_merge($files, $webp_files);
        }

        // Debug: include issues map in response for devtools inspection
        $debug_issues = [];
        foreach ($issues_by_file as $key => $codes) {
            $debug_issues[$key] = $codes;
        }

        wp_send_json_success([
            'attachment_id' => $attachment_id,
            'view' => 'files',
            'files' => $files,
            '_debug' => [
                'scan_id' => $scan_id,
                'issues_by_file' => $debug_issues,
                'storage_mode' => SQMedia_Settings::get_storage_mode(),
            ],
        ]);
    }

    /**
     * Calculate actual output dimensions for a size
     * 
     * Takes master dimensions and size config, returns what WordPress would actually generate.
     * For non-cropped sizes, calculates proportional dimensions.
     * For cropped sizes, returns the crop target dimensions.
     * 
     * @param int $master_w Master image width
     * @param int $master_h Master image height
     * @param int $max_w Size config max width (0 = unconstrained)
     * @param int $max_h Size config max height (0 = unconstrained)
     * @param bool $crop Whether this size uses cropping
     * @return array|null ['width' => int, 'height' => int] or null if dimensions can't be computed
     */
    private static function calculate_output_dimensions(int $master_w, int $master_h, int $max_w, int $max_h, bool $crop): ?array {
        // If master dimensions unknown, we can only return exact dimensions
        // for sizes where both width and height are specified. Otherwise we
        // can't compute proportional dimensions without the aspect ratio.
        if ($master_w <= 0 || $master_h <= 0) {
            if ($max_w <= 0 || $max_h <= 0) {
                return null;
            }
            return [
                'width' => $max_w,
                'height' => $max_h,
            ];
        }
        
        // If both constraints are 0, no resize
        if ($max_w <= 0 && $max_h <= 0) {
            return [
                'width' => $master_w,
                'height' => $master_h,
            ];
        }
        
        if ($crop) {
            // Cropped sizes use exact dimensions (if master is big enough)
            return [
                'width' => $max_w,
                'height' => $max_h,
            ];
        }
        
        // Non-cropped: calculate proportional dimensions using WordPress function
        // Use large value for unconstrained dimension
        $constrain_w = $max_w > 0 ? $max_w : 9999;
        $constrain_h = $max_h > 0 ? $max_h : 9999;
        
        list($dest_w, $dest_h) = wp_constrain_dimensions($master_w, $master_h, $constrain_w, $constrain_h);
        
        return [
            'width' => $dest_w,
            'height' => $dest_h,
        ];
    }

    /**
     * Create backup of wp_postmeta and wp_posts tables
     */
    public static function backup_tables(): void {
        check_ajax_referer('sqmedia_admin_nonce', 'nonce');

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

        $result = SQMedia_Database::create_table_backup('mls');

        if ($result['success']) {
            $now = current_time('mysql');
            wp_send_json_success([
                'message' => sprintf(
                    /* translators: %s: date and time of backup */
                    __('Backup exists from %s', 'staticq-media'),
                    wp_date(get_option('date_format') . ' ' . get_option('time_format'), strtotime($now))
                ),
                'tables'  => $result['tables'],
            ]);
        } else {
            wp_send_json_error($result['error']);
        }
    }
}