
// ============================================================================
// CONFIGURATION - Tuned for low-resource environments
// ============================================================================

// Batch sizes for chunked operations
define('EXTRACT_BATCH_SIZE', 100);      // Files to extract per batch
define('COPY_BATCH_SIZE', 100);         // Files to copy per batch
define('SQL_BATCH_SIZE', 50);           // SQL statements per batch

// Time management - safety margin before max_execution_time
define('TIME_SAFETY_MARGIN', 5);        // Seconds to reserve before timeout

// Hard cap for any single URL-transferred artifact (and sum of queued
// artifacts), to prevent a malicious or misconfigured remote from filling
// disk. Operators can lower this before deploying the emergency script.
define('ASENHA_EMERGENCY_URL_MAX_BYTES', 2 * 1024 * 1024 * 1024); // 2 GiB

// Get the max execution time, default to 30 if not set or unlimited
$max_exec_time = (int) ini_get('max_execution_time');
if ($max_exec_time <= 0) {
    $max_exec_time = 30; // Assume 30 seconds if unlimited or not set
}
define('MAX_EXEC_TIME', $max_exec_time);

// Try to increase limits (may be ignored by host)
if (function_exists('set_time_limit')) {
    @set_time_limit(300);
}
@ini_set('memory_limit', '512M');

// Track script start time for time-aware execution
$script_start_time = microtime(true);

// ============================================================================
// TIMEZONE - Match WordPress Settings -> General
// ============================================================================

/**
 * Get the site's timezone.
 *
 * WordPress stores either:
 * - timezone_string (e.g. "America/New_York"), or
 * - gmt_offset (e.g. 5.5) when no timezone string is set.
 *
 * This script is standalone (no WordPress bootstrap), so we inject both values
 * when generating the script.
 *
 * @param string $timezone_string Timezone identifier (IANA), if configured.
 * @param float  $gmt_offset      GMT offset, if no identifier is set.
 * @return array{0: DateTimeZone, 1: bool} {timezone, can_set_default_timezone}
 */
function asenha_get_site_timezone($timezone_string, $gmt_offset) {
    $timezone_string = is_string($timezone_string) ? trim($timezone_string) : '';
    if ($timezone_string !== '') {
        try {
            $tz = new DateTimeZone($timezone_string);
            $can_set_default = in_array($timezone_string, timezone_identifiers_list(), true);
            return array($tz, $can_set_default);
        } catch (Exception $e) {
            // Fall through to offset-based timezone.
        }
    }

    $offset = (float) $gmt_offset;
    $sign = ($offset < 0) ? '-' : '+';
    $offset = abs($offset);
    $hours = (int) floor($offset);
    $minutes = (int) round(($offset - $hours) * 60);

    if ($minutes >= 60) {
        $hours++;
        $minutes = 0;
    }

    $offset_string = sprintf('%s%02d:%02d', $sign, $hours, $minutes);

    try {
        return array(new DateTimeZone($offset_string), false);
    } catch (Exception $e) {
        return array(new DateTimeZone('UTC'), false);
    }
}

list($asenha_timezone, $asenha_can_set_default_timezone) = asenha_get_site_timezone($wp_timezone_string, $wp_gmt_offset);

// Best-effort: align PHP default timezone for any date() usage (only safe for identifiers).
if ($asenha_can_set_default_timezone && is_string($wp_timezone_string) && $wp_timezone_string !== '') {
    @date_default_timezone_set($wp_timezone_string);
}

/**
 * Format a Unix timestamp using the site's timezone.
 *
 * @param int    $timestamp Unix timestamp.
 * @param string $format    Date format.
 * @return string
 */
function asenha_format_local_datetime($timestamp, $format = 'Y-m-d H:i:s') {
    global $asenha_timezone;

    $ts = (int) $timestamp;
    try {
        $dt = new DateTimeImmutable('@' . $ts);
        if ($asenha_timezone instanceof DateTimeZone) {
            $dt = $dt->setTimezone($asenha_timezone);
        }
        return $dt->format($format);
    } catch (Exception $e) {
        return date($format, $ts);
    }
}

/**
 * Check whether a date format token exists and is not escaped.
 *
 * @param string $format Date format string.
 * @param string $tokens Character class tokens to detect.
 * @return bool
 */
function asenha_has_unescaped_format_token($format, $tokens) {
    $format = is_string($format) ? $format : '';
    $tokens = is_string($tokens) ? $tokens : '';
    if ($format === '' || $tokens === '') {
        return false;
    }

    return preg_match('/(?<!\\\\)[' . preg_quote($tokens, '/') . ']/', $format) === 1;
}

/**
 * Build display datetime format using site date format + minute precision time.
 *
 * @return string
 */
function asenha_get_archive_display_datetime_format() {
    global $wp_date_format, $wp_time_format;

    $date_format = is_string($wp_date_format) ? trim($wp_date_format) : '';
    $time_format = is_string($wp_time_format) ? trim($wp_time_format) : '';
    if ($date_format === '') {
        $date_format = 'F j, Y';
    }
    if ($time_format === '') {
        $time_format = 'g:i a';
    }

    if (asenha_has_unescaped_format_token($time_format, 's')) {
        $is_twelve_hour = asenha_has_unescaped_format_token($time_format, 'gh');
        $uses_upper_ampm = asenha_has_unescaped_format_token($time_format, 'A');
        $uses_ampm = $uses_upper_ampm || asenha_has_unescaped_format_token($time_format, 'a');

        if ($is_twelve_hour) {
            $time_format = 'g:i';
            if ($uses_ampm) {
                $time_format .= $uses_upper_ampm ? ' A' : ' a';
            }
        } else {
            $time_format = 'H:i';
        }
    }

    return $date_format . ', ' . $time_format;
}

/**
 * Format archive datetime for emergency restore table display.
 *
 * @param int $timestamp Unix timestamp.
 * @return string
 */
function asenha_format_archive_display_datetime($timestamp) {
    return asenha_format_local_datetime($timestamp, asenha_get_archive_display_datetime_format());
}

// ============================================================================
// STATE MANAGEMENT FUNCTIONS
// ============================================================================

/**
 * Get the state file path for a restore operation
 */
function get_state_file_path($backup_dir, $filename) {
    $state_id = md5($filename);
    return $backup_dir . DIRECTORY_SEPARATOR . 'restore_state_' . $state_id . '.json';
}

/**
 * Load restore state from file
 */
function load_state($backup_dir, $filename) {
    $state_file = get_state_file_path($backup_dir, $filename);
    
    if (!file_exists($state_file)) {
        return null;
    }
    
    $content = file_get_contents($state_file);
    if ($content === false) {
        return null;
    }
    
    $state = json_decode($content, true);
    return is_array($state) ? $state : null;
}

/**
 * Save restore state to file
 */
function save_state($backup_dir, $filename, $state) {
    $state_file = get_state_file_path($backup_dir, $filename);
    $state['updated_at'] = time();
    
    return file_put_contents($state_file, json_encode($state, JSON_PRETTY_PRINT)) !== false;
}

/**
 * Delete state file after successful restore
 */
function delete_state($backup_dir, $filename) {
    $state_file = get_state_file_path($backup_dir, $filename);
    if (file_exists($state_file)) {
        @unlink($state_file);
    }
    asenha_emergency_delete_sqlite_runner();
}

/**
 * Initialize a new restore state
 */
function init_state($filename, $backup_path) {
    // Get archive info
    $zip = new ZipArchive();
    if ($zip->open($backup_path) !== true) {
        return null;
    }
    
    $total_files = $zip->numFiles;
    $zip->close();
    
    return array(
        'filename' => $filename,
        'backup_path' => $backup_path,
        'phase' => 'extracting',
        'status' => 'running',
        'created_at' => time(),
        'updated_at' => time(),
        
        // Archive info (used for fallback extraction progress)
        'archive_total_files' => $total_files,
        
        // Extraction state (direct mode extracts only control files: manifest.json (+ database.sql if present))
        'extract_index' => 0,
        'extract_total' => 1,
        'extract_complete' => false,
        
        // File restore state
        'files_restore_mode' => 'direct', // 'direct' (from archive) or 'fallback' (extract-to-temp + copy)
        'zip_index' => 0, // ZipArchive index for direct restore resume

        // File copy state (used for both modes: direct updates copy_*; fallback uses copy_files queue)
        'copy_index' => 0,
        'copy_total' => 0,
        'copy_files' => array(),
        'copy_complete' => false,
        
        // Database state
        'sql_position' => 0,
        'sql_statements_executed' => 0,
        'sql_complete' => false,
        // Track SQL-referenced DB objects across resume batches (used for cleanup at the end).
        'sql_objects' => array(),
        
        // Results
        'files_extracted' => 0,
        'files_copied' => 0,
        'sql_executed' => 0,

        // Sync delete (wp-content) state (resumable).
        // When enabled, files in destination wp-content that are not present in the backup archive
        // (and are not excluded by is_eligible_wp_content_entry()) will be deleted.
        'sync_enabled' => false,
        'sync_queue_built' => false,
        'sync_scan_dirs' => array(''),
        'sync_scan_current_dir' => '',
        'sync_scan_current_index' => 0,
        'sync_files_queued' => 0,
        'sync_delete_queue_byte_offset' => 0,
        'sync_files_deleted' => 0,
        'sync_dirs_removed' => 0,
        'sync_done' => false,
        'sync_summary_added' => false,

        'messages' => array(),
        'errors' => array(),
    );
}

/**
 * Check if we're approaching the execution time limit
 */
function is_time_running_out($start_time) {
    $elapsed = microtime(true) - $start_time;
    $limit = MAX_EXEC_TIME - TIME_SAFETY_MARGIN;
    return $elapsed >= $limit;
}

// ============================================================================
// HELPER FUNCTIONS
// ============================================================================

/**
 * Sanitize backup filename basename without mutating dot segments.
 *
 * Avoid using sanitize_file_name() because it can alter middle dot segments
 * used by multipart metadata filenames (e.g. *.zip.parts.json).
 */
function asenha_sanitize_backup_basename($filename) {
    $filename = str_replace('\\', '/', (string) $filename);
    $filename = basename($filename);

    if ($filename === '' || strpos($filename, '..') !== false) {
        return '';
    }
    if (preg_match('/^[A-Za-z0-9._-]+$/', $filename) !== 1) {
        return '';
    }

    return $filename;
}

/**
 * Parse backup type + incremental marker from filename.
 *
 * @return array{type:string,label:string,is_incremental:bool}
 */
function asenha_parse_backup_type_from_filename($filename) {
    $name = strtolower((string) $filename);
    $type = 'unknown';
    $is_incremental = false;

    if (strpos($name, '_full_inc_') !== false) {
        $type = 'full';
        $is_incremental = true;
    } elseif (strpos($name, '_files_inc_') !== false) {
        $type = 'files';
        $is_incremental = true;
    } elseif (strpos($name, '_database_inc_') !== false) {
        $type = 'database';
        $is_incremental = true;
    } elseif (strpos($name, '_full_') !== false) {
        $type = 'full';
    } elseif (strpos($name, '_database_') !== false) {
        $type = 'database';
    } elseif (strpos($name, '_files_') !== false) {
        $type = 'files';
    }

    $label = 'unknown';
    if ($type === 'full') {
        $label = 'Full';
    } elseif ($type === 'database') {
        $label = 'Database';
    } elseif ($type === 'files') {
        $label = 'Files';
    }

    return array(
        'type' => $type,
        'label' => $label,
        'is_incremental' => (bool) $is_incremental,
    );
}

/**
 * Normalize backup method value.
 */
function asenha_normalize_backup_method($method, $fallback_is_incremental = false) {
    $method = strtolower(trim((string) $method));
    if ($method === 'baseline' || $method === 'incremental') {
        return $method;
    }

    return $fallback_is_incremental ? 'incremental' : 'baseline';
}

/**
 * Best-effort: read chain metadata from ZIP manifest.json.
 *
 * @return array{backup_method:string,base_filename:string,parent_filename:string,chain_id:string,sequence:int}
 */
function asenha_read_zip_manifest_chain_meta($zip_path) {
    $meta = array(
        'backup_method' => '',
        'base_filename' => '',
        'parent_filename' => '',
        'chain_id' => '',
        'sequence' => 0,
        'archive_encryption_enabled' => false,
        'archive_encryption_algo' => '',
        'archive_passphrase_required' => false,
    );

    if (!class_exists('ZipArchive') || !is_string($zip_path) || $zip_path === '' || !file_exists($zip_path)) {
        return $meta;
    }

    $zip = new ZipArchive();
    if ($zip->open($zip_path) !== true) {
        return $meta;
    }

    $manifest_content = $zip->getFromName('manifest.json');
    $zip->close();

    if ($manifest_content === false || $manifest_content === '') {
        return $meta;
    }

    $manifest = json_decode($manifest_content, true);
    if (!is_array($manifest)) {
        return $meta;
    }

    $manifest_method = strtolower(trim((string) (isset($manifest['backup_method']) ? $manifest['backup_method'] : '')));
    if ($manifest_method === 'baseline' || $manifest_method === 'incremental') {
        $meta['backup_method'] = $manifest_method;
    }

    $base_filename = asenha_sanitize_backup_basename(isset($manifest['base_filename']) ? $manifest['base_filename'] : '');
    if ($base_filename !== '') {
        $meta['base_filename'] = $base_filename;
    }

    $parent_filename = asenha_sanitize_backup_basename(isset($manifest['parent_filename']) ? $manifest['parent_filename'] : '');
    if ($parent_filename !== '') {
        $meta['parent_filename'] = $parent_filename;
    }

    $chain_id = preg_replace('/[^A-Za-z0-9._:-]/', '', (string) (isset($manifest['chain_id']) ? $manifest['chain_id'] : ''));
    if (is_string($chain_id) && $chain_id !== '') {
        $meta['chain_id'] = $chain_id;
    }

    $sequence = isset($manifest['sequence']) ? (int) $manifest['sequence'] : 0;
    if ($sequence > 0) {
        $meta['sequence'] = $sequence;
    }

    $archive_encryption_enabled = !empty($manifest['archive_encryption_enabled']);
    $archive_encryption_algo = strtolower(trim((string) (isset($manifest['archive_encryption_algo']) ? $manifest['archive_encryption_algo'] : '')));
    $archive_encryption_algo = preg_replace('/[^a-z0-9_-]/', '', $archive_encryption_algo);
    if (!is_string($archive_encryption_algo)) {
        $archive_encryption_algo = '';
    }
    if ($archive_encryption_enabled && $archive_encryption_algo === '') {
        $archive_encryption_algo = 'aes-256';
    }

    $meta['archive_encryption_enabled'] = $archive_encryption_enabled;
    $meta['archive_encryption_algo'] = $archive_encryption_algo;
    $meta['archive_passphrase_required'] = !empty($manifest['archive_passphrase_required']) || $archive_encryption_enabled;

    return $meta;
}

/**
 * Get list of backup files from backup directory.
 */
function get_backup_files($backup_dir) {
    $files = array();
    $meta_suffix = '.parts.json';

    if (!is_dir($backup_dir)) {
        return $files;
    }

    $iterator = new DirectoryIterator($backup_dir);

    foreach ($iterator as $file) {
        if ($file->isDot() || $file->isDir()) {
            continue;
        }

        $filename = asenha_sanitize_backup_basename($file->getFilename());
        if ($filename === '') {
            continue;
        }

        // Skip temp / state / migration-transfer artifacts (applies to both .zip and .parts.json).
        if (strpos($filename, 'temp_') === 0 || strpos($filename, 'transfer_') === 0 || strpos($filename, 'restore_state_') === 0 || strpos($filename, 'imported_') === 0) {
            continue;
        }

        if (!asenha_emergency_restore_filename_matches_embedded_slug($filename)) {
            continue;
        }

        $is_meta = ($meta_suffix === substr($filename, -strlen($meta_suffix)));

        // Only include zip files (single archive) and multipart metadata files.
        if (!$is_meta && pathinfo($filename, PATHINFO_EXTENSION) !== 'zip') {
            continue;
        }

        $parsed = asenha_parse_backup_type_from_filename($filename);
        $mtime = (int) $file->getMTime();

        if ($is_meta) {
            // Multipart logical backup: size/type comes from the metadata (not the JSON file size).
            $meta_path = $file->getPathname();
            $content = @file_get_contents($meta_path);
            if ($content === false || $content === '') {
                continue;
            }

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

            // Minimal validation.
            if (empty($meta['original_zip_name']) || empty($meta['total_bytes']) || empty($meta['part_bytes']) || empty($meta['parts']) || !is_array($meta['parts'])) {
                continue;
            }

            $backup_type = strtolower((string) (isset($meta['backup_type']) ? $meta['backup_type'] : ''));

            // Defense-in-depth: do not show migration/transfer multipart artifacts.
            if ($backup_type === 'migration') {
                continue;
            }

            $type = (string) $parsed['label'];
            if ($backup_type === 'full') {
                $type = 'Full';
            } elseif ($backup_type === 'database') {
                $type = 'Database';
            } elseif ($backup_type === 'files') {
                $type = 'Files';
            }

            $is_incremental = !empty($parsed['is_incremental']);
            $backup_method = asenha_normalize_backup_method(isset($meta['backup_method']) ? $meta['backup_method'] : '', $is_incremental);
            $base_filename = asenha_sanitize_backup_basename(isset($meta['base_filename']) ? $meta['base_filename'] : '');
            $parent_filename = asenha_sanitize_backup_basename(isset($meta['parent_filename']) ? $meta['parent_filename'] : '');
            $chain_id = preg_replace('/[^A-Za-z0-9._:-]/', '', (string) (isset($meta['chain_id']) ? $meta['chain_id'] : ''));
            $chain_id = is_string($chain_id) ? $chain_id : '';
            $sequence = isset($meta['sequence']) ? max(0, (int) $meta['sequence']) : 0;

            if ($backup_method === 'baseline' && $base_filename === '') {
                $base_filename = asenha_sanitize_backup_basename(isset($meta['original_zip_name']) ? $meta['original_zip_name'] : '');
                if ($base_filename === '') {
                    $base_filename = $filename;
                }
            }

            $total_bytes = (int) $meta['total_bytes'];
            $parts_count = is_array($meta['parts']) ? count($meta['parts']) : 0;
            $size_text = format_bytes($total_bytes);
            if ($parts_count > 0) {
                $size_text .= ' (multi-part, ' . $parts_count . ' parts)';
            } else {
                $size_text .= ' (multi-part)';
            }

            $archive_encryption_enabled = !empty($meta['archive_encryption_enabled']);
            $archive_encryption_algo = strtolower(trim((string) (isset($meta['archive_encryption_algo']) ? $meta['archive_encryption_algo'] : '')));
            $archive_encryption_algo = preg_replace('/[^a-z0-9_-]/', '', $archive_encryption_algo);
            if (!is_string($archive_encryption_algo)) {
                $archive_encryption_algo = '';
            }
            if ($archive_encryption_enabled && $archive_encryption_algo === '') {
                $archive_encryption_algo = 'aes-256';
            }
            $archive_passphrase_required = !empty($meta['archive_passphrase_required']) || $archive_encryption_enabled;

            $files[] = array(
                'filename' => $filename,
                'size' => $size_text,
                'size_bytes' => $total_bytes,
                'mtime' => $mtime,
                'date' => asenha_format_archive_display_datetime($mtime),
                'type' => $type,
                'backup_method' => $backup_method,
                'base_filename' => $base_filename,
                'parent_filename' => $parent_filename,
                'chain_id' => $chain_id,
                'sequence' => $sequence,
                'is_incremental' => ($backup_method === 'incremental'),
                'is_multipart' => true,
                'archive_encryption_enabled' => $archive_encryption_enabled,
                'archive_encryption_algo' => $archive_encryption_algo,
                'archive_passphrase_required' => $archive_passphrase_required,
            );
        } else {
            $chain_meta = asenha_read_zip_manifest_chain_meta($file->getPathname());

            $is_incremental = !empty($parsed['is_incremental']);
            $backup_method = asenha_normalize_backup_method(isset($chain_meta['backup_method']) ? $chain_meta['backup_method'] : '', $is_incremental);
            $base_filename = asenha_sanitize_backup_basename(isset($chain_meta['base_filename']) ? $chain_meta['base_filename'] : '');
            $parent_filename = asenha_sanitize_backup_basename(isset($chain_meta['parent_filename']) ? $chain_meta['parent_filename'] : '');
            $chain_id = preg_replace('/[^A-Za-z0-9._:-]/', '', (string) (isset($chain_meta['chain_id']) ? $chain_meta['chain_id'] : ''));
            $chain_id = is_string($chain_id) ? $chain_id : '';
            $sequence = isset($chain_meta['sequence']) ? max(0, (int) $chain_meta['sequence']) : 0;

            if ($backup_method === 'baseline' && $base_filename === '') {
                $base_filename = $filename;
            }

            $archive_encryption_enabled = !empty($chain_meta['archive_encryption_enabled']);
            $archive_encryption_algo = strtolower(trim((string) (isset($chain_meta['archive_encryption_algo']) ? $chain_meta['archive_encryption_algo'] : '')));
            $archive_encryption_algo = preg_replace('/[^a-z0-9_-]/', '', $archive_encryption_algo);
            if (!is_string($archive_encryption_algo)) {
                $archive_encryption_algo = '';
            }
            if ($archive_encryption_enabled && $archive_encryption_algo === '') {
                $archive_encryption_algo = 'aes-256';
            }
            $archive_passphrase_required = !empty($chain_meta['archive_passphrase_required']) || $archive_encryption_enabled;

            $files[] = array(
                'filename' => $filename,
                'size' => format_bytes($file->getSize()),
                'size_bytes' => $file->getSize(),
                'mtime' => $mtime,
                'date' => asenha_format_archive_display_datetime($mtime),
                'type' => (string) $parsed['label'],
                'backup_method' => $backup_method,
                'base_filename' => $base_filename,
                'parent_filename' => $parent_filename,
                'chain_id' => $chain_id,
                'sequence' => $sequence,
                'is_incremental' => ($backup_method === 'incremental'),
                'is_multipart' => false,
                'archive_encryption_enabled' => $archive_encryption_enabled,
                'archive_encryption_algo' => $archive_encryption_algo,
                'archive_passphrase_required' => $archive_passphrase_required,
            );
        }
    }

    // Sort by date, newest first.
    usort($files, function($a, $b) {
        $ma = isset($a['mtime']) ? (int) $a['mtime'] : 0;
        $mb = isset($b['mtime']) ? (int) $b['mtime'] : 0;
        if ($ma === $mb) {
            return strcmp((string) (isset($a['filename']) ? $a['filename'] : ''), (string) (isset($b['filename']) ? $b['filename'] : ''));
        }
        return ($ma < $mb) ? 1 : -1;
    });

    return $files;
}

/**
 * Format bytes to human readable.
 */
function format_bytes($bytes, $precision = 2) {
    $units = array('B', 'KB', 'MB', 'GB', 'TB');

    $bytes = max($bytes, 0);
    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
    $pow = min($pow, count($units) - 1);

    $bytes /= pow(1024, $pow);

    return round($bytes, $precision) . ' ' . $units[$pow];
}

/**
 * Build normalized matching key for filename comparisons.
 *
 * Treats *.zip and *.zip.parts.json as the same logical archive key.
 */
function asenha_normalize_filename_for_chain_match($filename) {
    $filename = asenha_sanitize_backup_basename($filename);
    if ($filename === '') {
        return '';
    }

    if (strpos($filename, 'imported_') === 0) {
        $filename = substr($filename, strlen('imported_'));
        if (preg_match('/^[0-9a-f]{8}_/i', $filename)) {
            $filename = (string) preg_replace('/^[0-9a-f]{8}_/i', '', $filename);
        }
    }

    if (substr($filename, -strlen('.parts.json')) === '.parts.json') {
        $filename = substr($filename, 0, -strlen('.parts.json'));
    }

    return $filename;
}

/**
 * Build a chain key from archive metadata.
 */
function asenha_build_chain_key_from_file($file) {
    if (!is_array($file)) {
        return '';
    }

    $chain_id = preg_replace('/[^A-Za-z0-9._:-]/', '', (string) (isset($file['chain_id']) ? $file['chain_id'] : ''));
    if (is_string($chain_id) && $chain_id !== '') {
        return 'cid:' . $chain_id;
    }

    $base_filename = asenha_sanitize_backup_basename(isset($file['base_filename']) ? $file['base_filename'] : '');
    if ($base_filename === '' && isset($file['backup_method']) && $file['backup_method'] === 'baseline') {
        $base_filename = asenha_sanitize_backup_basename(isset($file['filename']) ? $file['filename'] : '');
    }

    $base_key = asenha_normalize_filename_for_chain_match($base_filename);
    if ($base_key === '') {
        return '';
    }

    return 'base:' . $base_key;
}

/**
 * Group archives into chains for emergency restore table rendering.
 *
 * @return array<int,array{base:array,incrementals:array<int,array>,base_role:string}>
 */
function group_backup_files_into_chains($files) {
    $files = is_array($files) ? $files : array();
    $grouped = array();
    $ordered_group_keys = array();
    $standalone_chains = array();

    foreach ($files as $file) {
        if (!is_array($file) || empty($file['filename'])) {
            continue;
        }

        $filename = asenha_sanitize_backup_basename($file['filename']);
        if ($filename === '') {
            continue;
        }
        $file['filename'] = $filename;

        $file['backup_method'] = asenha_normalize_backup_method(
            isset($file['backup_method']) ? $file['backup_method'] : '',
            !empty($file['is_incremental'])
        );
        $file['is_incremental'] = ($file['backup_method'] === 'incremental');
        $file['base_filename'] = asenha_sanitize_backup_basename(isset($file['base_filename']) ? $file['base_filename'] : '');
        $file['parent_filename'] = asenha_sanitize_backup_basename(isset($file['parent_filename']) ? $file['parent_filename'] : '');
        $file['sequence'] = isset($file['sequence']) ? max(0, (int) $file['sequence']) : 0;

        if ($file['backup_method'] === 'baseline' && $file['base_filename'] === '') {
            $file['base_filename'] = $filename;
        }

        $group_key = asenha_build_chain_key_from_file($file);
        if ($group_key === '') {
            $standalone_chains[] = array(
                'base' => $file,
                'incrementals' => array(),
                'base_role' => ($file['backup_method'] === 'incremental') ? 'incremental_orphan' : 'baseline',
            );
            continue;
        }

        if (!isset($grouped[$group_key])) {
            $grouped[$group_key] = array();
            $ordered_group_keys[] = $group_key;
        }
        $grouped[$group_key][] = $file;
    }

    $chains = array();
    foreach ($ordered_group_keys as $group_key) {
        $members = isset($grouped[$group_key]) && is_array($grouped[$group_key]) ? $grouped[$group_key] : array();
        if (empty($members)) {
            continue;
        }

        $baselines = array();
        $incrementals = array();
        foreach ($members as $member) {
            if (!is_array($member)) {
                continue;
            }
            if (isset($member['backup_method']) && $member['backup_method'] === 'incremental') {
                $incrementals[] = $member;
            } else {
                $baselines[] = $member;
            }
        }

        $sort_desc_by_date = static function(&$items) {
            usort($items, function($a, $b) {
                $ma = isset($a['mtime']) ? (int) $a['mtime'] : 0;
                $mb = isset($b['mtime']) ? (int) $b['mtime'] : 0;
                if ($ma === $mb) {
                    return strcmp((string) (isset($a['filename']) ? $a['filename'] : ''), (string) (isset($b['filename']) ? $b['filename'] : ''));
                }
                return ($ma < $mb) ? 1 : -1;
            });
        };

        $sort_incrementals_asc = static function(&$items) {
            usort($items, function($a, $b) {
                $as = isset($a['sequence']) ? (int) $a['sequence'] : 0;
                $bs = isset($b['sequence']) ? (int) $b['sequence'] : 0;
                if ($as > 0 && $bs > 0 && $as !== $bs) {
                    return ($as < $bs) ? -1 : 1;
                }

                $ma = isset($a['mtime']) ? (int) $a['mtime'] : 0;
                $mb = isset($b['mtime']) ? (int) $b['mtime'] : 0;
                if ($ma !== $mb) {
                    return ($ma < $mb) ? -1 : 1;
                }

                return strcmp((string) (isset($a['filename']) ? $a['filename'] : ''), (string) (isset($b['filename']) ? $b['filename'] : ''));
            });
        };

        $sort_incrementals_asc($incrementals);

        if (!empty($baselines)) {
            $sort_desc_by_date($baselines);
            $chains[] = array(
                'base' => $baselines[0],
                'incrementals' => $incrementals,
                'base_role' => 'baseline',
            );
            continue;
        }

        // Keep orphan incrementals visible as standalone entries.
        foreach ($incrementals as $incremental) {
            $chains[] = array(
                'base' => $incremental,
                'incrementals' => array(),
                'base_role' => 'incremental_orphan',
            );
        }
    }

    foreach ($standalone_chains as $chain) {
        $chains[] = $chain;
    }

    usort($chains, function($a, $b) {
        $afile = (isset($a['base']) && is_array($a['base'])) ? $a['base'] : array();
        $bfile = (isset($b['base']) && is_array($b['base'])) ? $b['base'] : array();

        $ma = isset($afile['mtime']) ? (int) $afile['mtime'] : 0;
        $mb = isset($bfile['mtime']) ? (int) $bfile['mtime'] : 0;
        if ($ma === $mb) {
            return strcmp((string) (isset($afile['filename']) ? $afile['filename'] : ''), (string) (isset($bfile['filename']) ? $bfile['filename'] : ''));
        }
        return ($ma < $mb) ? 1 : -1;
    });

    return $chains;
}

/**
 * Build summary text for chain incrementals.
 */
function get_incrementals_summary_label($incrementals) {
    $incrementals = is_array($incrementals) ? $incrementals : array();
    if (empty($incrementals)) {
        return '';
    }

    $count = 0;
    $total_bytes = 0;
    $latest_ts = 0;
    $latest_human = '';

    foreach ($incrementals as $incremental) {
        if (!is_array($incremental) || empty($incremental['filename'])) {
            continue;
        }

        $count++;
        $size_bytes = isset($incremental['size_bytes']) ? max(0, (int) $incremental['size_bytes']) : 0;
        $total_bytes += $size_bytes;

        $modified_ts = isset($incremental['mtime']) ? max(0, (int) $incremental['mtime']) : 0;
        if ($modified_ts > $latest_ts) {
            $latest_ts = $modified_ts;
            $latest_human = asenha_format_archive_display_datetime($modified_ts);
        }
    }

    if ($count <= 0) {
        return '';
    }

    if ($latest_human === '') {
        if ($latest_ts > 0) {
            $latest_human = asenha_format_archive_display_datetime($latest_ts);
        } else {
            $latest_human = '-';
        }
    }

    $incremental_label = ($count === 1) ? 'incremental' : 'incrementals';

    return sprintf(
        '%1$d %2$s | %3$s | Latest: %4$s',
        (int) $count,
        $incremental_label,
        format_bytes($total_bytes),
        $latest_human
    );
}

/**
 * Whether a backup archive note has non-empty text content.
 */
function asenha_emergency_backup_note_is_nonempty($note) {
    $note = (string) $note;
    return trim(strip_tags($note)) !== '';
}

/**
 * Sanitize stored note HTML for read-only display.
 */
function asenha_emergency_sanitize_note_for_display($note) {
    $note = (string) $note;
    if ($note === '') {
        return '';
    }
    $allowed = '<p><br><strong><em><b><i><ul><ol><li><a><h1><h2><h3><h4><h5><h6><blockquote><table><thead><tbody><tr><th><td><div><span>';
    return strip_tags($note, $allowed);
}

/**
 * Resolve the baseline filename that owns a chain note.
 */
function asenha_emergency_resolve_chain_note_baseline($file_row) {
    $file_row = is_array($file_row) ? $file_row : array();
    $baseline = isset($file_row['filename']) ? asenha_sanitize_backup_basename((string) $file_row['filename']) : '';
    $method = isset($file_row['backup_method']) ? strtolower(trim((string) $file_row['backup_method'])) : '';

    if ($method === 'incremental' && !empty($file_row['base_filename'])) {
        $baseline = asenha_sanitize_backup_basename((string) $file_row['base_filename']);
    }

    return $baseline;
}

/**
 * Read admin_site_enhancements_extra from the live database for note display.
 */
function asenha_emergency_read_extra_option($mysqli, $db_prefix) {
    $table = preg_replace('/[^A-Za-z0-9_]/', '', (string) $db_prefix . 'options');
    if ($table === '') {
        return array('exists' => false, 'value' => array());
    }

    $option_name = $mysqli->real_escape_string('admin_site_enhancements_extra');
    $result = $mysqli->query("SELECT option_value FROM `{$table}` WHERE option_name = '{$option_name}' LIMIT 1");
    $row = $result ? $result->fetch_assoc() : null;
    if ($result) {
        $result->free();
    }

    if (!is_array($row) || !array_key_exists('option_value', $row)) {
        return array('exists' => false, 'value' => array());
    }

    $value = @unserialize((string) $row['option_value'], array('allowed_classes' => false));
    if (!is_array($value)) {
        $value = array();
    }

    return array('exists' => true, 'value' => $value);
}

/**
 * Read admin_site_enhancements from the live database.
 */
function asenha_emergency_read_main_option($mysqli, $db_prefix) {
    $table = preg_replace('/[^A-Za-z0-9_]/', '', (string) $db_prefix . 'options');
    if ($table === '') {
        return array('exists' => false, 'value' => array());
    }

    $option_name = $mysqli->real_escape_string('admin_site_enhancements');
    $result = $mysqli->query("SELECT option_value FROM `{$table}` WHERE option_name = '{$option_name}' LIMIT 1");
    $row = $result ? $result->fetch_assoc() : null;
    if ($result) {
        $result->free();
    }

    if (!is_array($row) || !array_key_exists('option_value', $row)) {
        return array('exists' => false, 'value' => array());
    }

    $value = @unserialize((string) $row['option_value'], array('allowed_classes' => false));
    if (!is_array($value)) {
        $value = array();
    }

    return array('exists' => true, 'value' => $value);
}

/**
 * Open a mysqli connection from a WordPress DB_HOST value.
 *
 * Parses port/socket, uses the PHP default socket for localhost when needed,
 * and retries 127.0.0.1 when the Unix socket is missing (errno 2002).
 */
function asenha_emergency_mysqli_connect($db_host, $db_user, $db_password, $db_name) {
    mysqli_report(MYSQLI_REPORT_OFF);

    $host = (string) $db_host;
    $port = 3306;
    $socket = null;

    if (strpos($host, ':/') !== false) {
        $parts = explode(':', $host, 2);
        $host = $parts[0];
        $socket = (isset($parts[1]) && $parts[1] !== '') ? $parts[1] : null;
    } elseif (strpos($host, ':') !== false && substr_count($host, ':') === 1) {
        $parts = explode(':', $host, 2);
        $host = $parts[0];
        if (isset($parts[1]) && is_numeric($parts[1]) && (int) $parts[1] > 0) {
            $port = (int) $parts[1];
        }
    }

    if (($socket === null || $socket === '') && strtolower($host) === 'localhost') {
        $ini_socket = ini_get('mysqli.default_socket');
        if (is_string($ini_socket) && $ini_socket !== '') {
            $socket = $ini_socket;
        }
    }

    $mysqli = @new mysqli($host, $db_user, $db_password, $db_name, $port, $socket);
    if ($mysqli && $mysqli->connect_error && strtolower($host) === 'localhost') {
        $errno = (int) $mysqli->connect_errno;
        $error = (string) $mysqli->connect_error;
        if ($errno === 2002 || stripos($error, 'No such file or directory') !== false || stripos($error, "Can't connect to local MySQL server") !== false) {
            $mysqli = @new mysqli('127.0.0.1', $db_user, $db_password, $db_name, $port);
        }
    }

    return $mysqli;
}

/**
 * Restore SQLite drop-ins that migration renamed to *.asenha-migration.bak.
 */
function asenha_emergency_restore_sqlite_dropins($wp_content_dir) {
    $restored = array();
    $wp_content_dir = rtrim((string) $wp_content_dir, '/\\');
    if ($wp_content_dir === '') {
        return $restored;
    }

    $db_php = $wp_content_dir . DIRECTORY_SEPARATOR . 'db.php';
    $db_bak = $db_php . '.asenha-migration.bak';
    if (!file_exists($db_php) && file_exists($db_bak)) {
        if (@rename($db_bak, $db_php)) {
            $restored[] = 'db.php';
        }
    }

    $mu_dir = $wp_content_dir . DIRECTORY_SEPARATOR . 'mu-plugins';
    if (!is_dir($mu_dir)) {
        return $restored;
    }

    $entries = @scandir($mu_dir);
    if (!is_array($entries)) {
        return $restored;
    }

    foreach ($entries as $entry) {
        if ($entry === '.' || $entry === '..') {
            continue;
        }
        if (false === strpos($entry, '.asenha-migration.bak')) {
            continue;
        }
        if (false === stripos($entry, 'sqlite')) {
            continue;
        }
        $source = $mu_dir . DIRECTORY_SEPARATOR . $entry;
        $dest_name = str_replace('.asenha-migration.bak', '', $entry);
        $dest = $mu_dir . DIRECTORY_SEPARATOR . $dest_name;
        if (file_exists($dest) || !file_exists($source) || is_dir($source)) {
            continue;
        }
        if (@rename($source, $dest)) {
            $restored[] = 'mu-plugins/' . $dest_name;
        }
    }

    return $restored;
}

/**
 * Absolute path of the sibling SQLite import runner in ABSPATH.
 *
 * @return string Empty when the baked filename is invalid.
 */
function asenha_emergency_sqlite_runner_path() {
    global $wp_root, $asenha_sqlite_runner_filename;
    $filename = isset($asenha_sqlite_runner_filename) ? (string) $asenha_sqlite_runner_filename : '';
    if ($filename === '' || 1 !== preg_match('/^asenha-emergency-sqlite-[A-Za-z0-9]+\.php$/', $filename)) {
        return '';
    }
    return rtrim((string) $wp_root, '/\\') . DIRECTORY_SEPARATOR . $filename;
}

