<?php
/**
 * Restore engine. Downloads an archive from a destination, decrypts if needed,
 * imports the database dump, and restores files.
 *
 * SAFETY:
 *  - Maintenance mode is enabled during restore if the setting is on.
 *  - Cleans up the staging directory whether the restore succeeds or fails.
 *  - Falls back to scanning Local destinations if the original source is missing.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

use Backreload\Plugin;
use Backreload\Models\BackupRecord;
use Backreload\Models\DestinationRecord;

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

class RestoreEngine {

	/** Cron hook name for async dispatch (background restore on cron loopback). */
	const HOOK_ASYNC = 'backreload_restore_async';

	/** Points at the restore currently running, so the destination's Receive
	 *  page can poll live progress. Empty when idle. */
	const ACTIVE_RESTORE_OPTION = 'backreload_active_restore';

	/**
	 * Atomic single-restore mutex. A restore wipes + rewrites the database and
	 * filesystem; two running at once (a manual REST restore racing a migration
	 * auto-restore, or two cron fires of the same async event) would interleave
	 * extraction and SQL import into mutual corruption. Unlike the
	 * `backreload_restore_in_progress` TRANSIENT — which is a read-then-set
	 * backup-SUPPRESSION signal and is TOCTOU-racy — this is an atomic
	 * `add_option()` INSERT (UNIQUE option_name), the same race-free pattern
	 * BackupEngine uses for its tick lock. Only one holder wins.
	 */
	const RESTORE_LOCK     = 'backreload_restore_lock';
	const RESTORE_LOCK_TTL = 1800; // 30 min — matches the in-progress transient TTL.

	/**
	 * Executable code components that BackReload never extracts into live
	 * WordPress paths (WP.org policy — no arbitrary code deployment). Backups
	 * may contain these for off-site safekeeping, but restore/import/migration
	 * skip them. Restore only writes database, uploads (media), and other
	 * non-code wp-content files.
	 */
	const CODE_COMPONENTS = [ 'plugins', 'themes', 'mu' ];

	/**
	 * WordPress drop-in filenames (wp-content root). Never restored from the
	 * "others" archive: they load before plugins and are bound to the origin
	 * host's environment, so deploying them onto a different server fatals every
	 * request. Lower-cased for case-insensitive matching. Kept in sync with
	 * BackupEngine::others_excluded_dropins() (which stops new backups carrying
	 * them); this is the restore-time net for backups built by older versions.
	 */
	const DROPIN_FILES = [
		'object-cache.php',
		'object-cache-mu.php',
		'advanced-cache.php',
		'db.php',
		'db-error.php',
		'sunrise.php',
		'php-error.php',
		'fatal-error-handler.php',
		'maintenance.php',
		'wp-cache-config.php',
		'advanced-cache-config.php',
	];

	/**
	 * Per-run flag: when true this restore MAY extract executable code
	 * (plugins/themes/mu-plugins). Set ONLY for a site-to-site migration
	 * auto-restore, which is authenticated (the destination issued a one-time
	 * key) and moves a whole site the admin controls. Manual restores and the
	 * arbitrary-upload Import stay data-only (this flag stays false there).
	 */
	private bool $allow_code_restore = false;

	/**
	 * Whether this build runs in DATA-ONLY mode — restore/import/migration bring
	 * back only the database, uploads, and non-code content, never plugins,
	 * themes, or mu-plugins. Default is FULL (code is restored). A distributor
	 * targeting the WordPress.org directory can force data-only by defining
	 * `BACKRELOAD_DATA_ONLY` truthy (e.g. in the main plugin file of that build)
	 * or via the `backreload_data_only` filter. Single source of truth used by
	 * the restore engine, the import classifier, and the admin UI.
	 */
	public static function data_only_mode(): bool {
		$on = defined( 'BACKRELOAD_DATA_ONLY' ) && BACKRELOAD_DATA_ONLY;
		return (bool) apply_filters( 'backreload_data_only', $on );
	}

	private Plugin $plugin;
	private Logger $logger;

	public function __construct( Plugin $plugin ) {
		$this->plugin = $plugin;
		$this->logger = $plugin->get( 'logger' );
		// Register the cron handler so dispatch_async() actually fires.
		// We add it unconditionally — adding an action with no scheduled
		// event is a no-op.
		add_action( self::HOOK_ASYNC, [ $this, 'run_async_handler' ], 10, 2 );
	}

	/**
	 * Cron entry point for async restores. Catches every Throwable so a
	 * crash inside run() lands a log line instead of a silent void.
	 *
	 * @param string $backup_id
	 * @param array  $args  Forwarded to run().
	 */
	public function run_async_handler( string $backup_id, array $args ): void {
		try {
			$this->run( $backup_id, $args );
		} catch ( \Throwable $e ) {
			$this->finish_progress( $backup_id, 'failed', $e->getMessage() );
			$this->logger->error(
				'Async restore failed: ' . $e->getMessage(),
				[ 'trace' => $e->getTraceAsString() ],
				$backup_id
			);
		}
	}

	/**
	 * Schedule a restore to run in a separate request via wp-cron. Used by
	 * the migration finalize handler — the source's HTTP request can't
	 * block for the whole restore (could be many minutes), and the user
	 * checks progress on the destination's Restore page instead.
	 *
	 * Mirrors BackupEngine::dispatch_async() — wp_schedule_single_event at
	 * time() with spawn_cron() to fire the loopback immediately rather
	 * than waiting for the next visitor.
	 */
	public function dispatch_async( string $backup_id, array $args = [] ): void {
		// Seed progress so the destination's Receive page shows the restore
		// immediately (before the background worker has even started).
		$this->set_progress( $backup_id, [
			'phase'         => 'starting',
			'label'         => 'Starting restore',
			'via_migration' => ! empty( $args['migrate'] ),
			'source_url'    => (string) ( $args['source_url'] ?? '' ),
		] );

		if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
			// No WP-Cron: don't block the caller (a migration's finalize request
			// would time out on a large restore). Fire a non-blocking background
			// loopback that runs the restore independently, token-authed so the
			// endpoint needs no login cookie. If the host blocks loopback, the
			// Receive page's status poll triggers the run as a fallback.
			$this->logger->info( 'WP-Cron is disabled; running restore in the background', [], $backup_id );
			$secret = wp_generate_password( 40, false );
			set_transient( self::secret_key( $backup_id ), $secret, HOUR_IN_SECONDS );
			set_transient( self::args_key( $backup_id ), $args, HOUR_IN_SECONDS );
			$this->spawn_background_restore( $backup_id, $secret );
			return;
		}
		wp_schedule_single_event( time(), self::HOOK_ASYNC, [ $backup_id, $args ] );
		if ( ! defined( 'DOING_CRON' ) && function_exists( 'spawn_cron' ) ) {
			spawn_cron();
		}
	}

	private static function secret_key( string $id ): string { return 'backreload_restore_tok_' . $id; }
	private static function args_key( string $id ): string { return 'backreload_restore_args_' . $id; }

	/** Fire a non-blocking loopback that runs the restore in the background. */
	public function spawn_background_restore( string $backup_id, string $secret ): void {
		wp_remote_post(
			rest_url( 'backreload/v1/restore/run' ),
			[
				'timeout'   => 0.01,
				'blocking'  => false,
				'sslverify' => false, // loopback to our own host
				'body'      => [ 'backup_id' => $backup_id, 'secret' => $secret ],
			]
		);
	}

	/**
	 * Token-authenticated background entry point (called by the loopback). Runs
	 * the stored restore once, timing-safely verifying the secret first.
	 */
	public function run_background( string $backup_id, string $secret ): bool {
		$stored = (string) get_transient( self::secret_key( $backup_id ) );
		if ( '' === $stored || ! hash_equals( $stored, $secret ) ) {
			return false;
		}
		// One-shot: burn the token so a replayed request can't start a 2nd run.
		delete_transient( self::secret_key( $backup_id ) );
		$args = (array) get_transient( self::args_key( $backup_id ) );
		delete_transient( self::args_key( $backup_id ) );
		@set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors -- long-running background restore.
		$this->run_async_handler( $backup_id, $args );
		return true;
	}

	/** Whether a not-yet-started restore is still awaiting its background run. */
	public function is_restore_pending( string $backup_id ): bool {
		return (bool) get_transient( self::secret_key( $backup_id ) );
	}

	/**
	 * Fallback trigger for the Receive page: if the background loopback never
	 * landed (host blocks self-requests) the restore is stuck 'starting' with
	 * its token still present. Re-fire the loopback so the poll keeps it moving.
	 */
	public function retry_background_restore( string $backup_id ): void {
		$secret = (string) get_transient( self::secret_key( $backup_id ) );
		if ( '' !== $secret ) {
			$this->spawn_background_restore( $backup_id, $secret );
		}
	}

	/* ------------------------------------------------------------------ *
	 *  Progress tracking (drives the destination's Receive progress panel)
	 * ------------------------------------------------------------------ */

	private function set_progress( string $backup_id, array $fields, bool $active = true ): void {
		$key  = 'backreload_restore_progress_' . $backup_id;
		$prev = (array) get_option( $key, [] );
		if ( empty( $prev['started_at'] ) ) {
			$fields['started_at'] = time();
		}
		$fields['updated_at'] = time();
		update_option( $key, array_merge( $prev, $fields ), false );
		if ( $active ) {
			update_option( self::ACTIVE_RESTORE_OPTION, $backup_id, false );
		}
	}

	/** Mark a restore terminal (complete/failed) and drop the active pointer. */
	private function finish_progress( string $backup_id, string $phase, ?string $error = null ): void {
		$this->set_progress( $backup_id, [ 'phase' => $phase, 'error' => $error, 'label' => ( 'complete' === $phase ? 'Restore complete' : 'Restore failed' ) ], false );
		if ( (string) get_option( self::ACTIVE_RESTORE_OPTION, '' ) === $backup_id ) {
			delete_option( self::ACTIVE_RESTORE_OPTION );
		}
	}

	/** Read a restore's progress (null if none). */
	public function get_progress( string $backup_id ): ?array {
		$data = get_option( 'backreload_restore_progress_' . $backup_id, null );
		return is_array( $data ) ? $data : null;
	}

	/** ext_id of the restore currently running, or '' when idle. */
	public function active_restore_id(): string {
		return (string) get_option( self::ACTIVE_RESTORE_OPTION, '' );
	}

	/**
	 * @param array{components?:array<int,string>,destination?:string,passphrase?:string} $args
	 */
	public function run( string $backup_id, array $args = [] ): bool {
		$rec = BackupRecord::find( $backup_id );
		if ( ! $rec ) {
			throw new \RuntimeException( esc_html(  "Backup not found: {$backup_id}"  )  );
		}
		@set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running backup/restore needs an extended time/memory limit, scoped to the engine's own execution.

		// Atomic single-restore claim. If another restore already holds the
		// lock we refuse rather than interleave two DB/filesystem rewrites.
		// Acquired BEFORE any destructive work so the loser touches nothing.
		if ( ! $this->acquire_restore_lock() ) {
			throw new \RuntimeException( esc_html(
				'Another restore is already in progress. Wait for it to finish before starting another.'
			) );
		}
		// Release on PHP fatal too (mirrors the transient's shutdown net).
		register_shutdown_function( static function () {
			delete_option( self::RESTORE_LOCK );
		} );

		// Restore-in-progress lock. The cron handlers (run_schedule,
		// execute_one) check this transient and bail when set, so a
		// stale event firing mid-restore can't spawn a phantom backup
		// alongside the restore. 30-min TTL — generous enough for a
		// large-site restore, short enough to self-clear if the restore
		// crashes without reaching the explicit delete_transient below.
		set_transient( 'backreload_restore_in_progress', $backup_id, 30 * MINUTE_IN_SECONDS );
		// Pre-emptive purge: nuke every queued backreload_* cron event
		// BEFORE the restore touches the database, so the replayed
		// snapshot can't even add new ones (the restored cron rows just
		// land in a table that gets re-cleared after the SQL import).
		try {
			$this->plugin->get( 'scheduler' )->purge_all_scheduled_events();
		} catch ( \Throwable $ignored ) {}
		// register_shutdown so the lock self-clears even on PHP fatal.
		register_shutdown_function( static function () {
			delete_transient( 'backreload_restore_in_progress' );
		} );

		$dest = $this->resolve_source( $rec, $args['destination'] ?? null );
		if ( ! $dest ) {
			throw new \RuntimeException( esc_html(  'No usable destination available for this backup. Original destinations are gone and no local copy was found.'  )  );
		}
		$handler = $this->plugin->get( 'destinations' )->handler( $dest->type );

		$staging = \Backreload\Storage::root() . '/.restore/' . $backup_id;
		wp_mkdir_p( $staging );

		// Whether this run may deploy executable code (plugins/themes/mu).
		//
		// Full-fidelity by default: restore, import, and site-to-site migration
		// all bring back plugins/themes/mu-plugins so the site works immediately
		// after the operation. A fully-compliant / data-only build can still
		// force everything to data-only (no code ever deployed, on any path) by
		// defining BACKRELOAD_DATA_ONLY — see data_only_mode().
		$this->allow_code_restore = ! self::data_only_mode();

		// Selected components — default to whatever the backup contains.
		$components = (array) ( $args['components'] ?? $rec->components );

		// Data-only gate: outside a site-to-site migration, BackReload never
		// deploys executable code into live WordPress paths — a restore/import
		// brings back only the database, uploads (media), and other non-code
		// wp-content files. During a migration the plugins/themes/mu-plugins DO
		// come across so the destination site works after the move.
		if ( ! $this->allow_code_restore ) {
			$code_components = self::CODE_COMPONENTS;
			$blocked         = array_values( array_intersect( $components, $code_components ) );
			if ( $blocked ) {
				$components = array_values( array_diff( $components, $code_components ) );
				$this->logger->warn(
					'Code components are not restored and were skipped: ' . implode( ', ', $blocked )
						. '. Reinstall plugins, themes, and mu-plugins through the WordPress installer. '
						. 'The database, uploads, and other content restore normally.',
					[],
					$backup_id
				);
			}
			if ( empty( $components ) ) {
				$this->release_restore_lock();
				delete_transient( 'backreload_restore_in_progress' );
				throw new \RuntimeException( esc_html(
					'This backup contains only plugins, themes, or mu-plugins, which BackReload does not restore. '
						. 'There is nothing to restore. Database, uploads, and other content are restored when present in the backup.'
				) );
			}
		} else {
			$this->logger->info( 'Site-to-site migration: restoring plugins, themes, and mu-plugins so the destination works after the move.', [], $backup_id );
		}

		// Build the list of files we actually need to download.
		// Multi-zip backups: filter `rec->files` by category. Legacy single-zip: synthesize one entry.
		$wanted = $this->files_to_restore( $rec, $components );
		if ( empty( $wanted ) ) {
			throw new \RuntimeException( esc_html(  'Nothing to restore, no files matched the selected components.'  )  );
		}

		// Clean-restore: wipe target directories before extracting so files
		// that were added AFTER the backup point (or planted by malware) are
		// removed rather than left in place. WordPress's default behaviour is
		// additive — `extractTo` overwrites same-named files but leaves
		// foreign files untouched. For malware-cleanup restores that's the
		// opposite of what users expect: the whole point is to roll the site
		// back to a clean state. Opt-in because it's destructive — any
		// content created since the backup is irreversibly lost.
		$clean = ! empty( $args['clean_restore'] );
		// Log the mode unconditionally so the user can verify from the log
		// feed which mode actually ran — a recurring user-confusion source
		// was "I checked the Clean restore box but new files survived",
		// where the front-end was on a stale build that didn't yet pass the
		// clean_restore flag. Logging both states makes it diagnosable.
		$this->logger->info(
			$clean
				? 'Clean restore enabled: target directories WILL BE WIPED before extracting each component'
				: 'Additive restore (default): files in the backup will overwrite same-named files, but any files added since the backup will REMAIN in place. To wipe new/unknown files, re-run with the Clean restore option checked in step 3.',
			[ 'clean_restore' => $clean, 'components' => $components ],
			$backup_id
		);

		$this->logger->info(
			'Restore starting from ' . $dest->name . ', ' . count( $wanted ) . ' file(s) selected',
			[ 'components' => $components, 'total_bytes' => array_sum( array_column( $wanted, 'size' ) ), 'clean' => $clean ],
			$backup_id
		);

		// Download + decrypt each file into staging.
		$this->set_progress( $backup_id, [
			'phase'         => 'downloading',
			'label'         => 'Downloading backup files',
			'files_total'   => count( $wanted ),
			'files_done'    => 0,
			'via_migration' => ! empty( $args['migrate'] ),
			'source_url'    => (string) ( $args['source_url'] ?? '' ),
		] );
		$pass = $args['passphrase'] ?? $this->plugin->get( 'options' )->get( 'encryption_passphrase' );
		$enc  = $this->plugin->get( 'encryption' );
		$prepared = []; // [ { category, local_path } ]
		$dl_idx = 0;
		foreach ( $wanted as $f ) {
			$remote_name = (string) $f['name'];
			// SECURITY: files[].name can originate from an imported manifest
			// (cross-server import) — treat it as untrusted. Reject absolute
			// paths, backslashes, null bytes and `..` segments so a crafted
			// name can neither read outside the destination root nor write
			// outside the restore staging directory.
			if (
				'' === $remote_name
				|| '/' === $remote_name[0]
				|| false !== strpos( $remote_name, '\\' )
				|| false !== strpos( $remote_name, "\0" )
				|| preg_match( '#(^|/)\.\.(/|$)#', $remote_name )
			) {
				throw new \RuntimeException( esc_html(  "Refusing to restore a part with an unsafe name: {$remote_name}"  )  );
			}
			// The local staging copy is keyed by basename only — there is no
			// need to recreate the bk_<id>/ subfolder in staging, and it keeps
			// the write strictly inside $staging.
			$local_path  = $staging . '/' . basename( $remote_name );
			$this->logger->info( "Downloading {$remote_name}", [], $backup_id );
			if ( ! $handler->download( $dest, $remote_name, $local_path ) ) {
				throw new \RuntimeException( esc_html(  "Failed to download {$remote_name}"  )  );
			}

			// Verify integrity if checksum is known.
			if ( ! empty( $f['checksum'] ) ) {
				$actual = hash_file( 'sha256', $local_path );
				if ( $actual !== $f['checksum'] ) {
					throw new \RuntimeException( esc_html(  "Integrity check failed for {$remote_name}"  )  );
				}
			}

			// Optional decrypt.
			if ( ! empty( $f['encrypted'] ) ) {
				if ( ! $pass ) throw new \RuntimeException( esc_html(  'Passphrase required to decrypt this backup.'  )  );
				$plain = preg_replace( '/\.enc$/', '', $local_path );
				if ( ! $enc->decrypt_file( $local_path, $plain, $pass ) ) {
					throw new \RuntimeException( esc_html(  "Decryption failed for {$remote_name} (wrong passphrase?)"  )  );
				}
				@unlink( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
				$local_path = $plain;
			}

			$prepared[] = [ 'category' => $f['category'] ?? 'files', 'path' => $local_path ];
			$this->set_progress( $backup_id, [ 'files_done' => ++$dl_idx, 'label' => 'Downloaded ' . basename( $remote_name ) ] );
		}
		$this->logger->success( 'All files downloaded and verified', [], $backup_id );
		$this->set_progress( $backup_id, [ 'phase' => 'restoring', 'label' => 'Restoring content' ] );

		// Capture THIS site's own address NOW, before the SQL import overwrites
		// the options table with the backup's (source) values. Needed so that a
		// cross-site restore — a MIGRATION *or* a MANUAL restore of a backup that
		// was taken on another domain — can rewrite the old URLs to this site and
		// pin siteurl/home. For a migration these are already in $args (captured
		// at finalize, before this background restore ran); a manual restore has
		// none, so derive them here from the live (pre-restore) site.
		$args['target_url']     = $args['target_url']     ?? untrailingslashit( home_url() );
		$args['target_home']    = $args['target_home']    ?? untrailingslashit( home_url() );
		$args['target_siteurl'] = $args['target_siteurl'] ?? untrailingslashit( site_url() );
		$args['source_url']     = $args['source_url']     ?? ( $rec->source_url ? untrailingslashit( (string) $rec->source_url ) : '' );

		// Maintenance mode — auto-toggle for the duration of the destructive phase.
		$maint    = (bool) $this->plugin->get( 'options' )->get( 'maintenance_on_restore', true );
		$mode_svc = $this->plugin->get( 'maintenance' );
		$was_off  = ! $mode_svc->is_active();
		if ( $maint && $was_off ) {
			$mode_svc->enable( [
				'heading' => "We'll be right back",
				'message' => 'Restoring from backup. The site will return automatically when this finishes.',
			] );
		}

		try {
			// Restore files first, DB last (DB import is usually fastest + commits the change).
			// Legacy single-zips are full archives — they handle their own component filtering.
			//
			// Clean-mode: wipe each category's target dir BEFORE extracting its
			// archive, exactly once per category. Doing it per-archive (rather
			// than upfront in a single sweep) means a multi-part category like
			// "uploads-1.zip + uploads-2.zip" would re-wipe between parts; to
			// avoid that, track which categories have already been wiped.
			$wiped = [];
			foreach ( $prepared as $p ) {
				if ( $p['category'] === 'legacy' ) {
					$this->restore_legacy_zip( $p['path'], $components, $backup_id, $clean );
				} elseif ( $p['category'] !== 'db' ) {
					$first_part = ! isset( $wiped[ $p['category'] ] );
					$this->restore_file_archive( $p['path'], $p['category'], $backup_id, $clean && $first_part );
					$wiped[ $p['category'] ] = true;
				}
			}
			foreach ( $prepared as $p ) {
				if ( $p['category'] === 'db' ) {
					$this->set_progress( $backup_id, [ 'phase' => 'database', 'label' => 'Restoring database' ] );
					$this->restore_database( $p['path'], $backup_id, ! empty( $args['migrate'] ) );
				}
			}

			// URL/path rewrite after the SQL import. Runs for a migration OR any
			// time the backup came from a DIFFERENT domain than this site — i.e.
			// a MANUAL restore of a migrated / cross-site backup. Without this,
			// restoring such a backup leaves siteurl/home (and every in-content
			// URL) pointing at the ORIGIN site, so the restored site redirects to
			// the old domain. Same-site restores skip it (source == target).
			$cross_domain = ! empty( $args['source_url'] )
				&& untrailingslashit( (string) $args['source_url'] ) !== untrailingslashit( (string) ( $args['target_url'] ?? '' ) );
			if ( ! empty( $args['migrate'] ) || $cross_domain ) {
				$this->set_progress( $backup_id, [ 'phase' => 'migrating', 'label' => 'Rewriting URLs and paths' ] );
				$this->run_migration( $rec, $args, $backup_id );
			}
		} finally {
			if ( $maint && $was_off ) {
				$mode_svc->disable();
			}
		}

		// Cleanup
		$this->rrmdir( $staging );

		// Restore done — release the cron lock + the single-restore mutex
		// explicitly so the next page load / next restore isn't needlessly
		// blocked. The shutdown handlers are the safety net for crashes;
		// these are the happy-path clears.
		delete_transient( 'backreload_restore_in_progress' );
		$this->release_restore_lock();

		$this->finish_progress( $backup_id, 'complete' );
		$this->logger->success( 'Restore complete', [], $backup_id );
		return true;
	}

	/**
	 * Atomically claim the single-restore mutex. Returns true if WE got it,
	 * false if another restore already holds a non-stale lock. A lock older
	 * than RESTORE_LOCK_TTL is treated as orphaned (a prior restore that
	 * hard-crashed without its shutdown handler firing) and stolen.
	 */
	private function acquire_restore_lock(): bool {
		if ( add_option( self::RESTORE_LOCK, (string) time(), '', 'no' ) ) {
			return true;
		}
		$held_at = (int) get_option( self::RESTORE_LOCK, 0 );
		if ( $held_at > 0 && ( time() - $held_at ) > self::RESTORE_LOCK_TTL ) {
			delete_option( self::RESTORE_LOCK );
			// Race-safe retry: if a concurrent restore grabbed it in the
			// microseconds between our delete and re-insert, we correctly
			// fail to acquire.
			return (bool) add_option( self::RESTORE_LOCK, (string) time(), '', 'no' );
		}
		return false;
	}

	private function release_restore_lock(): void {
		delete_option( self::RESTORE_LOCK );
	}

	/**
	 * Decide which backup files to download based on selected components.
	 * Falls back to a synthesized single-zip entry for legacy backups.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	private function files_to_restore( BackupRecord $rec, array $components ): array {
		// Multi-zip backups: filter by category. Legacy entries (imported single-zips)
		// always pass through — they're full archives that get filtered at extract time.
		if ( ! empty( $rec->files ) ) {
			$wanted_cats   = $this->components_to_categories( $components );
			$wanted_cats[] = 'legacy';
			return array_values( array_filter(
				$rec->files,
				fn( $f ) => in_array( $f['category'] ?? '', $wanted_cats, true )
			) );
		}

		// Pre-rewrite backups with no files[] metadata: synthesize one entry.
		// restore_legacy_zip() handles component filtering at extract time.
		$single_name = $rec->ext_id . ( $rec->encrypted ? '.zip.enc' : '.zip' );
		return [[
			'name'      => $single_name,
			'category'  => 'legacy',
			'size'      => $rec->size,
			'checksum'  => $rec->checksum,
			'encrypted' => $rec->encrypted,
		]];
	}

	/** Map component slugs to BackupRecord file categories. */
	private function components_to_categories( array $components ): array {
		$cats = [];
		foreach ( $components as $c ) {
			switch ( $c ) {
				case 'db':       $cats[] = 'db'; break;
				case 'uploads':  $cats[] = 'uploads'; break;
				case 'themes':   $cats[] = 'themes'; break;
				case 'plugins':  $cats[] = 'plugins'; break;
				case 'mu':       $cats[] = 'mu'; break;
				case 'others':   $cats[] = 'others'; break;
			}
		}
		return array_values( array_unique( $cats ) );
	}

	/** Extract a single zip part into the right wp-content subdir. */
	private function restore_file_archive( string $zip_path, string $category, string $backup_id, bool $clean = false ): void {
		// Data-only guard: outside a site-to-site migration, never extract
		// executable code into live WordPress paths. During a migration
		// ($this->allow_code_restore) plugins/themes/mu-plugins ARE extracted so
		// the destination site works after the move.
		if ( ! $this->allow_code_restore && in_array( $category, self::CODE_COMPONENTS, true ) ) {
			$this->logger->warn(
				"Skipping code component '{$category}' — code is only restored during a site-to-site migration.",
				[],
				$backup_id
			);
			return;
		}

		// Use wp_get_upload_dir() for uploads — sites can move uploads outside
		// wp-content via the UPLOADS constant or the upload_path option.
		$uploads_info = wp_get_upload_dir();
		$uploads_dir  = ! empty( $uploads_info['basedir'] )
			? rtrim( (string) $uploads_info['basedir'], '/\\' )
			: wp_get_upload_dir()['basedir'];

		// Non-code targets are always available. Code targets (plugins/themes/mu)
		// are only added when this run is allowed to deploy code (migration).
		$target_map = [
			'uploads' => $uploads_dir,
			'others'  => WP_CONTENT_DIR,
		];
		if ( $this->allow_code_restore ) {
			$target_map['plugins'] = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : WP_CONTENT_DIR . '/plugins';
			$target_map['themes']  = get_theme_root();
			$target_map['mu']      = defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins';
		}
		if ( ! isset( $target_map[ $category ] ) ) {
			$this->logger->warn( "Unknown category {$category}, skipping", [], $backup_id );
			return;
		}

		$this->logger->info( "Restoring {$category} from " . basename( $zip_path ), [], $backup_id );

		$target = $target_map[ $category ];

		// Clean-restore: wipe the target directory before extracting so files
		// added after the backup point (or malware-planted) don't survive.
		//
		// IMPORTANT exemptions, never to be touched even in clean mode:
		//   - The plugin's own folder, so the engine doesn't pull the rug
		//     out from under itself mid-restore.
		//   - wp-content/backreload-backups (staging + local backups) — wiping it
		//     would delete every locally-stored backup AND our own restore
		//     scratch dir, which would abort the in-flight restore.
		//   - The "others" category targets wp-content directly, which is
		//     way too broad to wipe wholesale — that would clobber every
		//     subdir including plugins/themes/uploads/backreload-backups. We DON'T
		//     wipe for "others"; the user can re-clean individual subdirs
		//     by selecting them explicitly.
		if ( $clean && 'others' !== $category ) {
			$this->clean_wipe_directory( $target, $backup_id );
		} elseif ( $clean && 'others' === $category ) {
			$this->logger->info(
				'Clean-restore is skipped for the "others" category, because wiping wp-content directly would delete plugins, themes, uploads, and the plugin\'s own storage. Use the per-category options instead.',
				[],
				$backup_id
			);
		}

		wp_mkdir_p( $target );

		$zip = new \ZipArchive();
		if ( true !== $zip->open( $zip_path ) ) {
			throw new \RuntimeException(  esc_html( "Cannot open archive: " . basename( $zip_path ) )  );
		}
		try {
			$this->guard_against_zipslip( $zip, $target );

			// Where to extract depends on how the archive was built:
			//  - Per-category archives (plugins/themes/uploads/mu) prefix each
			//    entry with the category name (e.g. "plugins/foo/bar.php"), so
			//    we extract to the PARENT of the target so "plugins/..." lands
			//    at WP_CONTENT_DIR/plugins/.
			//  - The "others" archive packs direct children of wp-content
			//    (e.g. "languages/en.mo", "elementor/x.json"), so we extract
			//    straight INTO WP_CONTENT_DIR.
			$extract_to = ( 'others' === $category ) ? $target : dirname( $target );

			// Build the entry list, skipping two classes of hostile/unwanted
			// entries — for EVERY category, including the plugin/theme/mu code
			// archives that Import can now supply:
			//
			//  - Symlink entries. A crafted backup could ship a symlink whose
			//    target is an arbitrary path, then a following entry that writes
			//    "through" it. PHP's extractTo() does not materialize symlinks,
			//    but we refuse them outright so this path matches the legacy
			//    path's protection and no crafted link ever lands. (High 16 bits
			//    of the external attrs are the Unix mode; 0xA000 == symlink.)
			//  - WordPress drop-ins at the ROOT of an "others" archive
			//    (object-cache.php, advanced-cache.php, db.php, …): host-bound
			//    code that loads before everything and fatals a moved site.
			$entries          = [];
			$skipped_symlinks = 0;
			$skipped_dropins  = [];
			for ( $i = 0; $i < $zip->numFiles; $i++ ) {
				$entry = (string) $zip->getNameIndex( $i );
				if ( '' === $entry ) {
					continue;
				}
				$opsys = 0;
				$attr  = 0;
				if ( @$zip->getExternalAttributesIndex( $i, $opsys, $attr ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Older libzip returns false for entries without attrs; we only care about the ones that DO report a symlink mode.
					&& \ZipArchive::OPSYS_UNIX === $opsys
					&& ( ( $attr >> 16 ) & 0xF000 ) === 0xA000 ) {
					$skipped_symlinks++;
					continue;
				}
				// A real drop-in sits at the archive root (no slash). A same-named
				// file inside a subdirectory is NOT a drop-in and is kept.
				if ( 'others' === $category
					&& false === strpos( rtrim( $entry, '/' ), '/' )
					&& in_array( strtolower( $entry ), self::DROPIN_FILES, true ) ) {
					$skipped_dropins[] = $entry;
					continue;
				}
				$entries[] = $entry;
			}
			if ( $skipped_symlinks > 0 ) {
				$this->logger->warn(
					"Skipped {$skipped_symlinks} symlink entr" . ( 1 === $skipped_symlinks ? 'y' : 'ies' ) . " in the {$category} archive (refused — a crafted backup could use a link target to write outside the restore directory).",
					[],
					$backup_id
				);
			}
			if ( $skipped_dropins ) {
				$this->logger->info(
					'Skipped WordPress drop-in(s) during restore to avoid a host-mismatch fatal: ' . implode( ', ', $skipped_dropins )
						. '. The owning plugin recreates its drop-in on activation.',
					[],
					$backup_id
				);
			}
			if ( empty( $entries ) ) {
				return; // nothing left to extract
			}
			if ( ! $zip->extractTo( $extract_to, $entries ) ) {
				throw new \RuntimeException(  esc_html( "Failed to extract " . basename( $zip_path ) )  );
			}
		} finally {
			$zip->close();
		}
	}

	/**
	 * Backwards-compat: a single-zip archive containing everything (database.sql + uploads/themes/plugins/mu-plugins).
	 *
	 * Used by:
	 *   - Pre-multi-zip backups (records without files[] metadata)
	 *   - Backups imported via the UI as a single .zip
	 *
	 * Extracts only the entries matching the requested $components.
	 */
	private function restore_legacy_zip( string $zip_path, array $components, string $backup_id, bool $clean = false ): void {
		// Data-only outside a migration: strip code components before reading the
		// archive. During a site-to-site migration ($this->allow_code_restore)
		// plugins/themes/mu are kept so the moved site works.
		if ( ! $this->allow_code_restore ) {
			$components = array_values( array_diff( $components, self::CODE_COMPONENTS ) );
		}
		$this->logger->info( 'Restoring from legacy single-zip archive', [ 'components' => $components, 'clean' => $clean ], $backup_id );

		$zip = new \ZipArchive();
		if ( true !== $zip->open( $zip_path ) ) {
			throw new \RuntimeException(  esc_html( 'Cannot open legacy archive: ' . basename( $zip_path ) )  );
		}
		try {
			$this->guard_against_zipslip( $zip, WP_CONTENT_DIR );

			// Map components → zip entry prefixes. Code prefixes are only included
			// during a migration; data-only restores carry an uploads/ tree only
			// (the database is handled separately by restore_database).
			$prefix_map = [
				'uploads' => 'uploads/',
			];
			if ( $this->allow_code_restore ) {
				$prefix_map['themes']  = 'themes/';
				$prefix_map['plugins'] = 'plugins/';
				$prefix_map['mu']      = 'mu-plugins/';
			}

			// Clean-restore for legacy single-zip: wipe each requested category's
			// target directory before extracting. Same safety rails as the
			// multi-zip path (clean_wipe_directory exempts the plugin's own
			// folder + wp-content/backreload-backups).
			if ( $clean ) {
				$target_map = [
					'uploads' => wp_get_upload_dir()['basedir'],
				];
				if ( $this->allow_code_restore ) {
					$target_map['themes']  = get_theme_root();
					$target_map['plugins'] = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : WP_CONTENT_DIR . '/plugins';
					$target_map['mu']      = defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins';
				}
				foreach ( $components as $c ) {
					if ( isset( $target_map[ $c ] ) ) {
						$this->clean_wipe_directory( rtrim( $target_map[ $c ], '/\\' ), $backup_id );
					}
				}
			}
			$wanted_prefixes = [];
			foreach ( $components as $c ) {
				if ( isset( $prefix_map[ $c ] ) ) {
					$wanted_prefixes[] = $prefix_map[ $c ];
				}
			}
			$wants_db = in_array( 'db', $components, true );

			// Walk the entry list and split into file-entries (extract in place) and DB-entry.
			// Skip symlinks: ZipArchive::extractTo() would materialize them on POSIX
			// filesystems, and a hostile (or merely careless) zip could ship a symlink
			// like `uploads/escape -> /etc/passwd` and have the next normal entry write
			// through it. Detect via the entry's external attribute on Unix opsys
			// (high 16 bits == Unix mode; 0xA000 indicates a symlink).
			$file_entries = [];
			$sql_entry    = null;
			$skipped_symlinks = 0;
			for ( $i = 0; $i < $zip->numFiles; $i++ ) {
				$entry = $zip->getNameIndex( $i );
				if ( false === $entry ) continue;

				// Symlink check. getExternalAttributesIndex fills $opsys + $attr
				// by reference; we don't need the function's bool return.
				$opsys = 0;
				$attr  = 0;
				if ( @$zip->getExternalAttributesIndex( $i, $opsys, $attr ) ) {
					if ( \ZipArchive::OPSYS_UNIX === $opsys && ( ( $attr >> 16 ) & 0xF000 ) === 0xA000 ) {
						$skipped_symlinks++;
						continue;
					}
				}

				if ( $wants_db && ( $entry === 'database.sql' || $entry === 'database.sql.gz' ) ) {
					$sql_entry = $entry;
					continue;
				}
				foreach ( $wanted_prefixes as $prefix ) {
					if ( 0 === strpos( $entry, $prefix ) ) {
						$file_entries[] = $entry;
						break;
					}
				}
			}
			if ( $skipped_symlinks > 0 ) {
				$this->logger->warn(
					"Skipped {$skipped_symlinks} symlink entr" . ( 1 === $skipped_symlinks ? 'y' : 'ies' ) . " in legacy archive (refused — would let a crafted backup overwrite arbitrary files via the link target)",
					[],
					$backup_id
				);
			}

			if ( empty( $file_entries ) && ! $sql_entry ) {
				$this->logger->warn(
					'Legacy archive contains no entries matching the selected components; nothing to restore.',
					[ 'components' => $components ],
					$backup_id
				);
				return;
			}

			// Extract file entries directly into wp-content (zip stores them as uploads/…, themes/…, etc.).
			if ( ! empty( $file_entries ) ) {
				if ( ! $zip->extractTo( WP_CONTENT_DIR, $file_entries ) ) {
					throw new \RuntimeException( esc_html(  'Failed to extract files from legacy archive.'  )  );
				}
				$this->logger->success(
					'Extracted ' . count( $file_entries ) . ' file entries from legacy archive',
					[],
					$backup_id
				);
			}

			// Extract the SQL dump to staging and import via restore_database (handles .gz).
			if ( $sql_entry ) {
				$staging = \Backreload\Storage::root() . '/.restore/' . $backup_id;
				wp_mkdir_p( $staging );
				// Re-guard against zip-slip with the narrower target. The earlier
				// guard validated entries against WP_CONTENT_DIR; we now want to
				// ensure $sql_entry doesn't escape the .restore/$id/ subdir.
				if ( false !== strpos( $sql_entry, '/' ) || preg_match( '#\.\.#', $sql_entry ) ) {
					throw new \RuntimeException( esc_html(  "Unsafe SQL entry name in legacy archive: {$sql_entry}"  )  );
				}
				if ( ! $zip->extractTo( $staging, [ $sql_entry ] ) ) {
					throw new \RuntimeException( esc_html(  'Failed to extract database from legacy archive.'  )  );
				}
				$this->restore_database( $staging . '/' . $sql_entry, $backup_id );
			}
		} finally {
			$zip->close();
		}
	}

	/** Restore database from a .sql or .sql.gz file. */
	private function restore_database( string $path, string $backup_id, bool $is_migration = false ): void {
		$this->logger->info( 'Restoring database from ' . basename( $path ), [], $backup_id );
		if ( substr( $path, -3 ) === '.gz' ) {
			// Stream-decompress to a temp .sql, then import.
			$sql_path = preg_replace( '/\.gz$/', '', $path );
			$this->gunzip_file( $path, $sql_path );
			@unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			$path = $sql_path;
		}
		$this->import_sql( $path, $backup_id );
		$this->logger->success( 'Database restored', [], $backup_id );

		// Strip the engine's tick-lock rows that the SQL replay just
		// resurrected. The backup engine writes `backreload_tick_lock_{ext_id}`
		// while it's actively processing a backup; that row lives in
		// `wp_options`, which the dumper captured along with the DB. After
		// the restore replays the dump, the stale lock is BACK in the live
		// options table, even though no engine is actually ticking anymore.
		// The mark_stale_as_failed sweep then sees a fresh-looking lock and
		// assumes the engine is alive — so it never heals or fails the
		// orphan running record. End result: a "running" row that sits
		// forever despite the user reporting "the files are already on the
		// destination".
		//
		// The legacy `_transient_backreload_ticking_*` keys are still swept here
		// for backward compatibility with any in-flight backup that was
		// active under the old key naming before this restore replayed an
		// older dump.
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
		$cleared_ticks = $wpdb->query(
			"DELETE FROM {$wpdb->options}
			 WHERE option_name LIKE 'backreload_tick_lock_%'
			    OR option_name LIKE '_transient_backreload_ticking_%'
			    OR option_name LIKE '_transient_timeout_backreload_ticking_%'"
		);
		if ( $cleared_ticks > 0 ) {
			// wp_options changed under our feet — clear the object cache
			// so subsequent get_transient calls don't return stale data.
			wp_cache_delete( 'alloptions', 'options' );
			$this->logger->info(
				"Cleared {$cleared_ticks} stale tick-lock transient row(s) replayed by the SQL restore; orphan 'running' backups can now self-heal on the next list refresh",
				[],
				$backup_id
			);
		}

		// Migration bookkeeping replayed from the SOURCE site.
		//
		// A migration restore imports the source's whole database, which
		// includes ITS migration state: the `backreload_active_migration`
		// pointer to the OUTGOING push it was running, the per-migration push
		// `backreload_migration_state_*` rows, and its `backreload_migration_keys`.
		// On the destination those describe a site that no longer exists in this
		// context — and, left in place, the Migrate page "resumes" the source's
		// push (e.g. an endless "Pushing to <this-site>…" panel) and shows the
		// source's keys instead of this site's. This is exactly the "the Send
		// panel is showing the wrong thing / my received key is gone" symptom:
		// the received key was single-use and already consumed at handshake, and
		// what's left is the source's copy. Reset all of it so the destination's
		// Migrate page reflects THIS site, not the site it was cloned from.
		//
		// The active-migration pointer and per-migration push state are always
		// stale after a DB restore (they describe whatever was in flight at the
		// snapshot moment), so clearing them is safe for EVERY restore — it just
		// stops a resurrected "still pushing…" panel. The in-flight restore
		// itself is driven by separate `backreload_active_restore` /
		// receive-progress options, which we deliberately leave untouched.
		delete_option( 'backreload_active_migration' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin clears its own replayed migration-state rows; transient/session state that must not be cached.
		$cleared_mig = $wpdb->query(
			"DELETE FROM {$wpdb->options}
			 WHERE option_name LIKE 'backreload_migration_state_%'
			    OR option_name LIKE '_transient_backreload_migration_%'
			    OR option_name LIKE '_transient_timeout_backreload_migration_%'"
		);

		// Migration KEYS are only reset for an actual site-to-site migration.
		// On a migration the replayed keys belong to the SOURCE and are useless
		// (and confusing) here, so we clear them. On a plain self-restore of the
		// site's OWN backup, however, those keys are this site's own inbound-push
		// keys — deleting them would silently break a partner site's configured
		// push until the user regenerated one. So leave them in place there.
		if ( $is_migration ) {
			delete_option( 'backreload_migration_keys' );
		}

		wp_cache_delete( 'alloptions', 'options' );
		$this->logger->info(
			'Reset stale migration bookkeeping (active-migration pointer, push state' . ( $cleared_mig > 0 ? " ×{$cleared_mig}" : '' ) . ( $is_migration ? ', and source migration keys' : '' ) . ') so this site\'s Migrate page reflects itself, not a snapshot in flight.',
			[],
			$backup_id
		);

		// Heal-from-destination pass. The snapshot likely captured the
		// `backreload_backups` table at a moment when this restore's source
		// backup was still being created — engine had built parts and
		// dumped the DB, but hadn't yet logged uploads (`uploaded_to`
		// is populated in a later step). So after SQL replay, the row
		// shows status=running with empty `uploaded_to`. The standard
		// heal-or-fail check would mark it failed because files[] have
		// no uploaded_to — but the *actual* truth is on the destination:
		// if a manifest.json exists there for the backup_id, the engine
		// completed every upload (manifest is written last). Walk every
		// running/pending row, peek at its destinations for a manifest,
		// and on a hit promote the row to success + retroactively
		// populate uploaded_to. This is the exact case the user has
		// been reporting: "I made a backup that succeeded, restored
		// from it, now it shows running again — but the files ARE on
		// the destination."
		try {
			$this->heal_running_records_from_destinations( $backup_id );
		} catch ( \Throwable $e ) {
			$this->logger->warn(
				'Heal-from-destination pass failed (continuing): ' . $e->getMessage(),
				[],
				$backup_id
			);
		}

		// Final stale-sweep — anything not healed above goes through the
		// standard heal-or-fail logic. Bypassing the once-a-minute throttle
		// so the user sees an immediate reconcile, AND setting the
		// post-restore flag so the sweep's age floor drops to zero (a
		// just-created record from the snapshot is younger than the
		// usual 6-minute orphan threshold but is OBJECTIVELY orphaned
		// because the restore just nuked all tick locks).
		// Direct invocation now that mark_stale_as_failed() was promoted to
		// public `sweep_stale()` and the REST-piggyback (with its throttle
		// transient) was removed. The `backreload_post_restore_pass` transient
		// still tells sweep_stale() to lower its age floor to zero — the
		// records we want to heal are seconds old, much younger than the
		// normal 6-minute floor.
		set_transient( 'backreload_post_restore_pass', 1, 60 );
		BackupRecord::sweep_stale();
		delete_transient( 'backreload_post_restore_pass' );

		// Final cleanup: NUKE worthless rows. After the heal/fail passes
		// above, any record that still has BOTH (status='failed' OR
		// 'cancelled') AND no parts uploaded anywhere is operationally
		// dead — no files on disk anywhere, no way to Resume meaningfully,
		// no Restore source. They just clutter the Backups list and confuse
		// users. The pattern is: snapshot replayed in-flight rows, those
		// rows had no uploaded_to entries, the sweep marked them failed,
		// they sit there forever as red "no copies" rows. The user's
		// repeated report of "still showing this after restore" is exactly
		// these phantom failures. Auto-delete them.
		try {
			$this->purge_phantom_failed_records( $backup_id );
		} catch ( \Throwable $e ) {
			$this->logger->warn(
				'Phantom-failure cleanup failed (continuing): ' . $e->getMessage(),
				[],
				$backup_id
			);
		}

		// Cron-storm suppression. The SQL import replayed the snapshot's
		// scheduled-backup events, all with past timestamps. Without this
		// reset, WP-Cron fires every "overdue" event on the next request —
		// users see a fresh "running" backup row appear seconds after
		// "Restore complete", which we've heard called out three separate
		// times now. Clear and re-register every schedule with a fresh
		// future-only fire time so the next scheduled run happens at its
		// next legitimate occurrence, not as a delayed retroactive fire.
		try {
			$scheduler = $this->plugin->get( 'scheduler' );
			if ( method_exists( $scheduler, 'reschedule_after_restore' ) ) {
				$rescheduled = $scheduler->reschedule_after_restore();
				if ( $rescheduled > 0 ) {
					$this->logger->info(
						"Cron reset: re-armed {$rescheduled} scheduled backup(s) with future fire times to prevent immediate post-restore replay",
						[],
						$backup_id
					);
				}
			}
		} catch ( \Throwable $e ) {
			// Non-fatal — the restore itself succeeded; logging only.
			$this->logger->warn(
				'Cron reset after restore failed (continuing): ' . $e->getMessage(),
				[],
				$backup_id
			);
		}
	}

	private function gunzip_file( string $src, string $dst ): void {
		$in  = @gzopen( $src, 'rb' );
		$out = @fopen( $dst, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		if ( ! $in || ! $out ) {
			if ( $in )  gzclose( $in );
			if ( $out ) fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			throw new \RuntimeException( esc_html(  'Cannot gunzip the database dump.'  )  );
		}
		try {
			while ( ! gzeof( $in ) ) {
				$buf = gzread( $in, 1048576 );
				if ( $buf === false ) break;
				fwrite( $out, $buf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			}
		} finally {
			gzclose( $in );
			fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		}
	}

	/**
	 * Verify every entry in the archive would extract to a path strictly
	 * inside the given target dir. Defends against:
	 *   - Absolute paths (`/etc/foo`)
	 *   - Parent-segment traversal (`../etc/foo`)
	 *   - Windows-style absolute paths (`C:\etc\foo`)
	 *   - Backslash separators that some unzip routines normalize
	 *   - NUL bytes that truncate path strings
	 *   - Symlink escapes (we don't extract symlinks anyway, and `extractTo`
	 *     doesn't materialize zip-internal symlinks as symlinks — but the
	 *     containing target dir might be a symlink whose realpath escapes)
	 *
	 * @throws \RuntimeException on any unsafe entry.
	 */
	private function guard_against_zipslip( \ZipArchive $zip, string $target_dir ): void {
		$real_target = realpath( $target_dir );
		if ( ! $real_target ) {
			// Target dir doesn't exist yet — fall back to the containing
			// directory (one level up). extractTo will create the missing
			// child segment.
			$real_target = realpath( dirname( $target_dir ) );
		}
		if ( ! $real_target ) {
			throw new \RuntimeException( esc_html(  "Cannot resolve a real target path for zip extraction: {$target_dir}"  )  );
		}
		$base = trailingslashit( $real_target );

		for ( $i = 0; $i < $zip->numFiles; $i++ ) {
			$entry = $zip->getNameIndex( $i );
			if ( false === $entry ) continue;

			// Fast string-level rejections.
			if (
				'' === $entry
				|| '/' === substr( $entry, 0, 1 )
				|| false !== strpos( $entry, "\0" )
				|| false !== strpos( $entry, "\\" )
				|| preg_match( '#(^|/)\.\.(/|$)#', $entry )
				|| preg_match( '/^[A-Za-z]:[\\\\\/]/', $entry ) // Windows drive
			) {
				throw new \RuntimeException( esc_html(  "Archive contains unsafe path: {$entry}"  )  );
			}

			// Final containment check using realpath on the parent (the entry
			// itself doesn't exist yet). dirname() collapses `.` segments so
			// the resolved path can't escape unless a parent is a symlink to
			// outside the base.
			$candidate = $base . $entry;
			$parent    = dirname( $candidate );
			$resolved_parent = realpath( $parent );
			if ( $resolved_parent && 0 !== strpos( trailingslashit( $resolved_parent ), $base ) ) {
				throw new \RuntimeException(
					 esc_html( "Archive entry would escape target dir: {$entry} resolves to {$resolved_parent}"
				 ) );
			}
		}
	}

	private function import_sql( string $sql_file, string $backup_id = '' ): void {
		global $wpdb;
		$fh = @fopen( $sql_file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		if ( ! $fh ) {
			throw new \RuntimeException( esc_html(  'Cannot read SQL dump.'  )  );
		}

		// SECURITY: refuse statements that could exfiltrate or escalate. Any
		// imported archive is potentially attacker-controlled (we accept
		// uploads at /backups/import) and we run the SQL with full DB rights.
		// This is best-effort — for true sandboxing we'd need a separate DB
		// connection with limited grants, but rejecting the obviously-bad
		// stops the easy attacks.
		// All patterns are anchored to statement-start (^\s*) so they match
		// against the SQL parser's perspective — not against bytes inside
		// INSERT VALUES string literals. The previous unanchored version
		// false-positived on legitimate data (a user named "Grant", a post
		// mentioning "permission revoked", etc.) and refused otherwise-clean
		// dumps. The privilege/exec statements we actually want to block can
		// only execute when they appear at statement-start; data containing
		// the same words is benign.
		$denied_patterns = [
			'/^\s*LOAD\s+DATA\b/i',                                                          // LOAD DATA INFILE — file read into a table
			'/^\s*SELECT\b[\s\S]*?\bINTO\s+(OUTFILE|DUMPFILE)\b/i',                          // SELECT … INTO OUTFILE — file write
			'/^\s*(GRANT|REVOKE)\b/i',                                                        // privilege grants / revokes
			'/^\s*(CREATE|DROP|ALTER)\s+(USER|ROLE|FUNCTION|PROCEDURE|TRIGGER|EVENT)\b/i',   // privilege/code paths
			'/^\s*(SET|SELECT)\s+GLOBAL\b/i',                                                 // global server state
		];
		$target_prefix = $wpdb->prefix;

		// Cross-prefix migration: WordPress sites randomize their table prefix
		// for security (wp_, wpre_, mysite_, …), so a backup from one site can
		// land on another with a different prefix. Detect the source prefix
		// from the dump (look for a known core table) and rewrite table
		// references before executing — without this every CREATE TABLE would
		// fail the safety check below or land tables nobody can find.
		$source_prefix = $this->detect_source_prefix( $sql_file );
		if ( null === $source_prefix ) {
			// No WP core table found in the head of the dump — fall back to
			// the target prefix so the safety check still works, just no
			// rewriting takes place. Unusual but not fatal.
			$source_prefix = $target_prefix;
		} elseif ( $source_prefix !== $target_prefix ) {
			$this->logger->info(
				"SQL dump uses prefix \"{$source_prefix}\"; rewriting to this site's prefix \"{$target_prefix}\"",
				[],
				$backup_id
			);
		}

		// Table-reference verbs we'll rewrite the prefix on. INSERT/UPDATE
		// included so DML against renamed tables lands in the right place.
		$verbs_rx = '(?:CREATE\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?'
		          . '|DROP\s+TABLE(?:\s+IF\s+EXISTS)?'
		          . '|ALTER\s+TABLE'
		          . '|TRUNCATE(?:\s+TABLE)?'
		          . '|RENAME\s+TABLE'
		          . '|INSERT\s+INTO|REPLACE\s+INTO'
		          . '|UPDATE|LOCK\s+TABLES)';

		// DDL detection regex — skip optional IF [NOT] EXISTS so the table
		// name capture is the actual identifier, not the literal "IF".
		$ddl_rx = '/^\s*(CREATE|DROP|ALTER|TRUNCATE|RENAME)\s+TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?`?([a-zA-Z0-9_]+)`?/i';

		// Use a transaction so a half-imported dump rolls back instead of
		// leaving the DB partially overwritten. InnoDB only — MyISAM ignores.
		$wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
		$failed = false;
		$exec   = function ( string $stmt ) use ( $wpdb, $denied_patterns, $target_prefix, $source_prefix, $verbs_rx, $ddl_rx, &$failed ) {
			if ( '' === trim( $stmt ) ) return;

			// Reject denied-pattern statements outright.
			foreach ( $denied_patterns as $rx ) {
				if ( preg_match( $rx, $stmt ) ) {
					throw new \RuntimeException( esc_html(  'Refused unsafe SQL statement in dump: matched ' . $rx  )  );
				}
			}

			// Cross-prefix rewrite — only at table-reference positions so
			// string data inside INSERT VALUES isn't accidentally rewritten.
			if ( $source_prefix !== $target_prefix && '' !== $source_prefix ) {
				$sp = preg_quote( $source_prefix, '/' );
				// Backtick-quoted identifiers (the common mysqldump shape).
				$stmt = preg_replace_callback(
					'/(\b' . $verbs_rx . '\s+)`' . $sp . '(\w+)`/i',
					static fn( $m ) => $m[1] . '`' . $target_prefix . $m[2] . '`',
					$stmt
				);
				// Unquoted identifiers (some dumps skip backticks).
				$stmt = preg_replace_callback(
					'/(\b' . $verbs_rx . '\s+)' . $sp . '(\w+)\b/i',
					static fn( $m ) => $m[1] . $target_prefix . $m[2],
					$stmt
				);
			}

			// DDL touching tables outside our prefix is forbidden — keeps a
			// rogue dump from rewriting other site's tables on shared MySQL.
			// Runs AFTER the cross-prefix rewrite above, so the captured
			// table name is already in target-prefix form.
			if ( preg_match( $ddl_rx, $stmt, $m ) ) {
				$tbl = $m[2];
				if ( 0 !== strpos( $tbl, $target_prefix ) ) {
					throw new \RuntimeException(
						 esc_html( "Refused DDL targeting table outside wpdb prefix: {$tbl} (expected prefix {$target_prefix})"
					 ) );
				}
			}

			// Idempotency rewrite: INSERT INTO → REPLACE INTO. The dumper
			// already writes "DROP TABLE IF EXISTS" before each CREATE, but
			// in practice that's not enough — DDL inside a transaction
			// implicitly commits and several real-world edge cases (a
			// long-running InnoDB lock during DROP, a parser hiccup that
			// merges adjacent statements, a CREATE-before-DROP race when the
			// dump was generated on a busy site) end with the OLD table
			// still in place when the INSERTs run. That makes the very next
			// INSERT explode on UNIQUE keys like wp_options.option_name and
			// the whole restore fails. REPLACE INTO has identical INSERT
			// semantics when no row collides, and on collision it deletes the
			// existing row before inserting — which is exactly the "the
			// backup wins" outcome we want from a restore. Cascades are safe
			// because the dumper sets FOREIGN_KEY_CHECKS=0 up front.
			//
			// Only the statement-start INSERT is rewritten — any "INSERT INTO"
			// substring inside a VALUES literal stays untouched.
			if ( preg_match( '/^\s*INSERT\s+INTO\b/i', $stmt ) ) {
				$stmt = preg_replace( '/^(\s*)INSERT(\s+INTO\b)/i', '$1REPLACE$2', $stmt, 1 );
			}

			$ok = $wpdb->query( $stmt ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Interpolated identifier is a table name derived from $wpdb->prefix (never user input) and cannot be bound via prepare(); all data values use %s/%d placeholders.
			if ( false === $ok ) {
				$failed = true;
				throw new \RuntimeException(  esc_html( 'SQL import failed: ' . ( $wpdb->last_error ?: 'unknown error' ) )  );
			}
		};

		try {
			$buffer = '';
			while ( ! feof( $fh ) ) {
				$line = fgets( $fh );
				if ( false === $line ) break;
				$trim = trim( $line );
				// Skip line-comments and block-comments-on-their-own-line.
				if ( '' === $trim || str_starts_with( $trim, '--' ) || str_starts_with( $trim, '/*' ) ) continue;
				$buffer .= $line;
				if ( substr( rtrim( $line ), -1 ) === ';' ) {
					$exec( $buffer );
					$buffer = '';
				}
			}
			if ( '' !== trim( $buffer ) ) {
				$exec( $buffer ); // trailing statement without terminating ;
			}
			$wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
		} catch ( \Throwable $e ) {
			$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
			throw $e;
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		}

		// Cross-prefix DATA fixup. The transaction above rewrote table
		// NAMES, but some meta_key + option_name VALUES embed the prefix
		// too — WordPress reads roles + per-user prefs through these
		// prefix-bound keys, and a mismatch silently strips admin
		// capabilities on cross-prefix migrations.
		//
		// The single most damaging symptom: "Sorry, you are not allowed
		// to access this page" on wp-admin even though the user can log
		// in (capabilities meta key is the source prefix, WP looks for
		// the target prefix, finds nothing, treats the user as a
		// subscriber with no admin capability).
		//
		// Skip if the prefixes match (most restores) or the source
		// prefix wasn't detected (we shouldn't blindly rewrite then).
		if ( $source_prefix !== $target_prefix && '' !== $source_prefix ) {
			$this->fixup_prefix_bound_data( $source_prefix, $target_prefix, $backup_id );
		}
	}

	/**
	 * Post-restore fixup for meta keys + option names that embed the
	 * table prefix. These are NOT table names — they're string DATA
	 * inside rows. The SQL-statement rewriter intentionally skips
	 * VALUES literals (so user content containing "wp_" isn't mangled),
	 * which means these prefix-bound keys land in the destination with
	 * the SOURCE's prefix and WordPress can't find them.
	 *
	 * Known prefix-bound keys (all standard WP core, no plugin extras):
	 *   wp_usermeta.meta_key:
	 *     <prefix>capabilities          — user role assignments (THE big one)
	 *     <prefix>user_level            — legacy role level (still consulted)
	 *     <prefix>user-settings         — per-user wp-admin prefs
	 *     <prefix>user-settings-time
	 *     <prefix>dashboard_quick_press_last_post_id
	 *   wp_options.option_name:
	 *     <prefix>user_roles            — the role/capability definitions
	 *
	 * We run the updates outside the import transaction because the
	 * rows we're updating just landed via COMMIT and a fresh statement
	 * needs to see them. Each UPDATE is a single prepared statement;
	 * even on a 100k-user site this completes in <1s.
	 */
	private function fixup_prefix_bound_data( string $source_prefix, string $target_prefix, string $backup_id ): void {
		global $wpdb;

		$usermeta_table = $target_prefix . 'usermeta';
		$options_table  = $target_prefix . 'options';

		// usermeta meta_keys to rewrite.
		$meta_keys = [
			'capabilities',
			'user_level',
			'user-settings',
			'user-settings-time',
			'dashboard_quick_press_last_post_id',
		];
		$rewritten_usermeta = 0;
		foreach ( $meta_keys as $suffix ) {
			$from = $source_prefix . $suffix;
			$to   = $target_prefix . $suffix;
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); all data values use %s placeholders; updates the site's own usermeta table, transient and uncacheable.
			$rows = $wpdb->query( $wpdb->prepare(
				"UPDATE `{$usermeta_table}` SET meta_key = %s WHERE meta_key = %s",
				$to,
				$from
			) );
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			if ( is_int( $rows ) && $rows > 0 ) {
				$rewritten_usermeta += $rows;
			}
		}

		// options table user_roles entry.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); all data values use %s placeholders; updates the site's own options table, transient and uncacheable.
		$rewritten_options = (int) $wpdb->query( $wpdb->prepare(
			"UPDATE `{$options_table}` SET option_name = %s WHERE option_name = %s",
			$target_prefix . 'user_roles',
			$source_prefix . 'user_roles'
		) );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

		$this->logger->info(
			sprintf(
				'Cross-prefix data fixup: rewrote %d usermeta key(s) + %d options row(s) from "%s" → "%s"',
				$rewritten_usermeta,
				$rewritten_options,
				$source_prefix,
				$target_prefix
			),
			[],
			$backup_id
		);

		// Clear any cached caps for users we just touched — WP's object
		// cache may still hold the empty-caps result it read a millisecond
		// before we rewrote the keys. Cheapest safe answer: flush the
		// user-meta cache group entirely. Trivial on a fresh import.
		if ( $rewritten_usermeta > 0 ) {
			// Per-group flush landed in WP 6.1. We support 6.0, so resolve the
			// function name dynamically: on 6.1+ flush just the user_meta group;
			// on 6.0 fall back to a full flush. Calling it via a variable keeps
			// the 6.1-only symbol from being a hard dependency on older WP.
			$flush_group = 'wp_cache_flush_group';
			if ( function_exists( $flush_group ) ) {
				$flush_group( 'user_meta' );
			} else {
				wp_cache_flush();
			}
		}
	}

	/**
	 * Sniff the table prefix the SQL dump was generated with, by looking for
	 * the first CREATE TABLE referencing a known WordPress core table.
	 * Returns null if no core table is found in the scanned head of the file
	 * (atypical; signals "import as-is, no rewrite").
	 *
	 * We scan only the first few MB — mysqldump always emits CREATE TABLE
	 * statements early, before the big INSERTs.
	 */
	private function detect_source_prefix( string $sql_file ): ?string {
		$fh = @fopen( $sql_file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		if ( ! $fh ) {
			return null;
		}
		$budget = 4 * 1024 * 1024; // 4 MB of head is plenty for the schema block
		$read   = 0;
		// `options` is the most distinctive core table (no plugin ships an
		// `<prefix>options` table). We try a few core tables so a partial dump
		// that skipped wp_options for some reason still detects.
		$core_suffixes = 'options|posts|postmeta|users|usermeta|term_relationships|term_taxonomy|termmeta|commentmeta|comments';
		$rx = '/CREATE\s+TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?`?([A-Za-z0-9_]*?)(?:' . $core_suffixes . ')`?\s*\(/i';

		// Collect EVERY prefix candidate rather than trusting the first hit.
		// A plugin table that merely ENDS in a core-table word — e.g.
		// `<prefix>aiowps_logged_in_users` (All-In-One WP Security) or
		// `<prefix>wc_admin_notes` — would otherwise be mistaken for the
		// prefix and leave most tables un-rewritten, failing the DDL guard.
		// Every table in a single-site dump starts with the real prefix, so the
		// genuine prefix is the SHORTEST candidate; false matches only add to it.
		$candidates = [];
		try {
			while ( ! feof( $fh ) && $read < $budget ) {
				$line = fgets( $fh );
				if ( false === $line ) break;
				$read += strlen( $line );
				if ( preg_match( $rx, $line, $m ) ) {
					// $m[1] may be '' when the source ran with NO prefix.
					$candidates[ $m[1] ] = strlen( $m[1] );
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		}
		if ( empty( $candidates ) ) {
			return null;
		}
		asort( $candidates ); // shortest prefix first
		return (string) array_key_first( $candidates );
	}

	/**
	 * Heal "running" / "pending" backup records whose destinations actually
	 * hold a completed backup. Called from run() after the SQL restore so
	 * the user's just-restored-from backup (and any others captured mid-
	 * flight in the snapshot) flip back to 'success' instead of looking
	 * like they got stuck.
	 *
	 * Detection rule: a backup is "objectively complete" if its manifest.json
	 * exists on at least one of its recorded destinations. The manifest is
	 * the LAST thing the engine writes — its presence guarantees every part
	 * was uploaded successfully. If we find one, we promote the record to
	 * success and retroactively populate `files[].uploaded_to` so the UI
	 * shows the destination chips correctly.
	 *
	 * @param string $current_restore_id  ext_id of the restore in progress
	 *                                    (used only for log tagging).
	 */
	private function heal_running_records_from_destinations( string $current_restore_id ): void {
		global $wpdb;
		$table = $wpdb->prefix . 'backreload_backups';
		// Scope to recent records only. The heal pass queries every
		// configured destination for every candidate row's files — on a
		// site with 4 destinations and 200+ historical "running" rows
		// (the failure mode this very function exists to fix), that's
		// hundreds of cloud round-trips serialized on the critical path
		// of every restore. The realistic recovery window is "the backup
		// that was running when the user took the snapshot they're now
		// restoring," which is at most a few minutes old. 72 hours is
		// generously wide — anything older is almost certainly a true
		// abandoned record that the hourly backreload_stale_sweep cron
		// would have already heal-or-failed.
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - 72 * HOUR_IN_SECONDS );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); reads the plugin's own backups table, transient and uncacheable.
		$rows   = $wpdb->get_results( $wpdb->prepare(
			"SELECT ext_id, files, destinations FROM {$table}
			 WHERE status IN ('pending','running','cancelling')
			   AND created_at >= %s",
			$cutoff
		), ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

		// Always log entry — the user reported the heal pass "appearing to do
		// nothing" several times, and silent zero-result runs made debugging
		// impossible. Now we always say what we saw and what we did.
		$candidate_count = count( $rows );
		$this->logger->info(
			"Heal-from-destination pass: scanning {$candidate_count} non-terminal record(s)",
			[],
			$current_restore_id
		);
		if ( empty( $rows ) ) return;

		$mgr      = $this->plugin->get( 'destinations' );
		$now      = current_time( 'mysql', true );
		$healed   = 0;
		foreach ( $rows as $row ) {
			$ext_id = (string) $row['ext_id'];
			$files  = json_decode( (string) ( $row['files']        ?? '[]' ), true ) ?: [];
			$dests  = json_decode( (string) ( $row['destinations'] ?? '[]' ), true ) ?: [];
			if ( empty( $files ) || empty( $dests ) ) {
				$this->logger->info(
					"Heal: {$ext_id} has no files or destinations, skipping",
					[ 'files' => count( $files ), 'destinations' => count( $dests ) ],
					$current_restore_id
				);
				continue;
			}

			// Names of the parts this backup is supposed to have, in basename
			// form (e.g. "uploads-1.zip"). We use these to verify the
			// destination genuinely holds every part — proof the backup
			// completed even when the manifest can't be read/parsed.
			$expected_parts = [];
			foreach ( $files as $f ) {
				$n = (string) ( $f['name'] ?? '' );
				if ( '' === $n ) continue;
				$expected_parts[] = basename( $n );
			}
			$expected_parts = array_values( array_unique( $expected_parts ) );

			$confirmed_dest_id = null;
			$confirmation     = '';
			$authoritative_manifest = null;
			foreach ( $dests as $dest_id ) {
				$dest = \Backreload\Models\DestinationRecord::find( (string) $dest_id );
				if ( ! $dest ) continue;
				if ( 'connected' !== $dest->status ) continue;
				if ( ! empty( $dest->paused ) )       continue;
				try {
					$handler = $mgr->handler( $dest->type );
				} catch ( \Throwable $e ) { continue; }
				if ( method_exists( $handler, 'is_browsable' ) && ! $handler->is_browsable() ) {
					continue;
				}
				try {
					$found = $handler->list_backups( $dest );
				} catch ( \Throwable $e ) {
					$this->logger->warn(
						"Heal: list_backups failed on {$dest->name}: " . $e->getMessage(),
						[],
						$current_restore_id
					);
					continue;
				}
				$matching_entry = null;
				foreach ( $found as $entry ) {
					if ( (string) ( $entry['ext_id'] ?? '' ) === $ext_id ) {
						$matching_entry = $entry;
						break;
					}
				}
				if ( ! $matching_entry ) {
					$this->logger->info(
						"Heal: {$ext_id} not found on {$dest->name} (destination has " . count( $found ) . ' folder(s) total)',
						[],
						$current_restore_id
					);
					continue;
				}

				// Two paths to confirmation, broadest first:
				//   (A) Manifest.json present + parses → use the manifest as
				//       the authoritative file list (it's written after the
				//       last add_file call, so it ALWAYS reflects every part
				//       the engine wrote — including db.sql.gz which the
				//       snapshot replay loses because the DB dump captures
				//       backreload_backups BEFORE add_file('db') runs).
				//   (B) Every expected part is present on the destination —
				//       fallback when the manifest can't be read but the
				//       parts are objectively there.
				if ( ! empty( $matching_entry['has_manifest'] ) ) {
					$confirmed_dest_id      = (string) $dest_id;
					$confirmation           = 'manifest.json present on destination';
					$authoritative_manifest = is_array( $matching_entry['manifest'] ?? null ) ? $matching_entry['manifest'] : null;
					break;
				}
				$found_part_names = array_map(
					static fn( $p ) => basename( (string) ( $p['name'] ?? '' ) ),
					(array) ( $matching_entry['parts'] ?? [] )
				);
				$missing = array_diff( $expected_parts, $found_part_names );
				if ( empty( $missing ) && ! empty( $expected_parts ) ) {
					$confirmed_dest_id = (string) $dest_id;
					$confirmation      = 'all ' . count( $expected_parts ) . ' expected parts present on destination (manifest missing/unreadable)';
					break;
				}
				$this->logger->info(
					"Heal: {$ext_id} on {$dest->name}. manifest=" . ( ! empty( $matching_entry['has_manifest'] ) ? 'yes' : 'no' ) .
					', parts=' . count( $found_part_names ) . '/' . count( $expected_parts ) .
					( $missing ? ' (missing: ' . implode( ', ', array_slice( $missing, 0, 3 ) ) . ')' : '' ),
					[],
					$current_restore_id
				);
			}

			if ( null === $confirmed_dest_id ) {
				$this->logger->info(
					"Heal: {$ext_id} could not be confirmed on any destination; leaving in current state for the stale-sweep to decide",
					[],
					$current_restore_id
				);
				continue;
			}

			// REBUILD files[] from the manifest when available. This is the
			// fix for the user's "DB component missing in restore wizard"
			// report: the snapshot inside the backup was captured DURING
			// the engine's DB dump step, BEFORE the engine called
			// $rec->add_file('db'). So the snapshot's backreload_backups row only
			// has 3 entries (uploads/themes/plugins). The restore replays
			// that 3-entry state. Without this rebuild, the record stays
			// with 3 files forever — restore wizard never offers the DB
			// component even though db.sql.gz is sitting right there on
			// the destination. The manifest is the authoritative truth
			// because it's written AFTER every add_file call.
			$update_files = $files;
			$updated_components = null;
			if ( $authoritative_manifest && ! empty( $authoritative_manifest['files'] ) ) {
				$update_files = [];
				foreach ( (array) $authoritative_manifest['files'] as $mf ) {
					$mname = (string) ( $mf['name'] ?? '' );
					if ( '' === $mname ) continue;
					$update_files[] = [
						'name'        => $mname,
						'category'    => (string) ( $mf['category'] ?? '' ),
						'size'        => (int) ( $mf['size'] ?? 0 ),
						'checksum'    => (string) ( $mf['checksum'] ?? '' ),
						'encrypted'   => ! empty( $mf['encrypted'] ),
						'uploaded_to' => [ $confirmed_dest_id ],
					];
				}
				if ( ! empty( $authoritative_manifest['components'] ) ) {
					$updated_components = array_values( (array) $authoritative_manifest['components'] );
				}
			} else {
				// Confirmed via parts-presence, no manifest. Just update
				// uploaded_to on the existing entries — don't trust the
				// possibly-partial files[] for any other rebuild.
				foreach ( $update_files as &$f ) {
					$ut = (array) ( $f['uploaded_to'] ?? [] );
					if ( ! in_array( $confirmed_dest_id, $ut, true ) ) {
						$ut[] = $confirmed_dest_id;
					}
					$f['uploaded_to'] = array_values( array_unique( $ut ) );
				}
				unset( $f );
			}

			$update_data = [
				'status'       => 'success',
				'error'        => null,
				'files'        => wp_json_encode( $update_files ),
				'completed_at' => $now,
			];
			if ( null !== $updated_components ) {
				$update_data['components'] = wp_json_encode( $updated_components );
			}
			$wpdb->update( $table, $update_data, [ 'ext_id' => $ext_id ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
			$healed++;
			$this->logger->info(
				"Healed '{$ext_id}' to success: {$confirmation}. Rebuilt files[] from manifest: " . count( $update_files ) . ' parts.',
				[ 'destination' => $confirmed_dest_id, 'parts' => count( $update_files ) ],
				$current_restore_id
			);
		}

		$this->logger->info(
			"Heal-from-destination pass: promoted {$healed} of {$candidate_count} record(s)",
			[],
			$current_restore_id
		);
	}

	/**
	 * Delete `failed`/`cancelled` backup records that have no parts uploaded
	 * to any destination. These are operationally dead — nothing to Restore,
	 * nothing to Resume, no files anywhere. They only exist as confusing
	 * "no copies / failed" rows in the Backups list, and the user has
	 * repeatedly reported them as "still showing after restore".
	 *
	 * Concrete trigger pattern: snapshot replay brings back a backup row
	 * that the engine was mid-creating (had built parts but not yet
	 * populated uploaded_to). The heal-from-destination pass can't promote
	 * it because no manifest exists for an unfinished backup. The stale
	 * sweep marks it failed. It sits forever as a "no copies" red row.
	 *
	 * Defensive bounds:
	 *   1. Only purge `failed` / `cancelled` — never `success` (real
	 *      backups stay), never `running` / `pending` (let those run/timeout).
	 *   2. Skip records that DO have any `uploaded_to` entries — those
	 *      represent real partial uploads; the user might want to Resume.
	 *   3. Skip the current restore's source backup — that one stays
	 *      regardless of its computed status, the user just used it.
	 *
	 * @param string $current_restore_id  ext_id of the restore source, to be preserved.
	 */
	private function purge_phantom_failed_records( string $current_restore_id ): void {
		global $wpdb;
		$table = $wpdb->prefix . 'backreload_backups';
		// Same recency scoping as heal_running_records_from_destinations.
		// Older failed/cancelled rows are kept — the user may have
		// historical "this run failed" rows they want to consult; we
		// only clean up the noise that the restore itself just
		// resurrected via the DB replay.
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - 72 * HOUR_IN_SECONDS );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); reads the plugin's own backups table, transient and uncacheable.
		$rows   = $wpdb->get_results( $wpdb->prepare(
			"SELECT id, ext_id, files FROM {$table}
			 WHERE status IN ('failed','cancelled')
			   AND created_at >= %s",
			$cutoff
		), ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( empty( $rows ) ) return;

		$purged = 0;
		foreach ( $rows as $row ) {
			$ext_id = (string) $row['ext_id'];
			if ( $ext_id === $current_restore_id ) continue;

			$files = json_decode( (string) ( $row['files'] ?? '[]' ), true ) ?: [];
			// Has ANY file been uploaded anywhere? If so, leave the row —
			// the user might want to Resume / Restore from the partial.
			$has_any_upload = false;
			foreach ( $files as $f ) {
				if ( ! empty( $f['uploaded_to'] ) && is_array( $f['uploaded_to'] ) ) {
					$has_any_upload = true;
					break;
				}
			}
			if ( $has_any_upload ) continue;

			$wpdb->delete( $table, [ 'id' => (int) $row['id'] ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
			$wpdb->delete( $wpdb->prefix . 'backreload_logs', [ 'backup_ext_id' => $ext_id ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
			delete_transient( 'backreload_settings_snap_' . $ext_id );
			// Tick lock — option-backed since the TOCTOU fix. Clear the new
			// key AND the legacy transient for backward compatibility with
			// any in-flight pre-fix record.
			delete_option( 'backreload_tick_lock_' . $ext_id );
			delete_transient( 'backreload_ticking_' . $ext_id );
			$purged++;
		}

		if ( $purged > 0 ) {
			$this->logger->info(
				"Phantom-failure cleanup: deleted {$purged} record(s) that were marked failed/cancelled with no parts uploaded anywhere (operationally dead; nothing to Resume or Restore from)",
				[],
				$current_restore_id
			);
		}
	}

	private function rrmdir( string $dir ): void {
		if ( ! is_dir( $dir ) ) return;
		$it = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::CHILD_FIRST
		);
		foreach ( $it as $f ) {
			$f->isDir() ? @rmdir( (string) $f ) : @unlink( (string) $f ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir,WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes the plugin's own staging directories under wp-content/backreload-backups.
		}
		@rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removes the plugin's own staging directories under wp-content/backreload-backups.
	}

	/**
	 * Wipe the contents of a directory for clean-restore, with safety rails:
	 *
	 *   1. The dir must exist and resolve via realpath() — defends against
	 *      restore archives that try to nudge us into deleting `/`.
	 *   2. The dir must be INSIDE WP_CONTENT_DIR (or the configured uploads
	 *      dir for offsite-uploads installs). Anything else gets refused.
	 *   3. The plugin's own folder (this file's directory) is skipped — we
	 *      can't yank our own code out mid-execution.
	 *   4. wp-content/backreload-backups is skipped — that's where the local backup
	 *      destination + our restore staging live; wiping it kills the
	 *      running restore.
	 *
	 * Each entry in the dir is checked against those exemptions before being
	 * removed. Counts get reported so the user sees what actually happened.
	 */
	private function clean_wipe_directory( string $dir, string $backup_id ): void {
		$real = realpath( $dir );
		if ( ! $real || ! is_dir( $real ) ) {
			$this->logger->info( "Clean-wipe target {$dir} doesn't exist; nothing to wipe", [], $backup_id );
			return;
		}
		// Safety: must live under wp-content (or the wp_uploads_dir if it's
		// been relocated outside wp-content). We accept any path that
		// starts with one of these roots.
		$content_real = realpath( WP_CONTENT_DIR );
		$uploads_info = wp_get_upload_dir();
		$uploads_real = ! empty( $uploads_info['basedir'] ) ? realpath( $uploads_info['basedir'] ) : false;
		$allowed_roots = array_filter( [ $content_real, $uploads_real ] );
		$ok = false;
		foreach ( $allowed_roots as $root ) {
			if ( 0 === strpos( $real . '/', rtrim( $root, '/' ) . '/' ) ) {
				$ok = true;
				break;
			}
		}
		if ( ! $ok ) {
			throw new \RuntimeException( esc_html(  "Refusing to clean-wipe a directory outside wp-content / uploads: {$real}"  )  );
		}

		// Exemptions: paths we must never wipe even in clean mode.
		$plugin_dir = realpath( dirname( __DIR__, 2 ) ); // includes/core/ → plugin root
		$backreload_local  = realpath( \Backreload\Storage::root() );
		$skip_paths = array_filter( [ $plugin_dir, $backreload_local ] );

		$entries = @scandir( $real );
		if ( ! is_array( $entries ) ) return;
		$removed = 0;
		$skipped = 0;
		foreach ( $entries as $name ) {
			if ( '.' === $name || '..' === $name ) continue;
			$child   = $real . DIRECTORY_SEPARATOR . $name;
			$resolved = realpath( $child ) ?: $child;
			// Skip exempt paths AND anything containing them (so a future
			// move of backreload-backups inside plugins/ would still be safe).
			$is_skip = false;
			foreach ( $skip_paths as $sp ) {
				if ( $resolved === $sp || 0 === strpos( $resolved . '/', $sp . '/' ) ) {
					$is_skip = true;
					break;
				}
			}
			if ( $is_skip ) {
				$skipped++;
				continue;
			}
			if ( is_dir( $resolved ) && ! is_link( $child ) ) {
				$this->rrmdir( $resolved );
			} else {
				@unlink( $child ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			}
			$removed++;
		}
		$this->logger->info(
			"Clean-restore wiped {$real}: {$removed} entries removed" . ( $skipped > 0 ? ", {$skipped} preserved (plugin / staging)" : '' ),
			[],
			$backup_id
		);
	}

	/**
	 * Repair Elementor dynamic tags whose settings can't be parsed.
	 *
	 * Elementor stores a dynamic tag inside `_elementor_data` as a shortcode:
	 *     [elementor-tag id="ab12" name="post-url" settings="<urlencoded json>"]
	 * and reads it back with `json_decode( urldecode( $settings ), true )`. When
	 * that yields null — the settings attribute is missing, empty, or not valid
	 * JSON — Elementor passes null into
	 * `Manager::get_tag_data_content( $id, $name, array $settings )`, whose
	 * strict `array` type hint throws a TypeError and takes the whole front end
	 * down with "There has been a critical error on this website".
	 *
	 * That crash fires whenever Elementor REBUILDS its CSS (page edit, plugin
	 * update, "Regenerate CSS", or — as here — an add-on like ElementsKit
	 * forcing a header/footer rebuild on every front-end load). A site can carry
	 * such a tag for months and only discover it after a move, because the move
	 * is what prompts the rebuild. Other migration tools don't hit it simply
	 * because they never trigger the rebuild — the broken tag stays armed.
	 *
	 * A tag whose settings won't parse is already non-functional (it renders
	 * nothing), so normalising it to an empty settings object `{}` (`%7B%7D`)
	 * loses no working configuration and lets Elementor render the tag's
	 * fallback instead of fataling. Runs only on migrations, and only touches
	 * tags that are already broken.
	 */
	private function repair_elementor_dynamic_tags( string $backup_id ): void {
		global $wpdb;
		$repaired_tags = 0;
		$repaired_rows = 0;
		$last_id       = 0;
		$batch         = 100; // keyset-paginated so a huge site can't OOM here.

		while ( true ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Keyset scan of the site's own postmeta; table name from $wpdb, all values bound via prepare(); uncacheable one-off repair.
			$rows = $wpdb->get_results( $wpdb->prepare(
				"SELECT meta_id, post_id, meta_value FROM {$wpdb->postmeta}
				 WHERE meta_key = '_elementor_data' AND meta_id > %d AND meta_value LIKE %s
				 ORDER BY meta_id ASC LIMIT %d",
				$last_id,
				'%' . $wpdb->esc_like( 'elementor-tag' ) . '%',
				$batch
			) );
			if ( ! $rows ) {
				break;
			}

			foreach ( $rows as $row ) {
			$last_id = (int) $row->meta_id;
			$data = json_decode( (string) $row->meta_value, true );
			if ( ! is_array( $data ) ) {
				continue; // not decodable — leave it strictly alone
			}
			$changed = false;
			// Walk every string in the structure; dynamic tags live in
			// settings.__dynamic__.<control> values.
			array_walk_recursive(
				$data,
				function ( &$value ) use ( &$changed, &$repaired_tags ) {
					if ( ! is_string( $value ) || false === strpos( $value, '[elementor-tag' ) ) {
						return;
					}
					$new = preg_replace_callback(
						'/\[elementor-tag([^\]]*)\]/',
						function ( $m ) use ( &$repaired_tags ) {
							$attrs = $m[1];
							if ( preg_match( '/settings="([^"]*)"/', $attrs, $sm ) ) {
								// Parses fine? Leave completely untouched.
								if ( is_array( json_decode( urldecode( $sm[1] ), true ) ) ) {
									return $m[0];
								}
								$repaired_tags++;
								return '[elementor-tag' . preg_replace( '/settings="[^"]*"/', 'settings="%7B%7D"', $attrs ) . ']';
							}
							// No settings attribute at all — Elementor would read null.
							$repaired_tags++;
							return '[elementor-tag' . rtrim( $attrs ) . ' settings="%7B%7D"]';
						},
						$value
					);
					if ( null !== $new && $new !== $value ) {
						$value   = $new;
						$changed = true;
					}
				}
			);
			if ( ! $changed ) {
				continue;
			}
			$encoded = wp_json_encode( $data );
			if ( false === $encoded ) {
				continue;
			}
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Writing back the site's own repaired postmeta row.
			$wpdb->update( $wpdb->postmeta, [ 'meta_value' => $encoded ], [ 'meta_id' => $row->meta_id ] );
			// A direct write bypasses WP's meta cache, so anything reading this
			// post later in the same request would still see the broken value.
			wp_cache_delete( (int) $row->post_id, 'post_meta' );
			$repaired_rows++;
			}
			if ( count( $rows ) < $batch ) {
				break;
			}
		}

		if ( $repaired_tags > 0 ) {
			$this->logger->info(
				"Repaired {$repaired_tags} broken Elementor dynamic tag(s) across {$repaired_rows} page(s): their settings could not be parsed, which makes Elementor fatal (\"critical error\") the moment it rebuilds CSS. They now carry empty settings so Elementor renders them instead of crashing.",
				[],
				$backup_id
			);
		}
	}

	/**
	 * Delete page-builder generated CSS so it rebuilds for the new domain.
	 *
	 * The compiled CSS Elementor ships in uploads/elementor/css bakes the ORIGIN
	 * site's URL into asset paths and encodes the source layout; kept as-is after
	 * a move it produces broken styling on the destination (e.g. an unstyled,
	 * oversized header). Deleting it forces Elementor to regenerate against the
	 * new URL on the next load.
	 *
	 * IMPORTANT ordering: this MUST run AFTER repair_elementor_dynamic_tags().
	 * Regenerating CSS is exactly what parses dynamic tags, so forcing a rebuild
	 * before the broken (null-settings) tags are repaired would re-trigger the
	 * Elementor "critical error". With the tags repaired first, the rebuild is
	 * safe and yields correct, new-domain CSS.
	 */
	private function flush_builder_caches( string $backup_id ): void {
		$up   = wp_get_upload_dir();
		$base = ! empty( $up['basedir'] ) ? rtrim( (string) $up['basedir'], '/\\' ) : '';
		if ( '' === $base ) {
			return;
		}
		$cleared = 0;
		$css_dir = $base . '/elementor/css';
		if ( is_dir( $css_dir ) ) {
			foreach ( (array) glob( $css_dir . '/*.css' ) as $f ) {
				if ( is_string( $f ) && @unlink( $f ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes the plugin's own regenerable cache files under uploads/.
					$cleared++;
				}
			}
		}
		// Option-backed compiled CSS that also carries the old URL.
		delete_option( '_elementor_global_css' );
		delete_option( 'elementor-custom-breakpoints-files' );
		if ( $cleared > 0 ) {
			$this->logger->info( "Cleared {$cleared} stale Elementor CSS cache file(s) so styling rebuilds for the new domain (safe now that dynamic tags are repaired).", [], $backup_id );
		}
	}

	/**
	 * Register a search→replace pair PLUS the encoded shapes of it that a plain
	 * match would miss. The same URL/path lives in the database in several forms:
	 *
	 *   plain                      https://x.com
	 *   JSON-escaped slashes       https:\/\/x.com              (raw JSON held as a string)
	 *   URL-encoded                https%3A%2F%2Fx.com          (query strings, encoded payloads)
	 *   URL-encoded + JSON-escaped https%3A%5C%2F%5C%2Fx.com    (Elementor dynamic-tag settings,
	 *                                                            which are urlencode(json_encode(...)))
	 *
	 * The recursive walker decodes real JSON/serialized values and the plain form
	 * catches those. But a value that is NOT decodable as a whole — an Elementor
	 * `[elementor-tag ... settings="%7B...%7D"]` shortcode, a querystring, an
	 * encoded blob — keeps its encoded bytes, so a plain match silently skips it
	 * and that link still points at the OLD site after the move. Registering the
	 * encoded shapes fixes those without touching anything else (each shape maps
	 * to the equivalently-encoded target, so the value stays well-formed).
	 *
	 * @param array<string,string> $replacements Modified in place.
	 */
	private function add_encoded_variants( array &$replacements, string $from, string $to ): void {
		$shapes = static function ( string $v ): array {
			$json_escaped = str_replace( '/', '\/', $v );
			return [
				$v,
				$json_escaped,
				urlencode( $v ),
				urlencode( $json_escaped ),
			];
		};
		$f = $shapes( $from );
		$t = $shapes( $to );
		foreach ( $f as $i => $needle ) {
			if ( '' === $needle || $needle === $t[ $i ] || isset( $replacements[ $needle ] ) ) {
				continue;
			}
			$replacements[ $needle ] = $t[ $i ];
		}
	}

	/**
	 * Run cross-site migration (URL + path rewrite) after SQL import.
	 *
	 * Receives override values from $args; falls back to backup record + current site.
	 */
	private function run_migration( $rec, array $args, string $backup_id ): void {
		$source_url  = (string) ( $args['source_url']  ?? $rec->source_url  ?? '' );
		$target_url  = (string) ( $args['target_url']  ?? untrailingslashit( home_url() ) );
		$source_path = (string) ( $args['source_path'] ?? $rec->source_path ?? '' );
		$target_path = (string) ( $args['target_path'] ?? untrailingslashit( ABSPATH ) );

		// Safety: never rewrite toward an EMPTY target — that would register
		// `source => ''` and blank every source URL/path in the database. In
		// practice run() captures a non-empty target before the SQL import, but
		// guard explicitly so a future caller can't corrupt the DB.
		$replacements = [];
		if ( $source_url && '' !== $target_url && $source_url !== $target_url ) {
			$this->add_encoded_variants( $replacements, $source_url, $target_url );
			// Also handle the inverse-scheme variant (http <-> https) — common gotcha.
			$flipped = ( 0 === strpos( $source_url, 'https://' ) )
				? 'http://'  . substr( $source_url, 8 )
				: ( 0 === strpos( $source_url, 'http://' )
					? 'https://' . substr( $source_url, 7 )
					: null );
			if ( $flipped ) {
				$this->add_encoded_variants( $replacements, $flipped, $target_url );
			}
		}
		if ( $source_path && '' !== $target_path && $source_path !== $target_path ) {
			$this->add_encoded_variants( $replacements, $source_path, $target_path );
		}

		if ( empty( $replacements ) ) {
			$this->logger->info( 'Migration requested but source/target match; nothing to rewrite.', [], $backup_id );
			return;
		}

		$this->logger->info( 'Running URL/path migration', $replacements, $backup_id );
		$engine = new MigrationEngine( $this->logger );
		$stats  = $engine->rewrite( $replacements );
		$this->logger->success(
			"Migration done: {$stats['cells_changed']} cells in {$stats['rows_changed']} rows across {$stats['tables']} tables",
			$stats,
			$backup_id
		);

		// Authoritatively pin siteurl/home to the DESTINATION's own domain.
		//
		// The search-replace above rewrites source→target everywhere it finds an
		// exact match, which normally includes the `siteurl`/`home` option rows.
		// But if the source stored those with a slightly different form than
		// `source_url` (a www/non-www variant, a trailing slash, or the opposite
		// scheme in a way the flip didn't cover), the rows are left holding the
		// SOURCE domain — and the migrated site then redirects to the old site
		// or fails to load. The destination captured its real siteurl/home at
		// finalize time (before the restore overwrote them); write them back
		// verbatim so the site always comes up on its OWN existing domain.
		$dest_home    = untrailingslashit( (string) ( $args['target_home']    ?? $target_url ) );
		$dest_siteurl = untrailingslashit( (string) ( $args['target_siteurl'] ?? $target_url ) );
		if ( '' !== $dest_home && untrailingslashit( (string) get_option( 'home' ) ) !== $dest_home ) {
			update_option( 'home', $dest_home );
		}
		if ( '' !== $dest_siteurl && untrailingslashit( (string) get_option( 'siteurl' ) ) !== $dest_siteurl ) {
			update_option( 'siteurl', $dest_siteurl );
		}
		$this->logger->info( "Pinned site address to the destination's own domain (home={$dest_home}, siteurl={$dest_siteurl}).", [], $backup_id );

		// Neutralise Elementor dynamic tags that would fatal the front end the
		// first time Elementor rebuilds CSS on the destination. See the method
		// docblock — these tags are already broken on the source; the move is
		// simply what makes Elementor parse them.
		try {
			$this->repair_elementor_dynamic_tags( $backup_id );
			// ORDER MATTERS: only flush the compiled CSS AFTER the tags are
			// repaired, so the rebuild Elementor does next can't hit the
			// null-settings fatal. This clears the stale, old-domain CSS that
			// otherwise leaves the header/styling broken after a move.
			$this->flush_builder_caches( $backup_id );
		} catch ( \Throwable $e ) {
			$this->logger->warn( 'Elementor dynamic-tag repair / CSS flush skipped: ' . $e->getMessage(), [], $backup_id );
		}

		// wp-config.php can hard-code the site address via WP_HOME / WP_SITEURL,
		// which OVERRIDE the option rows we just pinned. If a constant is set to
		// the old domain the migrated site will still load the source URL. We
		// must not edit wp-config ourselves (a WordPress.org guideline, and it's
		// risky), so surface a precise, actionable warning instead.
		foreach ( [ 'WP_HOME' => $dest_home, 'WP_SITEURL' => $dest_siteurl ] as $const => $expected ) {
			if ( '' !== $expected && defined( $const ) ) {
				$val = untrailingslashit( (string) constant( $const ) );
				if ( '' !== $val && 0 !== strcasecmp( $val, $expected ) ) {
					$this->logger->warn(
						"wp-config.php defines {$const} as \"{$val}\", which overrides the database and points at the old site. Edit wp-config.php on this destination: set {$const} to \"{$expected}\" (or remove the line) so the migrated site loads correctly.",
						[],
						$backup_id
					);
				}
			}
		}

		// (Elementor CSS is flushed just above, AFTER the dynamic-tag repair, so
		// the forced rebuild can't hit the null-settings fatal — see
		// flush_builder_caches() and its ordering note.)

		// Bust object cache so the site immediately reflects the new URLs.
		if ( function_exists( 'wp_cache_flush' ) ) {
			wp_cache_flush();
		}
	}

	/**
	 * Find a destination that can supply the archive.
	 *
	 * Order of preference:
	 *   1. Explicit destination requested by the caller (if still connected)
	 *   2. Any of the backup's original destinations that is still connected
	 *   3. Any current Local destination that has the file on disk
	 *      (covers "user disconnected → reconnected with a different ID" cases)
	 */
	private function resolve_source( $rec, ?string $requested_id ): ?DestinationRecord {
		// Filenames to probe for on local destinations.
		//   - Multi-zip: every entry in $rec->files
		//   - Legacy:    {ext_id}.zip or .zip.enc
		$probe_names = ! empty( $rec->files )
			? array_values( array_filter( array_column( $rec->files, 'name' ) ) )
			: [ $rec->ext_id . ( $rec->encrypted ? '.zip.enc' : '.zip' ) ];

		// Filter helper: a destination is usable as a SOURCE for restore only
		// if it's connected AND not paused AND, ideally, the part we want is
		// actually on it. A paused destination may still hold older copies but
		// the user has explicitly told us "skip this one" — honoring that
		// keeps the restore path consistent with what the user sees in the UI.
		$usable = static function ( ?DestinationRecord $d ): bool {
			return $d && 'connected' === $d->status && empty( $d->paused );
		};

		// 1. Explicit request
		if ( $requested_id ) {
			$d = DestinationRecord::find( $requested_id );
			if ( $usable( $d ) ) {
				return $d;
			}
		}

		// 2. Original destinations — pick the first that's usable AND
		//    actually has a copy of one of this backup's parts. A destination
		//    that was paused during the backup will be in this list (the
		//    backup record's destinations[] captures *intended* targets) but
		//    won't actually hold the files — falling through to try the next
		//    destination avoids the "Failed to download" dead-end the user
		//    just hit on a Local-paused restore.
		$first_usable = null;
		foreach ( (array) $rec->destinations as $id ) {
			$d = DestinationRecord::find( $id );
			if ( ! $usable( $d ) ) {
				continue;
			}
			if ( null === $first_usable ) {
				$first_usable = $d;
			}
			// For LOCAL destinations we can cheaply verify the file is present
			// before committing — that avoids picking a destination that's
			// "connected but empty" (e.g. paused during the backup) when a
			// later one actually has the data.
			if ( 'local' === $d->type ) {
				$base = trailingslashit( (string) ( $d->config['path'] ?? '' ) );
				$has  = false;
				foreach ( $probe_names as $name ) {
					if ( is_file( $base . $name ) ) { $has = true; break; }
				}
				if ( ! $has ) {
					continue; // Local but doesn't actually hold the backup — try next.
				}
			}
			return $d;
		}
		if ( null !== $first_usable ) {
			return $first_usable; // non-Local types — trust them
		}

		// 3. Local fallback — scan ALL current Local destinations (including
		//    ones not in $rec->destinations, e.g. a Local reconnected with a
		//    new id after a wp-config rotate) for the file. Still respects
		//    `paused` — a paused Local destination wasn't written to during
		//    the backup, so wouldn't have the file anyway.
		foreach ( DestinationRecord::all() as $d ) {
			if ( 'local' !== $d->type ) continue;
			if ( 'connected' !== $d->status || ! empty( $d->paused ) ) continue;
			$base = trailingslashit( $d->config['path'] ?? '' );
			foreach ( $probe_names as $name ) {
				if ( is_file( $base . $name ) ) {
					$this->logger->info( "Using fallback Local destination {$d->name}", [], $rec->ext_id );
					return $d;
				}
			}
		}

		return null;
	}

}