/**
 * Same-origin URL for the sibling SQLite import runner.
 *
 * @return string
 */
function asenha_emergency_sqlite_runner_url() {
    global $asenha_sqlite_runner_filename, $asenha_emergency_site_url;
    $filename = isset($asenha_sqlite_runner_filename) ? (string) $asenha_sqlite_runner_filename : '';
    if ($filename === '' || 1 !== preg_match('/^asenha-emergency-sqlite-[A-Za-z0-9]+\.php$/', $filename)) {
        return '';
    }
    $dir = asenha_emergency_self_dir_url();
    if ($dir !== '') {
        return $dir . $filename;
    }
    $site = isset($asenha_emergency_site_url) ? rtrim((string) $asenha_emergency_site_url, '/') : '';
    if ($site !== '') {
        return $site . '/' . $filename;
    }
    return '';
}

/**
 * Write the sibling SQLite import runner into ABSPATH (idempotent).
 *
 * @return string|false Filename on success, false on failure.
 */
function asenha_emergency_write_sqlite_runner() {
    global $asenha_sqlite_runner_source, $asenha_sqlite_runner_filename;
    $path = asenha_emergency_sqlite_runner_path();
    $filename = isset($asenha_sqlite_runner_filename) ? (string) $asenha_sqlite_runner_filename : '';
    $source = isset($asenha_sqlite_runner_source) ? (string) $asenha_sqlite_runner_source : '';
    if ($path === '' || $filename === '' || $source === '') {
        return false;
    }
    if (file_exists($path)) {
        $head = @file_get_contents($path, false, null, 0, 2048);
        if (is_string($head) && false !== strpos($head, 'ASE Emergency SQLite DB Runner Script')) {
            return $filename;
        }
        return false;
    }
    $written = @file_put_contents($path, $source);
    return ($written !== false) ? $filename : false;
}

/**
 * Delete the sibling SQLite import runner from ABSPATH.
 *
 * @return void
 */
function asenha_emergency_delete_sqlite_runner() {
    $path = asenha_emergency_sqlite_runner_path();
    if ($path === '' || !file_exists($path)) {
        return;
    }
    $head = @file_get_contents($path, false, null, 0, 2048);
    if (!is_string($head) || false === strpos($head, 'ASE Emergency SQLite DB Runner Script')) {
        return;
    }
    @unlink($path);
}

/**
 * Restore SQLite drop-ins and hand database import to the sibling runner.
 *
 * WordPress is not loaded in this process. The next continue request posts to
 * asenha-emergency-sqlite-*.php, which wp-loads and imports via $wpdb.
 *
 * @param string $sql_file       Path to database.sql.
 * @param string $wp_content_dir wp-content path.
 * @param array  $state          Restore state.
 * @return array
 */
function asenha_emergency_prepare_sqlite_database_import($sql_file, $wp_content_dir, $state) {
    $restored = asenha_emergency_restore_sqlite_dropins($wp_content_dir);
    if (!empty($restored) && empty($state['sqlite_dropins_restored'])) {
        $state['messages'][] = 'Restored SQLite bootstrap file(s): ' . implode(', ', $restored) . '.';
        $state['sqlite_dropins_restored'] = true;
    }

    $written = asenha_emergency_write_sqlite_runner();
    if ($written === false) {
        $state['errors'][] = 'Could not write the SQLite database import runner. Check that the WordPress root is writable.';
        $state['status'] = 'failed';
        return $state;
    }

    $url = asenha_emergency_sqlite_runner_url();
    if ($url === '') {
        $state['errors'][] = 'Could not determine the SQLite database import runner URL.';
        $state['status'] = 'failed';
        return $state;
    }

    $state['sqlite_sql_file'] = (string) $sql_file;
    $state['sqlite_runner_url'] = $url;
    $state['sqlite_runner_filename'] = $written;
    $state['phase'] = 'database';
    $state['sql_complete'] = !empty($state['sql_complete']);
    $state['status'] = 'running';
    $msg = 'Importing database via WordPress (SQLite)...';
    if (empty($state['messages']) || end($state['messages']) !== $msg) {
        $state['messages'][] = $msg;
    }
    return $state;
}

/**
 * Determine whether asenha-storage-* content should be included in emergency restore.
 */
function asenha_emergency_is_storage_content_included() {
    static $included = null;

    if ($included !== null) {
        return $included;
    }

    global $db_host, $db_user, $db_password, $db_name, $db_prefix, $asenha_is_mysql;

    if (isset($asenha_is_mysql) && !$asenha_is_mysql) {
        $included = !empty($GLOBALS['asenha_emergency_include_storage_content_fallback']);
        return $included;
    }

    if (isset($db_host, $db_user, $db_password, $db_name, $db_prefix)) {
        $mysqli = asenha_emergency_mysqli_connect($db_host, $db_user, $db_password, $db_name);
        if ($mysqli && !$mysqli->connect_error) {
            $option = asenha_emergency_read_main_option($mysqli, $db_prefix);
            $mysqli->close();

            if (!empty($option['exists']) && is_array($option['value']) && array_key_exists('site_backup_migration_include_storage_content', $option['value'])) {
                $included = !empty($option['value']['site_backup_migration_include_storage_content']);
                return $included;
            }
        }
    }

    $included = !empty($GLOBALS['asenha_emergency_include_storage_content_fallback']);
    return $included;
}

/**
 * Determine whether a wp-content-relative path is under asenha-storage-*.
 */
function asenha_emergency_path_contains_storage_dir($wp_content_rel) {
    $wp_content_rel = str_replace('\\', '/', (string) $wp_content_rel);
    $wp_content_rel = ltrim($wp_content_rel, '/');
    if ($wp_content_rel === '') {
        return false;
    }

    $storage_dir_prefix = 'asenha-storage-';

    return (bool) preg_match(
        '#(^|/)' . preg_quote($storage_dir_prefix, '#') . '[^/]*(/|$)#',
        $wp_content_rel
    );
}

/**
 * Determine whether asenha-storage-* paths should be excluded from emergency restore.
 */
function asenha_emergency_should_exclude_wp_content_storage($wp_content_rel) {
    if (asenha_emergency_is_storage_content_included()) {
        return false;
    }

    return asenha_emergency_path_contains_storage_dir($wp_content_rel);
}

/**
 * Load backup archive notes keyed by baseline filename from the archives index.
 */
function asenha_emergency_load_backup_notes_map() {
    global $db_host, $db_user, $db_password, $db_name, $db_prefix, $asenha_is_mysql;

    $map = array();
    if (isset($asenha_is_mysql) && !$asenha_is_mysql) {
        return $map;
    }
    if (!isset($db_host, $db_user, $db_password, $db_name, $db_prefix)) {
        return $map;
    }

    mysqli_report(MYSQLI_REPORT_OFF);
    $mysqli = asenha_emergency_mysqli_connect($db_host, $db_user, $db_password, $db_name);
    if (!$mysqli || $mysqli->connect_error) {
        return $map;
    }

    $option = asenha_emergency_read_extra_option($mysqli, $db_prefix);
    $mysqli->close();

    if (empty($option['value']) || !is_array($option['value'])) {
        return $map;
    }

    $extra = $option['value'];
    $index = isset($extra['site_backup']['archives_index']) && is_array($extra['site_backup']['archives_index'])
        ? $extra['site_backup']['archives_index']
        : array();
    $by_backup = isset($index['by_backup']) && is_array($index['by_backup']) ? $index['by_backup'] : array();

    foreach ($by_backup as $key => $entry) {
        if (!is_array($entry)) {
            continue;
        }
        $note = isset($entry['note']) ? (string) $entry['note'] : '';
        if (!asenha_emergency_backup_note_is_nonempty($note)) {
            continue;
        }

        $filename = isset($entry['filename']) ? asenha_sanitize_backup_basename((string) $entry['filename']) : asenha_sanitize_backup_basename((string) $key);
        if ($filename === '') {
            continue;
        }

        $map[$filename] = $note;
        $normalized = asenha_normalize_filename_for_chain_match($filename);
        if ($normalized !== '' && $normalized !== $filename) {
            $map[$normalized] = $note;
        }
    }

    return $map;
}

/**
 * Resolve the chain note HTML for an archive row.
 */
function asenha_emergency_resolve_chain_note($file_row, $notes_map) {
    $notes_map = is_array($notes_map) ? $notes_map : array();
    $baseline = asenha_emergency_resolve_chain_note_baseline($file_row);
    if ($baseline === '') {
        return '';
    }

    if (isset($notes_map[$baseline])) {
        return (string) $notes_map[$baseline];
    }

    $normalized = asenha_normalize_filename_for_chain_match($baseline);
    if ($normalized !== '' && isset($notes_map[$normalized])) {
        return (string) $notes_map[$normalized];
    }

    return '';
}

/**
 * Find backup file entry by exact sanitized filename.
 *
 * @param array  $files    Backup file entries.
 * @param string $filename Target filename.
 * @return array|null
 */
function asenha_find_backup_file_entry_by_filename($files, $filename) {
    $files = is_array($files) ? $files : array();
    $filename = asenha_sanitize_backup_basename($filename);
    if ($filename === '') {
        return null;
    }

    foreach ($files as $entry) {
        if (!is_array($entry) || empty($entry['filename'])) {
            continue;
        }
        $entry_filename = asenha_sanitize_backup_basename((string) $entry['filename']);
        if ($entry_filename === $filename) {
            return $entry;
        }
    }

    return null;
}

/**
 * Resolve effective recovery-point chain for emergency restore.
 *
 * Baseline target => restore baseline only.
 * Incremental target => restore baseline + incrementals up to selected sequence.
 *
 * @param string $target_filename Selected filename.
 * @param array  $backup_files    Backup file entries from get_backup_files().
 * @return array{ok:bool,error:string,backup_method:string,chain:array,target_sequence:int,base_filename:string,target_filename:string}
 */
function asenha_resolve_emergency_restore_chain($target_filename, $backup_files) {
    $result = array(
        'ok' => false,
        'error' => '',
        'backup_method' => 'baseline',
        'chain' => array(),
        'target_sequence' => 0,
        'base_filename' => '',
        'target_filename' => '',
    );

    $target_filename = asenha_sanitize_backup_basename($target_filename);
    if ($target_filename === '') {
        $result['error'] = 'Invalid backup filename.';
        return $result;
    }
    $result['target_filename'] = $target_filename;

    $target = asenha_find_backup_file_entry_by_filename($backup_files, $target_filename);
    if (!is_array($target)) {
        $result['error'] = 'Selected backup archive was not found.';
        return $result;
    }

    $target_backup_method = asenha_normalize_backup_method(
        isset($target['backup_method']) ? $target['backup_method'] : '',
        !empty($target['is_incremental'])
    );
    $target_seq = isset($target['sequence']) ? max(0, (int) $target['sequence']) : 0;
    $target_chain_id = isset($target['chain_id']) ? (string) $target['chain_id'] : '';
    $target_chain_id = preg_replace('/[^A-Za-z0-9._:-]/', '', $target_chain_id);
    $target_chain_id = is_string($target_chain_id) ? $target_chain_id : '';
    $target_base_filename = asenha_sanitize_backup_basename(isset($target['base_filename']) ? $target['base_filename'] : '');
    if ($target_backup_method === 'baseline' && $target_base_filename === '') {
        $target_base_filename = $target_filename;
    }

    // Baseline row restore: only restore this baseline archive (no incrementals).
    if ($target_backup_method === 'baseline') {
        $result['ok'] = true;
        $result['backup_method'] = 'baseline';
        $result['chain'] = array($target_filename);
        $result['target_sequence'] = 0;
        $result['base_filename'] = $target_filename;
        return $result;
    }

    // Incremental row restore: require baseline + contiguous incrementals up to selected sequence.
    if ($target_base_filename === '' || $target_seq <= 0) {
        $result['error'] = 'Invalid incremental backup metadata. Please restore the baseline archive or run a new baseline backup.';
        return $result;
    }

    $base = asenha_find_backup_file_entry_by_filename($backup_files, $target_base_filename);
    if (!is_array($base)) {
        $result['error'] = 'Cannot restore this incremental backup because the baseline archive is missing.';
        return $result;
    }

    $base_backup_method = asenha_normalize_backup_method(
        isset($base['backup_method']) ? $base['backup_method'] : '',
        !empty($base['is_incremental'])
    );
    if ($base_backup_method !== 'baseline') {
        $result['error'] = 'Cannot restore this incremental backup because the baseline archive metadata is invalid.';
        return $result;
    }

    $base_key = asenha_normalize_filename_for_chain_match($target_base_filename);
    $incrementals_by_seq = array();

    foreach ((array) $backup_files as $entry) {
        if (!is_array($entry) || empty($entry['filename'])) {
            continue;
        }

        $entry_backup_method = asenha_normalize_backup_method(
            isset($entry['backup_method']) ? $entry['backup_method'] : '',
            !empty($entry['is_incremental'])
        );
        if ($entry_backup_method !== 'incremental') {
            continue;
        }

        $entry_filename = asenha_sanitize_backup_basename((string) $entry['filename']);
        if ($entry_filename === '') {
            continue;
        }

        $entry_base_filename = asenha_sanitize_backup_basename(isset($entry['base_filename']) ? $entry['base_filename'] : '');
        $entry_base_key = asenha_normalize_filename_for_chain_match($entry_base_filename);

        $entry_chain_id = isset($entry['chain_id']) ? (string) $entry['chain_id'] : '';
        $entry_chain_id = preg_replace('/[^A-Za-z0-9._:-]/', '', $entry_chain_id);
        $entry_chain_id = is_string($entry_chain_id) ? $entry_chain_id : '';

        $same_chain = false;
        if ($target_chain_id !== '' && $entry_chain_id !== '') {
            $same_chain = ($target_chain_id === $entry_chain_id);
        } else {
            $same_chain = ($base_key !== '' && $entry_base_key === $base_key);
        }
        if (!$same_chain) {
            continue;
        }

        $seq = isset($entry['sequence']) ? (int) $entry['sequence'] : 0;
        if ($seq <= 0 || $seq > $target_seq) {
            continue;
        }

        if (!isset($incrementals_by_seq[$seq])) {
            $incrementals_by_seq[$seq] = $entry;
            continue;
        }

        // Deterministic tie-breaker: pick newer mtime, then lexical filename.
        $existing = $incrementals_by_seq[$seq];
        $entry_mtime = isset($entry['mtime']) ? (int) $entry['mtime'] : 0;
        $existing_mtime = isset($existing['mtime']) ? (int) $existing['mtime'] : 0;
        if ($entry_mtime > $existing_mtime) {
            $incrementals_by_seq[$seq] = $entry;
            continue;
        }
        if ($entry_mtime === $existing_mtime) {
            $existing_filename = asenha_sanitize_backup_basename(isset($existing['filename']) ? $existing['filename'] : '');
            if (strcmp($entry_filename, $existing_filename) > 0) {
                $incrementals_by_seq[$seq] = $entry;
            }
        }
    }

    // Always prefer the explicitly selected incremental for its sequence.
    if (is_array($target) && $target_seq > 0) {
        $incrementals_by_seq[$target_seq] = $target;
    }

    $chain = array($target_base_filename);
    for ($seq = 1; $seq <= $target_seq; $seq++) {
        if (empty($incrementals_by_seq[$seq]) || empty($incrementals_by_seq[$seq]['filename'])) {
            $result['error'] = sprintf(
                'Cannot restore: incremental #%d is missing. Please restore the last valid recovery point, or run a new baseline backup.',
                (int) $seq
            );
            return $result;
        }

        $inc_filename = asenha_sanitize_backup_basename((string) $incrementals_by_seq[$seq]['filename']);
        if ($inc_filename === '') {
            $result['error'] = sprintf(
                'Cannot restore: incremental #%d is invalid. Please restore the last valid recovery point, or run a new baseline backup.',
                (int) $seq
            );
            return $result;
        }
        $chain[] = $inc_filename;
    }

    $last_chain_item = end($chain);
    if (!is_string($last_chain_item) || $last_chain_item === '' || $last_chain_item !== $target_filename) {
        $result['error'] = 'Cannot restore this incremental backup because chain resolution selected a different recovery point.';
        return $result;
    }
    reset($chain);

    $result['ok'] = true;
    $result['backup_method'] = 'incremental';
    $result['chain'] = array_values($chain);
    $result['target_sequence'] = (int) $target_seq;
    $result['base_filename'] = $target_base_filename;
    return $result;
}

/**
 * Build human-readable chain summary for restore messages.
 *
 * @param array $resolved_chain Result of asenha_resolve_emergency_restore_chain().
 * @return string
 */
function asenha_get_emergency_restore_chain_summary($resolved_chain) {
    if (!is_array($resolved_chain) || empty($resolved_chain['ok'])) {
        return '';
    }

    $chain = isset($resolved_chain['chain']) && is_array($resolved_chain['chain']) ? $resolved_chain['chain'] : array();
    if (empty($chain)) {
        return '';
    }

    $backup_method = isset($resolved_chain['backup_method']) ? (string) $resolved_chain['backup_method'] : 'baseline';
    $base = asenha_sanitize_backup_basename((string) $chain[0]);
    $target = isset($resolved_chain['target_filename']) ? asenha_sanitize_backup_basename((string) $resolved_chain['target_filename']) : $base;
    $target_sequence = isset($resolved_chain['target_sequence']) ? (int) $resolved_chain['target_sequence'] : 0;

    if ($backup_method === 'incremental') {
        $incremental_count = max(0, count($chain) - 1);
        return sprintf(
            'Recovery point: baseline "%1$s" + %2$d incremental(s) up to #%3$d ("%4$s").',
            $base,
            (int) $incremental_count,
            (int) $target_sequence,
            $target
        );
    }

    return sprintf('Recovery point: baseline only ("%s").', $base);
}

/**
 * Recursively delete a directory
 */
function delete_directory($dir) {
    if (!is_dir($dir)) {
        return true;
    }
    
    $items = array_diff(scandir($dir), array('.', '..'));
    
    foreach ($items as $item) {
        $path = $dir . DIRECTORY_SEPARATOR . $item;
        if (is_dir($path)) {
            delete_directory($path);
        } else {
            @unlink($path);
        }
    }
    
    return @rmdir($dir);
}

/**
 * Clean up stale temp directories from previous restore attempts
 * 
 * Removes temp_restore_* directories that don't have a corresponding
 * active state file (restore_state_*.json), indicating they are orphaned
 * from interrupted or completed restore operations.
 */
function cleanup_stale_temp_directories($backup_dir) {
    if (!is_dir($backup_dir)) {
        return;
    }
    
    $items = @scandir($backup_dir);
    if (!is_array($items)) {
        return;
    }
    
    foreach ($items as $item) {
        // Check for temp_restore_* directories
        if (strpos($item, 'temp_restore_') === 0 && is_dir($backup_dir . DIRECTORY_SEPARATOR . $item)) {
            // Extract the hash from folder name
            $hash = substr($item, 13); // Remove 'temp_restore_' prefix
            
            // Check if corresponding state file exists
            $state_file = $backup_dir . DIRECTORY_SEPARATOR . 'restore_state_' . $hash . '.json';
            
            if (!file_exists($state_file)) {
                // No active state - safe to delete
                delete_directory($backup_dir . DIRECTORY_SEPARATOR . $item);
            }
        }
    }
}

/**
 * Get temp directory for extraction
 */
function asenha_emergency_get_temp_dir($backup_dir, $filename) {
    $temp_id = md5($filename);
    return $backup_dir . DIRECTORY_SEPARATOR . 'temp_restore_' . $temp_id;
}

// ============================================================================
// MULTIPART SUPPORT (STANDALONE)
// ============================================================================

/**
 * Check whether a filename is a multipart metadata file.
 */
function is_multipart_meta_filename($filename) {
    $suffix = '.parts.json';
    return ($suffix === substr((string) $filename, -strlen($suffix)));
}

/**
 * Read and validate multipart metadata JSON from disk.
 *
 * Returns meta array on success, or null on failure.
 */
function read_multipart_meta($meta_path) {
    if (empty($meta_path) || !is_string($meta_path) || !file_exists($meta_path)) {
        return null;
    }

    $content = @file_get_contents($meta_path);
    if ($content === false || $content === '') {
        return null;
    }

    $meta = json_decode($content, true);
    if (!is_array($meta)) {
        return null;
    }

    if (empty($meta['original_zip_name']) || empty($meta['total_bytes']) || empty($meta['part_bytes']) || empty($meta['parts']) || !is_array($meta['parts'])) {
        return null;
    }

    // Normalize and validate parts.
    foreach ($meta['parts'] as $part) {
        if (!is_array($part) || empty($part['name']) || empty($part['size'])) {
            return null;
        }
        $name = (string) $part['name'];

        // Prevent path traversal / separators.
        if ($name === '' || strpos($name, '..') !== false || strpos($name, '/') !== false || strpos($name, '\\') !== false) {
            return null;
        }
        if (preg_match('/^[A-Za-z0-9._-]+$/', $name) !== 1) {
            return null;
        }
    }

    return $meta;
}

/**
 * Assemble a multipart-backed archive into a contiguous ZIP file in chunks.
 *
 * Returns an array with:
 * - ok (bool)
 * - done (bool)
 * - next_offset (int)
 * - error (string)
 */
function assemble_multipart_to_zip_chunked($meta, $backup_dir, $dest_zip_path, $offset, $start_time) {
    $result = array(
        'ok' => false,
        'done' => false,
        'next_offset' => (int) $offset,
        'error' => '',
    );

    if (!is_array($meta) || empty($backup_dir) || empty($dest_zip_path)) {
        $result['error'] = 'Invalid multipart parameters';
        return $result;
    }

    $total_bytes = isset($meta['total_bytes']) ? (int) $meta['total_bytes'] : 0;
    $part_bytes  = isset($meta['part_bytes']) ? (int) $meta['part_bytes'] : 0;
    $parts       = (isset($meta['parts']) && is_array($meta['parts'])) ? $meta['parts'] : array();

    if ($total_bytes <= 0 || $part_bytes <= 0 || empty($parts)) {
        $result['error'] = 'Invalid multipart metadata';
        return $result;
    }

    $offset = max(0, (int) $offset);
    if ($offset >= $total_bytes) {
        $result['ok'] = true;
        $result['done'] = true;
        $result['next_offset'] = $total_bytes;
        return $result;
    }

    $dest_dir = dirname($dest_zip_path);
    if (!is_dir($dest_dir)) {
        if (!@mkdir($dest_dir, 0755, true)) {
            $result['error'] = 'Failed to create destination directory for assembled ZIP';
            return $result;
        }
    }

    // Soft byte budget per request to keep responses small and timeouts unlikely.
    $byte_budget = 16 * 1024 * 1024; // 16MB
    $bytes_written = 0;

    $out = @fopen($dest_zip_path, 'c+b');
    if ($out === false) {
        $result['error'] = 'Failed to open assembled ZIP for writing';
        return $result;
    }

    if ($offset !== 0) {
        @fseek($out, $offset);
    }

    while ($offset < $total_bytes && $bytes_written < $byte_budget) {
        if (is_time_running_out($start_time)) {
            break;
        }

        $part_index0 = (int) floor($offset / max(1, $part_bytes)); // 0-based
        if (!isset($parts[$part_index0]) || !is_array($parts[$part_index0])) {
            fclose($out);
            $result['error'] = 'Multipart metadata is inconsistent';
            return $result;
        }

        $part_name = isset($parts[$part_index0]['name']) ? (string) $parts[$part_index0]['name'] : '';
        $part_size = isset($parts[$part_index0]['size']) ? (int) $parts[$part_index0]['size'] : 0;
        if ($part_name === '' || $part_size <= 0) {
            fclose($out);
            $result['error'] = 'Multipart metadata contains an invalid part entry';
            return $result;
        }

        $part_start = $part_index0 * $part_bytes;
        $part_offset = max(0, $offset - $part_start);
        $remaining_in_part = max(0, $part_size - $part_offset);
        if ($remaining_in_part === 0) {
            // Defensive; avoid infinite loop.
            $offset = min($total_bytes, $part_start + $part_size);
            @fseek($out, $offset);
            continue;
        }

        $part_path = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . $part_name;
        if (!file_exists($part_path)) {
            fclose($out);
            $result['error'] = 'Backup part file not found: ' . $part_name;
            return $result;
        }

        $in = @fopen($part_path, 'rb');
        if ($in === false) {
            fclose($out);
            $result['error'] = 'Failed to read backup part file: ' . $part_name;
            return $result;
        }

        if ($part_offset !== 0) {
            @fseek($in, $part_offset);
        }

        $remaining_budget = max(0, $byte_budget - $bytes_written);
        $read_size = min(1024 * 1024, $remaining_in_part, $remaining_budget);

        $chunk = fread($in, $read_size);
        fclose($in);

        if ($chunk === false || $chunk === '') {
            fclose($out);
            $result['error'] = 'Failed to read backup part file: ' . $part_name;
            return $result;
        }

        $written = fwrite($out, $chunk);
        if ($written === false || $written <= 0) {
            fclose($out);
            $result['error'] = 'Failed to write assembled ZIP file';
            return $result;
        }

        $offset += (int) $written;
        $bytes_written += (int) $written;
    }

    fclose($out);

    $result['ok'] = true;
    $result['next_offset'] = min((int) $offset, (int) $total_bytes);
    $result['done'] = ($result['next_offset'] >= $total_bytes);

    return $result;
}

/**
 * Resolve a chain member into a readable ZIP path.
 *
 * For multipart metadata members (*.parts.json), assembles the member ZIP in chunks
 * and resumes from state offsets across requests.
 *
 * @param string $member_filename Chain member filename.
 * @param string $backup_dir      Backup directory.
 * @param string $temp_dir        Restore temp directory.
 * @param array  $state           Restore state.
 * @param float  $start_time      Request start time.
 * @return array{ok:bool,done:bool,zip_path:string,error:string,state:array}
 */
function asenha_get_chain_member_zip_path_chunked($member_filename, $backup_dir, $temp_dir, $state, $start_time) {
    $result = array(
        'ok' => false,
        'done' => false,
        'zip_path' => '',
        'error' => '',
        'state' => is_array($state) ? $state : array(),
    );

    $member_filename = asenha_sanitize_backup_basename($member_filename);
    if ($member_filename === '') {
        $result['error'] = 'Invalid chain member filename.';
        return $result;
    }

    // Single ZIP member (non-multipart).
    if (!is_multipart_meta_filename($member_filename)) {
        $zip_path = $backup_dir . DIRECTORY_SEPARATOR . $member_filename;
        if (!file_exists($zip_path)) {
            $result['error'] = 'Required archive file not found: ' . $member_filename;
            return $result;
        }

        $result['ok'] = true;
        $result['done'] = true;
        $result['zip_path'] = $zip_path;
        return $result;
    }

    // Multipart member => assemble logical ZIP in temp dir.
    $member_key = md5($member_filename);
    $assembled_zip_path = $temp_dir . DIRECTORY_SEPARATOR . 'collapse_assembled_' . $member_key . '.zip';
    $offset_key = 'collapse_member_assemble_offset_' . $member_key;
    $done_key = 'collapse_member_assemble_done_' . $member_key;
    $total_key = 'collapse_member_assemble_total_' . $member_key;

    $meta_path = $backup_dir . DIRECTORY_SEPARATOR . $member_filename;
    $meta = read_multipart_meta($meta_path);
    if (!is_array($meta)) {
        $result['error'] = 'Invalid multipart metadata for chain member: ' . $member_filename;
        return $result;
    }

    $assemble_done = !empty($result['state'][$done_key]);
    $assemble_offset = isset($result['state'][$offset_key]) ? max(0, (int) $result['state'][$offset_key]) : 0;
    $total_bytes = isset($meta['total_bytes']) ? max(0, (int) $meta['total_bytes']) : 0;
    if ($total_bytes > 0) {
        $result['state'][$total_key] = $total_bytes;
    }

    if ($assemble_done && !file_exists($assembled_zip_path)) {
        $assemble_done = false;
        $assemble_offset = 0;
        $result['state'][$done_key] = false;
        $result['state'][$offset_key] = 0;
    }

    if (!$assemble_done) {
        $res = assemble_multipart_to_zip_chunked($meta, $backup_dir, $assembled_zip_path, $assemble_offset, $start_time);
        if (empty($res['ok'])) {
            $result['error'] = !empty($res['error']) ? (string) $res['error'] : ('Failed to assemble multipart member: ' . $member_filename);
            return $result;
        }

        $next_offset = isset($res['next_offset']) ? max(0, (int) $res['next_offset']) : $assemble_offset;
        $done = !empty($res['done']);

        $result['state'][$offset_key] = (int) $next_offset;
        $result['state'][$done_key] = (bool) $done;

        if (!$done) {
            $result['ok'] = true;
            $result['done'] = false;
            return $result;
        }
    }

    if (!file_exists($assembled_zip_path)) {
        $result['error'] = 'Assembled archive not found for chain member: ' . $member_filename;
        return $result;
    }

    $result['ok'] = true;
    $result['done'] = true;
    $result['zip_path'] = $assembled_zip_path;
    return $result;
}

/**
 * Extract a ZIP archive into a directory in resumable chunks.
 *
 * Uses state key collapse_member_zip_index for resume.
 *
 * @param string $zip_path          ZIP path.
 * @param string $destination_dir   Destination directory.
 * @param array  $state             Restore state.
 * @param float  $start_time        Request start time.
 * @param string $archive_passphrase Optional archive passphrase.
 * @return array Updated state.
 */
function asenha_extract_archive_to_directory_chunked($zip_path, $destination_dir, $state, $start_time, $archive_passphrase = '') {
    if (!is_array($state)) {
        $state = array();
    }

    $zip = new ZipArchive();
    if ($zip->open($zip_path) !== true) {
        $state['errors'][] = 'Failed to open chain member archive.';
        $state['status'] = 'failed';
        return $state;
    }

    $zip_readiness = asenha_prepare_zip_for_read($zip, $archive_passphrase);
    if (empty($zip_readiness['ok'])) {
        $zip->close();
        $state['errors'][] = isset($zip_readiness['error']) ? (string) $zip_readiness['error'] : 'Failed to prepare chain member archive.';
        $state['status'] = 'failed';
        return $state;
    }

    if (!is_dir($destination_dir)) {
        if (!@mkdir($destination_dir, 0755, true)) {
            $zip->close();
            $state['errors'][] = 'Failed to create collapse staging directory.';
            $state['status'] = 'failed';
            return $state;
        }
    }

    $total_files = (int) $zip->numFiles;
    $index = isset($state['collapse_member_zip_index']) ? max(0, (int) $state['collapse_member_zip_index']) : 0;
    $state['collapse_member_zip_total'] = $total_files;

    $processed = 0;
    for ($i = $index; $i < $total_files; $i++) {
        if (is_time_running_out($start_time) || $processed >= EXTRACT_BATCH_SIZE) {
            $state['collapse_member_zip_index'] = $i;
            $zip->close();
            return $state;
        }

        $stat = $zip->statIndex($i);
        if ($stat === false || empty($stat['name'])) {
            continue;
        }

        $name = (string) $stat['name'];
        if (substr($name, -1) === '/') {
            $dir_path = rtrim($destination_dir, '/\\') . DIRECTORY_SEPARATOR . $name;
            if (!is_dir($dir_path)) {
                @mkdir($dir_path, 0755, true);
            }
            continue;
        }

        $extract_ok = $zip->extractTo($destination_dir, array($name));
        if (!$extract_ok) {
            $zip->close();
            $state['errors'][] = asenha_get_zip_extraction_error_message(
                !empty($zip_readiness['has_encrypted_entries']),
                $archive_passphrase,
                'Failed to extract chain member file: ' . $name
            );
            $state['status'] = 'failed';
            return $state;
        }

        $processed++;
        $state['collapse_member_zip_index'] = $i + 1;
    }

    $zip->close();
    $state['collapse_member_zip_index'] = $total_files;
    $state['collapse_member_zip_done'] = true;
    return $state;
}

/**
 * Build deleted-files queue for the current incremental member.
 *
 * @param string $zip_path           Incremental archive ZIP path.
 * @param string $queue_path         Queue file path.
 * @param string $archive_passphrase Archive passphrase.
 * @return array{ok:bool,error:string,total:int}
 */
function asenha_build_deleted_files_queue_for_member($zip_path, $queue_path, $archive_passphrase = '') {
    $result = array(
        'ok' => false,
        'error' => '',
        'total' => 0,
    );

    $zip = new ZipArchive();
    if ($zip->open($zip_path) !== true) {
        $result['error'] = 'Failed to open incremental archive for deleted-files queue.';
        return $result;
    }

    $zip_readiness = asenha_prepare_zip_for_read($zip, $archive_passphrase);
    if (empty($zip_readiness['ok'])) {
        $zip->close();
        $result['error'] = isset($zip_readiness['error']) ? (string) $zip_readiness['error'] : 'Failed to prepare incremental archive.';
        return $result;
    }

    $deleted_raw = $zip->getFromName('deleted_files.json');
    $zip->close();

    if ($deleted_raw === false || $deleted_raw === '') {
        $h = fopen($queue_path, 'wb');
        if ($h !== false) {
            fclose($h);
        }
        $result['ok'] = true;
        $result['total'] = 0;
        return $result;
    }

    $decoded = json_decode($deleted_raw, true);
    if (!is_array($decoded)) {
        $result['error'] = 'Invalid deleted_files.json format in incremental archive.';
        return $result;
    }

    $dir = dirname($queue_path);
    if (!is_dir($dir)) {
        @mkdir($dir, 0755, true);
    }
    $handle = fopen($queue_path, 'wb');
    if ($handle === false) {
        $result['error'] = 'Failed to create deleted-files queue.';
        return $result;
    }

    $total = 0;
    $seen = array();
    foreach ($decoded as $relative_path) {
        $relative_path = str_replace('\\', '/', (string) $relative_path);
        $relative_path = ltrim($relative_path, '/');
        if ($relative_path === '' || preg_match('#(^|/)\.\.(?:/|$)#', $relative_path)) {
            continue;
        }
        if (!is_eligible_wp_content_entry('wp-content/' . $relative_path)) {
            continue;
        }
        if (isset($seen[$relative_path])) {
            continue;
        }
        $seen[$relative_path] = true;
        fwrite($handle, json_encode($relative_path) . "\n");
        $total++;
    }
    fclose($handle);

    $result['ok'] = true;
    $result['total'] = (int) $total;
    return $result;
}

/**
 * Apply deleted-files queue for current incremental member in resumable chunks.
 *
 * @param string $staging_dir Staging directory that contains wp-content.
 * @param array  $state       Restore state.
 * @param float  $start_time  Request start time.
 * @return array Updated state.
 */
function asenha_apply_deleted_files_queue_chunked($staging_dir, $state, $start_time) {
    if (!is_array($state)) {
        $state = array();
    }

    $queue_path = isset($state['collapse_deleted_queue_path']) ? (string) $state['collapse_deleted_queue_path'] : '';
    if ($queue_path === '' || !file_exists($queue_path)) {
        $state['collapse_deleted_done'] = true;
        return $state;
    }

    $byte_offset = isset($state['collapse_deleted_queue_byte_offset']) ? max(0, (int) $state['collapse_deleted_queue_byte_offset']) : 0;
    $deleted_count = isset($state['collapse_deleted_count']) ? max(0, (int) $state['collapse_deleted_count']) : 0;

    $handle = fopen($queue_path, 'rb');
    if ($handle === false) {
        $state['errors'][] = 'Failed to open deleted-files queue.';
        $state['status'] = 'failed';
        return $state;
    }
    if ($byte_offset > 0) {
        fseek($handle, $byte_offset);
    }

    $processed = 0;
    $max_per_request = 750;
    while (!feof($handle)) {
        if ($processed >= $max_per_request || is_time_running_out($start_time)) {
            break;
        }

        $line = fgets($handle);
        if ($line === false) {
            break;
        }
        $line = trim($line);
        if ($line === '') {
            continue;
        }

        $relative_path = json_decode($line, true);
        if (!is_string($relative_path) || $relative_path === '') {
            continue;
        }
        if (!is_eligible_wp_content_entry('wp-content/' . $relative_path)) {
            continue;
        }

        $absolute_path = rtrim($staging_dir, '/\\') . DIRECTORY_SEPARATOR . 'wp-content' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative_path);
        if (file_exists($absolute_path)) {
            if (is_dir($absolute_path)) {
                delete_directory($absolute_path);
            } else {
                @unlink($absolute_path);
            }
        }

        $deleted_count++;
        $processed++;
    }

    $state['collapse_deleted_queue_byte_offset'] = ftell($handle);
    $done = feof($handle);
    fclose($handle);
    $state['collapse_deleted_count'] = $deleted_count;

    if ($done) {
        $state['collapse_deleted_done'] = true;
    }

    return $state;
}

/**
 * Collapse baseline + incremental chain into an effective staging source.
 *
 * The resulting staging directory is used as restore source for fallback mode:
 * - files from staging/wp-content
 * - control files from staging manifest/database.sql
 *
 * @param string $selected_filename  User-selected archive filename.
 * @param string $backup_dir         Backup directory.
 * @param string $temp_dir           Restore temp directory.
 * @param array  $state              Restore state.
 * @param float  $start_time         Request start time.
 * @param string $archive_passphrase Archive passphrase.
 * @return array Updated state.
 */
function asenha_collapse_incremental_chain_chunked($selected_filename, $backup_dir, $temp_dir, $state, $start_time, $archive_passphrase = '') {
    if (!is_array($state)) {
        $state = array();
    }

    if (!empty($state['collapse_done'])) {
        return $state;
    }

    $chain = isset($state['restore_chain']) && is_array($state['restore_chain']) ? $state['restore_chain'] : array();
    if (empty($chain)) {
        $state['errors'][] = 'Restore chain metadata is missing.';
        $state['status'] = 'failed';
        return $state;
    }

    $staging_dir = isset($state['collapse_staging_dir']) ? (string) $state['collapse_staging_dir'] : ($temp_dir . DIRECTORY_SEPARATOR . 'collapse_staging');
    if (!is_dir($staging_dir) && !@mkdir($staging_dir, 0755, true)) {
        $state['errors'][] = 'Failed to create collapse staging directory.';
        $state['status'] = 'failed';
        return $state;
    }
    $state['collapse_staging_dir'] = $staging_dir;

    if (!isset($state['collapse_chain_index'])) {
        $state['collapse_chain_index'] = 0;
    }

    while ((int) $state['collapse_chain_index'] < count($chain)) {
        if (is_time_running_out($start_time)) {
            return $state;
        }

        $chain_index = (int) $state['collapse_chain_index'];
        $member_filename = asenha_sanitize_backup_basename((string) $chain[$chain_index]);
        if ($member_filename === '') {
            $state['errors'][] = 'Invalid chain member filename.';
            $state['status'] = 'failed';
            return $state;
        }

        $state['phase'] = 'extracting';
        $state['collapse_member_filename'] = $member_filename;
        $state['collapse_member_is_incremental'] = ($chain_index > 0);
        if (!isset($state['collapse_last_announced_index']) || (int) $state['collapse_last_announced_index'] !== $chain_index) {
            $state['messages'][] = sprintf(
                'Preparing recovery point: applying archive %1$d of %2$d (%3$s).',
                (int) ($chain_index + 1),
                (int) count($chain),
                $member_filename
            );
            $state['collapse_last_announced_index'] = $chain_index;
        }

        // 1) Resolve member ZIP path (assemble multipart members when needed).
        $member_res = asenha_get_chain_member_zip_path_chunked($member_filename, $backup_dir, $temp_dir, $state, $start_time);
        $state = isset($member_res['state']) && is_array($member_res['state']) ? $member_res['state'] : $state;

        if (empty($member_res['ok'])) {
            $state['errors'][] = !empty($member_res['error']) ? (string) $member_res['error'] : 'Failed to resolve chain member archive.';
            $state['status'] = 'failed';
            return $state;
        }
        if (empty($member_res['done'])) {
            return $state;
        }
        $member_zip_path = (string) $member_res['zip_path'];

        // 2) Extract this member into staging (resumable).
        if (empty($state['collapse_member_zip_done'])) {
            $state = asenha_extract_archive_to_directory_chunked(
                $member_zip_path,
                $staging_dir,
                $state,
                $start_time,
                $archive_passphrase
            );

            if (!empty($state['status']) && $state['status'] === 'failed') {
                return $state;
            }
            if (empty($state['collapse_member_zip_done'])) {
                return $state;
            }
        }

        // 3) For incremental members, apply deleted_files.json in resumable chunks.
        if (!empty($state['collapse_member_is_incremental'])) {
            if (empty($state['collapse_deleted_queue_ready'])) {
                $queue_path = $temp_dir . DIRECTORY_SEPARATOR . 'collapse_deleted_queue_' . md5($member_filename) . '.jsonl';
                $queue_res = asenha_build_deleted_files_queue_for_member($member_zip_path, $queue_path, $archive_passphrase);
                if (empty($queue_res['ok'])) {
                    $state['errors'][] = !empty($queue_res['error']) ? (string) $queue_res['error'] : 'Failed to prepare incremental deletions queue.';
                    $state['status'] = 'failed';
                    return $state;
                }

                $state['collapse_deleted_queue_ready'] = true;
                $state['collapse_deleted_queue_path'] = $queue_path;
                $state['collapse_deleted_queue_total'] = isset($queue_res['total']) ? (int) $queue_res['total'] : 0;
                $state['collapse_deleted_queue_byte_offset'] = 0;
                $state['collapse_deleted_count'] = 0;
                $state['collapse_deleted_done'] = ($state['collapse_deleted_queue_total'] <= 0);
            }

            if (empty($state['collapse_deleted_done'])) {
                $state = asenha_apply_deleted_files_queue_chunked($staging_dir, $state, $start_time);
                if (!empty($state['status']) && $state['status'] === 'failed') {
                    return $state;
                }
                if (empty($state['collapse_deleted_done'])) {
                    return $state;
                }
            }
        }

        // 4) Member completed; reset member-scoped state and continue to next member.
        $state['collapse_member_zip_done'] = false;
        $state['collapse_member_zip_index'] = 0;
        $state['collapse_member_zip_total'] = 0;
        $state['collapse_deleted_queue_ready'] = false;
        $state['collapse_deleted_queue_path'] = '';
        $state['collapse_deleted_queue_total'] = 0;
        $state['collapse_deleted_queue_byte_offset'] = 0;
        $state['collapse_deleted_done'] = false;
        $state['collapse_deleted_count'] = 0;
        $state['collapse_chain_index'] = $chain_index + 1;
    }

    // Collapse complete. Normalize manifest metadata to baseline semantics.
    $manifest_path = $staging_dir . DIRECTORY_SEPARATOR . 'manifest.json';
    $manifest = array();
    if (file_exists($manifest_path)) {
        $raw_manifest = file_get_contents($manifest_path);
        $decoded_manifest = json_decode((string) $raw_manifest, true);
        if (is_array($decoded_manifest)) {
            $manifest = $decoded_manifest;
        }
    }
    $manifest['backup_method'] = 'baseline';
    $manifest['base_filename'] = '';
    $manifest['parent_filename'] = '';
    $manifest['sequence'] = 0;
    $manifest['delta_stats'] = array();
    $manifest['collapsed_from'] = array(
        'target' => asenha_sanitize_backup_basename($selected_filename),
        'chain' => array_values($chain),
    );
    $manifest['created_at'] = gmdate('Y-m-d H:i:s');

    $manifest_out = json_encode($manifest, JSON_PRETTY_PRINT);
    if ($manifest_out === false) {
        $manifest_out = json_encode($manifest);
    }
    file_put_contents($manifest_path, (string) $manifest_out);

    $state['collapse_done'] = true;
    $state['collapse_effective_source_dir'] = $staging_dir;
    $state['collapse_effective_source_wp_content_dir'] = $staging_dir . DIRECTORY_SEPARATOR . 'wp-content';
    $state['collapse_effective_sql_file'] = $staging_dir . DIRECTORY_SEPARATOR . 'database.sql';
    $state['extract_complete'] = true;
    $state['extract_total'] = 1;
    $state['extract_index'] = 1;
    $state['files_restore_mode'] = 'fallback';
    $state['messages'][] = 'Incremental recovery point prepared from baseline + chain.';

    return $state;
}

// ============================================================================
// DIRECT WP-CONTENT RESTORE FROM ARCHIVE (FAST PATH)
// ============================================================================

/**
 * Detect whether a ZIP archive contains encrypted entries.
 */
function asenha_zip_has_encrypted_entries($zip) {
    if (!($zip instanceof ZipArchive)) {
        return false;
    }

    $num_files = (int) $zip->numFiles;
    for ($i = 0; $i < $num_files; $i++) {
        $stat = $zip->statIndex($i);
        if (!is_array($stat)) {
            continue;
        }

        $encryption_method = isset($stat['encryption_method']) ? (int) $stat['encryption_method'] : 0;
        if ($encryption_method > 0) {
            return true;
        }
    }

    return false;
}

/**
 * Configure a ZIP instance for reading encrypted archives when needed.
 *
 * @return array{ok:bool,has_encrypted_entries:bool,error:string,error_code:string}
 */
function asenha_prepare_zip_for_read($zip, $archive_passphrase = '') {
    $result = array(
        'ok' => true,
        'has_encrypted_entries' => false,
        'error' => '',
        'error_code' => '',
    );

    if (!($zip instanceof ZipArchive)) {
        $result['ok'] = false;
        $result['error'] = 'Invalid ZIP handler.';
        $result['error_code'] = 'zip_handler_invalid';
        return $result;
    }

    $has_encrypted_entries = asenha_zip_has_encrypted_entries($zip);
    $result['has_encrypted_entries'] = $has_encrypted_entries;
    if (!$has_encrypted_entries) {
        return $result;
    }

    if (!method_exists($zip, 'setPassword')) {
        $result['ok'] = false;
        $result['error'] = 'Encrypted archive detected, but ZipArchive::setPassword is not available on this server.';
        $result['error_code'] = 'archive_passphrase_not_supported';
        return $result;
    }

    $archive_passphrase = (string) $archive_passphrase;
    if ($archive_passphrase === '') {
        $result['ok'] = false;
        $result['error'] = 'This backup archive is encrypted. Please enter the archive passphrase and try again.';
        $result['error_code'] = 'archive_passphrase_required';
        return $result;
    }

    $zip->setPassword($archive_passphrase);
    return $result;
}

/**
 * Build a user-facing ZIP extraction error message.
 */
function asenha_get_zip_extraction_error_message($has_encrypted_entries, $archive_passphrase, $default_message) {
    if ($has_encrypted_entries) {
        if ((string) $archive_passphrase === '') {
            return 'This backup archive is encrypted. Please enter the archive passphrase and try again.';
        }

        return 'Failed to decrypt archive entries. Please verify the archive passphrase.';
    }

    return (string) $default_message;
}

/**
 * Extract only control files from the archive (manifest.json and, if present, database.sql).
 *
 * This avoids bulk extracting wp-content for the direct restore mode.
 */
function extract_control_files($backup_path, $temp_dir, $state, $archive_passphrase = '') {
    $zip = new ZipArchive();
    if ($zip->open($backup_path) !== true) {
        $state['errors'][] = 'Failed to open backup archive';
        $state['status'] = 'failed';
        return $state;
    }

    $zip_readiness = asenha_prepare_zip_for_read($zip, $archive_passphrase);
    if (empty($zip_readiness['ok'])) {
        $zip->close();
        $state['errors'][] = isset($zip_readiness['error']) ? (string) $zip_readiness['error'] : 'Failed to prepare backup archive for extraction.';
        $state['status'] = 'failed';
        return $state;
    }

    if (!is_dir($temp_dir)) {
        if (!@mkdir($temp_dir, 0755, true)) {
            $zip->close();
            $state['errors'][] = 'Failed to create temp directory';
            $state['status'] = 'failed';
            return $state;
        }
    }

    // manifest.json is required.
    if (false === $zip->locateName('manifest.json')) {
        $zip->close();
        $state['errors'][] = 'Invalid backup: manifest.json not found in archive';
        $state['status'] = 'failed';
        return $state;
    }

    $ok = $zip->extractTo($temp_dir, array('manifest.json'));
    if (!$ok) {
        $zip->close();
        $state['errors'][] = asenha_get_zip_extraction_error_message(
            !empty($zip_readiness['has_encrypted_entries']),
            $archive_passphrase,
            'Failed to extract manifest.json'
        );
        $state['status'] = 'failed';
        return $state;
    }

    // database.sql is optional (files-only backups).
    if (false !== $zip->locateName('database.sql')) {
        $ok = $zip->extractTo($temp_dir, array('database.sql'));
        if (!$ok) {
            $zip->close();
            $state['errors'][] = asenha_get_zip_extraction_error_message(
                !empty($zip_readiness['has_encrypted_entries']),
                $archive_passphrase,
                'Failed to extract database.sql'
            );
            $state['status'] = 'failed';
            return $state;
        }
    }

    $zip->close();

    // Mark extraction as done for the UI.
    $state['extract_complete'] = true;
    // If this restore used multipart assembly, keep the extract_* fields as the assembly progress
    // (bytes assembled) so the UI doesn't regress after control file extraction.
    if (!empty($state['multipart_assemble_done'])) {
        if (isset($state['extract_total']) && isset($state['extract_index']) && (int) $state['extract_index'] < (int) $state['extract_total']) {
            $state['extract_index'] = (int) $state['extract_total'];
        }
    } else {
        $state['extract_total'] = 1;
        $state['extract_index'] = 1;
    }
    $state['messages'][] = 'Extracted control files (manifest/database).';

    return $state;
}

/**
 * Whether a path relative to wp-content lies under the reserved top-level cache directory only.
 *
 * Matches main-plugin restore eligibility: `cache` is excluded as a direct child of
 * wp-content (`cache` or `cache/...`), not folders named "cache" nested under plugins/themes.
 *
 * @param string $wp_content_relative_path Path relative to wp-content using forward slashes.
 * @return bool
 */
function asenha_emergency_is_reserved_wp_content_cache_tree_path($wp_content_relative_path) {
    $wp_content_relative_path = str_replace('\\', '/', (string) $wp_content_relative_path);
    $wp_content_relative_path = trim($wp_content_relative_path, '/');
    if ($wp_content_relative_path === '') {
        return false;
    }
    return ($wp_content_relative_path === 'cache' || strpos($wp_content_relative_path, 'cache/') === 0);
}

/**
 * Whether a path relative to wp-content lies under the reserved top-level upgrade directory only.
 *
 * Matches WordPress core's temporary wp-content/upgrade/. Nested plugin/theme folders named
 * "upgrade" (e.g. Elementor core/upgrade/) must remain restorable.
 *
 * @param string $wp_content_relative_path Path relative to wp-content using forward slashes.
 * @return bool
 */
function asenha_emergency_is_reserved_wp_content_upgrade_tree_path($wp_content_relative_path) {
    $wp_content_relative_path = str_replace('\\', '/', (string) $wp_content_relative_path);
    $wp_content_relative_path = trim($wp_content_relative_path, '/');
    if ($wp_content_relative_path === '') {
        return false;
    }
    return ($wp_content_relative_path === 'upgrade' || strpos($wp_content_relative_path, 'upgrade/') === 0);
}

/**
 * Determine whether a ZIP entry path is an eligible wp-content file to restore.
 *
 * Mirrors exclusions used in the main plugin restore engine.
 */
function is_eligible_wp_content_entry($zip_name) {
    $zip_name = str_replace('\\', '/', (string) $zip_name);

    // Only wp-content entries.
    if (strpos($zip_name, 'wp-content/') !== 0) {
        return false;
    }

    // Skip directories.
    if (substr($zip_name, -1) === '/') {
        return false;
    }

    // Basic traversal/absolute protection.
    if (strpos($zip_name, '/') === 0 || preg_match('#(^|/)\.\.(/|$)#', $zip_name)) {
        return false;
    }

    $wp_content_rel = substr($zip_name, strlen('wp-content/'));
    $wp_content_rel = ltrim($wp_content_rel, '/');
    if ($wp_content_rel === '') {
        return false;
    }

    static $excluded_directories = array(
        'upgrade',
        'updraft',
        'backup',
        'backups',
        'wflogs',
        'ai1wm-backups',
        'backupwordpress',
        'pb_backupbuddy',
        'asenha-backups',
        'asenha-restore-progress',
        'asenha-migration-progress',
    );

    static $excluded_basenames = array(
        '.htaccess',
        'wp-config.php',
        'debug.log',
        'error_log',
        'asenha-restore-dirs.json',
        'asenha-restore-dirs.json.tmp',
        '.git',
        '.gitignore',
        '.DS_Store',
        'Thumbs.db',
    );

    $basename = basename($wp_content_rel);
    if (in_array($basename, $excluded_basenames, true)) {
        return false;
    }

    if (asenha_emergency_is_reserved_wp_content_cache_tree_path($wp_content_rel)) {
        return false;
    }

    if (asenha_emergency_is_reserved_wp_content_upgrade_tree_path($wp_content_rel)) {
        return false;
    }

    $first_segment = strtolower(strtok($wp_content_rel, '/'));
    if ($first_segment && in_array($first_segment, $excluded_directories, true)) {
        return false;
    }

    // Exclude any ASE backup directories (asenha-backups-*), even if nested.
    $backup_dir_prefix = 'asenha-backups-';
    $has_backup_dir_segment = (bool) preg_match(
        '#(^|/)' . preg_quote($backup_dir_prefix, '#') . '[^/]*(/|$)#',
        $wp_content_rel
    );
    if ($has_backup_dir_segment) {
        return false;
    }

    // Exclude any ASE storage directories (asenha-storage-*), even if nested.
    if (asenha_emergency_should_exclude_wp_content_storage($wp_content_rel)) {
        return false;
    }

    return true;
}

/**
 * Count eligible wp-content files inside an archive.
 */
function count_eligible_wp_content_files($backup_path, $start_time = null) {
    $zip = new ZipArchive();
    if ($zip->open($backup_path) !== true) {
        return false;
    }

    $count = 0;
    $num_files = (int) $zip->numFiles;
    for ($i = 0; $i < $num_files; $i++) {
        // Be time-aware: if we're close to max_execution_time, stop counting and let the caller decide.
        if ($start_time !== null && is_time_running_out($start_time)) {
            $zip->close();
            return null;
        }

        $stat = $zip->statIndex($i);
        if ($stat === false || empty($stat['name'])) {
            continue;
        }
        if (is_eligible_wp_content_entry($stat['name'])) {
            $count++;
        }
    }

    $zip->close();
    return $count;
}

/**
 * Flush a batch of wp-content files to extract from the ZIP archive to the destination parent.
 *
 * Returns true on success, or an error string on failure.
 */
function flush_extract_batch_wp_content($zip, $destination_parent, $batch_names, $has_encrypted_entries = false, $archive_passphrase = '') {
    if (empty($batch_names)) {
        return true;
    }

    $ok = $zip->extractTo($destination_parent, $batch_names);

    if (!$ok) {
        // Fallback: extract one-by-one to identify the problematic file.
        foreach ($batch_names as $name) {
            $single_ok = $zip->extractTo($destination_parent, array($name));
            if (!$single_ok) {
                return asenha_get_zip_extraction_error_message(
                    $has_encrypted_entries,
                    $archive_passphrase,
                    'Failed to extract file: ' . $name
                );
            }
        }
    }

    return true;
}

/**
 * Restore eligible wp-content files directly from the ZIP archive in resumable chunks.
 *
 * Returns array('ok' => bool, 'state' => array, 'error' => string)
 */
function chunked_restore_wp_content_direct($backup_path, $destination_parent, $state, $start_time, $archive_passphrase = '') {
    $result = array(
        'ok' => true,
        'state' => $state,
        'error' => '',
        'error_code' => '',
    );

    $destination_parent = rtrim((string) $destination_parent, '/\\');
    if ($destination_parent === '' || !is_dir($destination_parent)) {
        $result['ok'] = false;
        $result['error'] = 'Invalid destination directory for direct restore.';
        return $result;
    }
    if (!is_writable($destination_parent)) {
        $result['ok'] = false;
        $result['error'] = 'Destination directory is not writable for direct restore.';
        return $result;
    }

    $zip = new ZipArchive();
    if ($zip->open($backup_path) !== true) {
        $result['ok'] = false;
        $result['error'] = 'Failed to open backup archive for direct restore.';
        return $result;
    }

    $zip_readiness = asenha_prepare_zip_for_read($zip, $archive_passphrase);
    if (empty($zip_readiness['ok'])) {
        $zip->close();
        $result['ok'] = false;
        $result['error'] = isset($zip_readiness['error']) ? (string) $zip_readiness['error'] : 'Failed to prepare archive for direct restore.';
        $result['error_code'] = isset($zip_readiness['error_code']) ? (string) $zip_readiness['error_code'] : 'archive_prepare_failed';
        return $result;
    }

    $num_files = (int) $zip->numFiles;
    $start_index = isset($state['zip_index']) ? (int) $state['zip_index'] : 0;
    if ($start_index < 0) {
        $start_index = 0;
    }

    $batch_size = 50;
    $batch_names = array();
    $files_restored = 0;
    $next_index = $start_index;

    for ($i = $start_index; $i < $num_files; $i++) {
        $next_index = $i + 1;

        $stat = $zip->statIndex($i);
        if ($stat === false || empty($stat['name'])) {
            continue;
        }

        $name = str_replace('\\', '/', (string) $stat['name']);

        if (!is_eligible_wp_content_entry($name)) {
            continue;
        }

        $batch_names[] = $name;

        $pending_total = $files_restored + count($batch_names);
        $time_exceeded = is_time_running_out($start_time);
        $batch_full = count($batch_names) >= $batch_size;
        $file_limit_hit = $pending_total >= COPY_BATCH_SIZE;

        if ($batch_full || $file_limit_hit || $time_exceeded) {
            $flush_result = flush_extract_batch_wp_content(
                $zip,
                $destination_parent,
                $batch_names,
                !empty($zip_readiness['has_encrypted_entries']),
                $archive_passphrase
            );
            if ($flush_result !== true) {
                $zip->close();
                $result['ok'] = false;
                $result['error'] = $flush_result;
                if (!empty($zip_readiness['has_encrypted_entries'])) {
                    $result['error_code'] = ((string) $archive_passphrase === '') ? 'archive_passphrase_required' : 'archive_passphrase_invalid';
                }
                return $result;
            }

            $files_restored += count($batch_names);
            $batch_names = array();

            if ($file_limit_hit || $time_exceeded) {
                break;
            }
        }
    }

    if (!empty($batch_names)) {
        $flush_result = flush_extract_batch_wp_content(
            $zip,
            $destination_parent,
            $batch_names,
            !empty($zip_readiness['has_encrypted_entries']),
            $archive_passphrase
        );
        if ($flush_result !== true) {
            $zip->close();
            $result['ok'] = false;
            $result['error'] = $flush_result;
            if (!empty($zip_readiness['has_encrypted_entries'])) {
                $result['error_code'] = ((string) $archive_passphrase === '') ? 'archive_passphrase_required' : 'archive_passphrase_invalid';
            }
            return $result;
        }

        $files_restored += count($batch_names);
    }

    $zip->close();

    $state = $result['state'];

    $state['zip_index'] = $next_index;
    $state['copy_index'] = isset($state['copy_index']) ? (int) $state['copy_index'] : 0;
    $state['copy_index'] += $files_restored;
    $state['files_copied'] = $state['copy_index'];

    // Mark file restore complete once we've restored all eligible files (copy_total),
    // or once we've scanned the entire archive.
    $copy_total = isset($state['copy_total']) ? (int) $state['copy_total'] : 0;
    if (($copy_total > 0 && $state['copy_index'] >= $copy_total) || $state['zip_index'] >= $num_files) {
        $state['copy_complete'] = true;
        $state['messages'][] = "Restored {$state['files_copied']} files from archive (direct mode).";
    }

    $result['state'] = $state;
    return $result;
}

/**
 * Switch the restore operation to fallback mode (extract-to-temp + copy).
 */
function switch_to_fallback_mode($state) {
    $state['files_restore_mode'] = 'fallback';

    // Reset extraction for full archive extraction into temp dir.
    $state['extract_index'] = 0;
    $state['extract_complete'] = false;
    $state['files_extracted'] = 0;

    if (isset($state['archive_total_files']) && (int) $state['archive_total_files'] > 0) {
        $state['extract_total'] = (int) $state['archive_total_files'];
    }

    // Reset copy state for extract+copy flow.
    $state['copy_index'] = 0;
    $state['copy_total'] = 0;
    $state['copy_files'] = array();
    $state['copy_complete'] = false;
    $state['files_copied'] = 0;

    // Reset direct restore cursor.
    $state['zip_index'] = 0;

    return $state;
}

// ============================================================================
// CHUNKED EXTRACTION
// ============================================================================

/**
 * Extract files from ZIP archive in chunks
 * Returns: array with 'complete' boolean and updated state
 */
function chunked_extract($backup_path, $temp_dir, $state, $start_time, $archive_passphrase = '') {
    $zip = new ZipArchive();
    if ($zip->open($backup_path) !== true) {
        $state['errors'][] = 'Failed to open backup archive';
        $state['status'] = 'failed';
        return $state;
    }

    $zip_readiness = asenha_prepare_zip_for_read($zip, $archive_passphrase);
    if (empty($zip_readiness['ok'])) {
        $zip->close();
        $state['errors'][] = isset($zip_readiness['error']) ? (string) $zip_readiness['error'] : 'Failed to prepare backup archive for extraction.';
        $state['status'] = 'failed';
        return $state;
    }
    
    // Ensure temp directory exists
    if (!is_dir($temp_dir)) {
        if (!@mkdir($temp_dir, 0755, true)) {
            $zip->close();
            $state['errors'][] = 'Failed to create temp directory';
            $state['status'] = 'failed';
            return $state;
        }
    }
    
    $total_files = (int) $zip->numFiles;
    $state['extract_total'] = $total_files;
    $start_index = isset($state['extract_index']) ? (int) $state['extract_index'] : 0;
    $files_extracted = 0;
    
    // Extract files in batch
    for ($i = $start_index; $i < $total_files; $i++) {
        // Check time limit
        if (is_time_running_out($start_time)) {
            $state['extract_index'] = $i;
            $state['files_extracted'] += $files_extracted;
            $zip->close();
            return $state; // Will continue in next request
        }
        
        // Check batch size
        if ($files_extracted >= EXTRACT_BATCH_SIZE) {
            $state['extract_index'] = $i;
            $state['files_extracted'] += $files_extracted;
            $zip->close();
            return $state; // Will continue in next request
        }
        
        $stat = $zip->statIndex($i);
        if ($stat === false) {
            continue;
        }
        
        $name = $stat['name'];
        
        // Handle directories
        if (substr($name, -1) === '/') {
            $dir_path = $temp_dir . DIRECTORY_SEPARATOR . $name;
            if (!is_dir($dir_path)) {
                @mkdir($dir_path, 0755, true);
            }
            continue;
        }
        
        // Extract single file
        $result = $zip->extractTo($temp_dir, array($name));
        if (!$result) {
            $zip->close();
            $state['errors'][] = asenha_get_zip_extraction_error_message(
                !empty($zip_readiness['has_encrypted_entries']),
                $archive_passphrase,
                'Failed to extract file during fallback restore: ' . $name
            );
            $state['status'] = 'failed';
            return $state;
        }
        $files_extracted++;
    }
    
    $zip->close();
    
    // Extraction complete
    $state['extract_index'] = $total_files;
    $state['files_extracted'] += $files_extracted;
    $state['extract_complete'] = true;
    $state['messages'][] = "Extracted {$state['files_extracted']} files from archive";
    
    return $state;
}

// ============================================================================
// CHUNKED FILE COPYING
// ============================================================================

/**
 * Build list of files to copy (only on first copy batch)
 */
function build_copy_file_list($source_dir) {
    $files = array();
    
    if (!is_dir($source_dir)) {
        return $files;
    }
    
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($source_dir, RecursiveDirectoryIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST
    );
    
    foreach ($iterator as $item) {
        if (!$item->isDir()) {
            $files[] = $iterator->getSubPathName();
        }
    }
    
    return $files;
}

/**
 * Copy files in chunks with state persistence
 */
function chunked_copy($source_dir, $dest_dir, $state, $start_time) {
    // Build file list on first run
    if (empty($state['copy_files'])) {
        $state['copy_files'] = build_copy_file_list($source_dir);
        $state['copy_total'] = count($state['copy_files']);
    }
    
    $files = $state['copy_files'];
    $total = $state['copy_total'];
    $start_index = $state['copy_index'];
    $files_copied = 0;
    
    for ($i = $start_index; $i < $total; $i++) {
        // Check time limit
        if (is_time_running_out($start_time)) {
            $state['copy_index'] = $i;
            $state['files_copied'] += $files_copied;
            return $state;
        }
        
        // Check batch size
        if ($files_copied >= COPY_BATCH_SIZE) {
            $state['copy_index'] = $i;
            $state['files_copied'] += $files_copied;
            return $state;
        }
        
        $relative_path = $files[$i];
        $source_file = $source_dir . DIRECTORY_SEPARATOR . $relative_path;
        $dest_file = $dest_dir . DIRECTORY_SEPARATOR . $relative_path;
        
        // Ensure destination directory exists
        $dest_parent = dirname($dest_file);
        if (!is_dir($dest_parent)) {
            @mkdir($dest_parent, 0755, true);
        }
        
        // Copy file
        if (@copy($source_file, $dest_file)) {
            $files_copied++;
        }
    }
    
    // Copy complete
    $state['copy_index'] = $total;
    $state['files_copied'] += $files_copied;
    $state['copy_complete'] = true;
    $state['messages'][] = "Restored {$state['files_copied']} files. 2 temporary files required for this restore operation are excluded.";
    
    return $state;
}

// ============================================================================
// SYNC DELETE (WP-CONTENT)
// Emergency restore must remain self-contained. Do not reference ASE plugin classes (ASENHA_*) here.
// ============================================================================

/**
 * Determine whether a wp-content relative directory should be excluded from sync scanning/deletion.
 *
 * Mirrors exclusions used in is_eligible_wp_content_entry().
 *
 * @param string $wp_content_rel_dir Path relative to wp-content (e.g. "plugins/my-plugin").
 * @return bool
 */
function is_excluded_wp_content_directory($wp_content_rel_dir) {
    $wp_content_rel_dir = str_replace('\\', '/', (string) $wp_content_rel_dir);
    $wp_content_rel_dir = trim($wp_content_rel_dir, '/');
    if ($wp_content_rel_dir === '') {
        return false;
    }

    // Hidden directories are excluded.
    $segments = explode('/', $wp_content_rel_dir);
    foreach ($segments as $seg) {
        if ($seg === '') {
            continue;
        }
        if (isset($seg[0]) && $seg[0] === '.') {
            return true;
        }
    }

    static $excluded_directories = array(
        'upgrade',
        'updraft',
        'backup',
        'backups',
        'wflogs',
        'ai1wm-backups',
        'backupwordpress',
        'pb_backupbuddy',
        'asenha-backups',
        'asenha-restore-progress',
        'asenha-migration-progress',
    );

    // Top-level wp-content/cache and wp-content/upgrade only (not nested plugin/theme folders).
    if (asenha_emergency_is_reserved_wp_content_cache_tree_path($wp_content_rel_dir)) {
        return true;
    }
    if (asenha_emergency_is_reserved_wp_content_upgrade_tree_path($wp_content_rel_dir)) {
        return true;
    }

    $basename = strtolower(basename($wp_content_rel_dir));
    // `upgrade` is handled above as a top-level reserved tree only.
    if ($basename && 'upgrade' !== $basename && in_array($basename, $excluded_directories, true)) {
        return true;
    }

    // Exclude any ASE backup directories (asenha-backups-*), even if nested.
    $backup_dir_prefix = 'asenha-backups-';
    $has_backup_dir_segment = (bool) preg_match(
        '#(^|/)' . preg_quote($backup_dir_prefix, '#') . '[^/]*(/|$)#',
        $wp_content_rel_dir
    );
    if ($has_backup_dir_segment) {
        return true;
    }

    // Exclude any ASE storage directories (asenha-storage-*), even if nested.
    if (asenha_emergency_should_exclude_wp_content_storage($wp_content_rel_dir)) {
        return true;
    }

    return false;
}

/**
 * Build a delete queue (JSONL) of wp-content relative file paths to delete, in resumable chunks.
 *
 * A file is queued when:
 * - It is eligible for restore (is_eligible_wp_content_entry()) AND
 * - It does not exist in the backup archive under "wp-content/...".
 *
 * @param string $backup_path Backup ZIP path.
 * @param string $wp_content_dir Destination wp-content absolute path.
 * @param string $temp_dir Temp directory for this restore.
 * @param array  $state Restore state.
 * @param float  $start_time Request start time.
 * @return array Updated state.
 */
function sync_build_delete_queue_wp_content_chunked($backup_path, $wp_content_dir, $temp_dir, $state, $start_time) {
    $queue_path = $temp_dir . DIRECTORY_SEPARATOR . 'sync_delete_queue_wpcontent.jsonl';
    $state['sync_delete_queue_path'] = $queue_path;

    if (!isset($state['sync_scan_dirs']) || !is_array($state['sync_scan_dirs'])) {
        $state['sync_scan_dirs'] = array('');
    }
    if (!isset($state['sync_scan_current_dir'])) {
        $state['sync_scan_current_dir'] = '';
    }
    if (!isset($state['sync_scan_current_index'])) {
        $state['sync_scan_current_index'] = 0;
    }
    if (!isset($state['sync_files_queued'])) {
        $state['sync_files_queued'] = 0;
    }

    // Back-compat for older state shapes: ensure we start scanning from wp-content root.
    if (empty($state['sync_scan_dirs']) && $state['sync_scan_current_dir'] === '' && (int) $state['sync_scan_current_index'] === 0 && empty($state['sync_queue_built'])) {
        $state['sync_scan_dirs'] = array('');
    }

    // Ensure temp dir exists for the queue.
    if (!is_dir($temp_dir)) {
        @mkdir($temp_dir, 0755, true);
    }

    // Open queue file (append when resuming).
    $queue_mode = file_exists($queue_path) ? 'ab' : 'wb';
    $queue_handle = fopen($queue_path, $queue_mode);
    if ($queue_handle === false) {
        $state['errors'][] = 'Failed to create sync delete queue file.';
        $state['status'] = 'failed';
        return $state;
    }

    $zip = new ZipArchive();
    if ($zip->open($backup_path) !== true) {
        fclose($queue_handle);
        $state['errors'][] = 'Failed to open backup archive for sync delete.';
        $state['status'] = 'failed';
        return $state;
    }

    $max_entries_per_request = 2500;
    $processed_entries = 0;

    while (true) {
        if (is_time_running_out($start_time) || $processed_entries >= $max_entries_per_request) {
            break;
        }

        // Select next directory to scan.
        if ($state['sync_scan_current_dir'] === '') {
            if (empty($state['sync_scan_dirs'])) {
                break;
            }
            $state['sync_scan_current_dir'] = (string) array_pop($state['sync_scan_dirs']);
            $state['sync_scan_current_index'] = 0;
        }

        $dir_rel = (string) $state['sync_scan_current_dir'];
        if (is_excluded_wp_content_directory($dir_rel)) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
            continue;
        }

        $abs_dir = rtrim((string) $wp_content_dir, '/\\');
        if ($dir_rel !== '') {
            $abs_dir .= DIRECTORY_SEPARATOR . $dir_rel;
        }
        if (!is_dir($abs_dir)) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
            continue;
        }

        $entries = @scandir($abs_dir);
        if (!is_array($entries)) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
            continue;
        }

        $i = (int) $state['sync_scan_current_index'];
        $total_entries = count($entries);

        for (; $i < $total_entries; $i++) {
            if (is_time_running_out($start_time) || $processed_entries >= $max_entries_per_request) {
                break;
            }
            $processed_entries++;

            $entry = (string) $entries[$i];
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            if ($entry !== '' && isset($entry[0]) && $entry[0] === '.') {
                continue;
            }

            $child_rel = ($dir_rel === '') ? $entry : ($dir_rel . '/' . $entry);
            $child_abs = $abs_dir . DIRECTORY_SEPARATOR . $entry;

            if (is_dir($child_abs)) {
                if (!is_excluded_wp_content_directory($child_rel)) {
                    $state['sync_scan_dirs'][] = $child_rel;
                }
                continue;
            }

            // Files: only consider eligible entries (to mirror what the archive contains).
            $zip_rel = str_replace('\\', '/', (string) $child_rel);
            if (!is_eligible_wp_content_entry('wp-content/' . $zip_rel)) {
                continue;
            }

            $zip_name = 'wp-content/' . $zip_rel;
            $found = $zip->locateName($zip_name);
            if ($found === false) {
                fwrite($queue_handle, json_encode($child_rel) . "\n");
                $state['sync_files_queued'] = (int) $state['sync_files_queued'] + 1;
            }
        }

        $state['sync_scan_current_index'] = $i;

        // Finished this directory; move on.
        if ($i >= $total_entries) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
        }
    }

    $zip->close();
    fclose($queue_handle);

    // If there are no more directories to scan and we're not mid-directory, queue build is done.
    if ($state['sync_scan_current_dir'] === '' && empty($state['sync_scan_dirs'])) {
        $state['sync_queue_built'] = true;
    }

    return $state;
}

/**
 * Build delete queue by comparing destination wp-content against a source wp-content directory.
 *
 * Used by incremental chain collapse mode where the effective restore source is a staged directory
 * instead of a single backup ZIP.
 *
 * @param string $source_wp_content_dir Source wp-content directory.
 * @param string $wp_content_dir        Destination wp-content directory.
 * @param string $temp_dir              Restore temp directory.
 * @param array  $state                 Restore state.
 * @param float  $start_time            Request start time.
 * @return array Updated state.
 */
function sync_build_delete_queue_wp_content_from_source_chunked($source_wp_content_dir, $wp_content_dir, $temp_dir, $state, $start_time) {
    $queue_path = $temp_dir . DIRECTORY_SEPARATOR . 'sync_delete_queue_wpcontent.jsonl';
    $state['sync_delete_queue_path'] = $queue_path;

    if (!isset($state['sync_scan_dirs']) || !is_array($state['sync_scan_dirs'])) {
        $state['sync_scan_dirs'] = array('');
    }
    if (!isset($state['sync_scan_current_dir'])) {
        $state['sync_scan_current_dir'] = '';
    }
    if (!isset($state['sync_scan_current_index'])) {
        $state['sync_scan_current_index'] = 0;
    }
    if (!isset($state['sync_files_queued'])) {
        $state['sync_files_queued'] = 0;
    }
    if (empty($state['sync_scan_dirs']) && $state['sync_scan_current_dir'] === '' && (int) $state['sync_scan_current_index'] === 0 && empty($state['sync_queue_built'])) {
        $state['sync_scan_dirs'] = array('');
    }

    if (!is_dir($temp_dir)) {
        @mkdir($temp_dir, 0755, true);
    }

    $queue_mode = file_exists($queue_path) ? 'ab' : 'wb';
    $queue_handle = fopen($queue_path, $queue_mode);
    if ($queue_handle === false) {
        $state['errors'][] = 'Failed to create sync delete queue file.';
        $state['status'] = 'failed';
        return $state;
    }

    $source_wp_content_dir = rtrim((string) $source_wp_content_dir, '/\\');
    if ($source_wp_content_dir === '' || !is_dir($source_wp_content_dir)) {
        fclose($queue_handle);
        $state['errors'][] = 'Effective restore source wp-content directory not found.';
        $state['status'] = 'failed';
        return $state;
    }

    $max_entries_per_request = 2500;
    $processed_entries = 0;

    while (true) {
        if (is_time_running_out($start_time) || $processed_entries >= $max_entries_per_request) {
            break;
        }

        if ($state['sync_scan_current_dir'] === '') {
            if (empty($state['sync_scan_dirs'])) {
                break;
            }
            $state['sync_scan_current_dir'] = (string) array_pop($state['sync_scan_dirs']);
            $state['sync_scan_current_index'] = 0;
        }

        $dir_rel = (string) $state['sync_scan_current_dir'];
        if (is_excluded_wp_content_directory($dir_rel)) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
            continue;
        }

        $abs_dir = rtrim((string) $wp_content_dir, '/\\');
        if ($dir_rel !== '') {
            $abs_dir .= DIRECTORY_SEPARATOR . $dir_rel;
        }
        if (!is_dir($abs_dir)) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
            continue;
        }

        $entries = @scandir($abs_dir);
        if (!is_array($entries)) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
            continue;
        }

        $i = (int) $state['sync_scan_current_index'];
        $total_entries = count($entries);

        for (; $i < $total_entries; $i++) {
            if (is_time_running_out($start_time) || $processed_entries >= $max_entries_per_request) {
                break;
            }
            $processed_entries++;

            $entry = (string) $entries[$i];
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            if ($entry !== '' && isset($entry[0]) && $entry[0] === '.') {
                continue;
            }

            $child_rel = ($dir_rel === '') ? $entry : ($dir_rel . '/' . $entry);
            $child_abs = $abs_dir . DIRECTORY_SEPARATOR . $entry;

            if (is_dir($child_abs)) {
                if (!is_excluded_wp_content_directory($child_rel)) {
                    $state['sync_scan_dirs'][] = $child_rel;
                }
                continue;
            }

            $zip_rel = str_replace('\\', '/', (string) $child_rel);
            if (!is_eligible_wp_content_entry('wp-content/' . $zip_rel)) {
                continue;
            }

            $source_abs = $source_wp_content_dir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $zip_rel);
            if (!file_exists($source_abs)) {
                fwrite($queue_handle, json_encode($child_rel) . "\n");
                $state['sync_files_queued'] = (int) $state['sync_files_queued'] + 1;
            }
        }

        $state['sync_scan_current_index'] = $i;
        if ($i >= $total_entries) {
            $state['sync_scan_current_dir'] = '';
            $state['sync_scan_current_index'] = 0;
        }
    }

    fclose($queue_handle);

    if ($state['sync_scan_current_dir'] === '' && empty($state['sync_scan_dirs'])) {
        $state['sync_queue_built'] = true;
    }

    return $state;
}

/**
 * Prune empty parent directories (best-effort), stopping at wp-content root and respecting exclusions.
 *
 * @param string $wp_content_dir Destination wp-content absolute path.
 * @param string $parent_rel_dir Parent directory relative to wp-content.
 * @param array  $state Restore state (for counters).
 * @return void
 */
function sync_prune_empty_parent_directories($wp_content_dir, $parent_rel_dir, &$state) {
    $parent_rel_dir = str_replace('\\', '/', (string) $parent_rel_dir);
    $parent_rel_dir = trim($parent_rel_dir, '/');
    static $preserved_root_directories = array(
        'uploads',
        'mu-plugins',
    );

    while ($parent_rel_dir !== '' && $parent_rel_dir !== '.' && $parent_rel_dir !== DIRECTORY_SEPARATOR) {
        // Keep core wp-content roots present even when they are temporarily empty.
        if (in_array(strtolower($parent_rel_dir), $preserved_root_directories, true)) {
            break;
        }

        if (is_excluded_wp_content_directory($parent_rel_dir)) {
            break;
        }

        $abs_dir = rtrim((string) $wp_content_dir, '/\\') . DIRECTORY_SEPARATOR . $parent_rel_dir;
        if (!is_dir($abs_dir)) {
            break;
        }

        $items = @scandir($abs_dir);
        if (!is_array($items)) {
            break;
        }

        // Only "." and ".." means empty.
        if (count($items) > 2) {
            break;
        }

        if (@rmdir($abs_dir)) {
            $state['sync_dirs_removed'] = isset($state['sync_dirs_removed']) ? ((int) $state['sync_dirs_removed'] + 1) : 1;
        } else {
            break;
        }

        $parent_rel_dir = trim(str_replace('\\', '/', dirname($parent_rel_dir)), '/');
    }
}

/**
 * Delete queued files from a delete queue (JSONL) in chunks, resuming by byte offset.
 *
 * @param string $wp_content_dir Destination wp-content absolute path.
 * @param array  $state Restore state (must include sync_delete_queue_path).
 * @param float  $start_time Request start time.
 * @return array Updated state.
 */
function sync_delete_from_queue_chunked($wp_content_dir, $state, $start_time) {
    if (empty($state['sync_delete_queue_path']) || !file_exists($state['sync_delete_queue_path'])) {
        $state['sync_done'] = true;
        return $state;
    }

    if (!isset($state['sync_delete_queue_byte_offset'])) {
        $state['sync_delete_queue_byte_offset'] = 0;
    }
    if (!isset($state['sync_files_deleted'])) {
        $state['sync_files_deleted'] = 0;
    }
    if (!isset($state['sync_dirs_removed'])) {
        $state['sync_dirs_removed'] = 0;
    }

    $byte_offset = max(0, (int) $state['sync_delete_queue_byte_offset']);
    $max_files_per_request = 750;
    $deleted_in_chunk = 0;

    $handle = fopen($state['sync_delete_queue_path'], 'rb');
    if ($handle === false) {
        $state['errors'][] = 'Failed to open sync delete queue file.';
        $state['status'] = 'failed';
        return $state;
    }

    if ($byte_offset !== 0) {
        fseek($handle, $byte_offset);
    }

    while (!feof($handle)) {
        if ($deleted_in_chunk >= $max_files_per_request) {
            break;
        }
        if (is_time_running_out($start_time)) {
            break;
        }

        $line = fgets($handle);
        if ($line === false) {
            break;
        }
        $line = trim($line);
        if ($line === '') {
            continue;
        }

        $relative_path = json_decode($line, true);
        if (empty($relative_path) || !is_string($relative_path)) {
            continue;
        }

        $normalized = str_replace('\\', '/', (string) $relative_path);
        if (!is_eligible_wp_content_entry('wp-content/' . $normalized)) {
            continue;
        }

        $dest_path = rtrim((string) $wp_content_dir, '/\\') . DIRECTORY_SEPARATOR . $relative_path;
        if (file_exists($dest_path)) {
            if (!@unlink($dest_path)) {
                fclose($handle);
                $state['errors'][] = 'Failed to delete file: ' . $relative_path;
                $state['status'] = 'failed';
                return $state;
            }
            $state['sync_files_deleted'] = (int) $state['sync_files_deleted'] + 1;
            $deleted_in_chunk++;

            $parent_rel = dirname($normalized);
            if ($parent_rel !== '.' && $parent_rel !== DIRECTORY_SEPARATOR) {
                sync_prune_empty_parent_directories($wp_content_dir, $parent_rel, $state);
            }
        }
    }

    $state['sync_delete_queue_byte_offset'] = ftell($handle);
    $done = feof($handle);
    fclose($handle);

    if ($done) {
        $state['sync_done'] = true;
    }

    return $state;
}

// ============================================================================
// STREAMING SQL IMPORT
// ============================================================================

/**
 * Check if a character at the given position is escaped by backslashes
 * Counts consecutive backslashes before the position - odd number means escaped
 *
 * @param string $line The line of text to check
 * @param int    $pos  The position to check
 * @return bool True if the character is escaped, false otherwise
 */
function is_escaped($line, $pos) {
    if ($pos === 0) {
        return false;
    }
    
    $backslash_count = 0;
    $check_pos = $pos - 1;
    
    while ($check_pos >= 0 && $line[$check_pos] === '\\') {
        $backslash_count++;
        $check_pos--;
    }
    
    return ($backslash_count % 2) === 1;
}

/**
 * Option name suffixes scoped to the table prefix.
 *
 * @return string[]
 */
function asenha_prefix_scoped_option_suffixes() {
    return array('user_roles');
}

/**
 * Usermeta key suffixes scoped to the table prefix.
 *
 * @return string[]
 */
function asenha_prefix_scoped_usermeta_suffixes() {
    return array(
        'capabilities',
        'user_level',
        'user-settings',
        'user-settings-time',
        'dashboard_quick_press_last_post_id',
        'media_library_mode',
    );
}

/**
 * Remap SQL table prefix without rewriting serialized value bodies.
 *
 * @param string $sql        SQL fragment.
 * @param string $old_prefix Source prefix.
 * @param string $new_prefix Destination prefix.
 * @return string
 */
function asenha_remap_sql_table_prefix($sql, $old_prefix, $new_prefix) {
    $sql = (string) $sql;
    $old_prefix = (string) $old_prefix;
    $new_prefix = (string) $new_prefix;

    if ($old_prefix === '' || $new_prefix === '' || $old_prefix === $new_prefix) {
        return $sql;
    }

    $sql = str_replace('`' . $old_prefix, '`' . $new_prefix, $sql);

    foreach (asenha_prefix_scoped_option_suffixes() as $suffix) {
        $suffix = (string) $suffix;
        if ($suffix === '') {
            continue;
        }
        $sql = str_replace("'" . $old_prefix . $suffix . "'", "'" . $new_prefix . $suffix . "'", $sql);
    }

    foreach (asenha_prefix_scoped_usermeta_suffixes() as $suffix) {
        $suffix = (string) $suffix;
        if ($suffix === '') {
            continue;
        }
        $sql = str_replace("'" . $old_prefix . $suffix . "'", "'" . $new_prefix . $suffix . "'", $sql);
        $pattern = "/'" . preg_quote($old_prefix, '/') . "(\\d+)_" . preg_quote($suffix, '/') . "'/";
        $replacement = "'" . $new_prefix . '$1_' . $suffix . "'";
        $rewritten = preg_replace($pattern, $replacement, $sql);
        if (is_string($rewritten)) {
            $sql = $rewritten;
        }
    }

    return $sql;
}

/**
 * Extract the table/view name from a DROP/CREATE/INSERT SQL statement.
 *
 * @param string $statement SQL statement.
 * @return string
 */
function asenha_extract_sql_statement_table_name($statement) {
    $statement = trim((string) $statement);
    if ($statement === '') {
        return '';
    }
    if (preg_match('/^DROP\s+(?:TABLE|VIEW)\s+IF\s+EXISTS\s+`?([^`;\s]+)`?/i', $statement, $matches)) {
        return (string) $matches[1];
    }
    if (preg_match('/^CREATE\s+TABLE\s+`?([^`\s(]+)`?/i', $statement, $matches)) {
        return (string) $matches[1];
    }
    if (preg_match('/^(INSERT|REPLACE)\s+(IGNORE\s+)?INTO\s+`?([^`\s(]+)`?/i', $statement, $matches)) {
        return (string) $matches[3];
    }
    if (preg_match('/^CREATE\s+(?:ALGORITHM\s*=\s*\w+\s+)?(?:DEFINER\s*=\s*`?[^`]+`?@`?[^`]+`?\s+)?(?:SQL\s+SECURITY\s+\w+\s+)?(?:OR\s+REPLACE\s+)?VIEW\s+`?([^\s`(]+)`?/i', $statement, $matches)) {
        return (string) $matches[1];
    }
    return '';
}

/**
 * Collect dumped table/view names from DROP/CREATE lines in a SQL file.
 *
 * @param string $sql_file Path to SQL dump.
 * @return string[]
 */
function asenha_collect_dumped_table_names_from_sql_file($sql_file) {
    $names = array();
    if (!is_string($sql_file) || $sql_file === '' || !file_exists($sql_file)) {
        return $names;
    }
    $handle = fopen($sql_file, 'r');
    if ($handle === false) {
        return $names;
    }
    while (($line = fgets($handle)) !== false) {
        $trim = ltrim((string) $line);
        if ($trim === '' || strpos($trim, '--') === 0 || strpos($trim, '/*') === 0) {
            continue;
        }
        $name = asenha_extract_sql_statement_table_name($trim);
        if ($name === '' && preg_match('/^(?:DROP\s+(?:TABLE|VIEW)\s+IF\s+EXISTS|CREATE\s+TABLE)\s+`([^`]+)`/i', $trim, $matches)) {
            $name = (string) $matches[1];
        }
        if ($name !== '') {
            $names[$name] = true;
        }
    }
    fclose($handle);
    return array_keys($names);
}

/**
 * Destination table names occupied by remapped source-prefix tables.
 *
 * @param string[] $table_names   Original dump table names.
 * @param string   $source_prefix Source prefix.
 * @param string   $dest_prefix   Destination prefix.
 * @return array<string,bool>
 */
function asenha_build_prefix_collision_table_names($table_names, $source_prefix, $dest_prefix) {
    $source_prefix = (string) $source_prefix;
    $dest_prefix = (string) $dest_prefix;
    $collision = array();
    if ($source_prefix === '' || $dest_prefix === '' || $source_prefix === $dest_prefix) {
        return $collision;
    }
    foreach ((array) $table_names as $name) {
        $name = (string) $name;
        if ($name === '' || strpos($name, $source_prefix) !== 0) {
            continue;
        }
        $collision[$dest_prefix . substr($name, strlen($source_prefix))] = true;
    }
    return $collision;
}

/**
 * Whether an original dump table would overwrite remapped live tables.
 *
 * @param string             $original_table_name Original table name.
 * @param string             $source_prefix       Source prefix.
 * @param string             $dest_prefix         Destination prefix.
 * @param array<string,bool> $collision_names     Collision set.
 * @return bool
 */
function asenha_should_skip_prefix_collision_table($original_table_name, $source_prefix, $dest_prefix, $collision_names) {
    $original_table_name = (string) $original_table_name;
    $source_prefix = (string) $source_prefix;
    $dest_prefix = (string) $dest_prefix;
    if ($original_table_name === '' || $source_prefix === '' || $dest_prefix === '' || $source_prefix === $dest_prefix) {
        return false;
    }
    if (strpos($original_table_name, $source_prefix) === 0) {
        return false;
    }
    return isset($collision_names[$original_table_name]);
}

/**
 * Post-import safety net for prefix-scoped option/meta keys (mysqli).
 *
 * @param mysqli $mysqli     Database connection.
 * @param string $old_prefix Source prefix.
 * @param string $new_prefix Destination prefix (also used for table names).
 * @return array{options:int,usermeta:int}
 */
function asenha_remap_prefix_scoped_option_and_meta_keys_mysqli($mysqli, $old_prefix, $new_prefix) {
    $stats = array('options' => 0, 'usermeta' => 0);
    $old_prefix = (string) $old_prefix;
    $new_prefix = (string) $new_prefix;

    if (!($mysqli instanceof mysqli) || $old_prefix === '' || $new_prefix === '' || $old_prefix === $new_prefix) {
        return $stats;
    }
    if (!preg_match('/^[A-Za-z0-9_]+$/', $new_prefix)) {
        return $stats;
    }

    $options_table = str_replace('`', '``', $new_prefix . 'options');
    $usermeta_table = str_replace('`', '``', $new_prefix . 'usermeta');

    foreach (asenha_prefix_scoped_option_suffixes() as $suffix) {
        $suffix = (string) $suffix;
        if ($suffix === '') {
            continue;
        }
        $old_name = $mysqli->real_escape_string($old_prefix . $suffix);
        $new_name = $mysqli->real_escape_string($new_prefix . $suffix);
        if ($mysqli->query("UPDATE `{$options_table}` SET option_name = '{$new_name}' WHERE option_name = '{$old_name}'")) {
            $stats['options'] += (int) $mysqli->affected_rows;
        }
    }

    foreach (asenha_prefix_scoped_usermeta_suffixes() as $suffix) {
        $suffix = (string) $suffix;
        if ($suffix === '') {
            continue;
        }
        $old_key = $mysqli->real_escape_string($old_prefix . $suffix);
        $new_key = $mysqli->real_escape_string($new_prefix . $suffix);
        if ($mysqli->query("UPDATE `{$usermeta_table}` SET meta_key = '{$new_key}' WHERE meta_key = '{$old_key}'")) {
            $stats['usermeta'] += (int) $mysqli->affected_rows;
        }

        $like = $mysqli->real_escape_string(addcslashes($old_prefix, '\\%_') . '%_' . addcslashes($suffix, '\\%_'));
        $result = $mysqli->query("SELECT umeta_id, meta_key FROM `{$usermeta_table}` WHERE meta_key LIKE '{$like}'");
        if ($result === false) {
            continue;
        }
        $pattern = '/^' . preg_quote($old_prefix, '/') . '(\\d+)_' . preg_quote($suffix, '/') . '$/';
        while ($row = $result->fetch_assoc()) {
            $umeta_id = isset($row['umeta_id']) ? (int) $row['umeta_id'] : 0;
            $meta_key = isset($row['meta_key']) ? (string) $row['meta_key'] : '';
            if ($umeta_id <= 0 || $meta_key === '' || !preg_match($pattern, $meta_key, $matches)) {
                continue;
            }
            $remapped = $new_prefix . $matches[1] . '_' . $suffix;
            if ($remapped === $meta_key) {
                continue;
            }
            $remapped_esc = $mysqli->real_escape_string($remapped);
            if ($mysqli->query("UPDATE `{$usermeta_table}` SET meta_key = '{$remapped_esc}' WHERE umeta_id = {$umeta_id}")) {
                $stats['usermeta'] += (int) $mysqli->affected_rows;
            }
        }
        $result->free();
    }

    return $stats;
}

/**
 * Import SQL file using line-by-line streaming with chunked execution
 * This avoids loading the entire SQL file into memory and handles comments properly
 */
function streaming_sql_import($sql_file, $db_host, $db_name, $db_user, $db_password, $db_charset, $manifest_prefix, $current_prefix, $state, $start_time) {
    // Disable mysqli exception mode for consistent error handling across PHP versions
    // PHP 8.1+ throws exceptions by default which bypasses our error recovery logic
    mysqli_report(MYSQLI_REPORT_OFF);

    global $db_collate;
    $target_collate = (!empty($db_collate) && is_string($db_collate) && stripos($db_collate, 'utf8mb4_') === 0)
        ? $db_collate
        : 'utf8mb4_unicode_ci';
    if (!isset($state['collation_remap_count'])) {
        $state['collation_remap_count'] = 0;
    }
    if (!isset($state['collation_remap_target']) || !is_string($state['collation_remap_target']) || $state['collation_remap_target'] === '') {
        $state['collation_remap_target'] = $target_collate;
    }
    
    // Connect to database
    $mysqli = asenha_emergency_mysqli_connect($db_host, $db_user, $db_password, $db_name);
    
    if ($mysqli->connect_error) {
        $state['errors'][] = 'Database connection failed: ' . $mysqli->connect_error;
        $state['status'] = 'failed';
        return $state;
    }
    
    // Always use utf8mb4 for maximum compatibility with 4-byte UTF-8 characters (emojis, etc.)
    // This is backward compatible with utf8 and prevents "Incorrect string value" errors
    $mysqli->set_charset('utf8mb4');
    
    // Open file for reading
    $handle = fopen($sql_file, 'r');
    if ($handle === false) {
        $mysqli->close();
        $state['errors'][] = 'Failed to open SQL file';
        $state['status'] = 'failed';
        return $state;
    }
    
    // Always disable foreign key checks for each batch/connection
    // These are session variables that reset when connection closes
    $mysqli->query('SET FOREIGN_KEY_CHECKS = 0');
    $mysqli->query('SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"');
    
    // Seek to saved position if resuming
    if ($state['sql_position'] > 0) {
        fseek($handle, $state['sql_position']);
    }
    
    $current_statement = '';
    $in_string = false;
    $string_char = '';
    $statements_executed = 0;
    $need_prefix_replace = !empty($manifest_prefix) && $manifest_prefix !== $current_prefix;
    $should_pause = false;

    if (!isset($state['prefix_collision_names']) || !is_array($state['prefix_collision_names'])) {
        $state['prefix_collision_names'] = $need_prefix_replace
            ? asenha_build_prefix_collision_table_names(
                asenha_collect_dumped_table_names_from_sql_file($sql_file),
                $manifest_prefix,
                $current_prefix
            )
            : array();
    }
    $collision_names = $state['prefix_collision_names'];
    if (!isset($state['skipped_prefix_collision_tables']) || !is_array($state['skipped_prefix_collision_tables'])) {
        $state['skipped_prefix_collision_tables'] = array();
    }

    // Track DB objects (tables/views) referenced by the SQL file across resume batches.
    if (!isset($state['sql_objects']) || !is_array($state['sql_objects'])) {
        $state['sql_objects'] = array();
    }
    
    // Track file position BEFORE reading each line for accurate resume
    $line_start_pos = ftell($handle);
    
    // Read file line by line to handle comments properly
    // This ensures we never save position in the middle of a comment
    while (($line = fgets($handle)) !== false) {
        // Check time limit at line boundaries (safe to pause here)
        if (is_time_running_out($start_time)) {
            $should_pause = true;
        }
        
        // Check batch size at line boundaries
        if ($statements_executed >= SQL_BATCH_SIZE) {
            $should_pause = true;
        }
        
        // If we need to pause and we're not in the middle of a statement, pause now
        // Use $line_start_pos (position BEFORE reading this line) so we re-read it on resume
        if ($should_pause && empty(trim($current_statement)) && !$in_string) {
            $state['sql_position'] = $line_start_pos;
            $state['sql_statements_executed'] += $statements_executed;
            fclose($handle);
            $mysqli->close();
            return $state;
        }
        
        $trimmed_line = trim($line);
        
        // Skip empty lines
        if (empty($trimmed_line)) {
            // Update position before continue so it's not stale on next iteration
            $line_start_pos = ftell($handle);
            continue;
        }
        
        // Skip comment lines (-- style) when not in a statement
        if (empty($current_statement) && !$in_string) {
            if (strpos($trimmed_line, '--') === 0) {
                // Update position before continue so it's not stale on next iteration
                $line_start_pos = ftell($handle);
                continue;
            }
            // Skip /* comment lines that start a block comment
            if (strpos($trimmed_line, '/*') === 0 && strpos($trimmed_line, '*/') !== false) {
                // Update position before continue so it's not stale on next iteration
                $line_start_pos = ftell($handle);
                continue;
            }
        }
        
        // Process line character by character
        $len = strlen($line);
        for ($i = 0; $i < $len; $i++) {
            $char = $line[$i];
            
            // Handle string literals - use proper escape detection that counts
            // consecutive backslashes (odd count = escaped, even count = not escaped)
            if (($char === "'" || $char === '"') && !is_escaped($line, $i)) {
                if (!$in_string) {
                    $in_string = true;
                    $string_char = $char;
                } elseif ($char === $string_char) {
                    $in_string = false;
                }
            }
            
            // Check for inline comment start (-- outside of string)
            if (!$in_string && $char === '-' && $i + 1 < $len && $line[$i + 1] === '-') {
                // Skip rest of line (it's a comment)
                break;
            }
            
            // Check for statement delimiter
            if ($char === ';' && !$in_string) {
                $statement = trim($current_statement);
                $current_statement = '';
                
                if (empty($statement)) {
                    continue;
                }
                
                // Skip certain statements
                if (preg_match('/^(SET|START|COMMIT|\/\*!)/i', $statement)) {
                    continue;
                }

                $original_table_name = asenha_extract_sql_statement_table_name($statement);
                if ($original_table_name !== '' && asenha_should_skip_prefix_collision_table($original_table_name, $manifest_prefix, $current_prefix, $collision_names)) {
                    if (empty($state['skipped_prefix_collision_tables'][$original_table_name])) {
                        $state['skipped_prefix_collision_tables'][$original_table_name] = true;
                        $state['messages'][] = 'Skipped leftover table that would overwrite remapped data: ' . $original_table_name;
                    }
                    $statements_executed++;
                    continue;
                }

                if ($need_prefix_replace) {
                    $statement = asenha_remap_sql_table_prefix($statement, $manifest_prefix, $current_prefix);
                }
                
                // Convert utf8 charset to utf8mb4 in CREATE TABLE statements
                // This ensures 4-byte UTF-8 characters (emojis, special symbols) can be stored
                if (preg_match('/^CREATE\s+TABLE/i', $statement)) {
                    // Convert CHARSET=utf8 to CHARSET=utf8mb4 (but not utf8mb4 which is already correct)
                    $statement = preg_replace(
                        '/CHARSET\s*=\s*utf8([^m]|$)/i',
                        'CHARSET=utf8mb4$1',
                        $statement
                    );
                    // Convert CHARACTER SET utf8 to CHARACTER SET utf8mb4
                    $statement = preg_replace(
                        '/CHARACTER SET utf8([^m]|$)/i',
                        'CHARACTER SET utf8mb4$1',
                        $statement
                    );
                    // Convert COLLATE utf8_* to COLLATE utf8mb4_*
                    // Preserve original format (with or without equals) for compatibility
                    // Column-level: COLLATE utf8_* -> COLLATE utf8mb4_*
                    // Table-level: COLLATE=utf8_* -> COLLATE=utf8mb4_*
                    $statement = preg_replace(
                        '/COLLATE(\s*=?\s*)utf8_(\w+)/i',
                        'COLLATE$1utf8mb4_$2',
                        $statement
                    );
                    // Remap MySQL 8 utf8mb4_*0900* collations unsupported on MariaDB / MySQL 5.7.
                    $statement = preg_replace_callback(
                        '/utf8mb4_[a-z0-9_]*0900_[a-z0-9_]+/i',
                        function ($matches) use ($target_collate, &$state) {
                            $found = (string) $matches[0];
                            $replacement = preg_match('/_bin$/i', $found) ? 'utf8mb4_bin' : $target_collate;
                            if (strcasecmp($found, $replacement) === 0) {
                                return $found;
                            }
                            $state['collation_remap_count'] = (int) $state['collation_remap_count'] + 1;
                            $state['collation_remap_target'] = $replacement;
                            return $replacement;
                        },
                        $statement
                    );
                }

                // Track SQL objects for cleanup (tables/views that should exist after restore).
                // Only track objects under the current prefix for safety.
                if (preg_match('/^DROP\s+VIEW\s+IF\s+EXISTS\s+`?([^`;\s]+)`?/i', $statement, $obj_matches)) {
                    if (strpos($obj_matches[1], $current_prefix) === 0) {
                        $state['sql_objects'][$obj_matches[1]] = true;
                    }
                }
                if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+`?([^`;\s]+)`?/i', $statement, $obj_matches)) {
                    if (strpos($obj_matches[1], $current_prefix) === 0) {
                        $state['sql_objects'][$obj_matches[1]] = true;
                    }
                }
                if (preg_match('/^CREATE\s+TABLE\s+`?([^`\s(]+)`?/i', $statement, $obj_matches)) {
                    if (strpos($obj_matches[1], $current_prefix) === 0) {
                        $state['sql_objects'][$obj_matches[1]] = true;
                    }
                }
                if (preg_match('/^CREATE\s+(?:ALGORITHM\s*=\s*\w+\s+)?(?:DEFINER\s*=\s*`?[^`]+`?@`?[^`]+`?\s+)?(?:SQL\s+SECURITY\s+\w+\s+)?(?:OR\s+REPLACE\s+)?VIEW\s+`?([^\s`(]+)`?/i', $statement, $obj_matches)) {
                    if (strpos($obj_matches[1], $current_prefix) === 0) {
                        $state['sql_objects'][$obj_matches[1]] = true;
                    }
                }
                
                // Before executing CREATE VIEW statements, drop any existing table/view with same name
                // This handles cases where a previous incomplete restore left an object with the same name
                // or where the SQL dump lacks DROP VIEW IF EXISTS statements
                if (preg_match('/^CREATE\s+(?:ALGORITHM\s*=\s*\w+\s+)?(?:DEFINER\s*=\s*`?[^`]+`?@`?[^`]+`?\s+)?(?:SQL\s+SECURITY\s+\w+\s+)?(?:OR\s+REPLACE\s+)?VIEW\s+`?([^\s`(]+)`?/i', $statement, $view_matches)) {
                    $view_name = $view_matches[1];
                    // Track view name for cleanup list.
                    if (strpos($view_name, $current_prefix) === 0) {
                        $state['sql_objects'][$view_name] = true;
                    }
                    // Drop any existing object with this name (could be table or view)
                    $mysqli->query("DROP VIEW IF EXISTS `{$view_name}`");
                    $mysqli->query("DROP TABLE IF EXISTS `{$view_name}`");
                }
                
                // Execute statement with exception handling for PHP 8.1+ mysqli
                try {
                    $query_result = $mysqli->query($statement);
                    if ($query_result === false) {
                        $error_msg = $mysqli->error;
                        
                        // Check for duplicate key error on *_options table.
                        // Accept legacy "for key 'option_name'" and MySQL 8/MariaDB qualified
                        // "for key 'wp_options.option_name'" (and errno 1062 as fallback).
                        // This can happen with backups created before deterministic export fix.
                        if ((preg_match('/Duplicate entry .+ for key [\'"`]?(?:\S+\.)?option_name/i', $error_msg) || 1062 === (int) $mysqli->errno) &&
                            preg_match('/^INSERT\s+(IGNORE\s+)?INTO\s+`?' . preg_quote($current_prefix, '/') . 'options`?/i', $statement)) {
                            
                            // Retry as REPLACE INTO to handle duplicate option_name gracefully
                            $replace_statement = preg_replace('/^INSERT(\s+IGNORE)?\s+INTO/i', 'REPLACE INTO', $statement);
                            $retry_result = $mysqli->query($replace_statement);
                            
                            if ($retry_result !== false) {
                                // Success - log warning and continue
                                $state['messages'][] = 'Warning: Used REPLACE INTO for options table due to duplicate key';
                                $statements_executed++;
                            } else {
                                throw new Exception($mysqli->error);
                            }
                        } elseif (preg_match('/Duplicate entry .* for key .PRIMARY/i', $error_msg) &&
                            preg_match('/^INSERT\s+(IGNORE\s+)?INTO/i', $statement)) {
                            // Handle generic duplicate PRIMARY key error on any INSERT statement
                            // This can happen when restore is interrupted and resumed, causing some rows
                            // to be re-inserted. Using REPLACE INTO ensures backup data takes precedence.
                            $replace_statement = preg_replace('/^INSERT(\s+IGNORE)?\s+INTO/i', 'REPLACE INTO', $statement);
                            $retry_result = $mysqli->query($replace_statement);
                            
                            if ($retry_result !== false) {
                                // Success - continue without logging to avoid spam on large restores
                                $statements_executed++;
                            } else {
                                throw new Exception($mysqli->error);
                            }
                        } elseif (preg_match('/Table .* already exists/i', $error_msg) &&
                            preg_match('/^CREATE\s+(?:ALGORITHM|OR\s+REPLACE\s+)?.*VIEW/i', $statement)) {
                            // Check for "already exists" error on VIEW creation (fallback if pre-drop failed)
                            // Extract view name and drop it, then retry
                            if (preg_match('/VIEW\s+`?([^\s`(]+)`?/i', $statement, $view_matches)) {
                                $view_name = $view_matches[1];
                                $mysqli->query("DROP VIEW IF EXISTS `{$view_name}`");
                                $mysqli->query("DROP TABLE IF EXISTS `{$view_name}`");
                                $retry_result = $mysqli->query($statement);
                                if ($retry_result !== false) {
                                    $state['messages'][] = 'Warning: Dropped existing object before creating view ' . $view_name;
                                    $statements_executed++;
                                } else {
                                    throw new Exception($mysqli->error);
                                }
                            } else {
                                throw new Exception($error_msg);
                            }
                        } elseif (preg_match('/not insertable-into/i', $error_msg) &&
                            preg_match('/^INSERT\s+/i', $statement)) {
                            // Skip INSERT into non-insertable VIEW - views don't store data
                            // The underlying tables already have the data
                            $state['messages'][] = 'Warning: Skipped INSERT into non-insertable view';
                            $statements_executed++;
                        } else {
                            throw new Exception($error_msg);
                        }
                    } else {
                        $statements_executed++;
                    }
                } catch (Exception $e) {
                    $state['errors'][] = 'SQL Error: ' . $e->getMessage() . ' | Statement: ' . substr($statement, 0, 200);
                    $state['status'] = 'failed';
                    fclose($handle);
                    $mysqli->close();
                    return $state;
                }
                
                // If we need to pause, do it after completing this statement
                // Save precise position after semicolon to not lose any following content
                if ($should_pause) {
                    $state['sql_position'] = $line_start_pos + $i + 1;
                    $state['sql_statements_executed'] += $statements_executed;
                    fclose($handle);
                    $mysqli->close();
                    return $state;
                }
            } else {
                $current_statement .= $char;
            }
        }
        
        // Update position for next iteration (position after this line)
        $line_start_pos = ftell($handle);
    }
    
    // Execute any remaining statement
    $statement = trim($current_statement);
    if (!empty($statement) && !preg_match('/^(SET|START|COMMIT|\/\*!)/i', $statement)) {
        $original_table_name = asenha_extract_sql_statement_table_name($statement);
        $skip_remaining = ($original_table_name !== '' && asenha_should_skip_prefix_collision_table($original_table_name, $manifest_prefix, $current_prefix, $collision_names));
        if ($skip_remaining) {
            if (empty($state['skipped_prefix_collision_tables'][$original_table_name])) {
                $state['skipped_prefix_collision_tables'][$original_table_name] = true;
                $state['messages'][] = 'Skipped leftover table that would overwrite remapped data: ' . $original_table_name;
            }
        } else {
        if ($need_prefix_replace) {
            $statement = asenha_remap_sql_table_prefix($statement, $manifest_prefix, $current_prefix);
        }
        // Convert utf8 charset to utf8mb4 in CREATE TABLE statements
        if (preg_match('/^CREATE\s+TABLE/i', $statement)) {
            $statement = preg_replace('/CHARSET\s*=\s*utf8([^m]|$)/i', 'CHARSET=utf8mb4$1', $statement);
            $statement = preg_replace('/CHARACTER SET utf8([^m]|$)/i', 'CHARACTER SET utf8mb4$1', $statement);
            $statement = preg_replace('/COLLATE(\s*=?\s*)utf8_(\w+)/i', 'COLLATE$1utf8mb4_$2', $statement);
            $statement = preg_replace_callback(
                '/utf8mb4_[a-z0-9_]*0900_[a-z0-9_]+/i',
                function ($matches) use ($target_collate, &$state) {
                    $found = (string) $matches[0];
                    $replacement = preg_match('/_bin$/i', $found) ? 'utf8mb4_bin' : $target_collate;
                    if (strcasecmp($found, $replacement) === 0) {
                        return $found;
                    }
                    $state['collation_remap_count'] = (int) $state['collation_remap_count'] + 1;
                    $state['collation_remap_target'] = $replacement;
                    return $replacement;
                },
                $statement
            );
        }

        // Track SQL objects for cleanup (tables/views that should exist after restore).
        // Only track objects under the current prefix for safety.
        if (preg_match('/^DROP\s+VIEW\s+IF\s+EXISTS\s+`?([^`;\s]+)`?/i', $statement, $obj_matches)) {
            if (strpos($obj_matches[1], $current_prefix) === 0) {
                $state['sql_objects'][$obj_matches[1]] = true;
            }
        }
        if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+`?([^`;\s]+)`?/i', $statement, $obj_matches)) {
            if (strpos($obj_matches[1], $current_prefix) === 0) {
                $state['sql_objects'][$obj_matches[1]] = true;
            }
        }
        if (preg_match('/^CREATE\s+TABLE\s+`?([^`\s(]+)`?/i', $statement, $obj_matches)) {
            if (strpos($obj_matches[1], $current_prefix) === 0) {
                $state['sql_objects'][$obj_matches[1]] = true;
            }
        }
        if (preg_match('/^CREATE\s+(?:ALGORITHM\s*=\s*\w+\s+)?(?:DEFINER\s*=\s*`?[^`]+`?@`?[^`]+`?\s+)?(?:SQL\s+SECURITY\s+\w+\s+)?(?:OR\s+REPLACE\s+)?VIEW\s+`?([^\s`(]+)`?/i', $statement, $obj_matches)) {
            if (strpos($obj_matches[1], $current_prefix) === 0) {
                $state['sql_objects'][$obj_matches[1]] = true;
            }
        }
        
        // Before executing CREATE VIEW statements, drop any existing table/view with same name
        if (preg_match('/^CREATE\s+(?:ALGORITHM\s*=\s*\w+\s+)?(?:DEFINER\s*=\s*`?[^`]+`?@`?[^`]+`?\s+)?(?:SQL\s+SECURITY\s+\w+\s+)?(?:OR\s+REPLACE\s+)?VIEW\s+`?([^\s`(]+)`?/i', $statement, $view_matches)) {
            $view_name = $view_matches[1];
            // Track view name for cleanup list.
            if (strpos($view_name, $current_prefix) === 0) {
                $state['sql_objects'][$view_name] = true;
            }
            $mysqli->query("DROP VIEW IF EXISTS `{$view_name}`");
            $mysqli->query("DROP TABLE IF EXISTS `{$view_name}`");
        }
        
        $query_result = $mysqli->query($statement);
        if ($query_result !== false) {
            $statements_executed++;
        } else {
            $error_msg = $mysqli->error;
            
            // Check for duplicate key error on *_options table.
            // Accept legacy and MySQL 8/MariaDB qualified option_name key names (and errno 1062).
            // This can happen with backups created before deterministic export fix.
            if ((preg_match('/Duplicate entry .+ for key [\'"`]?(?:\S+\.)?option_name/i', $error_msg) || 1062 === (int) $mysqli->errno) &&
                preg_match('/^INSERT\s+(IGNORE\s+)?INTO\s+`?' . preg_quote($current_prefix, '/') . 'options`?/i', $statement)) {
                
                // Retry as REPLACE INTO to handle duplicate option_name gracefully
                $replace_statement = preg_replace('/^INSERT(\s+IGNORE)?\s+INTO/i', 'REPLACE INTO', $statement);
                $retry_result = $mysqli->query($replace_statement);
                
                if ($retry_result !== false) {
                    $state['messages'][] = 'Warning: Used REPLACE INTO for options table due to duplicate key';
                    $statements_executed++;
                }
            } elseif (preg_match('/Table .* already exists/i', $error_msg) &&
                preg_match('/^CREATE\s+(?:ALGORITHM|OR\s+REPLACE\s+)?.*VIEW/i', $statement)) {
                // Check for "already exists" error on VIEW creation (fallback if pre-drop failed)
                if (preg_match('/VIEW\s+`?([^\s`(]+)`?/i', $statement, $view_matches)) {
                    $view_name = $view_matches[1];
                    $mysqli->query("DROP VIEW IF EXISTS `{$view_name}`");
                    $mysqli->query("DROP TABLE IF EXISTS `{$view_name}`");
                    $retry_result = $mysqli->query($statement);
                    if ($retry_result !== false) {
                        $state['messages'][] = 'Warning: Dropped existing object before creating view ' . $view_name;
                        $statements_executed++;
                    }
                }
            } elseif (preg_match('/not insertable-into/i', $error_msg) &&
                preg_match('/^INSERT\s+/i', $statement)) {
                // Skip INSERT into non-insertable VIEW - views don't store data
                // The underlying tables already have the data
                $state['messages'][] = 'Warning: Skipped INSERT into non-insertable view';
                $statements_executed++;
            }
        }
        } // end skip-remaining else
    }

    // Cleanup: drop leftover prefixed DB objects not present in the backup SQL.
    // Guardrail: if we couldn't detect any SQL objects, do not drop anything.
    if (!preg_match('/^[A-Za-z0-9_]+$/', $current_prefix)) {
        $state['errors'][] = 'Invalid database table prefix for cleanup';
        $state['status'] = 'failed';
        fclose($handle);
        $mysqli->close();
        return $state;
    }

    if (!empty($state['sql_objects']) && is_array($state['sql_objects'])) {
        $like = addcslashes($current_prefix, '\\%_') . '%';
        $like_sql = $mysqli->real_escape_string($like);
        $result = $mysqli->query("SHOW FULL TABLES LIKE '{$like_sql}'");

        if ($result === false) {
            $state['errors'][] = 'Database cleanup failed (list tables): ' . $mysqli->error;
            $state['status'] = 'failed';
            fclose($handle);
            $mysqli->close();
            return $state;
        }

        $dropped = 0;
        while ($row = $result->fetch_array(MYSQLI_NUM)) {
            $object_name = isset($row[0]) ? (string) $row[0] : '';
            $object_type = isset($row[1]) ? (string) $row[1] : 'BASE TABLE';

            if (empty($object_name) || strpos($object_name, $current_prefix) !== 0) {
                continue;
            }

            if (!isset($state['sql_objects'][$object_name])) {
                $safe_name = str_replace('`', '``', $object_name);

                if (strtoupper($object_type) === 'VIEW') {
                    if ($mysqli->query("DROP VIEW IF EXISTS `{$safe_name}`") === false) {
                        $state['errors'][] = 'Database cleanup failed (drop view): ' . $mysqli->error;
                        $state['status'] = 'failed';
                        $result->free();
                        fclose($handle);
                        $mysqli->close();
                        return $state;
                    }
                    if ($mysqli->query("DROP TABLE IF EXISTS `{$safe_name}`") === false) {
                        $state['errors'][] = 'Database cleanup failed (drop table for view name): ' . $mysqli->error;
                        $state['status'] = 'failed';
                        $result->free();
                        fclose($handle);
                        $mysqli->close();
                        return $state;
                    }
                } else {
                    if ($mysqli->query("DROP TABLE IF EXISTS `{$safe_name}`") === false) {
                        $state['errors'][] = 'Database cleanup failed (drop table): ' . $mysqli->error;
                        $state['status'] = 'failed';
                        $result->free();
                        fclose($handle);
                        $mysqli->close();
                        return $state;
                    }
                }

                $dropped++;
            }
        }
        $result->free();

        $state['messages'][] = "Removed {$dropped} leftover database tables/views not in the backup SQL";
    } else {
        $state['messages'][] = 'Skipped database cleanup: could not detect any tables in SQL file';
    }
    
    // Re-enable foreign key checks
    $mysqli->query('SET FOREIGN_KEY_CHECKS = 1');

    // Safety net: rename any leftover source-prefixed option/meta keys.
    if ($need_prefix_replace) {
        $prefix_key_remap = asenha_remap_prefix_scoped_option_and_meta_keys_mysqli($mysqli, $manifest_prefix, $current_prefix);
        if (!empty($prefix_key_remap['options']) || !empty($prefix_key_remap['usermeta'])) {
            $state['messages'][] = 'Remapped prefix-scoped option/meta keys after SQL import.';
        }
    }
    
    fclose($handle);
    $mysqli->close();
    
    // SQL import complete
    $state['sql_position'] = 0;
    $state['sql_statements_executed'] += $statements_executed;
    $state['sql_complete'] = true;
    $state['sql_executed'] = $state['sql_statements_executed'];
    $state['messages'][] = "Executed {$state['sql_statements_executed']} SQL statements";
    
    return $state;
}

/**
 * Sanitize active_plugins payload from backup manifest.
 *
 * @param mixed  $plugins        Raw active_plugins payload.
 * @param string $wp_content_dir Destination wp-content directory.
 * @return array Sanitized plugin basenames.
 */
function asenha_sanitize_manifest_active_plugins($plugins, $wp_content_dir) {
    if (!is_array($plugins)) {
        return array();
    }

    $wp_plugins_dir = rtrim((string) $wp_content_dir, '/\\') . DIRECTORY_SEPARATOR . 'plugins';
    $sanitized = array();

    foreach ($plugins as $plugin_file) {
        $plugin_file = str_replace('\\', '/', (string) $plugin_file);
        $plugin_file = ltrim($plugin_file, '/');
        $plugin_file = preg_replace('#/+#', '/', $plugin_file);
        if (!is_string($plugin_file) || $plugin_file === '') {
            continue;
        }
        if (preg_match('#(^|/)\.\.(?:/|$)#', $plugin_file)) {
            continue;
        }
        if (preg_match('/^[A-Za-z0-9._\/-]+$/', $plugin_file) !== 1) {
            continue;
        }
        if ('.php' !== substr(strtolower($plugin_file), -4)) {
            continue;
        }

        // Do not activate plugins whose main files are absent in restored wp-content.
        $plugin_abs = $wp_plugins_dir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $plugin_file);
        if (!file_exists($plugin_abs) || !is_file($plugin_abs)) {
            continue;
        }

        $sanitized[] = $plugin_file;
    }

    return array_values(array_unique($sanitized));
}

/**
 * Reconcile active_plugins option from backup manifest metadata.
 *
 * @param array  $manifest       Parsed manifest array.
 * @param string $db_host        Database host.
 * @param string $db_name        Database name.
 * @param string $db_user        Database user.
 * @param string $db_password    Database password.
 * @param string $db_prefix      Current WordPress DB prefix.
 * @param string $wp_content_dir Destination wp-content directory.
 * @return array{ok:bool,skipped:bool,count:int,error:string}
 */
function asenha_reconcile_active_plugins_from_manifest($manifest, $db_host, $db_name, $db_user, $db_password, $db_prefix, $wp_content_dir) {
    $result = array(
        'ok' => false,
        'skipped' => false,
        'count' => 0,
        'error' => '',
    );

    if (!is_array($manifest) || !array_key_exists('active_plugins', $manifest) || !is_array($manifest['active_plugins'])) {
        $result['ok'] = true;
        $result['skipped'] = true;
        return $result;
    }

    if (!preg_match('/^[A-Za-z0-9_]+$/', (string) $db_prefix)) {
        $result['error'] = 'Invalid database table prefix for active_plugins reconciliation.';
        return $result;
    }

    $active_plugins = asenha_sanitize_manifest_active_plugins($manifest['active_plugins'], $wp_content_dir);
    $serialized_plugins = serialize($active_plugins);
    $options_table = $db_prefix . 'options';
    $safe_options_table = '`' . str_replace('`', '``', $options_table) . '`';

    mysqli_report(MYSQLI_REPORT_OFF);
    $mysqli = asenha_emergency_mysqli_connect($db_host, $db_user, $db_password, $db_name);
    if ($mysqli->connect_error) {
        $result['error'] = 'Database connection failed while reconciling active plugins: ' . $mysqli->connect_error;
        return $result;
    }

    $mysqli->set_charset('utf8mb4');
    $plugins_esc = $mysqli->real_escape_string($serialized_plugins);
    $query = "REPLACE INTO {$safe_options_table} (`option_name`,`option_value`,`autoload`) VALUES ('active_plugins','{$plugins_esc}','yes')";
    $ok = $mysqli->query($query);
    if ($ok === false) {
        $result['error'] = 'Failed updating active_plugins option: ' . $mysqli->error;
        $mysqli->close();
        return $result;
    }

    $mysqli->close();

    $result['ok'] = true;
    $result['count'] = count($active_plugins);
    return $result;
}

/**
 * Schedule one-time object cache flush via self-deleting mu-plugin.
 *
 * Emergency restore writes data directly via mysqli, bypassing WordPress
 * option cache invalidation. This helper writes a one-time mu-plugin that
 * flushes persistent object cache on the first post-restore WP request.
 *
 * @param string $wp_content_dir Destination wp-content directory.
 * @return array{scheduled:bool,warning:string}
 */
function asenha_schedule_cache_flush_mu_plugin($wp_content_dir) {
    global $asenha_cache_flush_mu_plugin_script;

    $result = array(
        'scheduled' => false,
        'warning' => '',
    );

    $mu_plugins_dir = rtrim((string) $wp_content_dir, '/\\') . DIRECTORY_SEPARATOR . 'mu-plugins';
    if (!is_dir($mu_plugins_dir)) {
        if (!@mkdir($mu_plugins_dir, 0755, true) && !is_dir($mu_plugins_dir)) {
            $result['warning'] = 'Unable to create wp-content/mu-plugins directory. Object cache may remain stale until manually flushed.';
            return $result;
        }
    }

    if (!is_dir($mu_plugins_dir) || !is_writable($mu_plugins_dir)) {
        $result['warning'] = 'wp-content/mu-plugins is not writable. Object cache may remain stale until manually flushed.';
        return $result;
    }

    $flush_file = $mu_plugins_dir . DIRECTORY_SEPARATOR . '000-asenha-cache-flush.php';
    $flush_script = isset($GLOBALS['asenha_cache_flush_mu_plugin_script'])
        ? (string) $GLOBALS['asenha_cache_flush_mu_plugin_script']
        : '';

    if ('' === $flush_script || @file_put_contents($flush_file, $flush_script) === false) {
        $result['warning'] = 'Unable to write one-time cache flush MU plugin. Please flush object cache manually.';
        return $result;
    }

    if (!is_file($flush_file) || filesize($flush_file) <= 0) {
        $result['warning'] = 'One-time cache flush MU plugin was written empty. Please flush object cache manually.';
        return $result;
    }

    $result['scheduled'] = true;
    return $result;
}

// ============================================================================
// MAIN RESTORE ORCHESTRATOR
// ============================================================================

/**
 * Process one chunk of the restore operation
 * Returns state with status: 'running', 'complete', or 'failed'
 */
function process_restore_chunk($filename, $backup_dir, $wp_root, $wp_content_dir, $db_host, $db_name, $db_user, $db_password, $db_prefix, $db_charset, $start_time, $delete_extra_files = null, $archive_passphrase = '') {
    global $asenha_is_mysql;
    if (isset($asenha_is_mysql) && !$asenha_is_mysql) {
        asenha_emergency_restore_sqlite_dropins($wp_content_dir);
    }

    $backup_path = $backup_dir . DIRECTORY_SEPARATOR . $filename;
    $is_multipart_meta = is_multipart_meta_filename($filename);
    $meta_path = $backup_path;
    $temp_dir = asenha_emergency_get_temp_dir($backup_dir, $filename);
    
    if (!file_exists($backup_path)) {
        return array(
            'status' => 'failed',
            'phase' => 'init',
            'errors' => array('Backup file not found'),
        );
    }
    
    // Load or initialize state
    $state = load_state($backup_dir, $filename);
    if ($state === null) {
        if ($is_multipart_meta) {
            $meta = read_multipart_meta($meta_path);
            if (!is_array($meta)) {
                return array(
                    'status' => 'failed',
                    'phase' => 'init',
                    'errors' => array('Invalid multipart metadata'),
                );
            }

            $backup_type = isset($meta['backup_type']) ? strtolower((string) $meta['backup_type']) : '';
            if ($backup_type === 'migration') {
                return array(
                    'status' => 'failed',
                    'phase' => 'init',
                    'errors' => array('Migration archives are not eligible for Emergency Restore.'),
                );
            }

            $total_bytes = isset($meta['total_bytes']) ? (int) $meta['total_bytes'] : 0;
            $assembled_zip_path = $temp_dir . DIRECTORY_SEPARATOR . 'assembled.zip';

            // Initialize state without trying to open the meta file as a ZIP.
            $state = array(
                'filename' => $filename,
                'backup_path' => $assembled_zip_path,
                'phase' => 'extracting',
                'status' => 'running',
                'created_at' => time(),
                'updated_at' => time(),

                // Archive info (filled after assembly when possible).
                'archive_total_files' => 0,

                // Extraction state (used for assembly progress before control file extraction).
                'extract_index' => 0,
                'extract_total' => $total_bytes > 0 ? $total_bytes : 1,
                'extract_complete' => false,

                // File restore state
                'files_restore_mode' => 'direct',
                'zip_index' => 0,

                // File copy state
                'copy_index' => 0,
                'copy_total' => 0,
                'copy_files' => array(),
                'copy_complete' => false,

                // Database state
                'sql_position' => 0,
                'sql_statements_executed' => 0,
                'sql_complete' => false,
                'sql_objects' => array(),

                // Results
                'files_extracted' => 0,
                'files_copied' => 0,
                'sql_executed' => 0,

                // Sync delete (wp-content) state (resumable).
                'sync_enabled' => false,
                'sync_queue_built' => false,
                'sync_scan_dirs' => array(''),
                'sync_scan_current_dir' => '',
                'sync_scan_current_index' => 0,
                'sync_files_queued' => 0,
                'sync_delete_queue_byte_offset' => 0,
                'sync_files_deleted' => 0,
                'sync_dirs_removed' => 0,
                'sync_done' => false,
                'sync_summary_added' => false,
                'sync_incremental_forced_notice_added' => false,

                // Multipart assembly state.
                'multipart_meta_filename' => $filename,
                'multipart_assembled_zip_path' => $assembled_zip_path,
                'multipart_assemble_offset' => 0,
                'multipart_assemble_done' => false,

                'messages' => array(),
                'errors' => array(),
            );
        } else {
            $state = init_state($filename, $backup_path);
            if ($state === null) {
                return array(
                    'status' => 'failed',
                    'phase' => 'init',
                    'errors' => array('Failed to initialize restore state'),
                );
            }
        }

        // Persist restore options (only set on initial start; state persists across "continue").
        $state['delete_extra_files'] = ($delete_extra_files === true);

        // Persist state early so a hard timeout doesn't make "continue" think the restore already completed.
        save_state($backup_dir, $filename, $state);
    }

    // Ensure restore options exist.
    if (!isset($state['delete_extra_files'])) {
        $state['delete_extra_files'] = false;
    }

    // Ensure sync-delete state exists (resumable).
    if (!isset($state['sync_enabled'])) {
        $state['sync_enabled'] = false;
    }
    if (!isset($state['sync_queue_built'])) {
        $state['sync_queue_built'] = false;
    }
    if (!isset($state['sync_scan_dirs']) || !is_array($state['sync_scan_dirs'])) {
        $state['sync_scan_dirs'] = array('');
    }
    if (!isset($state['sync_scan_current_dir'])) {
        $state['sync_scan_current_dir'] = '';
    }
    if (!isset($state['sync_scan_current_index'])) {
        $state['sync_scan_current_index'] = 0;
    }
    if (!isset($state['sync_files_queued'])) {
        $state['sync_files_queued'] = 0;
    }
    if (!isset($state['sync_delete_queue_byte_offset'])) {
        $state['sync_delete_queue_byte_offset'] = 0;
    }
    if (!isset($state['sync_files_deleted'])) {
        $state['sync_files_deleted'] = 0;
    }
    if (!isset($state['sync_dirs_removed'])) {
        $state['sync_dirs_removed'] = 0;
    }
    if (!isset($state['sync_done'])) {
        $state['sync_done'] = false;
    }
    if (!isset($state['sync_summary_added'])) {
        $state['sync_summary_added'] = false;
    }
    if (!isset($state['sync_incremental_forced_notice_added'])) {
        $state['sync_incremental_forced_notice_added'] = false;
    }

    // Ensure restore mode exists.
    if (!isset($state['files_restore_mode']) || ($state['files_restore_mode'] !== 'direct' && $state['files_restore_mode'] !== 'fallback')) {
        $state['files_restore_mode'] = 'direct';
    }

    // Resolve selected recovery point semantics once and persist:
    // - baseline row => baseline only
    // - incremental row => baseline + incrementals up to selected sequence
    if (empty($state['restore_target_resolved'])) {
        $backup_files = get_backup_files($backup_dir);
        $resolved_chain = asenha_resolve_emergency_restore_chain($filename, $backup_files);
        if (empty($resolved_chain['ok'])) {
            $state['status'] = 'failed';
            $state['phase'] = 'init';
            $state['errors'][] = !empty($resolved_chain['error']) ? (string) $resolved_chain['error'] : 'Failed to resolve restore recovery point.';
            delete_directory($temp_dir);
            delete_state($backup_dir, $filename);
            return $state;
        }

        $state['restore_target_resolved'] = true;
        $state['restore_backup_method'] = isset($resolved_chain['backup_method']) ? (string) $resolved_chain['backup_method'] : 'baseline';
        $state['restore_chain'] = isset($resolved_chain['chain']) && is_array($resolved_chain['chain']) ? array_values($resolved_chain['chain']) : array();
        $state['restore_base_filename'] = isset($resolved_chain['base_filename']) ? asenha_sanitize_backup_basename((string) $resolved_chain['base_filename']) : '';
        $state['restore_target_sequence'] = isset($resolved_chain['target_sequence']) ? (int) $resolved_chain['target_sequence'] : 0;

        $chain_summary = asenha_get_emergency_restore_chain_summary($resolved_chain);
        if ($chain_summary !== '') {
            $state['messages'][] = $chain_summary;
        }

        if ($state['restore_backup_method'] === 'incremental') {
            // Chain collapse produces a staged source directory, so fallback mode is required.
            $state['files_restore_mode'] = 'fallback';
        }
    }

    $is_incremental_recovery_point = (
        isset($state['restore_backup_method'])
        && $state['restore_backup_method'] === 'incremental'
    );
    // Baseline rows remain checkbox-driven; incremental recovery points always enforce sync-delete parity.
    $state['sync_enabled'] = (!empty($state['delete_extra_files']) || $is_incremental_recovery_point);
    if (
        $is_incremental_recovery_point
        && empty($state['delete_extra_files'])
        && empty($state['sync_incremental_forced_notice_added'])
    ) {
        $state['messages'][] = 'Sync delete is automatically enabled for incremental recovery points so wp-content matches the selected baseline + incremental chain.';
        $state['sync_incremental_forced_notice_added'] = true;
    }

    // For incremental recovery points, collapse baseline + incrementals into an effective staging source.
    if (
        isset($state['restore_backup_method'])
        && $state['restore_backup_method'] === 'incremental'
        && empty($state['collapse_done'])
    ) {
        $state['phase'] = 'extracting';
        $state = asenha_collapse_incremental_chain_chunked(
            $filename,
            $backup_dir,
            $temp_dir,
            $state,
            $start_time,
            $archive_passphrase
        );

        if (!empty($state['status']) && $state['status'] === 'failed') {
            delete_directory($temp_dir);
            delete_state($backup_dir, $filename);
            return $state;
        }

        if (empty($state['collapse_done'])) {
            save_state($backup_dir, $filename, $state);
            return prepare_state_for_response($state);
        }
    }

    $skip_selected_archive_preflight = (!empty($state['collapse_done']) && !empty($state['collapse_effective_source_dir']));

    // Multipart preflight: assemble *.parts.json into a contiguous ZIP inside the temp restore dir.
    // This must happen before any ZipArchive operations.
    if ($is_multipart_meta && empty($skip_selected_archive_preflight)) {
        $assembled_zip_path = isset($state['multipart_assembled_zip_path']) ? (string) $state['multipart_assembled_zip_path'] : ($temp_dir . DIRECTORY_SEPARATOR . 'assembled.zip');
        $assemble_offset = isset($state['multipart_assemble_offset']) ? (int) $state['multipart_assemble_offset'] : 0;
        $assemble_done = !empty($state['multipart_assemble_done']);

        // If the assembled ZIP is missing, treat as not done (resume from offset).
        if ($assemble_done && !file_exists($assembled_zip_path)) {
            $assemble_done = false;
        }

        if (!$assemble_done) {
            $meta = read_multipart_meta($meta_path);
            if (!is_array($meta)) {
                $state['status'] = 'failed';
                $state['errors'][] = 'Invalid multipart metadata';
                delete_directory($temp_dir);
                delete_state($backup_dir, $filename);
                return $state;
            }

            $backup_type = isset($meta['backup_type']) ? strtolower((string) $meta['backup_type']) : '';
            if ($backup_type === 'migration') {
                $state['status'] = 'failed';
                $state['errors'][] = 'Migration archives are not eligible for Emergency Restore.';
                delete_directory($temp_dir);
                delete_state($backup_dir, $filename);
                return $state;
            }

            $total_bytes = isset($meta['total_bytes']) ? (int) $meta['total_bytes'] : 0;
            if (!isset($state['extract_total']) || (int) $state['extract_total'] <= 0) {
                $state['extract_total'] = $total_bytes > 0 ? $total_bytes : 1;
            }

            $state['phase'] = 'extracting';
            $res = assemble_multipart_to_zip_chunked($meta, $backup_dir, $assembled_zip_path, $assemble_offset, $start_time);
            if (empty($res['ok'])) {
                $state['status'] = 'failed';
                $state['errors'][] = !empty($res['error']) ? $res['error'] : 'Failed to assemble multipart archive';
                delete_directory($temp_dir);
                delete_state($backup_dir, $filename);
                return $state;
            }

            $next_offset = isset($res['next_offset']) ? (int) $res['next_offset'] : $assemble_offset;
            $done = !empty($res['done']);

            $state['multipart_assemble_offset'] = $next_offset;
            $state['multipart_assemble_done'] = $done;
            $state['extract_index'] = $next_offset;

            if (!$done) {
                save_state($backup_dir, $filename, $state);
                return $state;
            }

            // Assembly completed.
            $state['extract_index'] = $total_bytes > 0 ? $total_bytes : $next_offset;

            // Best-effort: set archive_total_files now that the ZIP exists (improves fallback progress).
            if (class_exists('ZipArchive') && (empty($state['archive_total_files']) || (int) $state['archive_total_files'] <= 0)) {
                $zip = new ZipArchive();
                if ($zip->open($assembled_zip_path) === true) {
                    $state['archive_total_files'] = (int) $zip->numFiles;
                    $zip->close();
                }
            }

            save_state($backup_dir, $filename, $state);
        }

        // Use assembled ZIP for all further operations in this request.
        $backup_path = $assembled_zip_path;
        if (!file_exists($backup_path)) {
            $state['status'] = 'failed';
            $state['errors'][] = 'Assembled ZIP file not found';
            delete_directory($temp_dir);
            delete_state($backup_dir, $filename);
            return $state;
        }
    }

    // Phase 1: Extract control files (direct) OR full archive (fallback)
    if ($state['files_restore_mode'] === 'fallback') {
        if (!$state['extract_complete']) {
            $state['phase'] = 'extracting';
            $state = chunked_extract($backup_path, $temp_dir, $state, $start_time, $archive_passphrase);

            if ($state['status'] === 'failed') {
                delete_directory($temp_dir);
                delete_state($backup_dir, $filename);
                return $state;
            }

            if (!$state['extract_complete']) {
                save_state($backup_dir, $filename, $state);
                return $state;
            }
        }
    } else {
        if (!$state['extract_complete']) {
            $state['phase'] = 'extracting';
            $state = extract_control_files($backup_path, $temp_dir, $state, $archive_passphrase);

            if ($state['status'] === 'failed') {
                delete_directory($temp_dir);
                delete_state($backup_dir, $filename);
                return $state;
            }
        }
    }
    
    // Read manifest (from effective source: selected archive extraction or collapsed chain staging).
    $effective_source_dir = (!empty($state['collapse_done']) && !empty($state['collapse_effective_source_dir']))
        ? (string) $state['collapse_effective_source_dir']
        : $temp_dir;
    $manifest_file = rtrim($effective_source_dir, '/\\') . DIRECTORY_SEPARATOR . 'manifest.json';
    $manifest = array();
    if (file_exists($manifest_file)) {
        $manifest_content = file_get_contents($manifest_file);
        $manifest = json_decode($manifest_content, true);
        if (!is_array($manifest)) {
            $manifest = array();
        }
    }

    $mf_site_err = asenha_emergency_manifest_matches_embedded_site($manifest);
    if ($mf_site_err !== '') {
        $state['status'] = 'failed';
        $state['errors'][] = $mf_site_err;
        return $state;
    }

    $state['archive_encryption_enabled'] = !empty($manifest['archive_encryption_enabled']);
    $state['archive_passphrase_required'] = !empty($manifest['archive_passphrase_required']) || !empty($state['archive_encryption_enabled']);
    if (!empty($state['archive_passphrase_required']) && !method_exists('ZipArchive', 'setPassword')) {
        $state['status'] = 'failed';
        $state['errors'][] = 'This backup archive is encrypted, but ZipArchive::setPassword is not available on this server.';
        return $state;
    }
    if (!empty($state['archive_passphrase_required']) && (string) $archive_passphrase === '') {
        $state['status'] = 'failed';
        $state['errors'][] = 'This backup archive is encrypted. Please enter the archive passphrase and try again.';
        return $state;
    }
    
    // Phase 2: Restore files (direct-from-archive with auto-fallback)
    if (!$state['copy_complete']) {
        $state['phase'] = 'copying';

        if ($state['files_restore_mode'] === 'direct') {
            // Pre-compute eligible file total once for progress (0 is valid for DB-only backups).
            if (!isset($state['copy_total']) || (int) $state['copy_total'] <= 0) {
                $eligible_total = count_eligible_wp_content_files($backup_path, $start_time);
                if ($eligible_total === false) {
                    $state['messages'][] = 'Direct restore preflight failed. Switching to fallback mode.';
                    $state = switch_to_fallback_mode($state);
                } elseif ($eligible_total === null) {
                    // Counting eligible files can be slow on very large archives; keep going with an approximate total.
                    $state['messages'][] = 'Direct restore: using archive file count for progress (eligible file count too slow).';
                    $state['copy_total'] = isset($state['archive_total_files']) ? (int) $state['archive_total_files'] : 0;
                } elseif ($eligible_total === 0) {
                    $state['copy_total'] = 0;
                    $state['copy_complete'] = true;
                } else {
                    $state['copy_total'] = (int) $eligible_total;
                }
            }

            if ($state['files_restore_mode'] === 'direct' && !$state['copy_complete']) {
                $destination_parent = dirname($wp_content_dir);
                $direct = chunked_restore_wp_content_direct($backup_path, $destination_parent, $state, $start_time, $archive_passphrase);
                $state = $direct['state'];

                if (!$direct['ok']) {
                    $direct_error_code = isset($direct['error_code']) ? (string) $direct['error_code'] : '';
                    if (
                        $direct_error_code === 'archive_passphrase_required'
                        || $direct_error_code === 'archive_passphrase_invalid'
                        || $direct_error_code === 'archive_passphrase_not_supported'
                    ) {
                        $state['status'] = 'failed';
                        $state['errors'][] = (string) $direct['error'];
                        return $state;
                    }

                    $state['messages'][] = 'Direct restore failed. Switching to fallback mode.';
                    $state['messages'][] = 'Fallback reason: ' . $direct['error'];
                    $state = switch_to_fallback_mode($state);
                } elseif (!$state['copy_complete']) {
                    save_state($backup_dir, $filename, $state);
                    return prepare_state_for_response($state);
                }
            }
        }

        // Fallback mode: extract archive to temp dir and copy wp-content to destination.
        if ($state['files_restore_mode'] === 'fallback') {
            if (!$state['extract_complete']) {
                $state['phase'] = 'extracting';
                $state = chunked_extract($backup_path, $temp_dir, $state, $start_time, $archive_passphrase);

                if ($state['status'] === 'failed') {
                    delete_directory($temp_dir);
                    delete_state($backup_dir, $filename);
                    return $state;
                }

                if (!$state['extract_complete']) {
                    save_state($backup_dir, $filename, $state);
                    return $state;
                }
            }

            $wp_content_backup_dir = (!empty($state['collapse_done']) && !empty($state['collapse_effective_source_wp_content_dir']))
                ? (string) $state['collapse_effective_source_wp_content_dir']
                : ($temp_dir . DIRECTORY_SEPARATOR . 'wp-content');
            if (is_dir($wp_content_backup_dir) && !$state['copy_complete']) {
                $state['phase'] = 'copying';
                $state = chunked_copy($wp_content_backup_dir, $wp_content_dir, $state, $start_time);

                if (!$state['copy_complete']) {
                    save_state($backup_dir, $filename, $state);
                    $response_state = $state;
                    unset($response_state['copy_files']);
                    return $response_state;
                }
            } else if (!is_dir($wp_content_backup_dir)) {
                $state['copy_complete'] = true;
            }
        }
    }
    
    // Phase 2.5: Sync delete extra files in wp-content (optional).
    // Only applies when the backup contains eligible wp-content files; otherwise, skip for safety.
    if (!empty($state['sync_enabled']) && empty($state['sync_done']) && !empty($state['copy_complete'])) {
        $state['phase'] = 'cleanup';

        $has_files_in_backup = false;
        if (isset($state['copy_total']) && (int) $state['copy_total'] > 0) {
            $has_files_in_backup = true;
        } elseif (isset($state['files_copied']) && (int) $state['files_copied'] > 0) {
            $has_files_in_backup = true;
        }

        if (!$has_files_in_backup) {
            $state['sync_done'] = true;
            if (empty($state['sync_summary_added'])) {
                $state['messages'][] = 'Sync delete skipped: backup contains no eligible wp-content files.';
                $state['sync_summary_added'] = true;
            }
        } else {
            // Build delete queue (resumable).
            if (empty($state['sync_queue_built'])) {
                if (!empty($state['collapse_done']) && !empty($state['collapse_effective_source_wp_content_dir'])) {
                    $state = sync_build_delete_queue_wp_content_from_source_chunked(
                        (string) $state['collapse_effective_source_wp_content_dir'],
                        $wp_content_dir,
                        $temp_dir,
                        $state,
                        $start_time
                    );
                } else {
                    $state = sync_build_delete_queue_wp_content_chunked($backup_path, $wp_content_dir, $temp_dir, $state, $start_time);
                }

                if ($state['status'] === 'failed') {
                    delete_directory($temp_dir);
                    delete_state($backup_dir, $filename);
                    return $state;
                }

                if (empty($state['sync_queue_built'])) {
                    save_state($backup_dir, $filename, $state);
                    return prepare_state_for_response($state);
                }
            }

            // Delete from queue (resumable).
            if (empty($state['sync_done'])) {
                $state = sync_delete_from_queue_chunked($wp_content_dir, $state, $start_time);

                if ($state['status'] === 'failed') {
                    delete_directory($temp_dir);
                    delete_state($backup_dir, $filename);
                    return $state;
                }

                if (empty($state['sync_done'])) {
                    save_state($backup_dir, $filename, $state);
                    return prepare_state_for_response($state);
                }
            }

            if (!empty($state['sync_done']) && empty($state['sync_summary_added'])) {
                $deleted_files = isset($state['sync_files_deleted']) ? (int) $state['sync_files_deleted'] : 0;
                $removed_dirs  = isset($state['sync_dirs_removed']) ? (int) $state['sync_dirs_removed'] : 0;
                $state['messages'][] = "Sync delete: removed {$deleted_files} files and {$removed_dirs} empty folders from wp-content.";
                $state['sync_summary_added'] = true;
            }
        }
    }

    // Phase 3: Import database
    $sql_file = (!empty($state['collapse_done']) && !empty($state['collapse_effective_sql_file']))
        ? (string) $state['collapse_effective_sql_file']
        : ($temp_dir . DIRECTORY_SEPARATOR . 'database.sql');
    if (file_exists($sql_file) && !$state['sql_complete']) {
        $state['phase'] = 'database';
        global $asenha_is_mysql;
        if (isset($asenha_is_mysql) && !$asenha_is_mysql) {
            $state = asenha_emergency_prepare_sqlite_database_import(
                $sql_file,
                $wp_content_dir,
                $state
            );
        } else {
            $manifest_prefix = isset($manifest['db_prefix']) ? $manifest['db_prefix'] : $db_prefix;
            $state = streaming_sql_import(
                $sql_file,
                $db_host,
                $db_name,
                $db_user,
                $db_password,
                $db_charset,
                $manifest_prefix,
                $db_prefix,
                $state,
                $start_time
            );
        }
        
        if ($state['status'] === 'failed') {
            delete_directory($temp_dir);
            delete_state($backup_dir, $filename);
            return $state;
        }
        
        // Save state and return if not complete
        if (!$state['sql_complete']) {
            save_state($backup_dir, $filename, $state);
            return $state;
        }
    } else if (!file_exists($sql_file)) {
        $state['sql_complete'] = true;
    }

    // Phase 3.5: Reconcile active_plugins from manifest metadata (deterministic plugin state restore).
    if (!isset($state['active_plugins_reconciled'])) {
        $state['active_plugins_reconciled'] = false;
    }
    if (!empty($state['sql_complete']) && empty($state['active_plugins_reconciled'])) {
        $state['phase'] = 'plugins';
        global $asenha_is_mysql;
        if (isset($asenha_is_mysql) && !$asenha_is_mysql) {
            $state['active_plugins_reconciled'] = true;
            $state['messages'][] = 'Active plugins reconcile skipped on SQLite (imported via WordPress).';
        } else {
        $plugins_reconcile = asenha_reconcile_active_plugins_from_manifest(
            $manifest,
            $db_host,
            $db_name,
            $db_user,
            $db_password,
            $db_prefix,
            $wp_content_dir
        );

        if (empty($plugins_reconcile['ok'])) {
            $state['status'] = 'failed';
            $state['errors'][] = !empty($plugins_reconcile['error'])
                ? (string) $plugins_reconcile['error']
                : 'Failed reconciling active plugins from backup metadata.';
            delete_directory($temp_dir);
            delete_state($backup_dir, $filename);
            return $state;
        }

        $state['active_plugins_reconciled'] = true;
        if (!empty($plugins_reconcile['skipped'])) {
            $state['messages'][] = 'Active plugins reconcile skipped: active_plugins metadata not present in manifest.';
        } else {
            $state['messages'][] = 'Reconciled active plugins from backup metadata (' . (int) $plugins_reconcile['count'] . ' plugin(s)).';
        }
        }
    }

    // Phase 3.6: Schedule one-time object cache flush via MU plugin.
    // Runs once per restore attempt so retries/resume are idempotent.
    if (!isset($state['cache_flush_mu_processed'])) {
        $state['cache_flush_mu_processed'] = false;
    }
    if (!isset($state['cache_flush_mu_scheduled'])) {
        $state['cache_flush_mu_scheduled'] = false;
    }
    if (!empty($state['sql_complete']) && !empty($state['active_plugins_reconciled']) && empty($state['cache_flush_mu_processed'])) {
        $state['phase'] = 'cache';
        $cache_flush_mu_result = asenha_schedule_cache_flush_mu_plugin($wp_content_dir);
        $state['cache_flush_mu_processed'] = true;
        $state['cache_flush_mu_scheduled'] = !empty($cache_flush_mu_result['scheduled']);

        if (!empty($cache_flush_mu_result['scheduled'])) {
            $state['messages'][] = 'Scheduled one-time object cache flush via MU plugin for first WordPress request.';
        } else if (!empty($cache_flush_mu_result['warning'])) {
            $state['messages'][] = 'Warning: ' . (string) $cache_flush_mu_result['warning'];
        } else {
            $state['messages'][] = 'Warning: Unable to schedule one-time object cache flush MU plugin. Please flush object cache manually.';
        }
    }
    
    // All phases complete - cleanup
    $state['phase'] = 'complete';
    $state['status'] = 'complete';
    
    // Cleanup temp directory
    delete_directory($temp_dir);
    
    // Delete state file
    delete_state($backup_dir, $filename);
    
    // Clean up large data before returning (not needed in response)
    unset($state['copy_files']);
    unset($state['backup_path']);
    unset($state['sync_scan_dirs']);
    unset($state['sync_delete_queue_path']);
    
    return $state;
}

/**
 * Prepare state for API response (remove large internal data)
 */
function prepare_state_for_response($state) {
    if (!is_array($state)) {
        return $state;
    }
    
    // Remove large arrays that are only needed internally
    $response = $state;
    unset($response['copy_files']);
    unset($response['backup_path']);
    unset($response['sync_scan_dirs']);
    unset($response['sync_scan_current_dir']);
    unset($response['sync_scan_current_index']);
    unset($response['sync_delete_queue_path']);
    unset($response['archive_passphrase']);
    unset($response['sqlite_sql_file']);
    
    return $response;
}

/**
 * Random UUID v4 for URL import jobs (standalone, no WordPress).
 *
 * @return string
 */
function asenha_emergency_random_uuid() {
    if (function_exists('random_bytes')) {
        $b = random_bytes(16);
    } else {
        $b = openssl_random_pseudo_bytes(16);
        if (false === $b) {
            return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
                mt_rand(0, 0xffff), mt_rand(0, 0xffff),
                mt_rand(0, 0xffff),
                mt_rand(0, 0x0fff) | 0x4000,
                mt_rand(0, 0x3fff) | 0x8000,
                mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
            );
        }
    }
    $b[6] = chr(ord($b[6]) & 0x0f | 0x40);
    $b[8] = chr(ord($b[8]) & 0x3f | 0x80);
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}

/**
 * Verify POST token against embedded security token.
 *
 * @param string $security_token Embedded token.
 * @return bool
 */
function asenha_emergency_verify_post_token($security_token) {
    $t = isset($_POST['token']) ? (string) $_POST['token'] : '';
    return $t !== '' && hash_equals((string) $security_token, $t);
}

/**
 * Best-effort self-origin of the running emergency script, e.g.
 * "https://example.com" (no trailing slash). Used to same-origin-gate POST
 * actions against CSRF-style replays if a token leaks.
 *
 * @return string Empty string when host is not available.
 */
function asenha_emergency_self_origin() {
    $host = isset($_SERVER['HTTP_HOST']) ? (string) $_SERVER['HTTP_HOST'] : '';
    if ($host === '' && isset($_SERVER['SERVER_NAME'])) {
        $host = (string) $_SERVER['SERVER_NAME'];
    }
    if ($host === '') {
        return '';
    }
    $https = isset($_SERVER['HTTPS']) ? (string) $_SERVER['HTTPS'] : '';
    $is_https = ($https !== '' && strtolower($https) !== 'off');
    $scheme = $is_https ? 'https' : 'http';
    return $scheme . '://' . $host;
}

/**
 * Best-effort self URL directory, used to validate Referer prefix.
 *
 * @return string Trailing-slash URL directory or '' on failure.
 */
function asenha_emergency_self_dir_url() {
    $origin = asenha_emergency_self_origin();
    if ($origin === '') {
        return '';
    }
    $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
    $q = strpos($uri, '?');
    if ($q !== false) {
        $uri = substr($uri, 0, $q);
    }
    $dir_raw = dirname($uri);
    if ($dir_raw === '.' || $dir_raw === '' || $dir_raw === '\\') {
        $dir = '/';
    } else {
        $dir = rtrim($dir_raw, '/') . '/';
    }
    return $origin . $dir;
}

/**
 * Scheme-agnostic URL identity (host + port + path) for manifest comparison.
 *
 * @param string $url Full URL.
 * @return string
 */
function asenha_emergency_url_identity_key($url) {
    $url = is_string($url) ? trim($url) : '';
    if ($url === '') {
        return '';
    }
    $p = @parse_url($url);
    if (!is_array($p) || empty($p['host'])) {
        return '';
    }
    $key = strtolower((string) $p['host']);
    if (!empty($p['port'])) {
        $key .= ':' . (int) $p['port'];
    }
    $path = isset($p['path']) ? trim((string) $p['path'], '/') : '';
    if ($path !== '') {
        $key .= '/' . $path;
    }
    return $key;
}

/**
 * Compare manifest site/home URLs to values embedded when the script was generated.
 *
 * @param array $manifest Decoded manifest.
 * @return string Empty if OK, otherwise an error message.
 */
function asenha_emergency_manifest_matches_embedded_site($manifest) {
    global $asenha_emergency_site_url, $asenha_emergency_home_url;

    // Scripts generated before site-identity embedding: skip (re-download script for full protection).
    if (!isset($asenha_emergency_site_url) || (string) $asenha_emergency_site_url === '') {
        return '';
    }

    if (!is_array($manifest)) {
        return 'Invalid backup: could not read manifest.';
    }

    $man_site = isset($manifest['site_url']) ? asenha_emergency_url_identity_key((string) $manifest['site_url']) : '';
    $man_home = isset($manifest['home_url']) ? asenha_emergency_url_identity_key((string) $manifest['home_url']) : '';
    $cur_site = asenha_emergency_url_identity_key(isset($asenha_emergency_site_url) ? (string) $asenha_emergency_site_url : '');
    $cur_home = asenha_emergency_url_identity_key(isset($asenha_emergency_home_url) ? (string) $asenha_emergency_home_url : '');

    if ($man_site === '' && $man_home === '') {
        return 'Invalid backup: manifest is missing site information for this installation.';
    }
    if ($man_site !== '' && $man_site !== $cur_site) {
        return 'This backup was created for a different site. Use the WordPress admin Migration tab to import it, or restore it only on the site that created it.';
    }
    if ($man_home !== '' && $man_home !== $cur_home) {
        return 'This backup was created for a different site. Use the WordPress admin Migration tab to import it, or restore it only on the site that created it.';
    }
    return '';
}

/**
 * Whether a backup basename matches the site slug embedded at script generation time.
 *
 * @param string $filename Sanitized backup filename.
 * @return bool
 */
function asenha_emergency_restore_filename_matches_embedded_slug($filename) {
    global $asenha_emergency_backup_filename_site_slug;

    $filename = is_string($filename) ? $filename : '';
    $slug = isset($asenha_emergency_backup_filename_site_slug) ? (string) $asenha_emergency_backup_filename_site_slug : '';
    if ($slug === '') {
        return true;
    }
    if ($filename === '') {
        return false;
    }
    $prefix = $slug . '_';
    return (strpos($filename, $prefix) === 0);
}

/**
 * Check that a POST request's Origin or Referer matches the script's own
 * host. Rejects cross-origin replays of a leaked token.
 *
 * @return bool True when request passes.
 */
function asenha_emergency_verify_same_origin() {
    $self_origin = asenha_emergency_self_origin();
    if ($self_origin === '') {
        // Cannot determine own host: fail closed.
        return false;
    }
    $origin = isset($_SERVER['HTTP_ORIGIN']) ? (string) $_SERVER['HTTP_ORIGIN'] : '';
    if ($origin !== '') {
        $o = @parse_url($origin);
        $s = @parse_url($self_origin);
        if (is_array($o) && is_array($s)
            && !empty($o['scheme']) && !empty($s['scheme'])
            && !empty($o['host']) && !empty($s['host'])
            && strtolower((string) $o['scheme']) === strtolower((string) $s['scheme'])
            && strtolower((string) $o['host']) === strtolower((string) $s['host'])
            && ((isset($o['port']) ? (int) $o['port'] : 0) === (isset($s['port']) ? (int) $s['port'] : 0))
        ) {
            return true;
        }
    }
    $referer = isset($_SERVER['HTTP_REFERER']) ? (string) $_SERVER['HTTP_REFERER'] : '';
    if ($referer !== '') {
        $dir = asenha_emergency_self_dir_url();
        if ($dir !== '' && 0 === strpos($referer, $dir)) {
            return true;
        }
        // Some referers may land on the script URL itself without the trailing slash dir form.
        if (0 === strpos($referer, $self_origin . '/')) {
            return true;
        }
    }
    return false;
}

/**
 * Whether an IP must be blocked for URL import (SSRF mitigation).
 *
 * @param string $ip IP address.
 * @return bool True if blocked.
 */
function asenha_emergency_is_blocked_ip($ip) {
    $ip = trim((string) $ip);
    if ($ip === '') {
        return true;
    }
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
    }
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
        return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
    }
    return true;
}

/**
 * Resolve hostname to IPs, ensure none are blocked (SSRF), and report whether
 * IPv6 (AAAA) records could be successfully queried so callers can decide
 * whether to force IPv4 at the cURL layer.
 *
 * @param string $host Hostname or literal IP.
 * @return array{ok:bool,ips:array<int,string>,aaaa_lookup_available:bool,had_ipv4:bool,had_ipv6:bool}
 */
function asenha_emergency_resolve_host_ips($host) {
    $out = array(
        'ok' => false,
        'ips' => array(),
        'aaaa_lookup_available' => false,
        'had_ipv4' => false,
        'had_ipv6' => false,
    );
    $host = trim((string) $host);
    if ($host === '') {
        return $out;
    }
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        if (asenha_emergency_is_blocked_ip($host)) {
            return $out;
        }
        $out['ok'] = true;
        $out['ips'] = array($host);
        $out['had_ipv4'] = (bool) filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
        $out['had_ipv6'] = !$out['had_ipv4'];
        // Literal IPs bypass DNS entirely, so AAAA availability is moot.
        $out['aaaa_lookup_available'] = true;
        return $out;
    }
    $ips = array();
    $had_ipv4 = false;
    $had_ipv6 = false;
    $a = @gethostbynamel($host);
    if (is_array($a)) {
        foreach ($a as $ip) {
            $ips[] = $ip;
            $had_ipv4 = true;
        }
    }
    $aaaa_available = false;
    if (function_exists('dns_get_record')) {
        $aaaa = @dns_get_record($host, DNS_AAAA);
        if (false !== $aaaa) {
            $aaaa_available = true;
            if (is_array($aaaa)) {
                foreach ($aaaa as $rec) {
                    if (!empty($rec['ipv6'])) {
                        $ips[] = $rec['ipv6'];
                        $had_ipv6 = true;
                    }
                }
            }
        }
    }
    if (empty($ips)) {
        return $out;
    }
    foreach ($ips as $ip) {
        if (asenha_emergency_is_blocked_ip($ip)) {
            return $out;
        }
    }
    $out['ok'] = true;
    $out['ips'] = $ips;
    $out['aaaa_lookup_available'] = $aaaa_available;
    $out['had_ipv4'] = $had_ipv4;
    $out['had_ipv6'] = $had_ipv6;
    return $out;
}

/**
 * Backwards-compatible boolean wrapper used by asenha_emergency_sanitize_http_import_url().
 *
 * @param string $host Hostname.
 * @return bool True if safe to connect.
 */
function asenha_emergency_url_host_passes_ssrf($host) {
    $r = asenha_emergency_resolve_host_ips($host);
    return !empty($r['ok']);
}

/**
 * Validate a URL for establishing an outbound connection: scheme, host, SSRF,
 * and produce a pinned CURLOPT_RESOLVE entry so cURL cannot re-resolve between
 * validation and connect (DNS rebinding mitigation). Extension is NOT checked
 * here so that redirect targets to signed CDN URLs still work.
 *
 * @param string $url Absolute URL.
 * @return array{ok:bool,error:string,url:string,scheme:string,host:string,port:int,resolve:string,force_v4:bool}
 */
function asenha_emergency_validate_url_for_connect($url) {
    $out = array(
        'ok' => false,
        'error' => '',
        'url' => '',
        'scheme' => '',
        'host' => '',
        'port' => 0,
        'resolve' => '',
        'force_v4' => false,
    );
    $url = trim((string) $url);
    if ($url === '') {
        $out['error'] = 'Empty URL.';
        return $out;
    }
    if (0 === stripos($url, 'asenha-sftp://')) {
        $out['error'] = 'SFTP references are not supported.';
        return $out;
    }
    $parsed = @parse_url($url);
    if (!is_array($parsed) || empty($parsed['scheme']) || empty($parsed['host'])) {
        $out['error'] = 'Malformed URL.';
        return $out;
    }
    $scheme = strtolower((string) $parsed['scheme']);
    if (!in_array($scheme, array('http', 'https'), true)) {
        $out['error'] = 'Only http(s) URLs are allowed.';
        return $out;
    }
    $host = (string) $parsed['host'];
    $port = isset($parsed['port']) && (int) $parsed['port'] > 0 ? (int) $parsed['port'] : ($scheme === 'https' ? 443 : 80);
    $resolved = asenha_emergency_resolve_host_ips($host);
    if (empty($resolved['ok']) || empty($resolved['ips'])) {
        $out['error'] = 'Host resolution failed or resolves to a blocked network.';
        return $out;
    }
    // Prefer the first IPv4 when available (cURL IPRESOLVE_V4 pairs cleanly with this),
    // otherwise fall back to the first IPv6.
    $pin_ip = '';
    foreach ($resolved['ips'] as $ip) {
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            $pin_ip = $ip;
            break;
        }
    }
    if ($pin_ip === '') {
        foreach ($resolved['ips'] as $ip) {
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
                $pin_ip = $ip;
                break;
            }
        }
    }
    if ($pin_ip === '') {
        $out['error'] = 'No valid resolved IP.';
        return $out;
    }
    // Force IPv4 whenever AAAA lookup was unavailable: prevents cURL's system
    // resolver from silently preferring an unvalidated IPv6 address.
    $force_v4 = (!$resolved['aaaa_lookup_available'] && $resolved['had_ipv4']);
    // For IPv6 in CURLOPT_RESOLVE, cURL expects a bare address (no brackets).
    $out['ok'] = true;
    $out['url'] = $url;
    $out['scheme'] = $scheme;
    $out['host'] = $host;
    $out['port'] = $port;
    $out['resolve'] = $host . ':' . $port . ':' . $pin_ip;
    $out['force_v4'] = $force_v4;
    return $out;
}

/**
 * Resolve a (possibly relative) Location header against the current URL and
 * strip any credentials embedded in the redirect target.
 *
 * @param string $current_url Absolute URL of the request that returned the Location.
 * @param string $location    Raw Location header value.
 * @return string Absolute URL or '' on failure.
 */
function asenha_emergency_resolve_redirect_location($current_url, $location) {
    $location = trim((string) $location);
    if ($location === '') {
        return '';
    }
    if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $location)) {
        $abs = $location;
    } else {
        $base = @parse_url((string) $current_url);
        if (!is_array($base) || empty($base['scheme']) || empty($base['host'])) {
            return '';
        }
        $scheme = (string) $base['scheme'];
        $host = (string) $base['host'];
        $port = isset($base['port']) ? ':' . (int) $base['port'] : '';
        if (strlen($location) > 0 && $location[0] === '/') {
            $abs = $scheme . '://' . $host . $port . $location;
        } else {
            $base_path = isset($base['path']) ? (string) $base['path'] : '/';
            $dir_raw = dirname($base_path);
            $dir = (substr($base_path, -1) === '/') ? $base_path : (($dir_raw === '.' || $dir_raw === '\\') ? '/' : rtrim($dir_raw, '/') . '/');
            $abs = $scheme . '://' . $host . $port . $dir . $location;
        }
    }
    $parts = @parse_url($abs);
    if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
        return '';
    }
    $rebuilt = strtolower((string) $parts['scheme']) . '://' . (string) $parts['host'];
    if (!empty($parts['port'])) {
        $rebuilt .= ':' . (int) $parts['port'];
    }
    $rebuilt .= isset($parts['path']) ? (string) $parts['path'] : '';
    if (isset($parts['query']) && $parts['query'] !== '') {
        $rebuilt .= '?' . (string) $parts['query'];
    }
    return $rebuilt;
}

/**
 * Sanitize and validate HTTP(S) URL for import (zip or .parts.json path).
 *
 * @param string $url Raw URL.
 * @return string Empty string on failure.
 */
function asenha_emergency_sanitize_http_import_url($url) {
    $url = trim((string) $url);
    if ($url === '') {
        return '';
    }
    $lower = strtolower($url);
    if (0 === strpos($lower, 'asenha-sftp://')) {
        return '';
    }
    $parsed = @parse_url($url);
    if (!is_array($parsed) || empty($parsed['scheme']) || empty($parsed['host'])) {
        return '';
    }
    $scheme = strtolower((string) $parsed['scheme']);
    if (!in_array($scheme, array('http', 'https'), true)) {
        return '';
    }
    $path = isset($parsed['path']) ? (string) $parsed['path'] : '';
    if ($path === '' || (!preg_match('/\.zip$/i', $path) && !preg_match('/\.parts\.json$/i', $path))) {
        return '';
    }
    if (!asenha_emergency_url_host_passes_ssrf((string) $parsed['host'])) {
        return '';
    }
    return $url;
}

/**
 * Remove asenha_chain query arg from URL string.
 *
 * @param string $url URL.
 * @return string
 */
function asenha_emergency_remove_chain_query_arg($url) {
    $url = (string) $url;
    $parts = @parse_url($url);
    if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
        return $url;
    }
    $q = array();
    if (!empty($parts['query'])) {
        parse_str((string) $parts['query'], $q);
    }
    unset($q['asenha_chain']);
    $rebuilt = $parts['scheme'] . '://';
    if (!empty($parts['user'])) {
        $rebuilt .= $parts['user'];
        if (!empty($parts['pass'])) {
            $rebuilt .= ':' . $parts['pass'];
        }
        $rebuilt .= '@';
    }
    $rebuilt .= $parts['host'];
    if (!empty($parts['port'])) {
        $rebuilt .= ':' . (int) $parts['port'];
    }
    $rebuilt .= isset($parts['path']) ? $parts['path'] : '';
    if (!empty($q)) {
        $rebuilt .= '?' . http_build_query($q);
    }
    if (!empty($parts['fragment'])) {
        $rebuilt .= '#' . $parts['fragment'];
    }
    return $rebuilt;
}

/**
 * Base64url decode.
 *
 * @param string $token Token.
 * @return string|false
 */
function asenha_emergency_base64url_decode($token) {
    $token = trim((string) $token);
    if ($token === '') {
        return false;
    }
    $token = strtr($token, '-_', '+/');
    $pad = strlen($token) % 4;
    if ($pad) {
        $token .= str_repeat('=', 4 - $pad);
    }
    return base64_decode($token, true);
}

/**
 * Extract chain archive basenames from asenha_chain query parameter.
 *
 * @param string $url Full URL.
 * @return array<int,string>
 */
function asenha_emergency_extract_chain_archives_from_url($url) {
    $parsed = @parse_url((string) $url);
    if (!is_array($parsed) || empty($parsed['query'])) {
        return array();
    }
    parse_str((string) $parsed['query'], $q);
    $tok = isset($q['asenha_chain']) ? (string) $q['asenha_chain'] : '';
    if (is_array($q) && isset($q['asenha_chain']) && is_array($q['asenha_chain'])) {
        $tok = '';
    }
    $tok = preg_replace('/[^A-Za-z0-9\-_]/', '', $tok);
    if ($tok === '') {
        return array();
    }
    $raw = asenha_emergency_base64url_decode($tok);
    if (false === $raw || $raw === '') {
        return array();
    }
    $data = json_decode((string) $raw, true);
    if (!is_array($data) || empty($data['archives']) || !is_array($data['archives'])) {
        return array();
    }
    $out = array();
    $seen = array();
    foreach ($data['archives'] as $fn) {
        $fn = asenha_sanitize_backup_basename((string) $fn);
        if ($fn === '' || isset($seen[$fn])) {
            continue;
        }
        if (!preg_match('/\.(zip|parts\.json)$/i', $fn)) {
            continue;
        }
        $out[] = $fn;
        $seen[$fn] = true;
        if (count($out) >= 32) {
            break;
        }
    }
    return $out;
}

/**
 * Directory URL (scheme + host + port + dirname of path) for building sibling URLs.
 *
 * @param string $url URL.
 * @return string Empty on failure.
 */
function asenha_emergency_get_url_directory($url) {
    $parsed = @parse_url((string) $url);
    if (!is_array($parsed) || empty($parsed['scheme']) || empty($parsed['host']) || empty($parsed['path'])) {
        return '';
    }
    $scheme = (string) $parsed['scheme'];
    $host = (string) $parsed['host'];
    $port = isset($parsed['port']) ? (int) $parsed['port'] : 0;
    $path = (string) $parsed['path'];
    $dir = dirname($path);
    if ($dir === '.' || $dir === '') {
        return '';
    }
    $base = $scheme . '://' . $host;
    if ($port > 0) {
        $base .= ':' . $port;
    }
    return rtrim($base . $dir, '/');
}

/**
 * Final unique basename in backup dir (restore mode — no imported_ prefix).
 *
 * @param string $backup_dir Backup directory.
 * @param string $basename   Desired basename.
 * @return string
 */
function asenha_emergency_unique_basename_in_backup_dir($backup_dir, $basename) {
    $basename = asenha_sanitize_backup_basename((string) $basename);
    if ($basename === '') {
        return '';
    }
    $dest = rtrim((string) $backup_dir, '/\\') . DIRECTORY_SEPARATOR . $basename;
    if (!file_exists($dest)) {
        return $basename;
    }
    $meta_suffix = '.parts.json';
    if ($meta_suffix === substr($basename, -strlen($meta_suffix))) {
        $base = substr($basename, 0, -strlen($meta_suffix));
        return $base . '_' . time() . '.parts.json';
    }
    $ext = pathinfo($basename, PATHINFO_EXTENSION);
    $base = pathinfo($basename, PATHINFO_FILENAME);
    $ext = $ext !== '' ? ('.' . $ext) : '';
    return $base . '_' . time() . $ext;
}

/**
 * Path to URL import job state file.
 *
 * @param string $backup_dir Backup dir.
 * @param string $job_id     Job ID.
 * @return string
 */
function asenha_emergency_url_job_state_path($backup_dir, $job_id) {
    $job_id = preg_replace('/[^A-Za-z0-9\-]/', '', (string) $job_id);
    return rtrim((string) $backup_dir, '/\\') . DIRECTORY_SEPARATOR . 'emergency_url_import_' . $job_id . '.json';
}

/**
 * Load URL import job state.
 *
 * @param string $backup_dir Backup dir.
 * @param string $job_id     Job ID.
 * @return array|null
 */
function asenha_emergency_url_job_load($backup_dir, $job_id) {
    $path = asenha_emergency_url_job_state_path($backup_dir, $job_id);
    if (!is_file($path)) {
        return null;
    }
    $json = @file_get_contents($path);
    if ($json === false || $json === '') {
        return null;
    }
    $data = json_decode($json, true);
    return is_array($data) ? $data : null;
}

/**
 * Save URL import job state.
 *
 * @param string $backup_dir Backup dir.
 * @param string $job_id     Job ID.
 * @param array  $state      State.
 * @return bool
 */
function asenha_emergency_url_job_save($backup_dir, $job_id, $state) {
    $path = asenha_emergency_url_job_state_path($backup_dir, $job_id);
    return false !== @file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT));
}

/**
 * Delete URL import job state file.
 *
 * @param string $backup_dir Backup dir.
 * @param string $job_id     Job ID.
 * @return void
 */
function asenha_emergency_url_job_delete($backup_dir, $job_id) {
    $path = asenha_emergency_url_job_state_path($backup_dir, $job_id);
    if (is_file($path)) {
        @unlink($path);
    }
}

/**
 * Mutate a URL import job's state under an exclusive advisory lock so that
 * concurrent poll requests cannot interleave load→process→save and corrupt
 * the `.part` or queue fields.
 *
 * The callback is invoked with the decoded state array by reference and must
 * return a result array with keys mirroring asenha_emergency_url_import_process_step's
 * contract: {done:bool,failed:bool,message:string,progress:int}.
 *
 * @param string   $backup_dir Backup directory.
 * @param string   $job_id     Job ID.
 * @param callable $cb         fn(array &$state): array
 * @return array{ok:bool,result:array,error:string}
 */
function asenha_emergency_url_job_with_lock($backup_dir, $job_id, $cb) {
    $out = array('ok' => false, 'result' => array(), 'error' => '');
    $path = asenha_emergency_url_job_state_path($backup_dir, $job_id);
    if (!is_file($path)) {
        $out['error'] = 'Job not found.';
        return $out;
    }
    // Open in read+write so we can both read existing content and overwrite
    // in place under the same lock.
    $fh = @fopen($path, 'c+');
    if (false === $fh) {
        $out['error'] = 'Cannot open job state.';
        return $out;
    }
    // Exclusive non-blocking lock with a short spin so two concurrent polls
    // do not stomp on each other. A pure LOCK_EX would serialize, but to
    // avoid piling up long-running steps we prefer to reject the second
    // caller and let the JS schedule the next tick.
    $locked = false;
    for ($i = 0; $i < 50; $i++) {
        if (@flock($fh, LOCK_EX | LOCK_NB)) {
            $locked = true;
            break;
        }
        usleep(100 * 1000); // 100ms
    }
    if (!$locked) {
        @fclose($fh);
        $out['error'] = 'Job is already being processed.';
        return $out;
    }
    @rewind($fh);
    $json = stream_get_contents($fh);
    if (false === $json || $json === '') {
        @flock($fh, LOCK_UN);
        @fclose($fh);
        $out['error'] = 'Empty job state.';
        return $out;
    }
    $state = json_decode((string) $json, true);
    if (!is_array($state)) {
        @flock($fh, LOCK_UN);
        @fclose($fh);
        $out['error'] = 'Corrupt job state.';
        return $out;
    }
    try {
        // Invoke the callback directly so the &$state parameter receives a
        // true by-reference binding without relying on call_user_func_array.
        $result = $cb($state);
    } catch (Exception $e) {
        @flock($fh, LOCK_UN);
        @fclose($fh);
        $out['error'] = 'Job step failed.';
        return $out;
    }
    $encoded = json_encode($state, JSON_PRETTY_PRINT);
    if (false === $encoded) {
        @flock($fh, LOCK_UN);
        @fclose($fh);
        $out['error'] = 'Failed to encode job state.';
        return $out;
    }
    @rewind($fh);
    @ftruncate($fh, 0);
    $written = @fwrite($fh, $encoded);
    @fflush($fh);
    @flock($fh, LOCK_UN);
    @fclose($fh);
    if (false === $written) {
        $out['error'] = 'Failed to save job state.';
        return $out;
    }
    $out['ok'] = true;
    $out['result'] = is_array($result) ? $result : array();
    return $out;
}

/**
 * Single non-redirecting cURL request with CURLOPT_RESOLVE pinning and
 * HTTP(S)-only protocol restrictions. Used as the per-hop primitive by
 * asenha_emergency_http_request().
 *
 * @param string $url     Absolute URL (already validated by caller).
 * @param array  $info    Output of asenha_emergency_validate_url_for_connect().
 * @param array  $options Options: method, headers, timeout, range.
 * @return array{ok:bool,code:int,body:string,error:string,content_length:int,accept_ranges:string,location:string,head:string}
 */
function asenha_emergency_http_single_request($url, $info, $options = array()) {
    $result = array(
        'ok' => false,
        'code' => 0,
        'body' => '',
        'error' => '',
        'content_length' => 0,
        'accept_ranges' => '',
        'location' => '',
        'head' => '',
    );
    if (!function_exists('curl_init')) {
        $result['error'] = 'cURL is not available on this server.';
        return $result;
    }
    $ch = curl_init((string) $url);
    if (false === $ch) {
        $result['error'] = 'Failed to initialize request.';
        return $result;
    }
    $method = isset($options['method']) ? strtoupper((string) $options['method']) : 'GET';
    $timeout = isset($options['timeout']) ? (int) $options['timeout'] : 30;
    if ($timeout < 5) {
        $timeout = 5;
    }
    if ($timeout > 300) {
        $timeout = 300;
    }
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(90, $timeout));
    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
    curl_setopt($ch, CURLOPT_HEADER, true);
    // Restrict to HTTP(S) at the cURL protocol layer to block file://, gopher://, etc.
    if (defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
        curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        // CURLOPT_REDIR_PROTOCOLS is harmless with redirects disabled, but set it in case a caller re-enables follow.
        curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
    }
    // DNS-pinning: prevent cURL from re-resolving the host (DNS rebinding mitigation).
    if (!empty($info['resolve'])) {
        curl_setopt($ch, CURLOPT_RESOLVE, array((string) $info['resolve']));
    }
    if (!empty($info['force_v4']) && defined('CURL_IPRESOLVE_V4')) {
        curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
    }
    if ('HEAD' === $method) {
        curl_setopt($ch, CURLOPT_NOBODY, true);
    }
    $headers = isset($options['headers']) && is_array($options['headers']) ? $options['headers'] : array();
    if (!empty($options['range'])) {
        $headers[] = 'Range: bytes=' . (string) $options['range'];
    }
    if (!empty($headers)) {
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }
    $raw = curl_exec($ch);
    $errno = curl_errno($ch);
    $errstr = curl_error($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $header_size = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    curl_close($ch);
    if (false === $raw) {
        $result['error'] = $errno ? ($errstr !== '' ? $errstr : 'Request failed') : 'Request failed';
        return $result;
    }
    $head = substr($raw, 0, $header_size);
    $body = substr($raw, $header_size);
    if ('HEAD' === $method) {
        $body = '';
    }
    $result['code'] = $code;
    $result['body'] = $body;
    $result['head'] = $head;
    $result['ok'] = ($code >= 200 && $code < 400);
    if (preg_match('/^Content-Length:\s*(\d+)/mi', $head, $m)) {
        $result['content_length'] = (int) $m[1];
    }
    if (preg_match('/^Accept-Ranges:\s*(\S+)/mi', $head, $m)) {
        $result['accept_ranges'] = strtolower((string) $m[1]);
    }
    if (preg_match('/^Location:\s*(.+?)\r?$/mi', $head, $m)) {
        $result['location'] = trim((string) $m[1]);
    }
    return $result;
}

/**
 * cURL HTTP response with a safe manual redirect loop. Each hop is re-validated
 * against SSRF + scheme allowlist, and each request is DNS-pinned via
 * CURLOPT_RESOLVE so cURL cannot re-resolve the host between validation and
 * connect (DNS rebinding mitigation).
 *
 * @param string $url     URL.
 * @param array  $options Options: method, headers, timeout, range.
 * @return array{ok:bool,code:int,body:string,error:string,content_length:int,accept_ranges:string,final_url:string,final_resolve:string,final_force_v4:bool}
 */
function asenha_emergency_http_request($url, $options = array()) {
    $result = array(
        'ok' => false,
        'code' => 0,
        'body' => '',
        'error' => '',
        'content_length' => 0,
        'accept_ranges' => '',
        'final_url' => '',
        'final_resolve' => '',
        'final_force_v4' => false,
    );
    $current_url = (string) $url;
    $max_hops = 3;
    for ($hop = 0; $hop <= $max_hops; $hop++) {
        $info = asenha_emergency_validate_url_for_connect($current_url);
        if (empty($info['ok'])) {
            $result['error'] = 'Blocked by SSRF policy: ' . (isset($info['error']) ? (string) $info['error'] : 'invalid URL');
            return $result;
        }
        $r = asenha_emergency_http_single_request($current_url, $info, $options);
        if (!empty($r['error']) && $r['code'] === 0) {
            $result['error'] = (string) $r['error'];
            $result['code'] = (int) $r['code'];
            return $result;
        }
        $code = (int) $r['code'];
        $is_redirect = ($code >= 300 && $code < 400 && $r['location'] !== '');
        if (!$is_redirect) {
            $result['ok'] = !empty($r['ok']);
            $result['code'] = $code;
            $result['body'] = (string) $r['body'];
            $result['content_length'] = (int) $r['content_length'];
            $result['accept_ranges'] = (string) $r['accept_ranges'];
            $result['final_url'] = $current_url;
            $result['final_resolve'] = (string) $info['resolve'];
            $result['final_force_v4'] = !empty($info['force_v4']);
            return $result;
        }
        if ($hop >= $max_hops) {
            $result['error'] = 'Too many redirects.';
            $result['code'] = $code;
            return $result;
        }
        $next = asenha_emergency_resolve_redirect_location($current_url, (string) $r['location']);
        if ($next === '') {
            $result['error'] = 'Invalid redirect target.';
            $result['code'] = $code;
            return $result;
        }
        $current_url = $next;
    }
    $result['error'] = 'Redirect loop exhausted.';
    return $result;
}

/**
 * Detect Content-Length via HEAD.
 *
 * @param string $url URL.
 * @return int
 */
function asenha_emergency_detect_content_length($url) {
    $r = asenha_emergency_http_request($url, array('method' => 'HEAD', 'timeout' => 30));
    if ($r['content_length'] > 0) {
        return (int) $r['content_length'];
    }
    return 0;
}

/**
 * Whether server supports byte ranges.
 *
 * @param string $url URL.
 * @return bool
 */
function asenha_emergency_supports_range_requests($url) {
    $r = asenha_emergency_http_request($url, array('timeout' => 30, 'range' => '0-0'));
    if (206 === $r['code']) {
        return true;
    }
    if (200 === $r['code'] && $r['accept_ranges'] === 'bytes') {
        return true;
    }
    return false;
}

/**
 * Download entire URL to a file (no range). Used when server does not advertise
 * ranges. Redirects are resolved via a manual HEAD loop in
 * asenha_emergency_http_request() (per-hop SSRF validation + DNS pinning), then
 * the final URL is streamed to disk with follow-location disabled. Optionally
 * enforces a maximum byte cap so a malicious or misconfigured remote cannot
 * exhaust disk.
 *
 * @param string $url       URL.
 * @param string $dest      Destination path.
 * @param int    $max_bytes Maximum accepted bytes (0 = no cap).
 * @return bool
 */
function asenha_emergency_curl_download_entire_file($url, $dest, $max_bytes = 0) {
    if (!function_exists('curl_init')) {
        return false;
    }
    $url = (string) $url;
    $dest = (string) $dest;
    $max_bytes = (int) $max_bytes;

    // Resolve redirects to the final URL (each hop re-validated for SSRF +
    // pinned via CURLOPT_RESOLVE). Bail immediately if any hop is blocked.
    $head = asenha_emergency_http_request($url, array('method' => 'HEAD', 'timeout' => 60));
    if (empty($head['ok']) || empty($head['final_url'])) {
        return false;
    }
    $final_url = (string) $head['final_url'];
    $final_resolve = (string) $head['final_resolve'];
    $force_v4 = !empty($head['final_force_v4']);
    $advertised = (int) $head['content_length'];
    if ($max_bytes > 0 && $advertised > 0 && $advertised > $max_bytes) {
        return false;
    }

    $fh = @fopen($dest, 'wb');
    if (false === $fh) {
        return false;
    }
    $ch = curl_init($final_url);
    if (false === $ch) {
        fclose($fh);
        return false;
    }
    curl_setopt($ch, CURLOPT_FILE, $fh);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 90);
    curl_setopt($ch, CURLOPT_TIMEOUT, 300);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
    if (defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
        curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
    }
    if ($final_resolve !== '') {
        curl_setopt($ch, CURLOPT_RESOLVE, array($final_resolve));
    }
    if ($force_v4 && defined('CURL_IPRESOLVE_V4')) {
        curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
    }
    // Hard byte cap. CURLOPT_MAXFILESIZE aborts early when Content-Length
    // exceeds the cap at connect time; the progress callback catches servers
    // that omit Content-Length or under-report it.
    if ($max_bytes > 0) {
        curl_setopt($ch, CURLOPT_MAXFILESIZE, $max_bytes);
        curl_setopt($ch, CURLOPT_NOPROGRESS, false);
        $cap = $max_bytes;
        curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($res, $dltotal, $dlnow) use ($cap) {
            return ($dlnow > $cap) ? 1 : 0;
        });
    }
    $ok = curl_exec($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    fclose($fh);
    if (!($ok && $code >= 200 && $code < 300)) {
        @unlink($dest);
        return false;
    }
    if ($max_bytes > 0 && file_exists($dest) && (int) filesize($dest) > $max_bytes) {
        @unlink($dest);
        return false;
    }
    return true;
}

/**
 * Move temp download to final path.
 *
 * @param string $tmp  Temp path.
 * @param string $dest Final path.
 * @return bool
 */
function asenha_emergency_move_temp_to_final($tmp, $dest) {
    if (!file_exists($tmp)) {
        return false;
    }
    if (@rename($tmp, $dest)) {
        return true;
    }
    if (@copy($tmp, $dest)) {
        @unlink($tmp);
        return true;
    }
    return false;
}

/**
 * Process one time slice of URL import job.
 *
 * @param array  $state          Job state (by ref).
 * @param string $backup_dir     Backup directory.
 * @param float  $start_time     Script start time.
 * @param int    $chunk_bytes    Download chunk size.
 * @return array{done:bool,failed:bool,message:string,progress:int}
 */
function asenha_emergency_url_import_process_step(&$state, $backup_dir, $start_time, $chunk_bytes = 2097152) {
    $out = array('done' => false, 'failed' => false, 'message' => '', 'progress' => 0);
    $chunk_bytes = (int) $chunk_bytes;
    if ($chunk_bytes < 65536) {
        $chunk_bytes = 2097152;
    }
    $size_cap = defined('ASENHA_EMERGENCY_URL_MAX_BYTES') ? (int) ASENHA_EMERGENCY_URL_MAX_BYTES : 0;

    if (empty($state['status']) || $state['status'] !== 'running') {
        $out['done'] = true;
        return $out;
    }

    if (is_time_running_out($start_time)) {
        $out['message'] = 'Pausing download (time limit).';
        $out['progress'] = isset($state['progress']) ? (int) $state['progress'] : 0;
        return $out;
    }

    $step = isset($state['step']) ? (string) $state['step'] : '';

    if ($step === 'failed' || $step === 'done') {
        $out['done'] = true;
        return $out;
    }

    // Sequential queue of zip / single meta files.
    if ($step === 'queue_download') {
        $queue = isset($state['queue']) && is_array($state['queue']) ? $state['queue'] : array();
        $qi = isset($state['queue_index']) ? (int) $state['queue_index'] : 0;
        if ($qi >= count($queue)) {
            $state['status'] = 'complete';
            $state['step'] = 'done';
            $state['progress'] = 100;
            $out['done'] = true;
            $out['message'] = 'Download completed.';
            return $out;
        }
        $item = $queue[$qi];
        $mode = isset($item['mode']) ? (string) $item['mode'] : 'zip';
        $dl_url = isset($item['url']) ? (string) $item['url'] : '';
        $final_base = isset($item['final_basename']) ? (string) $item['final_basename'] : '';
        if ($dl_url === '' || $final_base === '') {
            $state['status'] = 'failed';
            $state['step'] = 'failed';
            $state['error'] = 'Invalid queue entry.';
            $out['failed'] = true;
            return $out;
        }
        if ($mode === 'multipart_meta') {
            $state['step'] = 'multipart_meta';
            $state['multipart'] = array(
                'meta_url' => $dl_url,
                'final_meta_basename' => $final_base,
                'meta_tmp' => rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . 'temp_emergency_url_' . (isset($state['job_id']) ? $state['job_id'] : 'x') . '_meta.part',
                'meta_received' => 0,
                'meta_expected' => 0,
                'supports_range' => false,
                'parts' => array(),
                'part_index' => 0,
                'dir_url' => '',
                'meta_local_path' => '',
                'total_plan_bytes' => 0,
                'bytes_done' => 0,
            );
            $state['multipart']['meta_expected'] = asenha_emergency_detect_content_length($dl_url);
            $state['multipart']['supports_range'] = asenha_emergency_supports_range_requests($dl_url);
            return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
        }
        // zip
        $final_path = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . $final_base;
        $tmp = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . 'temp_emergency_url_' . (isset($state['job_id']) ? $state['job_id'] : 'x') . '_q' . $qi . '.part';
        if (!isset($state['current_dl']) || !is_array($state['current_dl'])) {
            $exp = asenha_emergency_detect_content_length($dl_url);
            $state['current_dl'] = array(
                'url' => $dl_url,
                'tmp_path' => $tmp,
                'final_path' => $final_path,
                'expected' => $exp,
                'received' => 0,
                'supports_range' => asenha_emergency_supports_range_requests($dl_url),
            );
            if (file_exists($tmp)) {
                $state['current_dl']['received'] = (int) filesize($tmp);
            }
        }
        $cd = &$state['current_dl'];
        $url = $cd['url'];
        $expected = (int) $cd['expected'];
        $received = (int) $cd['received'];
        $supports = !empty($cd['supports_range']);
        $tmp_path = $cd['tmp_path'];
        $final_path = $cd['final_path'];

        if ($received > 0 && !$supports) {
            @unlink($tmp_path);
            $received = 0;
            $cd['received'] = 0;
        }

        if (!$supports) {
            @unlink($tmp_path);
            if ($size_cap > 0 && $expected > 0 && $expected > $size_cap) {
                $state['status'] = 'failed';
                $state['error'] = 'Remote file exceeds the configured size cap.';
                $out['failed'] = true;
                return $out;
            }
            if (!asenha_emergency_curl_download_entire_file($url, $tmp_path, $size_cap)) {
                $state['status'] = 'failed';
                $state['error'] = 'Download failed. The server may not support partial downloads, or the file exceeds the size cap; try uploading the archive instead.';
                $out['failed'] = true;
                return $out;
            }
            $cd['received'] = file_exists($tmp_path) ? (int) filesize($tmp_path) : 0;
            if ($size_cap > 0 && $cd['received'] > $size_cap) {
                @unlink($tmp_path);
                $state['status'] = 'failed';
                $state['error'] = 'Downloaded content exceeds the configured size cap.';
                $out['failed'] = true;
                return $out;
            }
            if ($expected > 0 && $cd['received'] < $expected) {
                $state['status'] = 'failed';
                $state['error'] = 'Download incomplete (expected ' . $expected . ' bytes, got ' . $cd['received'] . ').';
                $out['failed'] = true;
                return $out;
            }
            if (!asenha_emergency_move_temp_to_final($tmp_path, $final_path)) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to finalize downloaded file.';
                $out['failed'] = true;
                return $out;
            }
            $state['bytes_weight_done'] = isset($state['bytes_weight_done']) ? (int) $state['bytes_weight_done'] + max(1, $expected > 0 ? $expected : $cd['received']) : max(1, $expected > 0 ? $expected : $cd['received']);
            unset($state['current_dl']);
            $state['queue_index'] = $qi + 1;
            $state['step'] = 'queue_download';
            return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
        }

        $offset = $received;
        if ($expected > 0 && $offset >= $expected) {
            if (!asenha_emergency_move_temp_to_final($tmp_path, $final_path)) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to finalize downloaded file.';
                $out['failed'] = true;
                return $out;
            }
            $state['bytes_weight_done'] = isset($state['bytes_weight_done']) ? (int) $state['bytes_weight_done'] + $expected : $expected;
            unset($state['current_dl']);
            $state['queue_index'] = $qi + 1;
            $state['step'] = 'queue_download';
            return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
        }
        $end = $offset + $chunk_bytes - 1;
        if ($expected > 0) {
            $end = min($end, $expected - 1);
        }
        $r = asenha_emergency_http_request($url, array('timeout' => 120, 'range' => $offset . '-' . $end));
        if (!$r['ok'] || (200 !== $r['code'] && 206 !== $r['code'])) {
            $state['status'] = 'failed';
            $state['error'] = 'HTTP error while downloading: ' . (int) $r['code'];
            $out['failed'] = true;
            return $out;
        }
        $body = $r['body'];
        if ($body === '') {
            if ($expected > 0 && $offset < $expected) {
                $state['status'] = 'failed';
                $state['error'] = 'Unexpected empty response.';
                $out['failed'] = true;
                return $out;
            }
            if (!asenha_emergency_move_temp_to_final($tmp_path, $final_path)) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to finalize downloaded file.';
                $out['failed'] = true;
                return $out;
            }
            $state['bytes_weight_done'] = isset($state['bytes_weight_done']) ? (int) $state['bytes_weight_done'] + max(1, $expected) : max(1, $expected);
            unset($state['current_dl']);
            $state['queue_index'] = $qi + 1;
            $state['step'] = 'queue_download';
            return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
        }
        if ($size_cap > 0 && ($offset + strlen($body)) > $size_cap) {
            @unlink($tmp_path);
            $state['status'] = 'failed';
            $state['error'] = 'Download would exceed the configured size cap.';
            $out['failed'] = true;
            return $out;
        }
        $fh = @fopen($tmp_path, $offset > 0 ? 'ab' : 'wb');
        if (false === $fh) {
            $state['status'] = 'failed';
            $state['error'] = 'Cannot write temporary file.';
            $out['failed'] = true;
            return $out;
        }
        if ($offset > 0) {
            @fseek($fh, 0, SEEK_END);
        }
        @fwrite($fh, $body);
        fclose($fh);
        $cd['received'] = file_exists($tmp_path) ? (int) filesize($tmp_path) : 0;
        if ($size_cap > 0 && $cd['received'] > $size_cap) {
            @unlink($tmp_path);
            $state['status'] = 'failed';
            $state['error'] = 'Downloaded content exceeds the configured size cap.';
            $out['failed'] = true;
            return $out;
        }
        $state['progress'] = (int) min(99, ($state['total_weight'] > 0 ? (($state['bytes_weight_done'] + $cd['received']) / max(1, $state['total_weight'])) * 100 : 50));
        $out['message'] = 'Downloading…';
        return $out;
    }

    if ($step === 'multipart_meta' && isset($state['multipart']) && is_array($state['multipart'])) {
        $mp = &$state['multipart'];
        $meta_url = (string) $mp['meta_url'];
        $meta_tmp = (string) $mp['meta_tmp'];
        $final_meta = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . (string) $mp['final_meta_basename'];
        $mp['meta_local_path'] = $final_meta;
        $expected = (int) $mp['meta_expected'];
        $received = file_exists($meta_tmp) ? (int) filesize($meta_tmp) : 0;
        $mp['meta_received'] = $received;
        if ($received === 0) {
            @unlink($meta_tmp);
            // Metadata is tiny in practice; still enforce a conservative cap.
            $meta_cap = ($size_cap > 0) ? min($size_cap, 16 * 1024 * 1024) : 16 * 1024 * 1024;
            if (!asenha_emergency_curl_download_entire_file($meta_url, $meta_tmp, $meta_cap)) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to download metadata.';
                $out['failed'] = true;
                return $out;
            }
            $mp['meta_received'] = file_exists($meta_tmp) ? (int) filesize($meta_tmp) : 0;
        }
        if ($expected > 0 && $mp['meta_received'] > 0 && $mp['meta_received'] < $expected) {
            $state['status'] = 'failed';
            $state['error'] = 'Metadata download incomplete.';
            $out['failed'] = true;
            return $out;
        }
        if (!asenha_emergency_move_temp_to_final($meta_tmp, $final_meta)) {
            $state['status'] = 'failed';
            $state['error'] = 'Failed to save metadata file.';
            $out['failed'] = true;
            return $out;
        }
        $content = @file_get_contents($final_meta);
        if ($content === false || $content === '') {
            $state['status'] = 'failed';
            $state['error'] = 'Metadata file is empty.';
            $out['failed'] = true;
            return $out;
        }
        $meta = json_decode($content, true);
        if (!is_array($meta) || empty($meta['parts']) || !is_array($meta['parts'])) {
            $state['status'] = 'failed';
            $state['error'] = 'Invalid metadata JSON.';
            $out['failed'] = true;
            return $out;
        }
        $dir_url = asenha_emergency_get_url_directory($meta_url);
        if ($dir_url === '') {
            $state['status'] = 'failed';
            $state['error'] = 'Cannot resolve metadata directory URL.';
            $out['failed'] = true;
            return $out;
        }
        $mp['dir_url'] = $dir_url;
        $parts_out = array();
        $parts_declared_sum = 0;
        foreach ($meta['parts'] as $p) {
            if (!is_array($p) || empty($p['name'])) {
                continue;
            }
            $pname = asenha_sanitize_backup_basename((string) $p['name']);
            if ($pname === '') {
                continue;
            }
            $remote = $pname;
            $psize = isset($p['size']) ? (int) $p['size'] : 0;
            if ($size_cap > 0 && $psize > $size_cap) {
                $state['status'] = 'failed';
                $state['error'] = 'A declared part exceeds the configured size cap.';
                $out['failed'] = true;
                return $out;
            }
            $parts_declared_sum += max(0, $psize);
            if ($size_cap > 0 && $parts_declared_sum > $size_cap) {
                $state['status'] = 'failed';
                $state['error'] = 'Declared parts sum exceeds the configured size cap.';
                $out['failed'] = true;
                return $out;
            }
            $parts_out[] = array(
                'name' => $pname,
                'remote_name' => $remote,
                'size' => $psize,
                'url' => $dir_url . '/' . rawurlencode($remote),
            );
        }
        if (empty($parts_out)) {
            $state['status'] = 'failed';
            $state['error'] = 'No valid parts in metadata.';
            $out['failed'] = true;
            return $out;
        }
        $mp['parts'] = $parts_out;
        $mp['part_index'] = 0;
        $tb = isset($meta['total_bytes']) ? (int) $meta['total_bytes'] : 0;
        $mp['total_plan_bytes'] = $tb > 0 ? $tb : 0;
        $state['step'] = 'multipart_parts';
        return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
    }

    if ($step === 'multipart_parts' && isset($state['multipart']) && is_array($state['multipart'])) {
        $mp = &$state['multipart'];
        $parts = isset($mp['parts']) && is_array($mp['parts']) ? $mp['parts'] : array();
        $pi = (int) $mp['part_index'];
        if ($pi >= count($parts)) {
            $meta_path = isset($mp['meta_local_path']) ? (string) $mp['meta_local_path'] : '';
            if ($meta_path !== '' && is_file($meta_path)) {
                $mc = @file_get_contents($meta_path);
                $meta = json_decode((string) $mc, true);
                if (is_array($meta) && !empty($meta['parts']) && is_array($meta['parts'])) {
                    foreach ($meta['parts'] as &$pp) {
                        if (is_array($pp) && isset($pp['name'])) {
                            $pp['name'] = asenha_sanitize_backup_basename((string) $pp['name']);
                        }
                    }
                    unset($pp);
                    @file_put_contents($meta_path, json_encode($meta, JSON_PRETTY_PRINT));
                }
            }
            $state['queue_index'] = isset($state['queue_index']) ? (int) $state['queue_index'] + 1 : 1;
            unset($state['multipart']);
            $state['step'] = 'queue_download';
            return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
        }
        $part = $parts[$pi];
        $purl = (string) $part['url'];
        $pfinal = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . (string) $part['name'];
        $ptmp = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . 'temp_emergency_url_' . (isset($state['job_id']) ? $state['job_id'] : 'x') . '_p' . $pi . '.part';
        if (!isset($mp['part_dl']) || !is_array($mp['part_dl']) || (isset($mp['part_dl']['path']) && $mp['part_dl']['path'] !== $pfinal)) {
            $pexp = isset($part['size']) ? (int) $part['size'] : 0;
            $mp['part_dl'] = array(
                'url' => $purl,
                'tmp_path' => $ptmp,
                'final_path' => $pfinal,
                'expected' => $pexp,
                'received' => file_exists($ptmp) ? (int) filesize($ptmp) : 0,
                'supports_range' => asenha_emergency_supports_range_requests($purl),
            );
        }
        $pd = &$mp['part_dl'];
        $url = $pd['url'];
        $expected = (int) $pd['expected'];
        $received = (int) $pd['received'];
        $supports = !empty($pd['supports_range']);
        $tmp_path = $pd['tmp_path'];
        $final_path = $pd['final_path'];

        if (!$supports) {
            if ($size_cap > 0 && $expected > 0 && $expected > $size_cap) {
                $state['status'] = 'failed';
                $state['error'] = 'Part exceeds the configured size cap.';
                $out['failed'] = true;
                return $out;
            }
            $r = asenha_emergency_http_request($url, array('timeout' => min(180, (int) (MAX_EXEC_TIME - TIME_SAFETY_MARGIN))));
            if (!$r['ok'] || $r['code'] < 200 || $r['code'] >= 300) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to download part.';
                $out['failed'] = true;
                return $out;
            }
            if ($size_cap > 0 && strlen((string) $r['body']) > $size_cap) {
                $state['status'] = 'failed';
                $state['error'] = 'Downloaded part exceeds the configured size cap.';
                $out['failed'] = true;
                return $out;
            }
            @file_put_contents($tmp_path, $r['body']);
            if (!asenha_emergency_move_temp_to_final($tmp_path, $final_path)) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to save part file.';
                $out['failed'] = true;
                return $out;
            }
            unset($mp['part_dl']);
            $mp['part_index'] = $pi + 1;
            $out['message'] = 'Downloaded part ' . ($pi + 1) . ' of ' . count($parts);
            return $out;
        }
        if ($expected > 0 && $received >= $expected) {
            if (!asenha_emergency_move_temp_to_final($tmp_path, $final_path)) {
                $state['status'] = 'failed';
                $state['error'] = 'Failed to save part file.';
                $out['failed'] = true;
                return $out;
            }
            unset($mp['part_dl']);
            $mp['part_index'] = $pi + 1;
            return asenha_emergency_url_import_process_step($state, $backup_dir, $start_time, $chunk_bytes);
        }
        $offset = $received;
        $end = $offset + $chunk_bytes - 1;
        if ($expected > 0) {
            $end = min($end, $expected - 1);
        }
        $r = asenha_emergency_http_request($url, array('timeout' => 120, 'range' => $offset . '-' . $end));
        if (!$r['ok'] || (200 !== $r['code'] && 206 !== $r['code'])) {
            $state['status'] = 'failed';
            $state['error'] = 'HTTP error downloading part.';
            $out['failed'] = true;
            return $out;
        }
        if ($size_cap > 0 && ($offset + strlen((string) $r['body'])) > $size_cap) {
            @unlink($tmp_path);
            $state['status'] = 'failed';
            $state['error'] = 'Part download would exceed the configured size cap.';
            $out['failed'] = true;
            return $out;
        }
        $fh = @fopen($tmp_path, $offset > 0 ? 'ab' : 'wb');
        if (false !== $fh) {
            if ($offset > 0) {
                @fseek($fh, 0, SEEK_END);
            }
            @fwrite($fh, $r['body']);
            fclose($fh);
        }
        $pd['received'] = file_exists($tmp_path) ? (int) filesize($tmp_path) : 0;
        if ($size_cap > 0 && $pd['received'] > $size_cap) {
            @unlink($tmp_path);
            $state['status'] = 'failed';
            $state['error'] = 'Part download exceeds the configured size cap.';
            $out['failed'] = true;
            return $out;
        }
        $out['message'] = 'Downloading part ' . ($pi + 1) . '…';
        return $out;
    }

    $out['message'] = 'Working…';
    return $out;
}

// ============================================================================
// REQUEST HANDLERS
// ============================================================================

// Handle POST request (restore action)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Disable error display for JSON response
    ini_set('display_errors', 0);
    error_reporting(0);
    
    ob_start();
    header('Content-Type: application/json');
    
    try {
        $action = isset($_POST['action']) ? $_POST['action'] : '';

        if (!asenha_emergency_verify_post_token($security_token)) {
            ob_end_clean();
            echo json_encode(array('status' => 'failed', 'error' => 'Invalid or missing security token.'));
            exit;
        }

        // Same-origin guard: even with a valid token, reject cross-origin
        // replays unless the operator has explicitly opted out. Default is
        // strict; flip $asenha_emergency_disable_origin_check near the top of
        // this script only when running it from a different host on purpose.
        if (empty($asenha_emergency_disable_origin_check) && !asenha_emergency_verify_same_origin()) {
            ob_end_clean();
            echo json_encode(array('status' => 'failed', 'error' => 'Invalid origin.'));
            exit;
        }

        if ($action === 'upload_archive') {
            if (empty($_FILES['archive_file']) || !isset($_FILES['archive_file']['tmp_name'])) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'No file uploaded.'));
                exit;
            }
            $f = $_FILES['archive_file'];
            if (!empty($f['error']) && (int) $f['error'] !== UPLOAD_ERR_OK) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Upload failed (PHP error ' . (int) $f['error'] . '). Check upload_max_filesize and post_max_size.'));
                exit;
            }
            $tmp_name = isset($f['tmp_name']) ? (string) $f['tmp_name'] : '';
            if ($tmp_name === '' || !is_uploaded_file($tmp_name)) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Upload was not received correctly.'));
                exit;
            }
            $orig = isset($f['name']) ? (string) $f['name'] : '';
            $base = asenha_sanitize_backup_basename($orig);
            if ($base === '') {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Invalid filename.'));
                exit;
            }
            // Reject reserved artifact prefixes. Parity with the 'restore'
            // action's existing guard: migration/transfer packages, internal
            // temp artifacts, and restore state files are not eligible for
            // user upload via Emergency Restore.
            if (
                strpos($base, 'imported_') === 0
                || strpos($base, 'transfer_') === 0
                || strpos($base, 'temp_') === 0
                || strpos($base, 'restore_state_') === 0
            ) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Filenames starting with imported_/transfer_/temp_/restore_state_ are not accepted.'));
                exit;
            }
            $is_meta = is_multipart_meta_filename($base);
            if (!$is_meta) {
                $ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
                if ($ext !== 'zip') {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Only .zip or multipart .parts.json files are allowed.'));
                    exit;
                }
            }
            // Structure validation before moving the upload into place.
            if ($is_meta) {
                $meta_content = @file_get_contents($tmp_name, false, null, 0, 16 * 1024 * 1024);
                if ($meta_content === false || $meta_content === '') {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Uploaded metadata file is empty or unreadable.'));
                    exit;
                }
                $meta_decoded = json_decode($meta_content, true);
                if (!is_array($meta_decoded) || empty($meta_decoded['parts']) || !is_array($meta_decoded['parts'])) {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Uploaded file is not a valid multipart metadata JSON.'));
                    exit;
                }
            } else {
                $fh_check = @fopen($tmp_name, 'rb');
                if (false === $fh_check) {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Uploaded file is unreadable.'));
                    exit;
                }
                $magic = (string) @fread($fh_check, 4);
                @fclose($fh_check);
                // Valid ZIP magic signatures: local file header (PK\x03\x04),
                // empty archive (PK\x05\x06), and spanned/split (PK\x07\x08).
                $magic_ok = (0 === strpos($magic, "PK\x03\x04") || 0 === strpos($magic, "PK\x05\x06") || 0 === strpos($magic, "PK\x07\x08"));
                if (!$magic_ok) {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Uploaded file does not look like a ZIP archive.'));
                    exit;
                }
            }
            if (!asenha_emergency_restore_filename_matches_embedded_slug($base)) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'This backup filename does not match this site. Use the WordPress admin Migration tab to import backups from other sites.'));
                exit;
            }
            $final_base = asenha_emergency_unique_basename_in_backup_dir($backup_dir, $base);
            if ($final_base === '') {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Could not determine destination filename.'));
                exit;
            }
            $dest = rtrim($backup_dir, '/\\') . DIRECTORY_SEPARATOR . $final_base;
            if (!@move_uploaded_file($tmp_name, $dest)) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Failed to save uploaded file.'));
                exit;
            }
            ob_end_clean();
            echo json_encode(array('ok' => true));
            exit;
        }

        if ($action === 'url_import_start') {
            $raw_url = isset($_POST['url']) ? (string) $_POST['url'] : '';
            $raw_url = trim($raw_url);
            if (0 === stripos($raw_url, 'asenha-sftp://')) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'SFTP references are not supported in Emergency Restore. Use a direct HTTP(S) URL, upload the file, or use the WordPress admin Restore tab.'));
                exit;
            }
            $clean = asenha_emergency_sanitize_http_import_url($raw_url);
            if ($clean === '') {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Enter a direct http(s) URL ending in .zip or .parts.json from a public host.'));
                exit;
            }
            $job_id = asenha_emergency_random_uuid();
            $base_url = asenha_emergency_remove_chain_query_arg($clean);
            $parsed = @parse_url($base_url);
            $path = is_array($parsed) && isset($parsed['path']) ? (string) $parsed['path'] : '';
            $clicked = asenha_sanitize_backup_basename(basename($path));
            $archives = asenha_emergency_extract_chain_archives_from_url($clean);
            $queue = array();
            $total_weight = 0;

            $size_cap = (int) ASENHA_EMERGENCY_URL_MAX_BYTES;
            $advertised_sum = 0;
            if (count($archives) > 1 && $clicked !== '' && isset($archives[0]) && $archives[0] === $clicked) {
                $dir_url = asenha_emergency_get_url_directory($base_url);
                if ($dir_url === '') {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Invalid chain URL.'));
                    exit;
                }
                foreach ($archives as $fname) {
                    $fname_san = asenha_sanitize_backup_basename((string) $fname);
                    if ($fname_san === '' || !asenha_emergency_restore_filename_matches_embedded_slug($fname_san)) {
                        ob_end_clean();
                        echo json_encode(array('ok' => false, 'error' => 'This backup filename does not match this site. Use the WordPress admin Migration tab to import backups from other sites.'));
                        exit;
                    }
                    $u = $dir_url . '/' . rawurlencode($fname);
                    $v = asenha_emergency_sanitize_http_import_url($u);
                    if ($v === '') {
                        ob_end_clean();
                        echo json_encode(array('ok' => false, 'error' => 'Invalid chain archive URL.'));
                        exit;
                    }
                    $mode = (preg_match('/\.parts\.json$/i', $fname)) ? 'multipart_meta' : 'zip';
                    $fb = asenha_emergency_unique_basename_in_backup_dir($backup_dir, $fname);
                    $clen = asenha_emergency_detect_content_length($v);
                    if ($size_cap > 0 && $clen > $size_cap) {
                        ob_end_clean();
                        echo json_encode(array('ok' => false, 'error' => 'A queued archive exceeds the configured size cap.'));
                        exit;
                    }
                    $advertised_sum += max(0, $clen);
                    if ($size_cap > 0 && $advertised_sum > $size_cap) {
                        ob_end_clean();
                        echo json_encode(array('ok' => false, 'error' => 'Total queued download size exceeds the configured size cap.'));
                        exit;
                    }
                    $queue[] = array('url' => $v, 'final_basename' => $fb, 'mode' => $mode);
                    $total_weight += max(1, $clen);
                }
            } else {
                $raw_single_base = $clicked !== '' ? $clicked : basename($path);
                $single_san = asenha_sanitize_backup_basename((string) $raw_single_base);
                if ($single_san !== '' && !asenha_emergency_restore_filename_matches_embedded_slug($single_san)) {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'This backup filename does not match this site. Use the WordPress admin Migration tab to import backups from other sites.'));
                    exit;
                }
                $fb = asenha_emergency_unique_basename_in_backup_dir($backup_dir, $raw_single_base);
                if ($fb === '') {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Invalid destination name.'));
                    exit;
                }
                $mode = preg_match('/\.parts\.json$/i', $path) ? 'multipart_meta' : 'zip';
                $clen = asenha_emergency_detect_content_length($clean);
                if ($size_cap > 0 && $clen > $size_cap) {
                    ob_end_clean();
                    echo json_encode(array('ok' => false, 'error' => 'Archive exceeds the configured size cap.'));
                    exit;
                }
                $queue[] = array('url' => $clean, 'final_basename' => $fb, 'mode' => $mode);
                $total_weight = max(1, $clen);
            }

            $state = array(
                'job_id' => $job_id,
                'status' => 'running',
                'step' => 'queue_download',
                'queue' => $queue,
                'queue_index' => 0,
                'progress' => 0,
                'total_weight' => $total_weight > 0 ? $total_weight : count($queue) * 1000000,
                'bytes_weight_done' => 0,
                'error' => '',
            );
            asenha_emergency_url_job_save($backup_dir, $job_id, $state);
            ob_end_clean();
            echo json_encode(array('ok' => true, 'job_id' => $job_id));
            exit;
        }

        if ($action === 'url_import_step') {
            $job_id = isset($_POST['job_id']) ? preg_replace('/[^A-Za-z0-9\-]/', '', (string) $_POST['job_id']) : '';
            // Fast-path checks: handle pre-terminal states without taking the
            // lock, since the lock wrapper assumes the state file is intact.
            $peek = asenha_emergency_url_job_load($backup_dir, $job_id);
            if ($peek === null) {
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => 'Job not found.'));
                exit;
            }
            if (isset($peek['status']) && $peek['status'] === 'failed') {
                asenha_emergency_url_job_delete($backup_dir, $job_id);
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => isset($peek['error']) ? (string) $peek['error'] : 'Download failed.'));
                exit;
            }
            if (isset($peek['status']) && $peek['status'] === 'complete') {
                asenha_emergency_url_job_delete($backup_dir, $job_id);
                ob_end_clean();
                echo json_encode(array('ok' => true, 'done' => true, 'progress' => 100));
                exit;
            }
            // Run the load → process → save cycle under an exclusive advisory
            // lock so concurrent pollers cannot interleave and corrupt state.
            $locked = asenha_emergency_url_job_with_lock($backup_dir, $job_id, function (&$state) use ($backup_dir, $script_start_time) {
                return asenha_emergency_url_import_process_step($state, $backup_dir, $script_start_time, 2097152);
            });
            if (empty($locked['ok'])) {
                // Likely the other poller is still running. Tell the client to
                // retry on the next tick; leave the job state untouched.
                $err = isset($locked['error']) ? (string) $locked['error'] : 'Step failed.';
                if ($err === 'Job is already being processed.') {
                    $prog = isset($peek['progress']) ? (int) $peek['progress'] : 0;
                    ob_end_clean();
                    echo json_encode(array('ok' => true, 'done' => false, 'progress' => $prog, 'message' => 'Working…'));
                    exit;
                }
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => $err));
                exit;
            }
            // Re-load the post-step state to report progress/terminal status.
            $state = asenha_emergency_url_job_load($backup_dir, $job_id);
            $res = is_array($locked['result']) ? $locked['result'] : array();
            if (!empty($res['failed'])) {
                $err_msg = is_array($state) && isset($state['error']) ? (string) $state['error'] : 'Download failed.';
                asenha_emergency_url_job_delete($backup_dir, $job_id);
                ob_end_clean();
                echo json_encode(array('ok' => false, 'error' => $err_msg));
                exit;
            }
            if (!empty($res['done']) && is_array($state) && isset($state['status']) && $state['status'] === 'complete') {
                asenha_emergency_url_job_delete($backup_dir, $job_id);
                ob_end_clean();
                echo json_encode(array('ok' => true, 'done' => true, 'progress' => 100));
                exit;
            }
            $prog = is_array($state) && isset($state['progress']) ? (int) $state['progress'] : 0;
            ob_end_clean();
            echo json_encode(array('ok' => true, 'done' => false, 'progress' => $prog, 'message' => isset($res['message']) ? (string) $res['message'] : ''));
            exit;
        }
        
        if ($action === 'restore' || $action === 'continue') {
            $filename = isset($_POST['filename']) ? $_POST['filename'] : '';
            $archive_passphrase = isset($_POST['archive_passphrase']) ? (string) $_POST['archive_passphrase'] : '';
            if ($archive_passphrase === '' && isset($_POST['archive_encryption_passphrase'])) {
                $archive_passphrase = (string) $_POST['archive_encryption_passphrase'];
            }
            
            // Sanitize filename
            $filename = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
            
            if (empty($filename)) {
                ob_end_clean();
                echo json_encode(array('status' => 'failed', 'error' => 'Invalid filename'));
                exit;
            }

            // Do not allow migration/transfer artifacts to be restored via this emergency script.
            // - Migration imports are prefixed with 'imported_'
            // - Transfer receive packages are prefixed with 'transfer_'
            // - Internal temp/state files must never be targeted
            if (strpos($filename, 'imported_') === 0 || strpos($filename, 'transfer_') === 0 || strpos($filename, 'temp_') === 0 || strpos($filename, 'restore_state_') === 0) {
                ob_end_clean();
                echo json_encode(array('status' => 'failed', 'error' => 'Migration/transfer archives are not eligible for Emergency Restore.'));
                exit;
            }

            if (!asenha_emergency_restore_filename_matches_embedded_slug($filename)) {
                ob_end_clean();
                echo json_encode(array('status' => 'failed', 'error' => 'This backup filename does not match this site. Use the WordPress admin Migration tab to import backups from other sites.'));
                exit;
            }
            
            // Verify file exists
            $filepath = $backup_dir . DIRECTORY_SEPARATOR . $filename;
            if (!file_exists($filepath)) {
                ob_end_clean();
                echo json_encode(array('status' => 'failed', 'error' => 'Backup file not found'));
                exit;
            }

            // Defense-in-depth for multipart: if metadata declares a migration package, do not proceed.
            if (is_multipart_meta_filename($filename)) {
                $meta = read_multipart_meta($filepath);
                if (!is_array($meta)) {
                    ob_end_clean();
                    echo json_encode(array('status' => 'failed', 'error' => 'Invalid multipart metadata'));
                    exit;
                }
                $backup_type = isset($meta['backup_type']) ? strtolower((string) $meta['backup_type']) : '';
                if ($backup_type === 'migration') {
                    ob_end_clean();
                    echo json_encode(array('status' => 'failed', 'error' => 'Migration/transfer archives are not eligible for Emergency Restore.'));
                    exit;
                }
            }
            
            // For "continue" action, check if state exists first
            // If not, the restore might have already completed
            if ($action === 'continue') {
                $existing_state = load_state($backup_dir, $filename);
                if ($existing_state === null) {
                    // No state file means restore already completed or was never started
                    ob_end_clean();
                    echo json_encode(array(
                        'status' => 'complete',
                        'phase' => 'complete',
                        'messages' => array('Restore already completed'),
                        'files_extracted' => 0,
                        'files_copied' => 0,
                        'sql_executed' => 0,
                    ));
                    exit;
                }
            }
            
            $delete_extra_files = false;
            if ($action === 'restore') {
                $delete_extra_files = (isset($_POST['delete_extra_files']) && (string) $_POST['delete_extra_files'] === '1');
            }

            // Process restore chunk
            $result = process_restore_chunk(
                $filename,
                $backup_dir,
                $wp_root,
                $wp_content_dir,
                $db_host,
                $db_name,
                $db_user,
                $db_password,
                $db_prefix,
                $db_charset,
                $script_start_time,
                $delete_extra_files,
                $archive_passphrase
            );
            
            // Prepare response (remove large internal data)
            $response = prepare_state_for_response($result);
            
            ob_end_clean();
            echo json_encode($response);
            exit;
            
        } elseif ($action === 'status') {
            $filename = isset($_POST['filename']) ? $_POST['filename'] : '';
            $filename = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
            
            $state = load_state($backup_dir, $filename);
            
            ob_end_clean();
            if ($state) {
                echo json_encode(prepare_state_for_response($state));
            } else {
                echo json_encode(array('status' => 'none'));
            }
            exit;
            
        } elseif ($action === 'cancel') {
            $filename = isset($_POST['filename']) ? $_POST['filename'] : '';
            $filename = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
            
            // Delete state and temp files
            delete_state($backup_dir, $filename);
            $temp_dir = asenha_emergency_get_temp_dir($backup_dir, $filename);
            delete_directory($temp_dir);
            
            ob_end_clean();
            echo json_encode(array('status' => 'cancelled'));
            exit;
        }
        
        ob_end_clean();
        echo json_encode(array('status' => 'failed', 'error' => 'Invalid action'));
        exit;
        
    } catch (Exception $e) {
        ob_end_clean();
        echo json_encode(array('status' => 'failed', 'error' => 'Exception: ' . $e->getMessage()));
        exit;
    } catch (Error $e) {
        ob_end_clean();
        echo json_encode(array('status' => 'failed', 'error' => 'Error: ' . $e->getMessage()));
        exit;
    }
}

// Get backup files for display
$backup_files = get_backup_files($backup_dir);
$backup_chains = group_backup_files_into_chains($backup_files);
$backup_notes_map = asenha_emergency_load_backup_notes_map();

// Clean up stale temp directories from previous restore attempts
cleanup_stale_temp_directories($backup_dir);

?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ASE Emergency Restore</title>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
            background: #f0f0f1;
            margin: 0;
            padding: 20px;
            line-height: 1.6;
        }
        .container {
            max-width: 1200px;
            margin: 0 auto;
        }
        .header {
            background: #fff;
            border: 1px solid #c3c4c7;
            border-left: 4px solid #2271b1;
            padding: 20px 25px;
            margin-bottom: 20px;
        }
        .header h1 {
            margin: 0 0 10px 0;
            font-size: 23px;
            font-weight: 400;
            color: #1d2327;
        }
        .header p {
            margin: 0;
            color: #646970;
        }
        .info-box {
            background: #f0f6fc;
            border: 1px solid #72aee6;
            border-left-width: 4px;
            padding: 12px 15px;
            margin-bottom: 15px;
            font-size: 13px;
            color: #1d2327;
        }
        .warning {
            background: #fff8e5;
            border: 1px solid #ffb900;
            border-left-width: 4px;
            padding: 12px 15px;
            margin-bottom: 20px;
            display: flex;
            align-items: center;
            gap: 10px;
        }
        .warning-icon {
            font-size: 20px;
        }
        .card {
            background: #fff;
            border: 1px solid #c3c4c7;
            padding: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        th, td {
            text-align: left;
            padding: 12px 10px;
            border-bottom: 1px solid #dcdcde;
        }
        th {
            background: #f6f7f7;
            font-weight: 600;
            color: #1d2327;
        }
        tr:last-child td {
            border-bottom: none;
        }
        .btn {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 8px 16px;
            background: #2271b1;
            color: #fff;
            border: none;
            border-radius: 3px;
            cursor: pointer;
            font-size: 13px;
            text-decoration: none;
            transition: background 0.2s;
        }
        .btn:hover {
            background: #135e96;
        }
        .btn:disabled {
            background: #a7aaad;
            cursor: not-allowed;
        }
        .btn-restoring {
            background: #dba617;
        }
        .btn-cancel {
        	display: none;
            background: #d63638;
            margin-left: 8px;
            padding: 8px 12px;
        }
        .btn-cancel:hover {
            background: #b32d2e;
        }
        .spinner {
            display: inline-block;
            width: 14px;
            height: 14px;
            border: 2px solid rgba(255,255,255,0.3);
            border-top-color: #fff;
            border-radius: 50%;
            animation: spin 0.8s linear infinite;
        }
        @keyframes spin {
            to { transform: rotate(360deg); }
        }
        .progress-container {
            margin-top: 15px;
            display: none;
        }
        .progress-bar-wrapper {
            background: #dcdcde;
            border-radius: 3px;
            height: 20px;
            overflow: hidden;
            margin-bottom: 10px;
        }
        .progress-bar {
            background: linear-gradient(90deg, #2271b1, #72aee6);
            height: 100%;
            width: 0%;
            transition: width 0.3s ease;
            border-radius: 3px;
        }
        .progress-text {
            font-size: 13px;
            color: #646970;
        }
        .progress-phase {
            font-weight: 600;
            color: #1d2327;
            margin-bottom: 5px;
        }
        .success-message {
            background: #edfaef;
            border: 1px solid #00a32a;
            color: #00a32a;
            padding: 15px;
            border-radius: 4px;
            margin-top: 10px;
        }
        .error-message {
            background: #fcf0f1;
            border: 1px solid #d63638;
            color: #d63638;
            padding: 15px;
            border-radius: 4px;
            margin-top: 10px;
        }
        .no-backups {
            text-align: center;
            padding: 40px 20px;
            color: #646970;
            font-style: italic;
        }
        .type-badge {
            display: inline-block;
            padding: 2px 8px;
            background: #f0f0f1;
            border-radius: 3px;
            font-size: 12px;
            color: #50575e;
        }
        .asenha-emergency-archive-label {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            min-width: 0;
        }
        .asenha-archive-encrypted-indicator {
            display: inline-flex;
            flex: 0 0 auto;
            align-items: center;
            justify-content: center;
            width: 14px;
            height: 14px;
            line-height: 1;
        }
        .asenha-archive-encrypted-indicator svg {
            display: block;
            width: 14px;
            height: 14px;
        }
        .screen-reader-text {
            border: 0;
            clip: rect(1px, 1px, 1px, 1px);
            clip-path: inset(50%);
            height: 1px;
            margin: -1px;
            overflow: hidden;
            padding: 0;
            position: absolute;
            width: 1px;
            word-wrap: normal !important;
        }
        .action-cell {
            white-space: nowrap;
        }
        .restore-options {
            margin-bottom: 15px;
            padding: 12px 15px;
            background: #f6f7f7;
            border: 1px solid #dcdcde;
            border-radius: 4px;
            color: #1d2327;
        }
        .restore-options label {
            display: flex;
            align-items: flex-start;
            gap: 8px;
            cursor: pointer;
        }
        .restore-options input[type="checkbox"] {
            margin-top: 6px;
        }
        .restore-options .help {
            margin: 0 0 0 28px;
            color: #646970;
            font-size: 14px;
        }
        .asenha-emergency-passphrase-field {
            margin-top: 12px;
        }
        .asenha-emergency-passphrase-field label {
            display: block;
            margin-bottom: 6px;
            cursor: default;
        }
        .asenha-emergency-passphrase-field input[type="password"] {
            width: 100%;
            max-width: 380px;
            padding: 6px 8px;
            border: 1px solid #8c8f94;
            border-radius: 4px;
            font-size: 13px;
            line-height: 1.4;
        }
        .asenha-emergency-passphrase-help {
            margin: 6px 0 0;
            color: #646970;
            font-size: 14px;
        }
        .asenha-emergency-notes-row > td,
        .asenha-emergency-incrementals-row > td {
            padding: 0 10px 12px;
            border-bottom: 1px solid #dcdcde;
            background: #fff;
        }
        .asenha-emergency-notes {
            margin: 0;
        }
        .asenha-emergency-notes > summary {
            cursor: pointer;
            color: #135e96;
            font-weight: 500;
            padding: 8px 0;
            user-select: none;
        }
        .asenha-emergency-notes > summary:hover {
            color: #0a4b78;
        }
        .asenha-emergency-notes-body {
            margin-top: 6px;
            border: 1px solid #dcdcde;
            border-radius: 4px;
            background: #f6f7f7;
            overflow-x: auto;
        }
        .asenha-emergency-notes-content {
            padding: 10px 12px;
            background: #fcf9f0;
            border: 1px solid #f0dba2;
            border-radius: 4px;
            font-size: 13px;
            line-height: 1.5;
            color: #1d2327;
            word-break: break-word;
        }
        .asenha-emergency-notes-content p {
            margin: 0 0 0.75em;
        }
        .asenha-emergency-notes-content p:last-child {
            margin-bottom: 0;
        }
        .asenha-emergency-notes-content ul,
        .asenha-emergency-notes-content ol {
            margin: 0 0 0.75em 1.25em;
            padding: 0;
        }
        .asenha-emergency-notes-content li + li {
            margin-top: 0.25em;
        }
        .asenha-emergency-incrementals {
            margin: 0;
        }
        .asenha-emergency-incrementals > summary {
            cursor: pointer;
            color: #135e96;
            font-weight: 500;
            padding: 8px 0;
            user-select: none;
        }
        .asenha-emergency-incrementals > summary:hover {
            color: #0a4b78;
        }
        .asenha-emergency-incrementals-body {
            margin-top: 6px;
            border: 1px solid #dcdcde;
            border-radius: 4px;
            background: #f6f7f7;
            overflow-x: auto;
        }
        .asenha-emergency-incrementals-table {
            margin: 0;
            border-collapse: collapse;
            background: #fff;
        }
        .asenha-emergency-incrementals-table th,
        .asenha-emergency-incrementals-table td {
            padding: 10px 8px;
            border-bottom: 1px solid #e5e5e5;
            font-size: 12px;
        }
        .asenha-emergency-incrementals-table th {
            background: #f6f7f7;
            color: #50575e;
        }
        .asenha-emergency-incrementals-table tr:last-child td {
            border-bottom: none;
        }
        .asenha-emergency-import-section {
            margin-bottom: 15px;
            padding: 12px 15px;
            background: #f8f9fa;
            border: 1px solid #dcdcde;
            border-radius: 4px;
        }
        .asenha-emergency-import-actions-row {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            align-items: center;
            margin-bottom: 8px;
        }
        .asenha-emergency-import-section .description {
            margin: 0 0 10px;
        }
        .asenha-emergency-upload-panel,
        .asenha-emergency-url-panel {
            display: none;
            margin-top: 10px;
            padding-top: 10px;
            border-top: 1px solid #dcdcde;
        }
        .asenha-emergency-url-controls {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            align-items: center;
        }
        .asenha-emergency-url-controls input[type="text"] {
            flex: 1 1 240px;
            min-width: 200px;
            padding: 6px 8px;
            border: 1px solid #8c8f94;
            border-radius: 4px;
            font-size: 13px;
        }
        .asenha-emergency-import-progress {
            margin-top: 10px;
            display: none;
        }
        .asenha-emergency-import-progress .asenha-progress-bar {
            background: #dcdcde;
            border-radius: 3px;
            height: 16px;
            overflow: hidden;
            margin-bottom: 6px;
        }
        .asenha-emergency-import-progress .asenha-progress-fill {
            background: linear-gradient(90deg, #2271b1, #72aee6);
            height: 100%;
            width: 0%;
            transition: width 0.2s ease;
        }
        .asenha-emergency-import-progress .asenha-upload-msg {
            font-size: 13px;
            color: #646970;
        }
        .asenha-emergency-dropzone {
            border: 1px dashed #8c8f94;
            border-radius: 4px;
            padding: 16px;
            text-align: center;
            background: #fff;
            cursor: pointer;
        }
        .asenha-emergency-dropzone:focus {
            outline: 2px solid #2271b1;
            outline-offset: 1px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>ASE Emergency Restore</h1>
            <p>Select a backup archive to restore your site. This script works independently of WordPress.</p>
        </div>
        
        <div class="info-box" style="display: none;">
            This script processes large backups in chunks and can resume if interrupted. 
            Server timeout: <?php echo MAX_EXEC_TIME; ?> seconds.
        </div>
        
        <div class="warning">
            <span class="warning-icon">⚠️</span>
            <span><strong>Important:</strong> Delete this script immediately after completing the restore operation.</span>
        </div>
        
        <div class="card">
            <input type="hidden" id="asenha-emergency-token" value="<?php echo htmlspecialchars($security_token, ENT_QUOTES, 'UTF-8'); ?>">
            <div class="asenha-emergency-import-section">
                <p class="description">Add a backup archive to this server&rsquo;s backup folder.</p>
                <div class="asenha-emergency-import-actions-row">
                    <button type="button" class="btn" id="asenha-emergency-show-upload">Upload a Backup Archive</button>
                    <button type="button" class="btn" id="asenha-emergency-show-url">Transfer a Backup Archive from URL</button>
                </div>
                <div class="asenha-emergency-upload-panel" id="asenha-emergency-upload-panel" style="display: none;">
                    <p class="description">Select a <code>.zip</code> or multipart metadata <code>.zip.parts.json</code> file. Very large uploads may require higher PHP <code>upload_max_filesize</code> / <code>post_max_size</code>.</p>
                    <div class="asenha-emergency-dropzone" id="asenha-emergency-dropzone" tabindex="0" role="button" aria-label="Drop backup file here or press to browse">
                        <p style="margin:0 0 8px;">Drag and drop a file here, or click to browse.</p>
                        <input type="file" id="asenha-emergency-file" accept=".zip,.json,application/zip,application/json" style="display: none;">
                        <button type="button" class="btn" id="asenha-emergency-browse">Select file</button>
                    </div>
                    <div class="asenha-emergency-import-progress" id="asenha-emergency-upload-progress">
                        <div class="asenha-progress-bar">
                            <div class="asenha-progress-fill" id="asenha-emergency-upload-fill" style="width: 0%;"></div>
                        </div>
                        <span class="asenha-upload-msg" id="asenha-emergency-upload-msg">Uploading…</span>
                    </div>
                </div>
                <div class="asenha-emergency-url-panel" id="asenha-emergency-url-panel" style="display: none;">
                    <p class="description">Paste a direct <code>http(s)</code> URL to a <code>.zip</code> or <code>.parts.json</code> file.</p>
                    <div class="asenha-emergency-url-controls">
                        <label class="screen-reader-text" for="asenha-emergency-url-input">Backup archive URL</label>
                        <input type="text" id="asenha-emergency-url-input" autocomplete="off" placeholder="https://example.com/.../backup.zip" inputmode="url">
                        <button type="button" class="btn" id="asenha-emergency-url-transfer">Transfer</button>
                    </div>
                    <div class="asenha-emergency-import-progress" id="asenha-emergency-url-progress">
                        <div class="asenha-progress-bar">
                            <div class="asenha-progress-fill" id="asenha-emergency-url-fill" style="width: 0%;"></div>
                        </div>
                        <span class="asenha-upload-msg" id="asenha-emergency-url-msg">Downloading…</span>
                    </div>
                </div>
            </div>
            <?php if (empty($backup_files)): ?>
                <div class="no-backups">
                    <p>No backup files found in the backup directory.</p>
                    <p><small><?php echo htmlspecialchars($backup_dir); ?></small></p>
                </div>
            <?php else: ?>
                <?php $encrypted_archive_label = 'This archive is encrypted'; ?>
                <div class="restore-options">
                    <label for="asenha-delete-extra-files">
                        <input type="checkbox" id="asenha-delete-extra-files" value="1">
                        Delete files in wp-content that are not in the backup archive (sync)
                    </label>
                    <div class="help">
                        Warning: This can remove plugins/uploads that were created after the backup. For baseline + incremental recovery points, sync delete runs automatically to match the selected recovery point.
                    </div>
                    <div class="asenha-emergency-passphrase-field">
                        <label for="asenha-archive-passphrase">Archive passphrase (encrypted backups only)</label>
                        <input type="password" id="asenha-archive-passphrase" autocomplete="current-password" spellcheck="false">
                        <p class="asenha-emergency-passphrase-help">Leave this empty for unencrypted backups.</p>
                    </div>
                </div>
                <table>
                    <thead>
                        <tr>
                            <th>Type</th>
                            <th>Filename</th>
                            <th>Size</th>
                            <th>Date</th>
                            <th>Action</th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php foreach ($backup_chains as $chain): ?>
                            <?php
                            $base = (isset($chain['base']) && is_array($chain['base'])) ? $chain['base'] : array();
                            if (empty($base['filename'])) {
                                continue;
                            }
                            $base_filename = (string) $base['filename'];
                            $incrementals = (isset($chain['incrementals']) && is_array($chain['incrementals'])) ? $chain['incrementals'] : array();
                            ?>
                            <tr id="row-<?php echo md5($base_filename); ?>" data-filename="<?php echo htmlspecialchars($base_filename); ?>" data-passphrase-required="<?php echo !empty($base['archive_passphrase_required']) ? '1' : '0'; ?>">
                                <td><span class="type-badge"><?php echo htmlspecialchars(isset($base['type']) ? $base['type'] : 'unknown'); ?></span></td>
                                <td>
                                    <?php $base_is_encrypted = !empty($base['archive_passphrase_required']) || !empty($base['archive_encryption_enabled']); ?>
                                    <span class="asenha-emergency-archive-label">
                                        <span><?php echo htmlspecialchars($base_filename); ?></span>
                                        <?php if ($base_is_encrypted): ?>
                                            <span class="asenha-archive-encrypted-indicator" title="<?php echo htmlspecialchars($encrypted_archive_label); ?>" aria-label="<?php echo htmlspecialchars($encrypted_archive_label); ?>" role="img">
                                                <svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="#50575e" d="M11.991 0a.883.883 0 0 0-.871.817v3.02a.883.883 0 0 0 .88.884a.883.883 0 0 0 .88-.88V.816A.883.883 0 0 0 11.991 0m7.705 3.109a.88.88 0 0 0-.521.174L16.8 5.231a.88.88 0 0 0 .559 1.563a.88.88 0 0 0 .56-.2l2.37-1.951a.88.88 0 0 0-.594-1.534M4.32 3.122a.883.883 0 0 0-.611 1.52l2.37 1.951a.88.88 0 0 0 .56.2v-.002a.88.88 0 0 0 .56-1.56L4.828 3.283a.9.9 0 0 0-.508-.16zm7.66 3.228a5.046 5.046 0 0 0-5.026 5.045v1.488H5.787a.967.967 0 0 0-.965.964v9.189a.967.967 0 0 0 .965.964h12.426a.967.967 0 0 0 .964-.964v-9.19a.967.967 0 0 0-.964-.963h-1.168v-1.488A5.046 5.046 0 0 0 11.98 6.35m.012 2.893a2.15 2.15 0 0 1 2.16 2.152v1.488H9.847v-1.488a2.15 2.15 0 0 1 2.145-2.152m7.382.503a.883.883 0 1 0 .07 1.763h3.027a.883.883 0 0 0 0-1.76h-3.027zM1.529 9.75a.883.883 0 0 0 0 1.76h2.999a.883.883 0 0 0 0-1.76zm10.46 6.774a1.28 1.28 0 0 1 .64 2.393v1.245a.63.63 0 0 1-1.259 0v-1.245a1.28 1.28 0 0 1 .619-2.393"/></svg>
                                                <span class="screen-reader-text"><?php echo htmlspecialchars($encrypted_archive_label); ?></span>
                                            </span>
                                        <?php endif; ?>
                                    </span>
                                </td>
                                <td><?php echo htmlspecialchars(isset($base['size']) ? $base['size'] : '-'); ?></td>
                                <td><?php echo htmlspecialchars(isset($base['date']) ? $base['date'] : '-'); ?></td>
                                <td class="action-cell">
                                    <button
                                        class="btn restore-btn"
                                        data-filename="<?php echo htmlspecialchars($base_filename); ?>"
                                        data-restore-scope="baseline"
                                        title="Restore this baseline archive only (no incrementals)."
                                    >
                                        Restore
                                    </button>
                                </td>
                            </tr>
                            <?php
                            $chain_note = asenha_emergency_resolve_chain_note($base, $backup_notes_map);
                            $has_chain_note = asenha_emergency_backup_note_is_nonempty($chain_note);
                            ?>
                            <?php if ($has_chain_note): ?>
                                <tr class="asenha-emergency-notes-row">
                                    <td colspan="5">
                                        <details class="asenha-emergency-notes">
                                            <summary>View notes</summary>
                                            <div class="asenha-emergency-notes-body">
                                                <div class="asenha-emergency-notes-content">
                                                    <?php echo asenha_emergency_sanitize_note_for_display($chain_note); ?>
                                                </div>
                                            </div>
                                        </details>
                                    </td>
                                </tr>
                            <?php endif; ?>
                            <?php if (!empty($incrementals)): ?>
                                <?php $incrementals_summary = get_incrementals_summary_label($incrementals); ?>
                                <tr class="asenha-emergency-incrementals-row">
                                    <td colspan="5">
                                        <details class="asenha-emergency-incrementals">
                                            <summary><?php echo htmlspecialchars($incrementals_summary); ?></summary>
                                            <div class="asenha-emergency-incrementals-body">
                                                <table class="asenha-emergency-incrementals-table">
                                                    <thead>
                                                        <tr>
                                                            <th>Type</th>
                                                            <th>Filename</th>
                                                            <th>Size</th>
                                                            <th>Date</th>
                                                            <th>Action</th>
                                                        </tr>
                                                    </thead>
                                                    <tbody>
                                                        <?php foreach ($incrementals as $incremental): ?>
                                                            <?php if (empty($incremental['filename'])) { continue; } ?>
                                                            <?php $inc_filename = (string) $incremental['filename']; ?>
                                                            <tr id="row-<?php echo md5($inc_filename); ?>" data-filename="<?php echo htmlspecialchars($inc_filename); ?>" data-passphrase-required="<?php echo !empty($incremental['archive_passphrase_required']) ? '1' : '0'; ?>">
                                                                <td><span class="type-badge"><?php echo htmlspecialchars(isset($incremental['type']) ? $incremental['type'] : 'unknown'); ?></span></td>
                                                                <td>
                                                                    <?php $incremental_is_encrypted = !empty($incremental['archive_passphrase_required']) || !empty($incremental['archive_encryption_enabled']); ?>
                                                                    <span class="asenha-emergency-archive-label">
                                                                        <span><?php echo htmlspecialchars($inc_filename); ?></span>
                                                                        <?php if ($incremental_is_encrypted): ?>
                                                                            <span class="asenha-archive-encrypted-indicator" title="<?php echo htmlspecialchars($encrypted_archive_label); ?>" aria-label="<?php echo htmlspecialchars($encrypted_archive_label); ?>" role="img">
                                                                                <svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="#50575e" d="M11.991 0a.883.883 0 0 0-.871.817v3.02a.883.883 0 0 0 .88.884a.883.883 0 0 0 .88-.88V.816A.883.883 0 0 0 11.991 0m7.705 3.109a.88.88 0 0 0-.521.174L16.8 5.231a.88.88 0 0 0 .559 1.563a.88.88 0 0 0 .56-.2l2.37-1.951a.88.88 0 0 0-.594-1.534M4.32 3.122a.883.883 0 0 0-.611 1.52l2.37 1.951a.88.88 0 0 0 .56.2v-.002a.88.88 0 0 0 .56-1.56L4.828 3.283a.9.9 0 0 0-.508-.16zm7.66 3.228a5.046 5.046 0 0 0-5.026 5.045v1.488H5.787a.967.967 0 0 0-.965.964v9.189a.967.967 0 0 0 .965.964h12.426a.967.967 0 0 0 .964-.964v-9.19a.967.967 0 0 0-.964-.963h-1.168v-1.488A5.046 5.046 0 0 0 11.98 6.35m.012 2.893a2.15 2.15 0 0 1 2.16 2.152v1.488H9.847v-1.488a2.15 2.15 0 0 1 2.145-2.152m7.382.503a.883.883 0 1 0 .07 1.763h3.027a.883.883 0 0 0 0-1.76h-3.027zM1.529 9.75a.883.883 0 0 0 0 1.76h2.999a.883.883 0 0 0 0-1.76zm10.46 6.774a1.28 1.28 0 0 1 .64 2.393v1.245a.63.63 0 0 1-1.259 0v-1.245a1.28 1.28 0 0 1 .619-2.393"/></svg>
                                                                                <span class="screen-reader-text"><?php echo htmlspecialchars($encrypted_archive_label); ?></span>
                                                                            </span>
                                                                        <?php endif; ?>
                                                                    </span>
                                                                </td>
                                                                <td><?php echo htmlspecialchars(isset($incremental['size']) ? $incremental['size'] : '-'); ?></td>
                                                                <td><?php echo htmlspecialchars(isset($incremental['date']) ? $incremental['date'] : '-'); ?></td>
                                                                <td class="action-cell">
                                                                    <button
                                                                        class="btn restore-btn"
                                                                        data-filename="<?php echo htmlspecialchars($inc_filename); ?>"
                                                                        data-restore-scope="incremental"
                                                                        title="Restore baseline + incrementals up to this recovery point."
                                                                    >
                                                                        Restore
                                                                    </button>
                                                                </td>
                                                            </tr>
                                                        <?php endforeach; ?>
                                                    </tbody>
                                                </table>
                                            </div>
                                        </details>
                                    </td>
                                </tr>
                            <?php endif; ?>
                        <?php endforeach; ?>
                    </tbody>
                </table>
            <?php endif; ?>
            
            <div id="progress-container" class="progress-container">
                <div class="progress-phase" id="progress-phase">Initializing</div>
                <div class="progress-bar-wrapper">
                    <div class="progress-bar" id="progress-bar"></div>
                </div>
                <div class="progress-text" id="progress-text">Starting restore</div>
            </div>
            
            <div id="result-area"></div>
        </div>
    </div>
    
    <script>
        (function() {
            var activeRestore = null;
            var continueTimer = null;
            var activeRestorePassphrase = '';
            var urlImportTimer = null;
            var sqliteRunnerUrl = '';

            function getSecurityToken() {
                var el = document.getElementById('asenha-emergency-token');
                return el ? String(el.value || '') : '';
            }

            function getPhaseLabel(phase) {
                var labels = {
                    'extracting': 'Extracting archive',
                    'copying': 'Restoring files',
                    'cleanup': 'Cleaning up extra files',
                    'database': 'Importing database',
                    'plugins': 'Restoring plugin states',
                    'cache': 'Scheduling cache flush',
                    'complete': 'Restore complete!'
                };
                return labels[phase] || 'Processing';
            }
            
            function calculateProgress(state) {
                if (!state) return 0;
                
                // If already complete, return 100
                if (state.status === 'complete') return 100;
                
                var progress = 0;
                
                // Extraction: 0-40%
                if (!state.extract_complete) {
                    var extractTotal = state.extract_total || 1;
                    var extractIndex = state.extract_index || 0;
                    if (extractTotal > 0) {
                        progress = (extractIndex / extractTotal) * 40;
                    }
                } else {
                    progress = 40;
                    
                    // Copying: 40-70%
                    if (!state.copy_complete) {
                        var copyTotal = state.copy_total || 1;
                        var copyIndex = state.copy_index || 0;
                        if (copyTotal > 0) {
                            progress += (copyIndex / copyTotal) * 30;
                        }
                    } else {
                        progress = 70;
                        
                        // Database: 70-100%
                        if (!state.sql_complete) {
                            // SQL progress is harder to track, use statement count
                            var sqlExecuted = state.sql_statements_executed || 0;
                            progress += Math.min(sqlExecuted * 0.1, 29);
                        } else {
                            progress = 100;
                        }
                    }
                }
                
                return Math.min(Math.round(progress), 100);
            }
            
            function updateProgressUI(state) {
                if (!state) return;
                
                var progressContainer = document.getElementById('progress-container');
                var progressBar = document.getElementById('progress-bar');
                var progressPhase = document.getElementById('progress-phase');
                var progressText = document.getElementById('progress-text');
                
                progressContainer.style.display = 'block';
                
                var progress = calculateProgress(state);
                progressBar.style.width = progress + '%';
                progressPhase.textContent = getPhaseLabel(state.phase || 'processing');
                
                var details = [];
                var filesExtracted = state.files_extracted || 0;
                var filesCopied = state.files_copied || 0;
                var sqlExecuted = state.sql_statements_executed || state.sql_executed || 0;
                var filesDeleted = state.sync_files_deleted || 0;
                var dirsRemoved = state.sync_dirs_removed || 0;
                
                if (filesExtracted > 0) {
                    details.push('Extracted: ' + filesExtracted + ' files');
                }
                if (filesCopied > 0) {
                    details.push('Restored: ' + filesCopied + ' files');
                }
                if (filesDeleted > 0) {
                    details.push('Deleted: ' + filesDeleted + ' files');
                }
                if (dirsRemoved > 0) {
                    details.push('Removed: ' + dirsRemoved + ' folders');
                }
                if (sqlExecuted > 0) {
                    details.push('SQL: ' + sqlExecuted + ' statements');
                }
                if (state.collation_remap_count && parseInt(state.collation_remap_count, 10) > 0) {
                    var remapTarget = state.collation_remap_target ? String(state.collation_remap_target) : 'utf8mb4_unicode_ci';
                    details.push('Collations adjusted to ' + remapTarget);
                }
                
                progressText.textContent = details.length > 0 ? details.join(' | ') : 'Processing';
            }
            
            function continueRestore(filename, btn, row, archivePassphrase) {
                var formData = new FormData();
                formData.append('token', getSecurityToken());
                formData.append('filename', filename);
                formData.append('archive_passphrase', archivePassphrase || '');

                var requestUrl = window.location.href;
                if (sqliteRunnerUrl) {
                    formData.append('action', 'import');
                    requestUrl = sqliteRunnerUrl;
                } else {
                    formData.append('action', 'continue');
                }
                
                fetch(requestUrl, {
                    method: 'POST',
                    body: formData
                })
                .then(function(response) {
                    if (!response.ok) {
                        throw new Error('Server returned status ' + response.status);
                    }
                    return response.text();
                })
                .then(function(text) {
                    try {
                        return JSON.parse(text);
                    } catch (e) {
                        throw new Error('Invalid response: ' + text.substring(0, 200));
                    }
                })
                .then(function(data) {
                    handleRestoreResponse(data, filename, btn, row, archivePassphrase);
                })
                .catch(function(error) {
                    handleRestoreError(error, btn);
                });
            }
            
            function handleRestoreResponse(data, filename, btn, row, archivePassphrase) {
                updateProgressUI(data);
                
                if (data.status === 'complete') {
                    sqliteRunnerUrl = '';
                    // Success!
                    activeRestore = null;
                    activeRestorePassphrase = '';
                    
                    var actionCell = row.querySelector('td:last-child');
                    actionCell.innerHTML = '<span style="color: #00a32a;">✓ Restored</span>';
                    
                    var messages = data.messages ? data.messages.join('<br>') : '';
                    var collationNote = '';
                    if (data.collation_remap_count && parseInt(data.collation_remap_count, 10) > 0) {
                        var remapTarget = data.collation_remap_target ? String(data.collation_remap_target) : 'utf8mb4_unicode_ci';
                        collationNote = '<br>Collations adjusted to ' + remapTarget + ' for this server.';
                    }
                    document.getElementById('result-area').innerHTML = 
                        '<div class="success-message">' +
                        '<strong>Restore completed successfully!</strong><br>' +
                        messages + collationNote + '</div>';
                    
                    document.getElementById('progress-container').style.display = 'none';
                    
                } else if (data.status === 'failed') {
                    sqliteRunnerUrl = '';
                    // Error
                    activeRestore = null;
                    activeRestorePassphrase = '';
                    
                    btn.disabled = false;
                    btn.classList.remove('btn-restoring');
                    btn.innerHTML = 'Restore';
                    
                    var cancelBtn = row.querySelector('.btn-cancel');
                    if (cancelBtn) cancelBtn.remove();
                    
                    var errorMsg = data.errors ? data.errors.join('<br>') : (data.error || 'Unknown error');
                    document.getElementById('result-area').innerHTML = 
                        '<div class="error-message"><strong>Restore failed:</strong> ' + errorMsg + '</div>';
                    
                    document.getElementById('progress-container').style.display = 'none';
                    
                } else {
                    // Still running - continue after a short delay.
                    // SQLite DB import runs in a sibling PHP file so WordPress can
                    // load without colliding with this script's helper names.
                    if (data.sql_complete) {
                        sqliteRunnerUrl = '';
                    } else if (data.sqlite_runner_url) {
                        sqliteRunnerUrl = String(data.sqlite_runner_url);
                    }
                    continueTimer = setTimeout(function() {
                        continueRestore(filename, btn, row, archivePassphrase);
                    }, 100); // Small delay to prevent overwhelming the server
                }
            }
            
            function handleRestoreError(error, btn) {
                sqliteRunnerUrl = '';
                activeRestore = null;
                activeRestorePassphrase = '';
                
                btn.disabled = false;
                btn.classList.remove('btn-restoring');
                btn.innerHTML = 'Restore';
                
                var cancelBtn = btn.closest('tr').querySelector('.btn-cancel');
                if (cancelBtn) cancelBtn.remove();
                
                document.getElementById('result-area').innerHTML = 
                    '<div class="error-message"><strong>Error:</strong> ' + error.message + '</div>';
                
                document.getElementById('progress-container').style.display = 'none';
            }
            
            function cancelRestore(filename) {
                sqliteRunnerUrl = '';
                if (continueTimer) {
                    clearTimeout(continueTimer);
                    continueTimer = null;
                }
                
                var formData = new FormData();
                formData.append('action', 'cancel');
                formData.append('token', getSecurityToken());
                formData.append('filename', filename);
                
                fetch(window.location.href, {
                    method: 'POST',
                    body: formData
                })
                .then(function() {
                    activeRestore = null;
                    activeRestorePassphrase = '';
                    
                    var row = document.querySelector('tr[data-filename="' + filename + '"]');
                    if (row) {
                        var btn = row.querySelector('.restore-btn');
                        btn.disabled = false;
                        btn.classList.remove('btn-restoring');
                        btn.innerHTML = 'Restore';
                        
                        var cancelBtn = row.querySelector('.btn-cancel');
                        if (cancelBtn) cancelBtn.remove();
                    }
                    
                    document.getElementById('progress-container').style.display = 'none';
                    document.getElementById('result-area').innerHTML = 
                        '<div class="info-box">Restore cancelled.</div>';
                });
            }
            
            // Attach event listeners
            document.querySelectorAll('.restore-btn').forEach(function(btn) {
                btn.addEventListener('click', function() {
                    if (activeRestore) {
                        alert('A restore is already in progress.');
                        return;
                    }
                    
                    var filename = this.getAttribute('data-filename');
                    var restoreScope = this.getAttribute('data-restore-scope') || 'baseline';
                    var row = this.closest('tr');
                    var passphraseRequired = row && row.getAttribute('data-passphrase-required') === '1';
                    var passphraseEl = document.getElementById('asenha-archive-passphrase');
                    var archivePassphrase = passphraseEl ? String(passphraseEl.value || '') : '';

                    if (passphraseRequired && archivePassphrase === '') {
                        document.getElementById('result-area').innerHTML =
                            '<div class="error-message"><strong>Archive passphrase required:</strong> This backup archive is encrypted. Please enter the archive passphrase and try again.</div>';
                        return;
                    }
                    
                    var confirmMessage = 'Are you sure you want to restore from this backup?\n\nThis will overwrite your current site data.';
                    if (restoreScope === 'incremental') {
                        confirmMessage += '\n\nRecovery point mode: baseline + incrementals up to this row.';
                        confirmMessage += '\n\nSync delete in wp-content runs automatically for this mode.';
                    } else {
                        confirmMessage += '\n\nRecovery point mode: baseline archive only (no incrementals).';
                    }

                    if (!confirm(confirmMessage)) {
                        return;
                    }
                    
                    activeRestore = filename;
                    activeRestorePassphrase = archivePassphrase;
                    
                    // Update button state
                    this.disabled = true;
                    this.classList.add('btn-restoring');
                    this.innerHTML = '<span class="spinner"></span> Restoring';
                    
                    // Add cancel button
                    var cancelBtn = document.createElement('button');
                    cancelBtn.className = 'btn btn-cancel';
                    cancelBtn.textContent = 'Cancel';
                    cancelBtn.onclick = function(e) {
                        e.preventDefault();
                        if (confirm('Cancel the restore operation?')) {
                            cancelRestore(filename);
                        }
                    };
                    this.parentNode.appendChild(cancelBtn);
                    
                    // Clear previous results
                    document.getElementById('result-area').innerHTML = '';
                    
                    // Show progress
                    var progressContainer = document.getElementById('progress-container');
                    progressContainer.style.display = 'block';
                    document.getElementById('progress-bar').style.width = '0%';
                    document.getElementById('progress-phase').textContent = 'Initializing';
                    document.getElementById('progress-text').textContent = 'Starting restore';

                    // Scroll to the progress section after it has been displayed.
                    window.requestAnimationFrame(function() {
                        progressContainer.scrollIntoView({
                            behavior: 'smooth',
                            block: 'start'
                        });
                    });
                    
                    // Start restore
                    var formData = new FormData();
                    formData.append('action', 'restore');
                    formData.append('token', getSecurityToken());
                    formData.append('filename', filename);
                    formData.append('archive_passphrase', archivePassphrase);
                    var deleteExtraEl = document.getElementById('asenha-delete-extra-files');
                    var deleteExtra = (deleteExtraEl && deleteExtraEl.checked) ? '1' : '0';
                    formData.append('delete_extra_files', deleteExtra);
                    
                    var that = this;
                    
                    fetch(window.location.href, {
                        method: 'POST',
                        body: formData
                    })
                    .then(function(response) {
                        if (!response.ok) {
                            throw new Error('Server returned status ' + response.status);
                        }
                        return response.text();
                    })
                    .then(function(text) {
                        try {
                            return JSON.parse(text);
                        } catch (e) {
                            throw new Error('Invalid response: ' + text.substring(0, 200));
                        }
                    })
                    .then(function(data) {
                        handleRestoreResponse(data, filename, that, row, activeRestorePassphrase);
                    })
                    .catch(function(error) {
                        handleRestoreError(error, that);
                    });
                });
            });

            function showImportMessage(message, isError, prefix) {
                var el = document.getElementById('result-area');
                if (!el) return;
                while (el.firstChild) {
                    el.removeChild(el.firstChild);
                }
                var wrap = document.createElement('div');
                wrap.className = isError ? 'error-message' : 'info-box';
                if (prefix) {
                    var strong = document.createElement('strong');
                    strong.textContent = String(prefix) + ' ';
                    wrap.appendChild(strong);
                }
                var text = document.createTextNode(String(message == null ? '' : message));
                wrap.appendChild(text);
                el.appendChild(wrap);
            }

            function postUpload(file) {
                if (!file) return;
                var fd = new FormData();
                fd.append('action', 'upload_archive');
                fd.append('token', getSecurityToken());
                fd.append('archive_file', file);
                var prog = document.getElementById('asenha-emergency-upload-progress');
                var fill = document.getElementById('asenha-emergency-upload-fill');
                var msg = document.getElementById('asenha-emergency-upload-msg');
                if (prog) prog.style.display = 'block';
                if (fill) fill.style.width = '40%';
                if (msg) msg.textContent = 'Uploading…';
                fetch(window.location.href, { method: 'POST', body: fd })
                    .then(function(r) { return r.text(); })
                    .then(function(text) {
                        var data;
                        try { data = JSON.parse(text); } catch (e) { throw new Error(text.substring(0, 200)); }
                        if (data.ok) {
                            if (fill) fill.style.width = '100%';
                            if (msg) msg.textContent = 'Done. Reloading…';
                            window.location.reload();
                        } else {
                            if (fill) fill.style.width = '0%';
                            if (prog) prog.style.display = 'none';
                            showImportMessage(data.error || 'Unknown error', true, 'Upload failed:');
                        }
                    })
                    .catch(function(err) {
                        if (prog) prog.style.display = 'none';
                        showImportMessage(err && err.message ? err.message : 'Upload failed.', true, 'Upload failed:');
                    });
            }

            var browseBtn = document.getElementById('asenha-emergency-browse');
            var fileEl = document.getElementById('asenha-emergency-file');
            var dz = document.getElementById('asenha-emergency-dropzone');
            if (browseBtn && fileEl) {
                browseBtn.addEventListener('click', function(e) { e.preventDefault(); fileEl.click(); });
            }
            if (fileEl) {
                fileEl.addEventListener('change', function() {
                    if (fileEl.files && fileEl.files[0]) postUpload(fileEl.files[0]);
                });
            }
            if (dz) {
                dz.addEventListener('click', function(e) {
                    if (e.target === dz || e.target.tagName === 'P') fileEl.click();
                });
                dz.addEventListener('keydown', function(e) {
                    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileEl.click(); }
                });
                dz.addEventListener('dragover', function(e) { e.preventDefault(); dz.style.background = '#f0f6fc'; });
                dz.addEventListener('dragleave', function() { dz.style.background = '#fff'; });
                dz.addEventListener('drop', function(e) {
                    e.preventDefault();
                    dz.style.background = '#fff';
                    if (e.dataTransfer.files && e.dataTransfer.files[0]) postUpload(e.dataTransfer.files[0]);
                });
            }

            var showUp = document.getElementById('asenha-emergency-show-upload');
            var showUrl = document.getElementById('asenha-emergency-show-url');
            var uploadPanelEl = document.getElementById('asenha-emergency-upload-panel');
            var urlPanelEl = document.getElementById('asenha-emergency-url-panel');
            if (showUp && uploadPanelEl) {
                showUp.addEventListener('click', function() {
                    if (urlPanelEl) urlPanelEl.style.display = 'none';
                    uploadPanelEl.style.display = uploadPanelEl.style.display === 'none' ? 'block' : 'none';
                });
            }
            if (showUrl && urlPanelEl) {
                showUrl.addEventListener('click', function() {
                    if (uploadPanelEl) uploadPanelEl.style.display = 'none';
                    urlPanelEl.style.display = urlPanelEl.style.display === 'none' ? 'block' : 'none';
                });
            }

            var urlImportInFlight = false;
            function pollUrlImport(jobId) {
                // Client-side guard: the server also serializes via flock, but
                // skipping redundant requests here saves a roundtrip.
                if (urlImportInFlight) {
                    return;
                }
                urlImportInFlight = true;
                var fd = new FormData();
                fd.append('action', 'url_import_step');
                fd.append('token', getSecurityToken());
                fd.append('job_id', jobId);
                fetch(window.location.href, { method: 'POST', body: fd })
                    .then(function(r) { return r.text(); })
                    .then(function(text) {
                        var data;
                        try { data = JSON.parse(text); } catch (e) { throw new Error(text.substring(0, 200)); }
                        var fill = document.getElementById('asenha-emergency-url-fill');
                        var msg = document.getElementById('asenha-emergency-url-msg');
                        var prog = document.getElementById('asenha-emergency-url-progress');
                        if (!data.ok) {
                            if (urlImportTimer) clearInterval(urlImportTimer);
                            urlImportTimer = null;
                            if (prog) prog.style.display = 'none';
                            showImportMessage(data.error || 'Unknown error', true, 'Transfer failed:');
                            return;
                        }
                        var p = typeof data.progress === 'number' ? data.progress : 0;
                        if (fill) fill.style.width = Math.min(100, Math.max(0, p)) + '%';
                        if (msg) msg.textContent = data.message || 'Downloading…';
                        if (data.done) {
                            if (urlImportTimer) clearInterval(urlImportTimer);
                            urlImportTimer = null;
                            if (msg) msg.textContent = 'Done. Reloading…';
                            window.location.reload();
                        }
                    })
                    .catch(function(err) {
                        if (urlImportTimer) clearInterval(urlImportTimer);
                        urlImportTimer = null;
                        var prog = document.getElementById('asenha-emergency-url-progress');
                        if (prog) prog.style.display = 'none';
                        showImportMessage(err && err.message ? err.message : 'Transfer failed.', true, 'Transfer failed:');
                    })
                    .then(function() {
                        urlImportInFlight = false;
                    });
            }

            var urlTransferBtn = document.getElementById('asenha-emergency-url-transfer');
            if (urlTransferBtn) {
                urlTransferBtn.addEventListener('click', function() {
                    var input = document.getElementById('asenha-emergency-url-input');
                    var url = input ? String(input.value || '').trim() : '';
                    if (!url) {
                        showImportMessage('Please paste a backup archive URL.', true);
                        return;
                    }
                    var lower = url.toLowerCase();
                    if (lower.indexOf('asenha-sftp://') === 0) {
                        showImportMessage('SFTP references are not supported in Emergency Restore. Use HTTP(S), upload the file, or use the WordPress admin Restore tab.', true);
                        return;
                    }
                    var urlBase = url.split('#')[0].split('?')[0].toLowerCase();
                    if (urlBase.indexOf('asenha_chain=') === -1 && !urlBase.endsWith('.zip') && !urlBase.endsWith('.parts.json')) {
                        showImportMessage('URL must point to a .zip or .parts.json file (or include a valid chain query).', true);
                        return;
                    }
                    var prog = document.getElementById('asenha-emergency-url-progress');
                    var fill = document.getElementById('asenha-emergency-url-fill');
                    var msg = document.getElementById('asenha-emergency-url-msg');
                    if (prog) prog.style.display = 'block';
                    if (fill) fill.style.width = '5%';
                    if (msg) msg.textContent = 'Starting…';
                    var fd = new FormData();
                    fd.append('action', 'url_import_start');
                    fd.append('token', getSecurityToken());
                    fd.append('url', url);
                    fetch(window.location.href, { method: 'POST', body: fd })
                        .then(function(r) { return r.text(); })
                        .then(function(text) {
                            var data;
                            try { data = JSON.parse(text); } catch (e) { throw new Error(text.substring(0, 200)); }
                            if (!data.ok || !data.job_id) {
                                if (prog) prog.style.display = 'none';
                                showImportMessage(data.error || 'Could not start download.', true, 'Transfer failed:');
                                return;
                            }
                            if (urlImportTimer) clearInterval(urlImportTimer);
                            urlImportTimer = setInterval(function() { pollUrlImport(data.job_id); }, 400);
                        })
                        .catch(function(err) {
                            if (prog) prog.style.display = 'none';
                            showImportMessage(err && err.message ? err.message : 'Transfer failed.', true, 'Transfer failed:');
                        });
                });
            }
        })();
    </script>
</body>
</html>
