<?php
/**
 * Restore state, execution, database import, migration, and safety helpers for BackupService.
 *
 * @package BackupRestore
 */

namespace BackupRestore\Includes;

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

trait BackupRestoreTrait {
	private function restore_job_transient_key( string $backup_id ): string {
		return self::RESTORE_JOB_PREFIX . $backup_id;
	}

	/**
	 * Persist restore job state in private storage (not inside web-served backup archives).
	 */
	private function restore_state_file_path( string $backup_id ): string {
		$backup_id = sanitize_file_name( $backup_id );

		return trailingslashit( $this->get_private_storage_root() ) . 'restore-' . $backup_id . '.json';
	}

	/**
	 * Legacy restore state path (pre-1.1.1); read-only for in-progress job migration.
	 */
	private function legacy_restore_state_file_path( string $backup_id ): string {
		$backup_id = sanitize_file_name( $backup_id );

		return trailingslashit( $this->get_backup_root() ) . $backup_id . '/.wpps-restore-job.json';
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function write_restore_state_file( string $backup_id, array $state ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return;
		}
		if ( ! $this->ensure_private_storage_root() ) {
			return;
		}
		$path = $this->restore_state_file_path( $backup_id );
		$dir  = dirname( $path );
		if ( ! wp_mkdir_p( $dir ) ) {
			return;
		}
		$json = wp_json_encode( $state, JSON_UNESCAPED_UNICODE );
		if ( ! is_string( $json ) || $json === '' ) {
			return;
		}
		$tmp = $path . '.tmp.' . wp_generate_password( 8, false, false );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if ( false === file_put_contents( $tmp, $json, LOCK_EX ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $tmp );

			return;
		}
		if ( ! is_file( $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename
			if ( @rename( $tmp, $path ) ) {
				return;
			}
		}

		/*
		 * Windows (and some hosts) reject rename() when $path already exists.
		 * Keep old file as .bak until the replacement is confirmed on disk so
		 * a crash between delete-and-rename can never lose both copies.
		 */
		$bak = $path . '.bak';
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		@unlink( $bak );
		if ( is_file( $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename
			@rename( $path, $bak );
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename
		if ( @rename( $tmp, $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $bak );
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if ( false !== file_put_contents( $path, $json, LOCK_EX ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $tmp );
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $bak );
			return;
		}
		if ( is_file( $bak ) && ! is_file( $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename
			@rename( $bak, $path );
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		@unlink( $tmp );
	}

	/**
	 * @return array<string, mixed>|null
	 */
	private function read_restore_state_file( string $backup_id ): ?array {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return null;
		}

		$data = $this->read_restore_state_file_at_path( $this->restore_state_file_path( $backup_id ), $backup_id );
		if ( is_array( $data ) ) {
			return $data;
		}

		$legacy = $this->legacy_restore_state_file_path( $backup_id );
		$data   = $this->read_restore_state_file_at_path( $legacy, $backup_id );
		if ( is_array( $data ) ) {
			$this->write_restore_state_file( $backup_id, $data );
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $legacy );
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $legacy . '.bak' );
		}

		return $data;
	}

	/**
	 * @return array<string, mixed>|null
	 */
	private function read_restore_state_file_at_path( string $path, string $backup_id ): ?array {
		if ( ! is_readable( $path ) ) {
			$bak = $path . '.bak';
			if ( is_readable( $bak ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename
				@rename( $bak, $path );
			}
			if ( ! is_readable( $path ) ) {
				return null;
			}
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = file_get_contents( $path );
		if ( ! is_string( $raw ) || $raw === '' ) {
			return null;
		}
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return null;
		}
		if ( ! isset( $data['restore_backup_id'], $data['plan'], $data['step_index'] ) ) {
			return null;
		}
		if ( (string) $data['restore_backup_id'] !== $backup_id ) {
			return null;
		}

		return $data;
	}

	/**
	 * @deprecated 1.1.1 Post-body restore_job_id auth removed; use verify_restore_continuation_cookie().
	 */
	public function restore_continuation_valid_for_backup( string $backup_id, string $posted_secret ): bool {
		unset( $posted_secret );

		return $this->verify_restore_continuation_cookie( $backup_id );
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function save_restore_state( string $backup_id, array $state ): void {
		// Write disk first so continuation always has a correct step_index even if object-cache misses transients.
		$this->write_restore_state_file( $backup_id, $state );
		set_transient( $this->restore_job_transient_key( $backup_id ), $state, self::JOB_TTL );
	}

	/**
	 * @return array<string, mixed>|null
	 */
	public function get_restore_state( string $backup_id ): ?array {
		$from_disk = $this->read_restore_state_file( $backup_id );
		$key       = $this->restore_job_transient_key( $backup_id );
		$s         = get_transient( $key );

		if ( is_array( $from_disk ) && is_array( $s ) ) {
			$disk_i = (int) ( $from_disk['step_index'] ?? -1 );
			$mem_i  = (int) ( $s['step_index'] ?? -1 );
			$pick   = $disk_i >= $mem_i ? $from_disk : $s;
			/*
			 * On some hosts (nginx + persistent object cache) the transient can disagree with disk
			 * or lag a write. Theme and plugin snapshots must survive every tick/teardown path or
			 * the site can be left on the temporary fallback theme / WP PowerSuite-only plugin list.
			 */
			foreach ( $this->restore_snapshot_state_keys() as $snap_key ) {
				if ( $this->restore_state_has_snapshot_value( $pick, $snap_key ) ) {
					continue;
				}
				if ( $this->restore_state_has_snapshot_value( $from_disk, $snap_key ) ) {
					$pick[ $snap_key ] = $from_disk[ $snap_key ];
				} elseif ( $this->restore_state_has_snapshot_value( $s, $snap_key ) ) {
					$pick[ $snap_key ] = $s[ $snap_key ];
				}
			}
			set_transient( $key, $pick, self::JOB_TTL );

			return $pick;
		}
		if ( is_array( $from_disk ) ) {
			set_transient( $key, $from_disk, self::JOB_TTL );

			return $from_disk;
		}
		if ( is_array( $s ) ) {
			return $s;
		}

		return null;
	}

	public function delete_restore_state( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		delete_transient( $this->restore_job_transient_key( $backup_id ) );
		$row = get_transient( self::TRANSIENT_RESTORE_AJAX_JOB );
		if ( is_array( $row ) && isset( $row['backup_id'] ) && (string) $row['backup_id'] === $backup_id ) {
			delete_transient( self::TRANSIENT_RESTORE_AJAX_JOB );
		}
		if ( '' !== $backup_id ) {
			$path = $this->restore_state_file_path( $backup_id );
			if ( is_file( $path ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $path );
			}
			$legacy = $this->legacy_restore_state_file_path( $backup_id );
			if ( is_file( $legacy ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $legacy );
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $legacy . '.bak' );
		}
		self::clear_restore_continuation_cookie();
	}

	private function acquire_restore_lock( string $backup_id ): bool {
		global $wpdb;

		self::clear_legacy_lock_transients();

		// Flush object cache for the lock key so we don't read stale values
		// from LiteSpeed / Redis / Memcached that survived a database reset.
		wp_cache_delete( self::TRANSIENT_RESTORE_LOCK, 'transient' );
		wp_cache_delete( self::TRANSIENT_RESTORE_TOUCH, 'transient' );

		$held = get_transient( self::TRANSIENT_RESTORE_LOCK );
		if ( $held && (string) $held === $backup_id ) {
			$this->touch_restore_lock();
			return true;
		}
		if ( $held ) {
			// Check whether the lock is stale: touch must exist and be recent.
			if ( $this->is_restore_lock_stale() ) {
				$this->release_restore_lock();
			} else {
				return false;
			}
		}

		$lock_key  = '_transient_' . self::TRANSIENT_RESTORE_LOCK;
		$touch_key = '_transient_timeout_' . self::TRANSIENT_RESTORE_LOCK;
		$now       = time();
		$expire    = $now + 30 * MINUTE_IN_SECONDS;

		// Atomic: INSERT … ON DUPLICATE KEY prevents two concurrent first ticks from both succeeding.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->query(
			$wpdb->prepare(
				"INSERT INTO `{$wpdb->options}` (option_name, option_value, autoload) VALUES (%s, %s, 'no'), (%s, %s, 'no') ON DUPLICATE KEY UPDATE option_value = IF(option_name = %s AND (option_value = '' OR option_value = '0' OR CAST(option_value AS UNSIGNED) < %d), VALUES(option_value), option_value)",
				$lock_key,
				$backup_id,
				$touch_key,
				(string) $expire,
				$lock_key,
				$now
			)
		);

		// The raw INSERT bypasses WordPress's option cache. The earlier get_transient()
		// call may have added the option names to the "notoptions" cache (list of options
		// that don't exist). Without clearing that, get_option() will keep returning false
		// even though the rows now exist in the database — this is the root cause of
		// "Another restore is already running" on every attempt.
		wp_cache_delete( $lock_key, 'options' );
		wp_cache_delete( $touch_key, 'options' );
		$notoptions = wp_cache_get( 'notoptions', 'options' );
		if ( is_array( $notoptions ) ) {
			unset( $notoptions[ $lock_key ], $notoptions[ $touch_key ] );
			wp_cache_set( 'notoptions', $notoptions, 'options' );
		}
		wp_cache_delete( self::TRANSIENT_RESTORE_LOCK, 'transient' );
		wp_cache_delete( self::TRANSIENT_RESTORE_TOUCH, 'transient' );

		// Verify we actually hold the lock (another request may have won the race).
		$verify = get_transient( self::TRANSIENT_RESTORE_LOCK );
		if ( $verify && (string) $verify === $backup_id ) {
			set_transient( self::TRANSIENT_RESTORE_TOUCH, $now, 30 * MINUTE_IN_SECONDS );
			return true;
		}

		// Fallback: raw INSERT may have failed (non-MySQL, or object-cache-only transient store).
		// Use set_transient() which always works through the WordPress API.
		if ( false === $rows || 0 === $rows || ! $verify ) {
			$held2 = get_transient( self::TRANSIENT_RESTORE_LOCK );
			if ( $held2 && (string) $held2 !== $backup_id ) {
				return false;
			}
			set_transient( self::TRANSIENT_RESTORE_LOCK, $backup_id, 30 * MINUTE_IN_SECONDS );
			set_transient( self::TRANSIENT_RESTORE_TOUCH, $now, 30 * MINUTE_IN_SECONDS );
			$held3 = get_transient( self::TRANSIENT_RESTORE_LOCK );
			return $held3 && (string) $held3 === $backup_id;
		}

		return false;
	}

	/**
	 * A restore lock is stale when the touch timestamp is missing, expired, or no restore state exists for the holder.
	 */
	private function is_restore_lock_stale(): bool {
		$held = get_transient( self::TRANSIENT_RESTORE_LOCK );
		if ( ! $held || ! is_string( $held ) || '' === $held ) {
			return false;
		}
		$touch = get_transient( self::TRANSIENT_RESTORE_TOUCH );
		$now   = time();
		if ( $touch ) {
			$touch_ts = (int) $touch;
			// Lock was touched recently (within 10 minutes) — not stale.
			if ( $touch_ts > 0 && ( $now - $touch_ts ) < 10 * MINUTE_IN_SECONDS ) {
				// Also verify restore state actually exists.
				if ( self::is_valid_backup_folder_name( $held ) ) {
					$state = $this->get_restore_state( $held );
					if ( is_array( $state ) ) {
						return false;
					}
				}
				// Touch exists and is recent but no state → stale.
				return true;
			}
		}
		// No touch or touch is old → stale.
		return true;
	}

	private function touch_restore_lock(): void {
		if ( get_transient( self::TRANSIENT_RESTORE_LOCK ) ) {
			set_transient( self::TRANSIENT_RESTORE_TOUCH, time(), 30 * MINUTE_IN_SECONDS );
		}
	}

	/**
	 * Re-write restore lock + touch into the active database (post-import wp_options, or self-heal).
	 *
	 * Full DB import replaces wp_options; without this the next tick sees no lock. Some object caches also
	 * return a cache miss for the lock row even when job state exists — restore_job_run_step() self-heals by calling this.
	 */
	private function rewrite_restore_lock_after_database_import( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return;
		}
		set_transient( self::TRANSIENT_RESTORE_LOCK, $backup_id, 30 * MINUTE_IN_SECONDS );
		set_transient( self::TRANSIENT_RESTORE_TOUCH, time(), 30 * MINUTE_IN_SECONDS );
	}

	private function release_restore_lock(): void {
		delete_transient( self::TRANSIENT_RESTORE_LOCK );
		delete_transient( self::TRANSIENT_RESTORE_TOUCH );
		delete_transient( self::TRANSIENT_RESTORE_AJAX_JOB );
		self::clear_legacy_lock_transients();
		self::clear_restore_continuation_cookie();
	}

	/**
	 * Refresh continuation credentials used when admin cookies no longer match the restored database.
	 *
	 * @param array<string, mixed> $state
	 */
	private function ensure_restore_ajax_continuation( string $backup_id, array &$state ): void {
		$generated = false;
		if ( empty( $state['ajax_continuation_secret'] ) || ! is_string( $state['ajax_continuation_secret'] ) || strlen( $state['ajax_continuation_secret'] ) < 32 ) {
			$state['ajax_continuation_secret']    = bin2hex( random_bytes( 24 ) );
			$state['ajax_continuation_client_ip'] = self::request_client_ip();
			$generated                            = true;
		}
		set_transient(
			self::TRANSIENT_RESTORE_AJAX_JOB,
			[
				'secret'    => (string) $state['ajax_continuation_secret'],
				'backup_id' => sanitize_file_name( $backup_id ),
				'client_ip' => self::request_client_ip(),
			],
			self::RESTORE_AJAX_CONTINUATION_TTL
		);
		self::issue_restore_continuation_cookie( $backup_id, (string) $state['ajax_continuation_secret'] );
		if ( $generated ) {
			$this->save_restore_state( $backup_id, $state );
		}
	}

	public function abort_restore( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return;
		}
		if ( (string) get_transient( self::TRANSIENT_RESTORE_LOCK ) !== $backup_id ) {
			return;
		}
		$state = $this->get_restore_state( $backup_id );
		$state = $this->restore_reapply_snapshots_before_teardown( $backup_id, $state );
		$this->delete_restore_state( $backup_id );
		$this->release_restore_lock();
		$this->cleanup_restore_workdir( $backup_id );
		$this->restore_remove_boot_guard_mu_plugin();
		if ( is_array( $state ) ) {
			$this->disable_restore_maintenance_mode( $state );
		}
	}

	/**
	 * Staging folder inside the backup set directory (not wp-content/upgrade).
	 *
	 * WordPress and many plugins clear wp-content/upgrade during updates; long restores span many
	 * AJAX requests, so staging there could be deleted between ticks (empty “publish” / missing staging).
	 */
	public function get_restore_workdir_abs( string $backup_id ): string {
		$backup_id = sanitize_file_name( $backup_id );
		$dir       = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' ) {
			return '';
		}

		return wp_normalize_path( $dir . '.restore-staging' );
	}

	/**
	 * Absolute roots that may contain backup files (private + legacy + staging).
	 *
	 * @return list<string>
	 */
	public function get_managed_storage_roots(): array {
		$roots = [];
		$primary = $this->get_backup_root();
		if ( $primary !== '' ) {
			$roots[] = $primary;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			$roots[] = $legacy;
		}
		$staging = $this->get_download_staging_dir();
		if ( $staging !== '' ) {
			$roots[] = $staging;
		}
		$private = $this->get_private_storage_root();
		if ( $private !== '' ) {
			$roots[] = $private;
		}

		return array_values( array_unique( array_filter( $roots ) ) );
	}

	/**
	 * Whether an absolute path is under a managed backup/staging directory.
	 */
	public function is_path_under_managed_storage( string $abs_path ): bool {
		$file_r = realpath( $abs_path );
		if ( false === $file_r || ( ! is_file( $file_r ) && ! is_dir( $file_r ) ) ) {
			return false;
		}
		$fn = wp_normalize_path( $file_r );

		foreach ( $this->get_managed_storage_roots() as $root ) {
			$root_r = realpath( rtrim( $root, '/\\' ) );
			if ( false === $root_r ) {
				continue;
			}
			$rn = wp_normalize_path( $root_r );
			if ( $fn === $rn || str_starts_with( $fn, trailingslashit( $rn ) ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Environment prerequisites for the restore verify step (PHP, extensions, limits, disk).
	 *
	 * @param bool $needs_zip      Whether ZipArchive is required for the selected components.
	 * @param bool $needs_database Whether the database component is selected.
	 * @return list<array{id:string,label:string,ok:bool,severity:string,detail:string}>
	 */
	private function restore_environment_checks( bool $needs_zip, bool $needs_database ): array {
		$checks = [];
		$heavy  = $needs_zip || $needs_database;

		// Required-for-selection: always list when that part of the restore needs it.
		if ( $needs_database ) {
			$db_ext_ok = extension_loaded( 'mysqli' ) || extension_loaded( 'mysqlnd' );
			$checks[]  = [
				'id'       => 'mysqli',
				'label'    => __( 'Database driver (mysqli / mysqlnd)', 'wppowersuite-lite' ),
				'ok'       => $db_ext_ok,
				'severity' => $db_ext_ok ? 'ok' : 'error',
				'detail'   => $db_ext_ok
					? __( 'Required for database restore — extension is available.', 'wppowersuite-lite' )
					: __( 'Required for database restore — enable mysqli or mysqlnd.', 'wppowersuite-lite' ),
			];
		}

		if ( $needs_zip ) {
			$zip_ok = class_exists( 'ZipArchive' );
			$checks[] = [
				'id'       => 'zip',
				'label'    => __( 'PHP ZIP (ZipArchive)', 'wppowersuite-lite' ),
				'ok'       => $zip_ok,
				'severity' => $zip_ok ? 'ok' : 'error',
				'detail'   => $zip_ok
					? __( 'Required for file archive restore — extension is loaded.', 'wppowersuite-lite' )
					: __( 'Required for file archive restore — enable ext-zip.', 'wppowersuite-lite' ),
			];
		}

		// Everything else: only when it fails or needs attention for this workload.
		$min_php = defined( 'WP_POWERSUITE_MIN_PHP_VERSION' ) ? WP_POWERSUITE_MIN_PHP_VERSION : '8.1';
		if ( ! version_compare( PHP_VERSION, $min_php, '>=' ) ) {
			$checks[] = [
				'id'       => 'php_version',
				'label'    => __( 'PHP version', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => 'error',
				'detail'   => sprintf(
					/* translators: 1: current PHP version, 2: minimum expected */
					__( 'Running PHP %1$s — need %2$s or newer for PowerSuite.', 'wppowersuite-lite' ),
					PHP_VERSION,
					$min_php
				),
			];
		}

		global $wp_version;
		$min_wp = defined( 'WP_POWERSUITE_MIN_WP_VERSION' ) ? WP_POWERSUITE_MIN_WP_VERSION : '6.8';
		if ( ! version_compare( (string) $wp_version, $min_wp, '>=' ) ) {
			$checks[] = [
				'id'       => 'wp_version',
				'label'    => __( 'WordPress version', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => 'warning',
				'detail'   => sprintf(
					/* translators: 1: current WordPress version, 2: minimum recommended */
					__( 'Running WordPress %1$s — recommend %2$s+ for this plugin.', 'wppowersuite-lite' ),
					(string) $wp_version,
					$min_wp
				),
			];
		}

		if ( ! extension_loaded( 'json' ) ) {
			$checks[] = [
				'id'       => 'json',
				'label'    => __( 'PHP json extension', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => 'error',
				'detail'   => __( 'Required for admin AJAX — enable ext-json.', 'wppowersuite-lite' ),
			];
		}

		if ( ! extension_loaded( 'mbstring' ) ) {
			$checks[] = [
				'id'       => 'mbstring',
				'label'    => __( 'PHP mbstring', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => 'warning',
				'detail'   => __( 'Not loaded — WordPress expects mbstring for reliable string handling during restore.', 'wppowersuite-lite' ),
			];
		}

		$mem_raw = (string) ( ini_get( 'memory_limit' ) ?: '' );
		$mem_b   = 0;
		if ( function_exists( 'wp_convert_hr_to_bytes' ) && $mem_raw !== '' ) {
			$mem_b = (int) wp_convert_hr_to_bytes( $mem_raw );
		}
		$mem_crit = $mem_b > 0 && $mem_b < 40 * MB_IN_BYTES;
		$mem_warn = ! $mem_crit && $heavy && $mem_b > 0 && $mem_b < 128 * MB_IN_BYTES;
		if ( $mem_crit || $mem_warn || ( $heavy && $mem_raw === '' ) ) {
			if ( $mem_crit ) {
				$mem_detail = sprintf(
					/* translators: %s: php.ini value */
					__( '%s — too low for this restore; raise to at least 128M if possible.', 'wppowersuite-lite' ),
					$mem_raw
				);
			} elseif ( $mem_warn ) {
				$mem_detail = sprintf(
					/* translators: %s: php.ini value */
					__( '%s — consider 256M+ for large file or database restores.', 'wppowersuite-lite' ),
					$mem_raw
				);
			} else {
				$mem_detail = __( 'Could not read memory_limit — confirm php.ini for this restore.', 'wppowersuite-lite' );
			}
			$checks[] = [
				'id'       => 'memory_limit',
				'label'    => __( 'PHP memory_limit', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => $mem_crit ? 'error' : 'warning',
				'detail'   => $mem_detail,
			];
		}

		$met = (int) ini_get( 'max_execution_time' );
		if ( $met > 0 && $met < 60 ) {
			$checks[] = [
				'id'       => 'max_execution_time',
				'label'    => __( 'PHP max_execution_time', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => 'warning',
				'detail'   => sprintf(
					/* translators: %d: seconds */
					__( '%d s per request — very low; slow hosts may hit timeouts even with AJAX steps.', 'wppowersuite-lite' ),
					$met
				),
			];
		}

		$root = $this->get_backup_root();
		$free = function_exists( 'disk_free_space' ) ? @disk_free_space( $root ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( false !== $free && $free >= 0 && $free < 50 * MB_IN_BYTES ) {
			$checks[] = [
				'id'       => 'disk_free',
				'label'    => __( 'Free disk space (backup folder volume)', 'wppowersuite-lite' ),
				'ok'       => false,
				'severity' => 'warning',
				'detail'   => sprintf(
					/* translators: %s: formatted size */
					__( 'Only about %s free — restores need extra space while unpacking.', 'wppowersuite-lite' ),
					size_format( (int) $free, 1 )
				),
			];
		}

		if ( $needs_database ) {
			$disabled = array_filter( array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ) );
			$exec_ok  = function_exists( 'exec' ) && ! in_array( 'exec', $disabled, true );
			if ( ! $exec_ok ) {
				$checks[] = [
					'id'       => 'exec',
					'label'    => __( 'PHP exec()', 'wppowersuite-lite' ),
					'ok'       => false,
					'severity' => 'warning',
					'detail'   => __(
						'Disabled for database restore — very large SQL may import slower via PHP only (mysql CLI unavailable).',
						'wppowersuite-lite'
					),
				];
			}
		}

		return $checks;
	}

	/**
	 * Detect the table prefix used in a backup (manifest first, then SQL header/content).
	 */
	public function detect_backup_table_prefix( string $backup_id ): string {
		$man = $this->get_manifest( $backup_id );
		if ( is_array( $man ) && ! empty( $man['table_prefix'] ) && is_string( $man['table_prefix'] ) ) {
			return $man['table_prefix'];
		}
		$f = $this->get_component_download( $backup_id, 'database' );
		if ( is_array( $f ) && ! empty( $f['path'] ) && is_readable( $f['path'] ) ) {
			$detected = $this->detect_table_prefix_from_sql( $f['path'] );
			if ( is_string( $detected ) && $detected !== '' ) {
				return $detected;
			}
		}
		return '';
	}

	/**
	 * Pre-flight checks before restore (filesystem staging, archives, disk — similar goals to UpdraftPlus::pre_restore_backup).
	 *
	 * @param list<string> $components Selected keys: database, plugins, themes, uploads, others.
	 * @return array<string, mixed> ok, blocking (list), warnings (list), bytes_estimate, free_bytes (?int)
	 */
	public function restore_precheck( string $backup_id, array $components ): array {
		global $wpdb;
		$backup_id = sanitize_file_name( $backup_id );
		$blocking  = [];
		$warnings  = [];

		if ( '' === $backup_id || ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			$blocking[] = __( 'Invalid backup set.', 'wppowersuite-lite' );
			return [
				'ok'             => false,
				'blocking'       => $blocking,
				'warnings'       => [],
				'items'          => [],
				'server_checks'  => [],
				'bytes_estimate' => 0,
				'free_bytes'     => null,
			];
		}

		$staging = $this->get_restore_workdir_abs( $backup_id );
		if ( ! wp_mkdir_p( $staging ) ) {
			$blocking[] = sprintf(
				/* translators: %s: directory path */
				__( 'Could not create the restore staging directory (%s). Ensure the backup folder is writable.', 'wppowersuite-lite' ),
				$staging
			);
		} else {
			$probe = trailingslashit( $staging ) . '.wpps_br_write_test_' . wp_generate_password( 6, false, false ) . '.tmp';
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $probe, 'ok' ) ) {
				$blocking[] = sprintf(
					/* translators: %s: path */
					__( 'The restore staging directory is not writable (%s). Adjust permissions so PHP can create files there.', 'wppowersuite-lite' ),
					$staging
				);
			} else {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $probe );
			}
		}

		$disk       = $this->list_components_on_disk( $backup_id );
		$allowed_c  = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		$user_sel   = array_values( array_filter( array_map( 'sanitize_key', $components ) ) );
		$pick       = array_values( array_intersect( $allowed_c, $disk, $user_sel ) );

		if ( count( $pick ) === 0 ) {
			$blocking[] = __( 'Nothing to restore: selected parts are missing from this backup folder, or the selection was empty.', 'wppowersuite-lite' );
		}

		$needs_zip = count( array_diff( $pick, [ 'database' ] ) ) > 0;
		if ( $needs_zip && ! class_exists( 'ZipArchive' ) ) {
			$blocking[] = __( 'The PHP ZipArchive extension is required to restore file archives.', 'wppowersuite-lite' );
		}

		$needs_db      = in_array( 'database', $pick, true );
		$server_checks = $this->restore_environment_checks( $needs_zip, $needs_db );
		$env_block_ids  = [ 'php_version', 'mysqli', 'json', 'memory_limit' ];
		foreach ( $server_checks as $sc ) {
			if ( ! empty( $sc['ok'] ) || ( $sc['severity'] ?? '' ) !== 'error' ) {
				continue;
			}
			$eid = (string) ( $sc['id'] ?? '' );
			if ( ! in_array( $eid, $env_block_ids, true ) ) {
				continue;
			}
			$blocking[] = sprintf( '%s — %s', (string) $sc['label'], (string) $sc['detail'] );
		}

		if ( $needs_db ) {
			$bp = $this->detect_backup_table_prefix( $backup_id );
			if ( $bp !== '' && $bp !== $wpdb->prefix ) {
				$server_checks[] = [
					'id'       => 'table_prefix',
					'label'    => __( 'Table prefix', 'wppowersuite-lite' ),
					'ok'       => true,
					'severity' => 'info',
					'detail'   => sprintf(
						'Backup uses "%s", this site uses "%s" — table names will be rewritten automatically during import.',
						$bp,
						$wpdb->prefix
					),
				];
			}
			if ( apply_filters( 'wpps_br_enable_maintenance_during_restore', true ) && defined( 'ABSPATH' ) ) {
				$maintenance_file = ABSPATH . '.maintenance';
				if ( is_file( $maintenance_file ) ) {
					$warnings[] = __( 'A .maintenance file already exists. WP PowerSuite will not remove a maintenance file it did not create; make sure no core/plugin/theme update is running before restoring the database.', 'wppowersuite-lite' );
				} else {
					$probe = rtrim( ABSPATH, '/\\' ) . '/.wpps_br_maintenance_write_test_' . wp_generate_password( 6, false, false ) . '.tmp';
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
					if ( false === @file_put_contents( $probe, 'ok' ) ) {
						$warnings[] = __( 'WP PowerSuite may not be able to create .maintenance in the WordPress root. Database restore can continue, but live traffic may reach the site while tables are being imported.', 'wppowersuite-lite' );
					} else {
						// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
						@unlink( $probe );
					}
				}
			}
			if ( in_array( 'plugins', $pick, true ) && defined( 'WPMU_PLUGIN_DIR' ) && is_string( WPMU_PLUGIN_DIR ) && '' !== WPMU_PLUGIN_DIR ) {
				$mu_dir = rtrim( wp_normalize_path( WPMU_PLUGIN_DIR ), '/' );
				if ( ( ! is_dir( $mu_dir ) && ! wp_mkdir_p( $mu_dir ) ) || ! is_writable( $mu_dir ) ) {
					$warnings[] = __( 'The mu-plugins directory is not writable, so WP PowerSuite cannot install its temporary plugin boot guard. If the restore takes multiple requests, active source-site plugins may boot before their files are fully copied.', 'wppowersuite-lite' );
				}
			}
		}

		$part_labels = [
			'database' => __( 'Database', 'wppowersuite-lite' ),
			'plugins'  => __( 'Plugins', 'wppowersuite-lite' ),
			'themes'   => __( 'Themes', 'wppowersuite-lite' ),
			'uploads'  => __( 'Uploads', 'wppowersuite-lite' ),
			'others'   => __( 'Other wp-content files', 'wppowersuite-lite' ),
		];

		$bytes_estimate = 0;
		$items          = [];
		foreach ( $pick as $c ) {
			$label = isset( $part_labels[ $c ] ) ? $part_labels[ $c ] : $c;
			if ( 'database' === $c ) {
				$f = $this->get_component_download( $backup_id, 'database' );
				if ( ! is_array( $f ) ) {
					$blocking[] = __( 'database.sql was not found for this backup.', 'wppowersuite-lite' );
					$items[ $c ] = [
						'ok'     => false,
						'label'  => $label,
						'detail' => __( 'database.sql was not found in the backup folder.', 'wppowersuite-lite' ),
					];
					continue;
				}
				$sz = is_readable( $f['path'] ) ? (int) filesize( $f['path'] ) : 0;
				$bytes_estimate += $sz;
				$max_php = 52 * MB_IN_BYTES;
				if ( $sz > $max_php ) {
					$disabled_exec = array_filter( array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ) );
					$exec_ok       = function_exists( 'exec' ) && ! in_array( 'exec', $disabled_exec, true );
					if ( strtoupper( substr( PHP_OS, 0, 3 ) ) !== 'WIN' && ! ( $this->find_mysql_binary() && $exec_ok ) ) {
						$warnings[] = __( 'Large database dump: ensure the mysql client is available on the server, or import SQL manually. PHP mysqli import is capped for very large files.', 'wppowersuite-lite' );
					}
				}
				$items[ $c ] = [
					'ok'     => true,
					'label'  => $label,
					'detail' => sprintf(
						/* translators: %s: file size */
						__( '%s — ready on server (local backup).', 'wppowersuite-lite' ),
						size_format( $sz, 1 )
					),
				];
				continue;
			}

			if ( ! class_exists( 'ZipArchive' ) ) {
				$items[ $c ] = [
					'ok'     => false,
					'label'  => $label,
					'detail' => __( 'PHP ZipArchive is required for this part.', 'wppowersuite-lite' ),
				];
				continue;
			}

			$f = $this->get_component_download( $backup_id, $c );
			if ( ! is_array( $f ) ) {
				$blocking[] = sprintf(
					/* translators: %s: component name */
					__( 'Archive missing or unreadable for: %s', 'wppowersuite-lite' ),
					$c
				);
				$items[ $c ] = [
					'ok'     => false,
					'label'  => $label,
					'detail' => __( 'This archive is not present or not readable on the server (upload the missing .zip into this backup folder and rescan).', 'wppowersuite-lite' ),
				];
				continue;
			}
			$sz         = is_readable( $f['path'] ) ? (int) filesize( $f['path'] ) : 0;
			$bytes_estimate += $sz;
			$bn         = basename( $f['path'] );

			$zip_test = new \ZipArchive();
			$open_res = $zip_test->open( $f['path'], \ZipArchive::RDONLY );
			if ( true !== $open_res ) {
				$blocking[] = sprintf(
					/* translators: 1: component, 2: file name */
					__( 'Corrupt or unreadable ZIP archive for %1$s (%2$s). Re-upload or re-create this backup.', 'wppowersuite-lite' ),
					$c,
					$bn
				);
				$items[ $c ] = [
					'ok'     => false,
					'label'  => $label,
					'detail' => __( 'The ZIP archive failed integrity validation.', 'wppowersuite-lite' ),
				];
				continue;
			}
			$zip_test->close();

			$items[ $c ] = [
				'ok'     => true,
				'label'  => $label,
				'detail' => sprintf(
					/* translators: 1: file name, 2: size */
					__( '%1$s (%2$s) — found on server (local backup).', 'wppowersuite-lite' ),
					$bn,
					size_format( $sz, 1 )
				),
			];

			$map = $this->restore_zip_prefix_and_dest( $c );
			if ( is_array( $map ) ) {
				$dest = rtrim( $map[1], '/' );
				if ( $dest !== '' && ( ! is_dir( $dest ) || ! is_writable( $dest ) ) ) {
					$warnings[] = sprintf(
						/* translators: 1: component, 2: path */
						__( '%1$s target may not be writable: %2$s', 'wppowersuite-lite' ),
						$c,
						$dest
					);
				}
			}
		}

		$free = function_exists( 'disk_free_space' ) ? @disk_free_space( WP_CONTENT_DIR ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( false !== $free && $bytes_estimate > 0 && $free < (int) ( $bytes_estimate * 2.2 ) ) {
			$warnings[] = sprintf(
				/* translators: 1: free space, 2: rough required */
				__( 'Disk space may be tight: about %1$s free. Full file restores can require roughly 2× the archive size during staging. Free space or restore fewer parts.', 'wppowersuite-lite' ),
				size_format( (int) $free, 1 ),
				size_format( (int) ( $bytes_estimate * 2.2 ), 1 )
			);
		}

		$file_only_without_db = ( count( array_intersect( $pick, [ 'plugins', 'themes' ] ) ) > 0 )
			&& ! in_array( 'database', $pick, true );
		if ( $file_only_without_db ) {
			$warnings[] = __(
				'You are restoring themes or plugins without the database. Copied plugins will look “inactive” because WordPress stores activation in the database—restore the database too, or activate plugins manually after.',
				'wppowersuite-lite'
			);
		}

		$manifest               = $this->get_manifest( $backup_id );
		$backup_site_url        = '';
		$site_url_from_manifest = false;
		$site_url_from_sql      = false;
		if ( is_array( $manifest ) && ! empty( $manifest['site_url'] ) ) {
			$backup_site_url        = (string) $manifest['site_url'];
			$site_url_from_manifest = true;
		} elseif ( in_array( 'database', $pick, true ) ) {
			$f = $this->get_component_download( $backup_id, 'database' );
			if ( is_array( $f ) && ! empty( $f['path'] ) && is_readable( $f['path'] ) ) {
				$guessed = $this->infer_site_url_from_sql_dump_file( $f['path'] );
				if ( is_string( $guessed ) && $guessed !== '' ) {
					$backup_site_url   = $guessed;
					$site_url_from_sql = true;
				}
			}
		}
		$current_site_url = home_url( '/' );
		$norm_backup      = $backup_site_url !== '' ? esc_url_raw( rtrim( $backup_site_url, '/' ) ) : '';
		$norm_current     = esc_url_raw( rtrim( $current_site_url, '/' ) );

		// Detect table prefix from manifest or SQL dump.
		$backup_table_prefix  = '';
		$current_table_prefix = $wpdb->prefix;
		if ( $needs_db ) {
			if ( is_array( $manifest ) && ! empty( $manifest['table_prefix'] ) ) {
				$backup_table_prefix = (string) $manifest['table_prefix'];
			} else {
				$f_sql = $this->get_component_download( $backup_id, 'database' );
				if ( is_array( $f_sql ) && ! empty( $f_sql['path'] ) && is_readable( $f_sql['path'] ) ) {
					$detected = $this->detect_table_prefix_from_sql( $f_sql['path'] );
					if ( is_string( $detected ) && $detected !== '' ) {
						$backup_table_prefix = $detected;
					}
				}
			}
		}
		$prefix_differs = $backup_table_prefix !== '' && $backup_table_prefix !== $current_table_prefix;

		if ( is_multisite() && in_array( 'database', $pick, true ) && ! is_super_admin() ) {
			$blocking[] = __( 'Multisite database restore requires a network super admin.', 'wppowersuite-lite' );
		}

		if ( is_multisite() && ( in_array( 'database', $pick, true ) || in_array( 'uploads', $pick, true ) ) ) {
			$warnings[] = __(
				'Multisite (current site only): database backups include this site’s tables plus shared network tables (users, sites, …), not other blogs’ tables. Uploads include only this site’s media library. Other sites on the network are not backed up or restored.',
				'wppowersuite-lite'
			);
		}

		if ( in_array( 'database', $pick, true ) && $norm_backup === '' ) {
			$warnings[] = __(
				'Could not read a site URL from manifest.json or from database.sql (header comments, siteurl, or home). Addresses in the imported database will not be rewritten to this install automatically.',
				'wppowersuite-lite'
			);
		} elseif ( $site_url_from_sql ) {
			$warnings[] = __(
				'manifest.json had no site URL; the original address was taken from database.sql (header or wp_options).',
				'wppowersuite-lite'
			);
		}

		return [
			'ok'               => count( $blocking ) === 0,
			'blocking'         => $blocking,
			'warnings'         => $warnings,
			'items'            => $items,
			'server_checks'    => $server_checks,
			'bytes_estimate'   => $bytes_estimate,
			'free_bytes'       => false === $free ? null : (int) $free,
			'migration'        => [
				'backup_site_url'              => $backup_site_url,
				'current_site_url'             => $current_site_url,
				'urls_differ'                  => $norm_backup !== '' && $norm_backup !== $norm_current,
				'is_database_selected'         => in_array( 'database', $pick, true ),
				'has_manifest_site_url'        => $norm_backup !== '',
				'inferred_site_url_from_sql'   => $site_url_from_sql,
				'site_url_from_manifest'        => $site_url_from_manifest,
				'will_auto_url_migrate'        => $norm_backup !== '' && $norm_backup !== $norm_current && in_array( 'database', $pick, true ),
				'backup_table_prefix'          => $backup_table_prefix,
				'current_table_prefix'         => $current_table_prefix,
				'table_prefix_differs'         => $prefix_differs,
				'will_auto_prefix_rewrite'     => $prefix_differs,
			],
		];
	}

	/**
	 * Comment lines at top of dump (UpdraftPlus-style). Prefer "# Backup of:" then "# Home URL:".
	 */
	private function infer_site_url_from_sql_dump_header_comments( string $sql_path ): ?string {
		if ( ! is_readable( $sql_path ) ) {
			return null;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$head = (string) file_get_contents( $sql_path, false, null, 0, 131072 );
		if ( $head === '' ) {
			return null;
		}
		if ( str_starts_with( $head, "\xEF\xBB\xBF" ) ) {
			$head = substr( $head, 3 );
		}
		$lines = preg_split( '/\R/', $head );
		if ( ! is_array( $lines ) ) {
			return null;
		}
		$backup_of = null;
		$home_of   = null;
		foreach ( $lines as $line ) {
			if ( preg_match( '/^\s*#\s*Backup of:\s*(.+?)\s*$/i', (string) $line, $m ) ) {
				$backup_of = trim( $m[1] );
			} elseif ( preg_match( '/^\s*#\s*Home URL:\s*(.+?)\s*$/i', (string) $line, $m ) ) {
				$home_of = trim( $m[1] );
			}
		}
		foreach ( [ $backup_of, $home_of ] as $candidate ) {
			if ( ! is_string( $candidate ) || $candidate === '' ) {
				continue;
			}
			$u = esc_url_raw( untrailingslashit( $candidate ) );
			if ( $u !== '' && str_starts_with( $u, 'http' ) ) {
				return $u;
			}
		}

		return null;
	}

	/**
	 * Lines written to the top of database.sql (after SET pragmas for PHP export; prepended for mysqldump).
	 * Matches UpdraftPlus conventions so restores and URL detection work the same way.
	 */
	private function get_database_dump_sql_header_lines(): string {
		global $wpdb;
		$wp_ver     = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'version' ) : '';
		$php_ver    = PHP_VERSION;
		$mysql_ver  = '';
		$plugin_ver = defined( 'WP_POWERSUITE_VERSION' ) ? (string) WP_POWERSUITE_VERSION : '';
		if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'get_var' ) ) {
			$v = $wpdb->get_var( 'SELECT VERSION()' );
			$mysql_ver = is_string( $v ) ? $v : '';
		}
		$abspath = defined( 'ABSPATH' ) ? trailingslashit( ABSPATH ) : '';
		$lines   = [
			'# WordPress MySQL database backup',
			'# WP PowerSuite Backup & Restore' . ( $plugin_ver !== '' ? ' v' . $plugin_ver : '' ),
			'# WordPress ' . $wp_ver . ', PHP ' . $php_ver . ( $mysql_ver !== '' ? ', MySQL ' . $mysql_ver : '' ),
			'# Backup of: ' . untrailingslashit( site_url() ),
			'# Home URL: ' . untrailingslashit( home_url() ),
			'# Table Prefix: ' . ( isset( $wpdb->prefix ) ? $wpdb->prefix : 'wp_' ),
		];
		if ( $abspath !== '' ) {
			$lines[] = '# ABSPATH: ' . $abspath;
		}
		$lines[] = '';

		return implode( "\n", $lines ) . "\n";
	}

	/**
	 * Prepend Updraft-style header to a raw mysqldump file (skip if a header is already present).
	 */
	private function prepend_database_sql_headers_to_file( string $path ): void {
		if ( ! is_readable( $path ) || ! is_file( $path ) ) {
			return;
		}
		$sz = (int) filesize( $path );
		if ( $sz < 16 ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$peek = (string) file_get_contents( $path, false, null, 0, 768 );
		if ( $peek !== '' && preg_match( '/^#\s*WordPress MySQL database backup/im', $peek ) && str_contains( $peek, '# Backup of:' ) ) {
			return;
		}
		$headers = $this->get_database_dump_sql_header_lines();
		$dir     = dirname( $path );
		$tmp     = $dir . '/.wpps-sql-hdr-' . sanitize_file_name( wp_generate_password( 12, false, false ) ) . '.tmp';
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! @rename( $path, $tmp ) ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$out = fopen( $path, 'wb' );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$in = fopen( $tmp, 'rb' );
		if ( false === $out || false === $in ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@rename( $tmp, $path );
			if ( false !== $out ) {
				fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			}
			if ( false !== $in ) {
				fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			}

			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
		fwrite( $out, $headers );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_stream_copy_to_stream
		stream_copy_to_stream( $in, $out );
		fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		@unlink( $tmp );
	}

	/**
	 * Best-effort: header comments (Updraft-style), then siteurl / home from INSERT lines targeting *options tables (streaming).
	 */
	private function infer_site_url_from_sql_dump_file( string $sql_path ): ?string {
		$from_comments = $this->infer_site_url_from_sql_dump_header_comments( $sql_path );
		if ( is_string( $from_comments ) && $from_comments !== '' ) {
			return $from_comments;
		}
		if ( ! is_readable( $sql_path ) ) {
			return null;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$h = fopen( $sql_path, 'rb' );
		if ( false === $h ) {
			return null;
		}
		$siteurl = null;
		$home    = null;
		try {
			while ( ( $line = fgets( $h ) ) !== false ) {
				if ( $siteurl !== null ) {
					break;
				}
				if ( stripos( $line, 'INSERT' ) === false || stripos( $line, 'INTO' ) === false ) {
					continue;
				}
				if ( ! $this->sql_insert_line_targets_options_table( $line ) ) {
					continue;
				}
				$v = $this->sql_insert_line_extract_option_value( $line, 'siteurl' );
				if ( is_string( $v ) && $v !== '' ) {
					$siteurl = $v;
					break;
				}
			}
			if ( is_string( $siteurl ) && $siteurl !== '' ) {
				return $siteurl;
			}
			rewind( $h );
			while ( ( $line = fgets( $h ) ) !== false ) {
				if ( stripos( $line, 'INSERT' ) === false || stripos( $line, 'INTO' ) === false ) {
					continue;
				}
				if ( ! $this->sql_insert_line_targets_options_table( $line ) ) {
					continue;
				}
				$v = $this->sql_insert_line_extract_option_value( $line, 'home' );
				if ( is_string( $v ) && $v !== '' ) {
					$home = $v;
					break;
				}
			}
		} finally {
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		return is_string( $home ) && $home !== '' ? $home : null;
	}

	/**
	 * INSERT targets a WordPress options table (wp_options, wp_2_options, …).
	 */
	private function sql_insert_line_targets_options_table( string $line ): bool {
		return 1 === preg_match( '/INSERT\s+INTO\s+`[a-zA-Z0-9_]*options`\s+/i', $line );
	}

	/**
	 * Extract option_value for a given option_name from one INSERT line (single- or multi-row VALUES).
	 *
	 * Assumes typical mysqldump / PowerSuite export: ...,'siteurl','https://...',...
	 */
	private function sql_insert_line_extract_option_value( string $line, string $option_name ): ?string {
		if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $option_name ) ) {
			return null;
		}
		$q = preg_quote( $option_name, '/' );
		if ( ! preg_match_all( "/'{$q}'\\s*,\\s*'((?:\\\\\\\\.|[^'\\\\\\\\])*)'/S", $line, $matches, PREG_SET_ORDER ) ) {
			return null;
		}
		foreach ( $matches as $row ) {
			if ( ! isset( $row[1] ) || ! is_string( $row[1] ) ) {
				continue;
			}
			$raw = str_replace( [ '\\\\', "\\'", '\\n', '\\r', '\\t' ], [ '\\', "'", "\n", "\r", "\t" ], $row[1] );
			if ( $raw !== '' ) {
				return $raw;
			}
		}

		return null;
	}

	/**
	 * @param array<string, mixed>|null $restore_options Bootstrap-only: migration_from (?string) optional override for original URL;
	 *                                                    skip_url_migration (truthy) skips automatic URL rewrite.
	 * @return array<string, mixed>
	 */
	public function restore_tick( string $backup_id, ?array $components, ?array $restore_options = null ): array {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid backup set.', 'wppowersuite-lite' ) ];
		}

		$existing = $this->get_restore_state( $backup_id );

		// When the user explicitly starts a fresh restore from the wizard (sends components),
		// discard any stale state from a previous failed/abandoned run so we don't try to
		// continue a dead job and instead bootstrap cleanly.
		$fresh_wizard_start = is_array( $components ) && count( $components ) > 0;
		if ( is_array( $existing ) && $fresh_wizard_start ) {
			// Never tear down a live job: releasing the lock here while another request is
			// actively restoring (this backup or a different one) would allow two restores
			// to run concurrently. Only stale/abandoned state may be discarded.
			wp_cache_delete( self::TRANSIENT_RESTORE_LOCK, 'transient' );
			$held        = get_transient( self::TRANSIENT_RESTORE_LOCK );
			$lock_active = is_string( $held ) && '' !== $held && ! $this->is_restore_lock_stale();
			if ( $lock_active ) {
				return [
					'success'       => false,
					'message'       => ( $held === $backup_id )
						? __( 'A restore of this backup is already running. Wait for it to finish, use Stop restore, or “Clear stuck backup / restore lock”.', 'wppowersuite-lite' )
						: __( 'Another restore is already running.', 'wppowersuite-lite' ),
					'lock_conflict' => true,
				];
			}
			$this->restore_reapply_snapshots_before_teardown( $backup_id, $existing );
			$this->delete_restore_state( $backup_id );
			$this->cleanup_restore_workdir( $backup_id );
			$this->delete_restore_manifests( trailingslashit( $this->get_backup_root() ) . $backup_id );
			$this->release_restore_lock();
			$existing = null;
		}

		if ( ! is_array( $existing ) ) {
			self::clear_legacy_lock_transients();
			delete_transient( self::TRANSIENT_LOCK );
			delete_transient( self::TRANSIENT_LOCK_TOUCH );
			$components_omitted       = ( null === $components );
			$components_empty_wizard  = is_array( $components ) && count( $components ) === 0;
			if ( $components_empty_wizard ) {
				return [ 'success' => false, 'message' => __( 'Choose at least one item to restore.', 'wppowersuite-lite' ) ];
			}
			if ( $components_omitted ) {
				$held = get_transient( self::TRANSIENT_RESTORE_LOCK );
				if ( $held && (string) $held === $backup_id ) {
					$this->release_restore_lock();
					return [
						'success'               => false,
						'message'               => __( 'Restore progress state was lost while the restore lock was still active (session/cache). The lock has been cleared—start this restore again from the wizard.', 'wppowersuite-lite' ),
						'restore_session_lost' => true,
					];
				}
				if ( $held ) {
					return [
						'success'       => false,
						'message'       => __( 'Another restore is already running.', 'wppowersuite-lite' ),
						'lock_conflict' => true,
					];
				}
				return [
					'success'               => false,
					'message'               => __( 'Restore could not continue—the in-progress state was missing (often after a database import: options/transients changed, or object cache still had stale data before the next step saved). Refresh this page. If the site looks right, you are done; if not, run restore again from the wizard.', 'wppowersuite-lite' ),
					'restore_session_lost' => true,
				];
			}
			if ( ! $this->acquire_restore_lock( $backup_id ) ) {
				return [
					'success'       => false,
					'message'       => __( 'Another restore is already running.', 'wppowersuite-lite' ),
					'lock_conflict' => true,
				];
			}
			// Another request may have initialized between our first read and acquire (parallel first ticks).
			$existing = $this->get_restore_state( $backup_id );
			if ( is_array( $existing ) ) {
				return $this->restore_job_run_step( $backup_id );
			}

			$disk       = $this->list_components_on_disk( $backup_id );
			$allowed_c  = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
			$user_sel   = array_values( array_filter( array_map( 'sanitize_key', $components ) ) );
			$pick       = array_values( array_intersect( $allowed_c, $disk, $user_sel ) );
			if ( count( $pick ) === 0 ) {
				$this->release_restore_lock();
				return [ 'success' => false, 'message' => __( 'None of the selected parts exist in this backup folder.', 'wppowersuite-lite' ) ];
			}

			// Server-side enforcement of the precheck rule: the dump contains shared network
			// tables (users/usermeta) on multisite — importing it can change every site.
			if ( is_multisite() && in_array( 'database', $pick, true ) && ! is_super_admin() ) {
				$this->release_restore_lock();
				return [
					'success' => false,
					'message' => __( 'Multisite database restore requires a network super admin.', 'wppowersuite-lite' ),
				];
			}

			// Drop leftover staging/manifests from an abandoned prior run (e.g. last AJAX tick never completed).
			$this->cleanup_restore_workdir( $backup_id );
			$this->delete_restore_manifests( trailingslashit( $this->get_backup_root() ) . $backup_id );

			/*
			 * Restore order matches UpdraftPlus: database first, then content archives, then finalize.
			 * DB-first means the admin session may die after import; continuation AJAX uses the HttpOnly restore cookie.
			 */
			$plan = [];
			if ( in_array( 'database', $pick, true ) ) {
				$plan[] = 'restore_database';
			}
			foreach ( [ 'plugins', 'themes', 'uploads', 'others' ] as $p ) {
				if ( ! in_array( $p, $pick, true ) ) {
					continue;
				}
				$plan[] = 'restore_' . $p . '_extract';
				$plan[] = 'restore_' . $p . '_install';
			}
			$plan[] = 'restore_finalize';

			$job_dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
			$opts    = is_array( $restore_options ) ? $restore_options : [];
			$m_to    = esc_url_raw( rtrim( home_url( '/' ), '/' ) );
			$m_from  = '';
			$mig     = false;

			if ( in_array( 'database', $pick, true ) && empty( $opts['skip_url_migration'] ) ) {
				$override = isset( $opts['migration_from'] ) && is_string( $opts['migration_from'] )
					? esc_url_raw( rtrim( trim( $opts['migration_from'] ), '/' ) ) : '';
				$man      = $this->get_manifest( $backup_id );
				$man_url  = '';
				if ( is_array( $man ) && ! empty( $man['site_url'] ) ) {
					$man_url = esc_url_raw( rtrim( (string) $man['site_url'], '/' ) );
				}
				if ( $override === '' && $man_url === '' && in_array( 'database', $pick, true ) ) {
					$f_sql = $this->get_component_download( $backup_id, 'database' );
					if ( is_array( $f_sql ) && ! empty( $f_sql['path'] ) && is_readable( $f_sql['path'] ) ) {
						$guess = $this->infer_site_url_from_sql_dump_file( $f_sql['path'] );
						if ( is_string( $guess ) && $guess !== '' ) {
							$man_url = esc_url_raw( rtrim( $guess, '/' ) );
						}
					}
				}
				if ( $override !== '' ) {
					$m_from = $override;
				} else {
					$m_from = $man_url;
				}
				if ( $m_from !== '' && $m_from !== $m_to ) {
					$mig = true;
				}
			}

			$state   = [
				'restore_backup_id'        => $backup_id,
				'job_dir'                  => $job_dir,
				'step_index'               => 0,
				'plan'                     => $plan,
				'selected'                 => $pick,
				'url_migration'            => $mig,
				'migration_from'           => $mig ? $m_from : '',
				'migration_to'             => $mig ? $m_to : '',
				'ajax_continuation_secret' => bin2hex( random_bytes( 24 ) ),
				'ajax_continuation_client_ip' => self::request_client_ip(),
			];
			$this->ensure_restore_ajax_continuation( $backup_id, $state );
			$this->save_restore_state( $backup_id, $state );

			try {
				$this->open_job_log( $job_dir, 'restore.log' );
			} catch ( \Throwable $e ) {
				$this->delete_restore_state( $backup_id );
				$this->release_restore_lock();
				return [ 'success' => false, 'message' => $e->getMessage() ];
			}
			$this->log_line(
				sprintf(
					/* translators: 1: backup id, 2: comma-separated parts */
					__( 'Restore job started for %1$s: %2$s', 'wppowersuite-lite' ),
					$backup_id,
					implode( ', ', $pick )
				)
			);
			if ( $mig ) {
				$this->log_line(
					sprintf(
						/* translators: 1: old site URL, 2: new site URL */
						__( 'Automatic URL migration after database import: %1$s → %2$s', 'wppowersuite-lite' ),
						$m_from,
						$m_to
					)
				);
			} elseif ( in_array( 'database', $pick, true ) ) {
				// Always log the migration decision — a silent "not needed" is
				// indistinguishable from a failed detection when diagnosing a
				// restored site whose content points at the wrong domain.
				if ( ! empty( $opts['skip_url_migration'] ) ) {
					$this->log_line( __( 'URL migration skipped (advanced option).', 'wppowersuite-lite' ) );
				} elseif ( '' === $m_from ) {
					$this->log_line( __( 'URL migration: no source site URL found in the backup manifest or SQL dump — imported URLs are left unchanged. If this backup came from a different domain, run the restore again with the source URL filled in under Advanced options.', 'wppowersuite-lite' ) );
				} else {
					$this->log_line(
						sprintf(
							/* translators: %s: site URL */
							__( 'URL migration not needed: the backup was made on this same address (%s).', 'wppowersuite-lite' ),
							$m_from
						)
					);
				}
			}
			$this->close_job_log();

			$total = count( $plan );
			$first = isset( $plan[0] ) ? (string) $plan[0] : '';
			return [
				'success'                  => true,
				'backup_id'                => $backup_id,
				'done'                     => false,
				'progress'                 => 0,
				'message'                  => __( 'Restore initialized. Working…', 'wppowersuite-lite' ),
				'log_line'                 => __( 'Restore initialized. Working…', 'wppowersuite-lite' ),
				'total_steps'              => $total,
				'current_step'             => 0,
				'log_excerpt'              => '',
				'restore_plan'             => $plan,
				'restore_bootstrap'        => true,
				'restore_active_step'      => $first,
				'restore_fully_done_steps' => 0,
			];
		}

		return $this->restore_job_run_step( $backup_id );
	}

	/**
	 * @return array<string, mixed>
	 */
	private function restore_job_run_step( string $backup_id ): array {
		$state = $this->get_restore_state( $backup_id );
		if ( ! is_array( $state ) ) {
			$this->release_restore_lock();
			return [
				'success'               => false,
				'message'               => __( 'Restore session expired. Start again.', 'wppowersuite-lite' ),
				'restore_session_lost' => true,
			];
		}

		$this->ensure_restore_ajax_continuation( $backup_id, $state );

		$held       = get_transient( self::TRANSIENT_RESTORE_LOCK );
		$state_bid  = isset( $state['restore_backup_id'] ) ? (string) $state['restore_backup_id'] : '';

		if ( $held && (string) $held !== $backup_id ) {
			return [
				'success'       => false,
				'message'       => __( 'Another restore is already running, or the server lock is for a different backup set.', 'wppowersuite-lite' ),
				'lock_conflict' => true,
			];
		}

		if ( ! $held && $state_bid !== '' && $state_bid === $backup_id ) {
			$this->rewrite_restore_lock_after_database_import( $backup_id );
			$held = get_transient( self::TRANSIENT_RESTORE_LOCK );
		}

		if ( ! $held || (string) $held !== $backup_id ) {
			return [
				'success'               => false,
				'message'               => __( 'Restore lock lost. Try again.', 'wppowersuite-lite' ),
				'restore_session_lost' => true,
			];
		}

		$this->touch_restore_lock();

		/*
		 * Overlap guard. Proxies/gateways on managed hosts often cut a long tick after
		 * ~60s while PHP keeps executing it (ignore_user_abort). The browser then
		 * retries and, without this guard, the retry would run the SAME step
		 * concurrently with the still-running request (double extraction into the same
		 * staging dir) and usually die the same way — the UI reports failure even
		 * though the original request finishes the restore. While a tick is executing,
		 * answer new ticks with a cheap "still working" payload so the UI keeps polling.
		 */
		$marker = $this->restore_tick_marker_path( $state );
		if ( '' !== $marker && $this->restore_tick_marker_is_fresh( $marker ) ) {
			return $this->restore_tick_busy_payload( $backup_id, $state );
		}
		if ( '' !== $marker ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $marker, (string) time() );
		}
		try {
			return $this->restore_job_run_step_body( $backup_id, $state );
		} finally {
			// Not reached on hard fatals (OOM / max_execution_time): the marker then
			// goes stale after 180s and the next tick resumes from the saved state.
			if ( '' !== $marker && is_file( $marker ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $marker );
			}
		}
	}

	/**
	 * Path of the "a tick is currently executing" marker for this restore, or '' when unavailable.
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_tick_marker_path( array $state ): string {
		$dir = isset( $state['job_dir'] ) ? (string) $state['job_dir'] : '';
		if ( '' === $dir || ! is_dir( $dir ) ) {
			return '';
		}
		return trailingslashit( $dir ) . '.restore-tick-running';
	}

	private function restore_tick_marker_is_fresh( string $marker ): bool {
		if ( ! is_file( $marker ) ) {
			return false;
		}
		clearstatcache( true, $marker );
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$mtime = (int) @filemtime( $marker );
		if ( $mtime <= 0 ) {
			return false;
		}
		$age = time() - $mtime;
		// Post-DB in-request chaining may legitimately run up to ~120s; 180s covers it.
		return $age >= 0 && $age < 180;
	}

	/**
	 * Progress payload returned to polls that arrive while another tick is executing.
	 *
	 * @param array<string, mixed> $state
	 * @return array<string, mixed>
	 */
	private function restore_tick_busy_payload( string $backup_id, array $state ): array {
		$plan  = array_values( (array) ( $state['plan'] ?? [] ) );
		$index = (int) ( $state['step_index'] ?? 0 );
		$total = count( $plan );
		$step  = ( $index < $total && isset( $plan[ $index ] ) ) ? (string) $plan[ $index ] : 'restore_finalize';
		// State comes from the running tick's checkpoints, so busy polls show live
		// import progress (percent / statement count) instead of a frozen title.
		$fraction = $this->restore_step_progress_fraction( $step, $state );

		// The running tick flushes restore.log continuously; surface its tail so the
		// live log panel keeps updating while this request only polls.
		$log_excerpt = '';
		$log_path    = isset( $state['job_dir'] ) && is_string( $state['job_dir'] ) && '' !== $state['job_dir']
			? trailingslashit( $state['job_dir'] ) . 'restore.log'
			: '';
		if ( '' !== $log_path && is_readable( $log_path ) ) {
			$sz = (int) filesize( $log_path );
			if ( $sz > 0 ) {
				$len = min( 2000, $sz );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
				$tail        = (string) file_get_contents( $log_path, false, null, $sz - $len, $len );
				$log_excerpt = $this->sanitize_log_excerpt_for_json( $tail );
			}
		}

		return [
			'success'                  => true,
			'backup_id'                => $backup_id,
			'done'                     => false,
			'restore_tick_busy'        => true,
			'progress'                 => $total > 0 ? (int) min( 99, round( ( ( $index + $fraction ) / $total ) * 100 ) ) : 0,
			'message'                  => $this->restore_step_live_message( $step, $state ),
			'log_line'                 => '',
			'total_steps'              => $total,
			'current_step'             => $index,
			'log_excerpt'              => $log_excerpt,
			'restore_plan'             => [],
			'restore_bootstrap'        => false,
			'restore_active_step'      => $step,
			'restore_fully_done_steps' => $index,
		];
	}

	/**
	 * @param array<string, mixed> $state
	 * @return array<string, mixed>
	 */
	private function restore_job_run_step_body( string $backup_id, array $state ): array {
		if ( function_exists( 'ignore_user_abort' ) ) {
			ignore_user_abort( true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.ignore_user_abort_ignore_user_abort
		}
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 1800 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- restore ticks; align with Updraft-style long runs.
		}

		$this->open_job_log( (string) $state['job_dir'], 'restore.log' );

		$plan  = $state['plan'];
		$index = (int) $state['step_index'];
		$total = count( $plan );

		if ( $index >= $total ) {
			$this->close_job_log();
			$job_dir_done = trailingslashit( $this->get_backup_root() ) . $backup_id;
			$this->cleanup_restore_workdir( $backup_id );
			$this->delete_restore_manifests( $job_dir_done );
			$this->disable_restore_maintenance_mode( $state );
			$this->restore_remove_boot_guard_mu_plugin();
			$state = $this->restore_reapply_snapshots_before_teardown( $backup_id, $state );
			$this->delete_restore_state( $backup_id );
			$this->release_restore_lock();
			$log_token = $this->generate_restore_log_token( $backup_id );
			return [
				'success'                   => true,
				'backup_id'                 => $backup_id,
				'done'                      => true,
				'progress'                  => 100,
				'message'                   => __( 'Restore already finished.', 'wppowersuite-lite' ),
				'restore_log_download_url'  => add_query_arg(
					[
						'page'              => 'powersuite-backup-restore',
						'dl_restore_log'    => $backup_id,
						'restore_log_token' => $log_token,
					],
					admin_url( 'admin.php' )
				),
			];
		}

		$step       = (string) $plan[ $index ];
		$ui         = $this->restore_step_ui_message( $step );
		$fd_before  = (int) $state['step_index'];

		// Every tick refreshes the guard so third-party plugins from the imported
		// database can never boot (and fatal) while their files are half-published.
		if ( ! $this->restore_install_boot_guard_mu_plugin() && empty( $state['restore_boot_guard_warning_logged'] ) ) {
			$state['restore_boot_guard_warning_logged'] = true;
			$this->log_line( __( 'WARNING: Could not install the temporary mu-plugin boot guard. WP PowerSuite will still trim active_plugins in the database, but hosts with persistent object cache or network-active plugins may allow source-site plugins to boot before file publish finishes.', 'wppowersuite-lite' ) );
		}
		$this->ensure_restore_maintenance_allows_continuation( $state, $step );

		try {
			$step_done = $this->execute_restore_step( $step, $state );
		} catch ( \Throwable $e ) {
			$safety_note = '';
			if ( ! empty( $state['safety_dump_path'] ) && is_string( $state['safety_dump_path'] ) && is_readable( $state['safety_dump_path'] ) ) {
				$safety_note = ' ' . sprintf(
					/* translators: %s: path */
					__( 'Pre-restore safety dump kept at: %s', 'wppowersuite-lite' ),
					$state['safety_dump_path']
				);
			}
			$this->log_line( __( 'Restore error: ', 'wppowersuite-lite' ) . $e->getMessage() . $safety_note );
			$this->close_job_log();
			// Keep .restore-staging on failure so operators can inspect; orphan GC cleans aged dirs.
			$this->disable_restore_maintenance_mode( $state );
			$this->restore_remove_boot_guard_mu_plugin();
			$state = $this->restore_reapply_snapshots_before_teardown( $backup_id, $state );
			$this->delete_restore_state( $backup_id );
			$this->release_restore_lock();
			$msg = $e->getMessage() . $safety_note;
			self::set_last_message( $msg );
			return [
				'success'   => false,
				'message'   => $msg,
				'backup_id' => $backup_id,
			];
		}

		if ( ! $step_done ) {
			return $this->restore_step_ajax_payload( $backup_id, $state, $index, $total, $ui, false, false, $step, $fd_before );
		}

		++$index;
		$state['step_index'] = $index;
		$fully_done          = $fd_before + 1;
		$active_step         = $step;
		$ui_out              = $ui;

		// Persist progress so a server crash after a completed step doesn't re-run it.
		$this->save_restore_state( $backup_id, $state );

		/*
		 * After a database import the browser session is dead (restored DB has different
		 * session tokens). Relying on the browser to fire more AJAX ticks is fragile
		 * (nopriv handler may be blocked by WAF / security plugins / cache).
		 *
		 * Strategy: once the DB step completes, keep running ALL remaining steps in the
		 * same PHP request until finished or a wall-clock limit (120s) is hit. For
		 * non-DB steps we also continue when time allows, to reduce roundtrips. Steps
		 * that are not yet done (partial extraction/install) return false and we break
		 * back to the browser for the next tick.
		 */
		$db_just_ran  = ( 'restore_database' === $step );
		$wall_limit   = $db_just_ran ? 120 : 25;
		$request_time = defined( 'WP_START_TIMESTAMP' ) ? (float) WP_START_TIMESTAMP : microtime( true );
		$elapsed      = function () use ( $request_time ) {
			return microtime( true ) - $request_time;
		};

		while ( $index < $total && isset( $plan[ $index ] ) && $elapsed() < $wall_limit ) {
			$next    = (string) $plan[ $index ];
			$ui_out  = $this->restore_step_ui_message( $next );
			try {
				$next_done = $this->execute_restore_step( $next, $state );
			} catch ( \Throwable $e ) {
				$safety_note = '';
				if ( ! empty( $state['safety_dump_path'] ) && is_string( $state['safety_dump_path'] ) && is_readable( $state['safety_dump_path'] ) ) {
					$safety_note = ' ' . sprintf(
						/* translators: %s: path */
						__( 'Pre-restore safety dump kept at: %s', 'wppowersuite-lite' ),
						$state['safety_dump_path']
					);
				}
				$this->log_line( __( 'Restore error: ', 'wppowersuite-lite' ) . $e->getMessage() . $safety_note );
				$this->close_job_log();
				$this->disable_restore_maintenance_mode( $state );
				$this->restore_remove_boot_guard_mu_plugin();
				$state = $this->restore_reapply_snapshots_before_teardown( $backup_id, $state );
				$this->delete_restore_state( $backup_id );
				$this->release_restore_lock();
				$msg = $e->getMessage() . $safety_note;
				self::set_last_message( $msg );
				return [
					'success'   => false,
					'message'   => $msg,
					'backup_id' => $backup_id,
				];
			}
			if ( ! $next_done ) {
				// Step needs more ticks (partial unzip/copy); return to browser.
				return $this->restore_step_ajax_payload( $backup_id, $state, $index, $total, $ui_out, false, false, $next, $fully_done );
			}
			$active_step = $next;
			++$index;
			$state['step_index'] = $index;
			++$fully_done;
			$this->save_restore_state( $backup_id, $state );
			$this->touch_restore_lock();
		}

		$done = $index >= $total;
		return $this->restore_step_ajax_payload( $backup_id, $state, $index, $total, $ui_out, $done, true, $active_step, $fully_done );
	}

	/**
	 * Step title enriched with live progress detail from the job state
	 * ("Restoring database… 42% — 12,400 statements (88 MB of 210 MB)").
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_step_live_message( string $step, array $state ): string {
		$base = $this->restore_step_ui_message( $step );
		if ( 'restore_database' !== $step ) {
			return $base;
		}
		if ( empty( $state['safety_dump_done'] ) ) {
			return $base . ' ' . __( '(creating a safety backup of the current database first)', 'wppowersuite-lite' );
		}
		if ( empty( $state['db_sql_imported'] ) ) {
			$offset = isset( $state['db_php_import_offset'] ) ? (int) $state['db_php_import_offset'] : 0;
			$stmts  = isset( $state['db_php_import_stmt_count'] ) ? (int) $state['db_php_import_stmt_count'] : 0;
			$path   = isset( $state['db_import_path'] ) && is_string( $state['db_import_path'] ) ? $state['db_import_path'] : '';
			$size   = ( '' !== $path && is_readable( $path ) ) ? (int) filesize( $path ) : 0;
			if ( $offset > 0 && $size > 0 ) {
				return $base . ' ' . sprintf(
					/* translators: 1: percent done, 2: statement count, 3: bytes imported, 4: total bytes */
					__( '%1$d%% — %2$s statements (%3$s of %4$s)', 'wppowersuite-lite' ),
					min( 99, (int) floor( $offset / $size * 100 ) ),
					number_format_i18n( $stmts ),
					size_format( $offset, 1 ),
					size_format( $size, 1 )
				);
			}
			return $base;
		}
		if ( ! empty( $state['url_migration'] ) ) {
			return $base . ' ' . __( '(database imported — rewriting site URLs)', 'wppowersuite-lite' );
		}
		return $base . ' ' . __( '(database imported — finishing up)', 'wppowersuite-lite' );
	}

	/**
	 * How far the CURRENT step is (0..0.99) so the progress bar moves during
	 * long steps instead of sitting at the step boundary for minutes.
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_step_progress_fraction( string $step, array $state ): float {
		if ( 'restore_database' !== $step || empty( $state['safety_dump_done'] ) || ! empty( $state['db_sql_imported'] ) ) {
			return 0.0;
		}
		$offset = isset( $state['db_php_import_offset'] ) ? (int) $state['db_php_import_offset'] : 0;
		$path   = isset( $state['db_import_path'] ) && is_string( $state['db_import_path'] ) ? $state['db_import_path'] : '';
		$size   = ( '' !== $path && is_readable( $path ) ) ? (int) filesize( $path ) : 0;
		if ( $offset <= 0 || $size <= 0 ) {
			return 0.0;
		}
		return min( 0.99, $offset / $size );
	}

	private function restore_step_ui_message( string $step ): string {
		switch ( $step ) {
			case 'restore_database':
				return __( 'Restoring database…', 'wppowersuite-lite' );
			case 'restore_plugins_extract':
				return __( 'Extracting plugins to staging (wp-content/upgrade)…', 'wppowersuite-lite' );
			case 'restore_plugins_install':
				return __( 'Publishing plugins to wp-content…', 'wppowersuite-lite' );
			case 'restore_themes_extract':
				return __( 'Extracting themes to staging…', 'wppowersuite-lite' );
			case 'restore_themes_install':
				return __( 'Publishing themes…', 'wppowersuite-lite' );
			case 'restore_uploads_extract':
				return __( 'Extracting uploads to staging…', 'wppowersuite-lite' );
			case 'restore_uploads_install':
				return __( 'Publishing uploads…', 'wppowersuite-lite' );
			case 'restore_others_extract':
				return __( 'Extracting other wp-content files to staging…', 'wppowersuite-lite' );
			case 'restore_others_install':
				return __( 'Publishing other wp-content files…', 'wppowersuite-lite' );
			case 'restore_finalize':
				return __( 'Finishing restore…', 'wppowersuite-lite' );
			default:
				return __( 'Restoring…', 'wppowersuite-lite' );
		}
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function execute_restore_step( string $step, array &$state ): bool {
		$bid = (string) $state['restore_backup_id'];

		switch ( $step ) {
			case 'restore_database':
				$f = $this->get_component_download( $bid, 'database' );
				if ( ! is_array( $f ) ) {
					throw new \Exception( __( 'database.sql is missing for this backup.', 'wppowersuite-lite' ) );
				}

				$this->enable_restore_maintenance_mode( $state );

				// Pre-restore safety dump (may span ticks) before any import.
				if ( empty( $state['safety_dump_done'] ) ) {
					$safety_done = $this->run_pre_restore_safety_dump_tick( $bid, $state );
					if ( ! $safety_done ) {
						/*
						 * .maintenance stays in place between ticks: the file written by
						 * enable_restore_maintenance_mode() exempts our own continuation
						 * AJAX actions, so the next tick is not 503'd while visitors and
						 * cron stay blocked from writing into a half-imported database.
						 */
						$this->save_restore_state( $bid, $state );
						return false;
					}
					$state['safety_dump_done'] = true;
					$this->save_restore_state( $bid, $state );
				}

				/*
				 * If the SQL import succeeds but a later step (e.g. URL migration) times out, the next tick
				 * must not import again — that would overwrite the restored DB. Persist state right after import.
				 */
				$sql_imported = ! empty( $state['db_sql_imported'] );
				if ( ! $sql_imported ) {
					$import_done = $this->import_database_from_sql_file( $f['path'], $state );
					if ( ! $import_done ) {
						// Maintenance intentionally stays on between import ticks (see safety-dump note).
						$this->save_restore_state( $bid, $state );
						return false;
					}
					$this->save_restore_state( $bid, $state );
					if ( function_exists( 'wp_cache_flush' ) ) {
						wp_cache_flush();
					}
					wp_cache_delete( 'alloptions', 'options' );
					wp_cache_delete( 'notoptions', 'options' );
					$this->restore_ensure_powersuite_in_active_plugins();
					$this->restore_ensure_backup_module_enabled_in_settings();
					$this->restore_clear_module_registry_caches();
					$this->restore_snapshot_active_plugins_into_state( $state );
					$this->restore_deactivate_third_party_plugins_for_file_copy();
					$this->restore_snapshot_and_safe_theme( $state );
					$this->rewrite_restore_lock_after_database_import( $bid );
					if ( function_exists( 'wp_cache_flush' ) ) {
						wp_cache_flush();
					}
					$state['db_sql_imported'] = true;
					$this->save_restore_state( $bid, $state );
					$this->ensure_restore_ajax_continuation( $bid, $state );
				}
				if ( ! empty( $state['url_migration'] ) && ! empty( $state['migration_from'] ) && ! empty( $state['migration_to'] ) ) {
					$m_from = (string) $state['migration_from'];
					$m_to   = (string) $state['migration_to'];
					$n      = $this->migrate_database_urls_after_import( $m_from, $m_to );
					$this->log_line(
						sprintf(
							/* translators: %d: number of DB cells updated */
							__( 'Database URL migration: updated %d cell(s) for the new site address.', 'wppowersuite-lite' ),
							$n
						)
					);
					$this->restore_ensure_core_site_urls( $m_to, $m_from );
					$this->flush_third_party_caches_after_url_migration();
				}
				unset( $state['db_sql_imported'] );
				$ap_warn = $this->restore_verify_active_plugins_option_healthy();
				if ( is_string( $ap_warn ) && $ap_warn !== '' ) {
					$this->log_line( $ap_warn );
				}
				if ( ! empty( $state['safety_dump_path'] ) && is_string( $state['safety_dump_path'] ) ) {
					$this->log_line(
						sprintf(
							/* translators: %s: absolute path */
							__( 'Pre-restore safety dump retained at: %s', 'wppowersuite-lite' ),
							$state['safety_dump_path']
						)
					);
				}
				$this->log_line( __( 'Database restore completed.', 'wppowersuite-lite' ) );
				$this->ensure_restore_ajax_continuation( $bid, $state );
				if ( $this->restore_should_keep_maintenance_after_database( $state ) ) {
					$state['maintenance_retained_for_files'] = true;
					$this->save_restore_state( $bid, $state );
				} else {
					$this->release_restore_maintenance_after_database( $state );
				}
				return true;

			case 'restore_plugins_extract':
				return $this->restore_extract_to_workdir_step( $state, 'plugins' );
			case 'restore_plugins_install':
				return $this->restore_install_component_batch( $state, 'plugins', trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ) );

			case 'restore_themes_extract':
				return $this->restore_extract_to_workdir_step( $state, 'themes' );
			case 'restore_themes_install':
				$done = $this->restore_install_component_batch( $state, 'themes', trailingslashit( wp_normalize_path( get_theme_root() ) ) );
				if ( $done ) {
					$this->restore_reapply_snapshot_theme( $state );
				}
				return $done;

			case 'restore_uploads_extract':
				return $this->restore_extract_to_workdir_step( $state, 'uploads' );
			case 'restore_uploads_install':
				$u = wp_upload_dir();
				return $this->restore_install_component_batch( $state, 'uploads', trailingslashit( wp_normalize_path( $u['basedir'] ?? '' ) ) );

			case 'restore_others_extract':
				return $this->restore_extract_to_workdir_step( $state, 'others' );
			case 'restore_others_install':
				return $this->restore_install_component_batch( $state, 'others', trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) );

			case 'restore_finalize':
				$this->restore_reapply_snapshot_theme( $state );
				$this->restore_reapply_snapshot_active_plugins( $state );
				$this->cleanup_restore_workdir( $bid );
				$this->delete_restore_manifests( (string) $state['job_dir'] );
				if ( function_exists( 'wp_clean_plugins_cache' ) ) {
					wp_clean_plugins_cache();
				}
				if ( function_exists( 'wp_cache_delete' ) ) {
					wp_cache_delete( 'plugins', 'plugins' );
				}
				$this->log_line( __( 'Restore finished successfully.', 'wppowersuite-lite' ) );
				return true;

			default:
				throw new \Exception( __( 'Unknown restore step.', 'wppowersuite-lite' ) );
		}
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function restore_unzip_component_step( array &$state, string $component, ?array $map = null ): bool {
		$bid = (string) $state['restore_backup_id'];
		$dl  = $this->get_component_download( $bid, $component );
		if ( ! is_array( $dl ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: component key */
					__( 'Missing archive for: %s', 'wppowersuite-lite' ),
					$component
				)
			);
		}
		if ( ! is_array( $map ) ) {
			$map = $this->restore_zip_prefix_and_dest( $component );
		}
		if ( ! is_array( $map ) ) {
			throw new \Exception( __( 'Invalid restore target.', 'wppowersuite-lite' ) );
		}
		$key = 'ux_' . $component;
		$ok  = $this->restore_unzip_batch( $dl['path'], $map[0], $map[1], $state, $key );
		if ( $ok ) {
			$this->log_line(
				sprintf(
					/* translators: %s: component (plugins, themes, …) */
					__( 'Extracted archive: %s', 'wppowersuite-lite' ),
					$component
				)
			);
		}
		return $ok;
	}

	/**
	 * Unzip one component into the backup set’s .restore-staging folder, then build a line-delimited manifest for the install phase.
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_extract_to_workdir_step( array &$state, string $component ): bool {
		$bid = (string) $state['restore_backup_id'];
		$wd  = $this->get_restore_workdir_abs( $bid );
		if ( ! wp_mkdir_p( $wd ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: path */
					__( 'Could not create restore staging directory: %s', 'wppowersuite-lite' ),
					$wd
				)
			);
		}
		$map = $this->restore_zip_prefix_and_workdir( $component, $wd );
		if ( ! is_array( $map ) ) {
			throw new \Exception( __( 'Invalid restore staging layout.', 'wppowersuite-lite' ) );
		}
		$ok = $this->restore_unzip_component_step( $state, $component, $map );
		if ( $ok ) {
			$src_sub  = trailingslashit( wp_normalize_path( rtrim( $wd, '/' ) ) ) . $component;
			$job_dir  = (string) $state['job_dir'];
			$manifest = trailingslashit( $job_dir ) . 'wpps-restore-manifest-' . $component . '.txt';
			if ( ! is_dir( $src_sub ) ) {
				$this->log_line(
					sprintf(
						/* translators: %s: component */
						__( 'Staging folder for %s was missing after extract (empty archive?); nothing to publish.', 'wppowersuite-lite' ),
						$component
					)
				);
			}
			$this->restore_write_copy_manifest( $src_sub, $manifest );
		}
		return $ok;
	}

	/**
	 * @return array{0: string, 1: string}|null Zip internal prefix, absolute staging root with trailing slash.
	 */
	private function restore_zip_prefix_and_workdir( string $component, string $workdir_abs ): ?array {
		// Entries in component zips are like themes/slug/style.css; stripping "themes/" must land under
		// workdir/themes/slug/… so the manifest (walks workdir/themes) and install step match.
		$root = trailingslashit( wp_normalize_path( rtrim( $workdir_abs, '/' ) ) );
		switch ( $component ) {
			case 'plugins':
				return [ 'plugins/', trailingslashit( wp_normalize_path( $root . 'plugins' ) ) ];
			case 'themes':
				return [ 'themes/', trailingslashit( wp_normalize_path( $root . 'themes' ) ) ];
			case 'uploads':
				return [ 'uploads/', trailingslashit( wp_normalize_path( $root . 'uploads' ) ) ];
			case 'others':
				return [ 'others/', trailingslashit( wp_normalize_path( $root . 'others' ) ) ];
			default:
				return null;
		}
	}

	/**
	 * Write one relative path per line (under $source_dir) for chunked copy.
	 *
	 * @throws \Exception
	 */
	private function restore_write_copy_manifest( string $source_dir, string $manifest_file ): void {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $manifest_file, 'wb' );
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not write restore file manifest.', 'wppowersuite-lite' ) );
		}
		$source_dir = rtrim( wp_normalize_path( $source_dir ), '/' );
		try {
			if ( ! is_dir( $source_dir ) ) {
				return;
			}
			$component     = basename( $source_dir );
			$delayed_paths = [];
			$it = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $source_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
				\RecursiveIteratorIterator::LEAVES_ONLY
			);
			foreach ( $it as $file ) {
				if ( ! $file->isFile() ) {
					continue;
				}
				$full = wp_normalize_path( $file->getPathname() );
				$rel  = ltrim( substr( $full, strlen( $source_dir ) ), '/' );
				if ( $rel === '' || str_contains( $rel, '..' ) || $this->relative_path_contains_vcs_metadata( $rel ) ) {
					continue;
				}
				if ( 'plugins' === $component && $this->restore_copy_manifest_path_score( $rel, $component, $source_dir ) > 0 ) {
					$delayed_paths[] = $rel;
				} else {
					fwrite( $fh, $rel . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				}
			}

			if ( $delayed_paths ) {
				$this->sort_restore_copy_manifest_paths( $delayed_paths, $component, $source_dir );
				foreach ( $delayed_paths as $rel ) {
					fwrite( $fh, $rel . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}
	}

	/**
	 * Keep plugin bootstrap files last so a partially published plugin cannot boot
	 * before its required include files are present.
	 *
	 * @param list<string> $paths
	 */
	private function sort_restore_copy_manifest_paths( array &$paths, string $component, string $source_dir = '' ): void {
		if ( count( $paths ) < 2 ) {
			return;
		}
		$scores = [];
		foreach ( $paths as $rel ) {
			$scores[ $rel ] = $this->restore_copy_manifest_path_score( $rel, $component, $source_dir );
		}
		usort(
			$paths,
			static function ( string $a, string $b ) use ( $scores ): int {
				$sa = $scores[ $a ] ?? 0;
				$sb = $scores[ $b ] ?? 0;
				if ( $sa !== $sb ) {
					return $sa <=> $sb;
				}
				return strnatcasecmp( $a, $b );
			}
		);
	}

	private function restore_copy_manifest_path_score( string $rel, string $component, string $source_dir ): int {
		if ( 'plugins' !== $component ) {
			return 0;
		}
		$rel   = str_replace( '\\', '/', $rel );
		$parts = explode( '/', $rel );
		$file  = end( $parts );
		if ( ! is_string( $file ) || ! str_ends_with( strtolower( $file ), '.php' ) ) {
			return 0;
		}
		$is_top_level_plugin_file = count( $parts ) <= 2;
		if ( ! $is_top_level_plugin_file ) {
			return 0;
		}
		if ( count( $parts ) === 2 && strtolower( $parts[1] ) === strtolower( $parts[0] . '.php' ) ) {
			return 25;
		}
		if ( '' !== $source_dir ) {
			$full = wp_normalize_path( rtrim( $source_dir, '/\\' ) . '/' . $rel );
			if ( is_readable( $full ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
				$head = (string) file_get_contents( $full, false, null, 0, 8192 );
				if ( preg_match( '/^[ \t\/*#@]*Plugin Name\s*:/mi', $head ) ) {
					return 30;
				}
			}
		}
		return 20;
	}

	/**
	 * Copy staged files into the live site in batches (manifest built at end of extract step).
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_install_component_batch( array &$state, string $component, string $dest_root_trailing ): bool {
		$bid     = (string) $state['restore_backup_id'];
		$job_dir = (string) $state['job_dir'];
		$wd      = $this->get_restore_workdir_abs( $bid );
		$src_abs = trailingslashit( wp_normalize_path( rtrim( $wd, '/' ) ) ) . $component;
		$manifest = trailingslashit( $job_dir ) . 'wpps-restore-manifest-' . $component . '.txt';

		if ( ! is_readable( $manifest ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: component */
					__( 'Restore manifest missing for %s. Start the restore again.', 'wppowersuite-lite' ),
					$component
				)
			);
		}

		$dest_base = wp_normalize_path( rtrim( $dest_root_trailing, '/' ) );
		$src_norm  = wp_normalize_path( rtrim( $src_abs, '/' ) );
		$key       = 'icpos_' . $component;
		$limits    = $this->restore_chunk_limits();
		$tick_end  = microtime( true ) + (float) $limits['copy_max_seconds'];
		$flushes   = 0;
		$done      = false;

		while ( $flushes < $limits['copy_inner_flushes'] && microtime( true ) < $tick_end && ! $done ) {
			$done = $this->restore_install_component_batch_once(
				$state,
				$bid,
				$component,
				$dest_base,
				$src_norm,
				$manifest,
				$key,
				(int) $limits['copy_batch'],
				$tick_end
			);
			++$flushes;
		}

		return $done;
	}

	/**
	 * Directories the restore must never write into or delete: the running
	 * WP PowerSuite install (and the tree this module actually executes from,
	 * which differs on Pro add-on layouts).
	 *
	 * @return list<string> Normalized absolute paths without trailing slash.
	 */
	private function restore_self_protect_dirs(): array {
		$dirs = [];
		if ( defined( 'WP_POWERSUITE_PLUGIN_DIR' ) && is_string( WP_POWERSUITE_PLUGIN_DIR ) && WP_POWERSUITE_PLUGIN_DIR !== '' ) {
			$dirs[] = rtrim( wp_normalize_path( WP_POWERSUITE_PLUGIN_DIR ), '/' );
		}
		if ( defined( 'WP_POWERSUITE_FILE' ) && is_string( WP_POWERSUITE_FILE ) && WP_POWERSUITE_FILE !== '' ) {
			$dirs[] = rtrim( wp_normalize_path( dirname( WP_POWERSUITE_FILE ) ), '/' );
		}
		$dirs[] = rtrim( wp_normalize_path( dirname( __DIR__, 3 ) ), '/' );

		return array_values( array_unique( array_filter( $dirs ) ) );
	}

	/**
	 * Whether a publish/delete target lives inside the running plugin install.
	 * Case-insensitive: Windows/macOS filesystems overwrite across case variants.
	 */
	private function restore_target_is_self( string $abs_path ): bool {
		$p = strtolower( wp_normalize_path( $abs_path ) );
		foreach ( $this->restore_self_protect_dirs() as $dir ) {
			$d = strtolower( $dir );
			if ( $p === $d || str_starts_with( $p, $d . '/' ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * The restore engine must never replace itself mid-run.
	 *
	 * Publishing the backup's copy of WP PowerSuite over the running install
	 * swaps the executing code between ticks — a Lite/older copy from the
	 * source site has no backup-restore module (or none of the continuation
	 * fixes), so the very next tick gets HTTP 400 and the restore orphans
	 * itself. The installed plugin is kept; everything else restores normally.
	 * Matches by destination path AND by exact known package folder names,
	 * because the source install's folder may be a case/slug variant.
	 * Do not use a prefix match here: add-on plugins can legitimately start
	 * with wp-powersuite and must still be restored.
	 */
	private function restore_publish_should_skip_self( string $component, string $rel, string $to ): bool {
		if ( 'plugins' !== $component ) {
			return false;
		}
		if ( $this->restore_target_is_self( $to ) ) {
			return true;
		}
		$top = strtolower( (string) ( explode( '/', ltrim( wp_normalize_path( $rel ), '/' ), 2 )[0] ?? '' ) );

		$self_slugs = [
			'wppowersuite-lite',
			'wp_powersuite',
			'wppowersuite',
			'wp-powersuite-lite',
			'wp-powersuite-pro',
			'wp-powersuite-premium',
		];
		$self_slugs = apply_filters( 'wpps_br_restore_self_protect_plugin_slugs', $self_slugs );
		if ( ! is_array( $self_slugs ) ) {
			$self_slugs = [];
		}
		$self_slugs = array_map( 'strtolower', array_filter( array_map( 'strval', $self_slugs ) ) );

		return '' !== $top && in_array( $top, $self_slugs, true );
	}

	/**
	 * Copy one publish batch from staging manifest to the live site.
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_install_component_batch_once(
		array &$state,
		string $bid,
		string $component,
		string $dest_base,
		string $src_norm,
		string $manifest,
		string $key,
		int $copy_batch,
		float $tick_end
	): bool {
		$fpos = isset( $state[ $key ] ) ? (int) $state[ $key ] : 0;

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fp = fopen( $manifest, 'rb' );
		if ( false === $fp ) {
			throw new \Exception( __( 'Could not read restore manifest.', 'wppowersuite-lite' ) );
		}
		if ( $fpos > 0 ) {
			fseek( $fp, $fpos );
		}

		$copied   = 0;
		$read_cap = 0;
		while ( $copied < $copy_batch && $read_cap < 4000 && microtime( true ) < $tick_end ) {
			$line = fgets( $fp );
			if ( false === $line ) {
				break;
			}
			++$read_cap;
			$rel = trim( $line );
			if ( $rel === '' || $this->relative_path_contains_vcs_metadata( $rel ) ) {
				continue;
			}
			$from = wp_normalize_path( $src_norm . '/' . $rel );
			$to   = wp_normalize_path( $dest_base . '/' . $rel );

			if ( ! str_starts_with( $from, $src_norm . '/' ) && $from !== $src_norm ) {
				$this->log_line( sprintf( 'Skipped out-of-scope path: %s', $rel ) );
				continue;
			}
			if ( ! str_starts_with( $to, $dest_base . '/' ) && $to !== $dest_base ) {
				$this->log_line( sprintf( 'Skipped unsafe destination: %s', $rel ) );
				continue;
			}
			if ( $this->restore_publish_should_skip_self( $component, $rel, $to ) ) {
				if ( empty( $state['self_protect_logged'] ) ) {
					$state['self_protect_logged'] = true;
					$this->log_line( __( 'Keeping the installed WP PowerSuite: the plugin performing the restore never overwrites itself (the backup contained its own copy, e.g. a Lite/older edition).', 'wppowersuite-lite' ) );
				}
				continue;
			}
			if ( $this->restore_publish_should_skip_environment_dropin( $component, $rel ) ) {
				if ( empty( $state['environment_dropins_skipped'] ) || ! is_array( $state['environment_dropins_skipped'] ) ) {
					$state['environment_dropins_skipped'] = [];
				}
				if ( ! in_array( $rel, $state['environment_dropins_skipped'], true ) ) {
					$state['environment_dropins_skipped'][] = $rel;
					$this->log_line(
						sprintf(
							/* translators: %s: relative wp-content drop-in path */
							__( 'Skipped server-specific WordPress drop-in during restore: %s', 'wppowersuite-lite' ),
							$rel
						)
					);
				}
				continue;
			}
			if ( ! is_readable( $from ) ) {
				$this->log_line( sprintf( 'Skipped missing staged file: %s', $rel ) );
				continue;
			}

			$pdir = dirname( $to );
			if ( ! is_dir( $pdir ) && ! wp_mkdir_p( $pdir ) ) {
				fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				throw new \Exception(
					sprintf(
						/* translators: %s: directory */
						__( 'Could not create directory: %s', 'wppowersuite-lite' ),
						$pdir
					)
				);
			}

			$this->restore_prepare_target_for_file_write( $to );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy
			if ( ! @copy( $from, $to ) ) {
				fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				throw new \Exception(
					sprintf(
						/* translators: %s: file path */
						__( 'Could not copy restored file into place: %s', 'wppowersuite-lite' ),
						$to
					)
				);
			}
			++$copied;

			if ( $copied % 100 === 0 ) {
				$state[ $key ] = (int) ftell( $fp );
				$this->save_restore_state( $bid, $state );
			}
		}

		$at_end = feof( $fp );
		$newpos = ftell( $fp );
		fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose

		if ( $at_end ) {
			unset( $state[ $key ] );
			$delete_sync = ! empty( $state['delete_sync'] )
				|| (bool) apply_filters( 'wpps_br_restore_delete_sync', false, $component, $bid );
			if ( $delete_sync && in_array( $component, [ 'plugins', 'themes' ], true ) ) {
				$this->restore_delete_sync_component( $component, $dest_base, $manifest );
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $manifest );
			$this->log_line(
				sprintf(
					/* translators: %s: component */
					__( 'Published files for: %s', 'wppowersuite-lite' ),
					$component
				)
			);
			return true;
		}

		$state[ $key ] = $newpos;
		return false;
	}

	private function restore_publish_should_skip_environment_dropin( string $component, string $rel ): bool {
		if ( 'others' !== $component ) {
			return false;
		}

		$rel = strtolower( trim( str_replace( '\\', '/', $rel ), '/' ) );
		if ( '' === $rel || str_contains( $rel, '/' ) ) {
			return false;
		}

		$dropins = [
			'advanced-cache.php',
			'blog-deleted.php',
			'blog-inactive.php',
			'blog-suspended.php',
			'db-error.php',
			'db.php',
			'fatal-error-handler.php',
			'install.php',
			'maintenance.php',
			'object-cache.php',
			'sunrise.php',
		];
		$dropins = apply_filters( 'wpps_br_restore_environment_dropins', $dropins );
		if ( ! is_array( $dropins ) ) {
			$dropins = [];
		}
		$dropins = array_values(
			array_filter(
				array_map(
					static function ( $item ): string {
						return strtolower( trim( str_replace( '\\', '/', (string) $item ), '/' ) );
					},
					$dropins
				),
				static function ( string $item ): bool {
					return '' !== $item && ! str_contains( $item, '/' );
				}
			)
		);

		if ( ! in_array( $rel, $dropins, true ) ) {
			return false;
		}

		return (bool) apply_filters( 'wpps_br_restore_skip_environment_dropin', true, $rel, $component );
	}

	/**
	 * After publishing plugins/themes, remove live files not present in the restore manifest.
	 * Never deletes the PowerSuite plugin directory.
	 */
	private function restore_delete_sync_component( string $component, string $dest_base, string $manifest ): void {
		if ( ! is_readable( $manifest ) || ! is_dir( $dest_base ) ) {
			return;
		}
		$keep = [];
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = (string) file_get_contents( $manifest );
		foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) {
			$rel = trim( (string) $line );
			if ( $rel !== '' ) {
				$keep[ $rel ] = true;
			}
		}
		if ( $keep === [] ) {
			return;
		}

		$powersuite_guard = '';
		if ( 'plugins' === $component && defined( 'WP_POWERSUITE_PLUGIN_DIR' ) ) {
			$powersuite_guard = rtrim( wp_normalize_path( WP_POWERSUITE_PLUGIN_DIR ), '/' );
		} elseif ( 'plugins' === $component && defined( 'WP_POWERSUITE_FILE' ) ) {
			$powersuite_guard = rtrim( wp_normalize_path( dirname( WP_POWERSUITE_FILE ) ), '/' );
		}

		$dest_base = rtrim( wp_normalize_path( $dest_base ), '/' );
		$removed   = 0;
		try {
			$it = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $dest_base, \RecursiveDirectoryIterator::SKIP_DOTS ),
				\RecursiveIteratorIterator::CHILD_FIRST
			);
			foreach ( $it as $file ) {
				$full = wp_normalize_path( $file->getPathname() );
				if ( $powersuite_guard !== '' && ( $full === $powersuite_guard || str_starts_with( $full, $powersuite_guard . '/' ) ) ) {
					continue;
				}
				// Broader self-guard (case-insensitive, covers Pro add-on layouts).
				if ( 'plugins' === $component && $this->restore_target_is_self( $full ) ) {
					continue;
				}
				$rel = ltrim( substr( $full, strlen( $dest_base ) ), '/' );
				if ( $rel === '' || isset( $keep[ $rel ] ) ) {
					continue;
				}
				// Keep parent dirs that still have kept children — only delete files first, then empty dirs.
				if ( $file->isFile() || $file->isLink() ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					if ( @unlink( $full ) ) {
						++$removed;
					}
				} elseif ( $file->isDir() ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@rmdir( $full );
				}
			}
		} catch ( \Throwable $e ) {
			$this->log_line( 'Delete-sync skipped: ' . $e->getMessage() );
			return;
		}
		if ( $removed > 0 ) {
			$this->log_line(
				sprintf(
					/* translators: 1: component, 2: removed file count */
					__( 'Delete-sync (%1$s): removed %2$d file(s) not in backup.', 'wppowersuite-lite' ),
					$component,
					$removed
				)
			);
		}
	}

	private function cleanup_restore_workdir( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return;
		}
		$wd = rtrim( wp_normalize_path( $this->get_restore_workdir_abs( $backup_id ) ), '/' );
		if ( is_dir( $wd ) ) {
			$this->recursive_delete( $wd );
		}
	}

	private function delete_restore_manifests( string $job_dir ): void {
		$pattern = trailingslashit( $job_dir ) . 'wpps-restore-manifest-*.txt';
		$found   = glob( $pattern );
		if ( ! is_array( $found ) ) {
			return;
		}
		foreach ( $found as $f ) {
			if ( is_string( $f ) && is_file( $f ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $f );
			}
		}
	}

	/**
	 * @return array{0: string, 1: string}|null Prefix inside zip, absolute dest root with trailing slash.
	 */
	private function restore_zip_prefix_and_dest( string $component ): ?array {
		switch ( $component ) {
			case 'plugins':
				return [ 'plugins/', trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ) ];
			case 'themes':
				return [ 'themes/', trailingslashit( wp_normalize_path( get_theme_root() ) ) ];
			case 'uploads':
				$u = wp_upload_dir();
				return [ 'uploads/', trailingslashit( wp_normalize_path( $u['basedir'] ?? '' ) ) ];
			case 'others':
				return [ 'others/', trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) ];
			default:
				return null;
		}
	}

	/**
	 * Resolve common unzip write failures (directory blocking path, permissions) before creating a file.
	 *
	 * @throws \Exception
	 */
	private function restore_prepare_target_for_file_write( string $target ): void {
		if ( is_dir( $target ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: absolute filesystem path */
					__( 'Restore cannot write a file here because a folder already exists with that name. Remove or rename the folder, then try again: %s', 'wppowersuite-lite' ),
					$target
				)
			);
		}
		$pdir = dirname( $target );
		if ( is_dir( $pdir ) && ! is_writable( $pdir ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
			@chmod( $pdir, 0755 );
			clearstatcache( true, $pdir );
		}
		if ( file_exists( $target ) && ! is_writable( $target ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
			@chmod( $target, 0644 );
			clearstatcache( true, $target );
		}
	}

	/**
	 * @return resource
	 * @throws \Exception
	 */
	private function restore_fopen_write( string $target ) {
		if ( function_exists( 'error_clear_last' ) ) {
			error_clear_last();
		}
		$this->restore_prepare_target_for_file_write( $target );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$out = fopen( $target, 'wb' );
		if ( false === $out ) {
			$php_err = '';
			$le      = error_get_last();
			if ( is_array( $le ) && isset( $le['message'] ) && is_string( $le['message'] ) && $le['message'] !== '' ) {
				$php_err = ' — ' . $le['message'];
			}
			$disk = function_exists( 'disk_free_space' ) ? @disk_free_space( dirname( $target ) ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			$disk_hint = '';
			if ( false !== $disk && $disk >= 0 && $disk < 50 * MB_IN_BYTES ) {
				$disk_hint = ' ' . __( '(Disk space on this volume looks low.)', 'wppowersuite-lite' );
			}
			throw new \Exception(
				sprintf(
					/* translators: %s: absolute path */
					__( 'Could not write restored file: %s', 'wppowersuite-lite' ),
					$target
				) . $php_err . $disk_hint
			);
		}
		return $out;
	}

	/**
	 * @throws \Exception
	 */
	private function restore_file_put_contents( string $target, string $data ): void {
		$this->restore_prepare_target_for_file_write( $target );
		if ( function_exists( 'error_clear_last' ) ) {
			error_clear_last();
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		$ok = file_put_contents( $target, $data );
		if ( false === $ok ) {
			$php_err = '';
			$le      = error_get_last();
			if ( is_array( $le ) && isset( $le['message'] ) && is_string( $le['message'] ) && $le['message'] !== '' ) {
				$php_err = ' — ' . $le['message'];
			}
			throw new \Exception(
				sprintf(
					/* translators: %s: absolute path */
					__( 'Could not write restored file: %s', 'wppowersuite-lite' ),
					$target
				) . $php_err
			);
		}
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function restore_unzip_batch( string $zip_path, string $zip_prefix, string $dest_root, array &$state, string $state_key ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required to restore file archives.', 'wppowersuite-lite' ) );
		}
		if ( ! is_readable( $zip_path ) ) {
			throw new \Exception( __( 'Cannot read zip archive.', 'wppowersuite-lite' ) );
		}

		$limits  = $this->restore_chunk_limits();
		$start_i = isset( $state[ $state_key ] ) ? (int) $state[ $state_key ] : 0;
		if ( 0 === $start_i ) {
			$extract_to = dirname( rtrim( wp_normalize_path( $dest_root ), '/' ) );
			if ( ! $this->zip_entries_safe_for_extract( $zip_path, $zip_prefix, $dest_root, $extract_to ) ) {
				throw new \Exception( __( 'Archive failed safety validation — the specific reason is in restore.log (Show log). Restore stopped before extraction.', 'wppowersuite-lite' ) );
			}
		}
		if ( 0 === $start_i && $this->try_system_unzip_component( $zip_path, $zip_prefix, $dest_root ) ) {
			unset( $state[ $state_key ] );
			return true;
		}

		$tick_deadline = microtime( true ) + (float) $limits['unzip_max_seconds'];
		$flushes       = 0;
		while ( $flushes < $limits['unzip_inner_flushes'] && microtime( true ) < $tick_deadline ) {
			$done = $this->restore_unzip_batch_once(
				$zip_path,
				$zip_prefix,
				$dest_root,
				$state,
				$state_key,
				$limits,
				$tick_deadline
			);
			++$flushes;
			if ( $done ) {
				return true;
			}
			if ( ! isset( $state[ $state_key ] ) ) {
				break;
			}
		}

		return false;
	}

	/**
	 * One ZipArchive extract pass (single open/close cycle).
	 *
	 * @param array<string, mixed> $state
	 * @param array<string, int>   $limits
	 */
	private function restore_unzip_batch_once(
		string $zip_path,
		string $zip_prefix,
		string $dest_root,
		array &$state,
		string $state_key,
		array $limits,
		float $tick_deadline
	): bool {
		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_path, \ZipArchive::RDONLY ) ) {
			throw new \Exception( __( 'Could not open zip for restore.', 'wppowersuite-lite' ) );
		}

		$start_i   = isset( $state[ $state_key ] ) ? (int) $state[ $state_key ] : 0;
		$n         = $z->numFiles;
		$dest_base = wp_normalize_path( rtrim( $dest_root, '/' ) );
		$batch     = 0;
		$idx_cap   = $start_i + (int) $limits['unzip_max_index_step'];

		for ( $i = $start_i; $i < $n; $i++ ) {
			if ( microtime( true ) >= $tick_deadline ) {
				$state[ $state_key ] = $i;
				$this->log_line(
					sprintf(
						/* translators: 1: current zip entry index, 2: total entries, 3: files extracted this tick */
						__( 'Unzip: time budget reached at entry %1$d/%2$d (extracted %3$d files this tick); continuing next request…', 'wppowersuite-lite' ),
						$i,
						$n,
						$batch
					)
				);
				$z->close();
				return false;
			}
			if ( $i >= $idx_cap ) {
				$state[ $state_key ] = $i;
				$this->log_line(
					sprintf(
						/* translators: 1: current zip entry index, 2: total entries */
						__( 'Unzip: entry scan budget reached at index %1$d/%2$d; continuing next request…', 'wppowersuite-lite' ),
						$i,
						$n
					)
				);
				$z->close();
				return false;
			}
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( str_ends_with( $norm, '/' ) ) {
				continue;
			}
			if ( '' !== $zip_prefix ) {
				$plen = strlen( $zip_prefix );
				if ( strlen( $norm ) < $plen || strtolower( substr( $norm, 0, $plen ) ) !== strtolower( $zip_prefix ) ) {
					continue;
				}
				$rel = substr( $norm, $plen );
			} else {
				$rel = ltrim( $norm, '/' );
			}
			$rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' );
			if ( $rel === '' || $this->zip_entry_path_is_unsafe( $rel ) || $this->zip_entry_path_is_unsafe( $norm ) ) {
				$this->log_line( sprintf( 'Skipped zip entry (unsafe path): %s', $name ) );
				continue;
			}
			if ( $this->zip_entry_is_special_file( $z, $i ) ) {
				$this->log_line( sprintf( 'Skipped zip entry (special file): %s', $name ) );
				continue;
			}
			if ( $this->relative_path_contains_vcs_metadata( $rel ) ) {
				continue;
			}

			$target = wp_normalize_path( $dest_base . '/' . $rel );
			if ( ! str_starts_with( $target, $dest_base . '/' ) && $target !== $dest_base ) {
				$this->log_line( sprintf( 'Skipped zip entry (outside target): %s', $name ) );
				continue;
			}

			$pdir = dirname( $target );
			// Refuse to write through an existing symlink in the staging tree.
			$probe = $dest_base;
			foreach ( explode( '/', $rel ) as $segment ) {
				if ( $segment === '' || $segment === '.' ) {
					continue;
				}
				$probe .= '/' . $segment;
				if ( is_link( $probe ) ) {
					$this->log_line( sprintf( 'Skipped zip entry (symlink in path): %s', $name ) );
					continue 2;
				}
			}
			if ( ! is_dir( $pdir ) && ! wp_mkdir_p( $pdir ) ) {
				$z->close();
				throw new \Exception(
					sprintf(
						/* translators: %s: directory path */
						__( 'Could not create directory: %s', 'wppowersuite-lite' ),
						$pdir
					)
				);
			}

			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$in = $z->getStream( $name );
			if ( false !== $in && is_resource( $in ) ) {
				try {
					$out = $this->restore_fopen_write( $target );
				} catch ( \Throwable $e ) {
					fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
					$z->close();
					throw $e;
				}
				stream_copy_to_stream( $in, $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- streams
				fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			} else {
				$data = $z->getFromIndex( $i );
				if ( false === $data ) {
					continue;
				}
				try {
					$this->restore_file_put_contents( $target, $data );
				} catch ( \Throwable $e ) {
					$z->close();
					throw $e;
				}
			}

			++$batch;
			if ( $batch >= (int) $limits['unzip_max_files'] ) {
				$state[ $state_key ] = $i + 1;
				$this->log_line(
					sprintf(
						/* translators: 1: files extracted, 2: next zip index, 3: total entries */
						__( 'Unzip: extracted %1$d files; resume at zip index %2$d of %3$d.', 'wppowersuite-lite' ),
						$batch,
						$i + 1,
						$n
					)
				);
				$z->close();
				return false;
			}
		}

		unset( $state[ $state_key ] );
		$z->close();
		return true;
	}

	private function restore_verify_active_plugins_option_healthy(): ?string {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->options
		$raw = $wpdb->get_var( "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = 'active_plugins' LIMIT 1" );
		if ( null === $raw || $raw === '' ) {
			return __(
				'Warning: active_plugins is missing or empty after database restore. Plugins may all look inactive. If you used URL migration, try again with migration disabled or re-import database.sql manually.',
				'wppowersuite-lite'
			);
		}
		$v = maybe_unserialize( $raw );
		if ( ! is_array( $v ) ) {
			return __(
				'Warning: active_plugins data is invalid after restore (serialized option may be corrupted). Typical fix: restore again with URL migration off, or import database.sql via phpMyAdmin / mysql CLI, then run a separate safe search-replace if needed.',
				'wppowersuite-lite'
			);
		}

		return null;
	}

	/**
	 * Store the post-import active_plugins option blob so we can restore it at finalize (see restore_reapply_snapshot_active_plugins).
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_snapshot_active_plugins_into_state( array &$state ): void {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = %s LIMIT 1", 'active_plugins' ) );
		if ( is_string( $raw ) && $raw !== '' && is_array( maybe_unserialize( $raw ) ) ) {
			$state['restore_snap_active_plugins'] = $raw;
		}

		if ( is_multisite() ) {
			$network_active = get_site_option( 'active_sitewide_plugins', [] );
			if ( is_array( $network_active ) ) {
				$state['restore_snap_active_sitewide_plugins'] = $network_active;
			}
		}
	}

	/**
	 * After DB import + snapshot, set active_plugins to ONLY WP-PowerSuite.
	 *
	 * If the auto-continue wall limit is hit mid-file-copy and the browser sends a new AJAX tick,
	 * WordPress boots and loads every active plugin. Plugins whose files are half-copied will fatal
	 * (e.g. "Class X not found"). Deactivating everything except PowerSuite prevents this. The full
	 * list is restored from the snapshot during restore_finalize once all files are on disk.
	 */
	private function restore_deactivate_third_party_plugins_for_file_copy(): void {
		global $wpdb;

		if ( ! defined( 'WP_PLUGIN_DIR' ) || ! is_string( WP_PLUGIN_DIR ) || '' === WP_PLUGIN_DIR ) {
			return;
		}
		$main = wp_normalize_path( dirname( __DIR__, 3 ) . '/wp-powersuite.php' );
		if ( ! is_readable( $main ) ) {
			return;
		}
		$basename = plugin_basename( $main );
		if ( '' === $basename ) {
			return;
		}
		$only_ps = [ $basename ];

		update_option( 'active_plugins', $only_ps, true );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$wpdb->options,
			[ 'option_value' => maybe_serialize( $only_ps ) ],
			[ 'option_name' => 'active_plugins' ],
			[ '%s' ],
			[ '%s' ]
		);
		if ( is_multisite() ) {
			$network_active = get_site_option( 'active_sitewide_plugins', [] );
			if ( is_array( $network_active ) && $network_active !== [] ) {
				update_site_option(
					'active_sitewide_plugins',
					isset( $network_active[ $basename ] ) ? [ $basename => $network_active[ $basename ] ] : []
				);
				wp_cache_delete( 'active_sitewide_plugins', 'site-options' );
			}
		}
		add_filter(
			'option_active_plugins',
			static function () use ( $only_ps ) {
				return $only_ps;
			},
			0
		);
		add_filter(
			'site_option_active_sitewide_plugins',
			static function ( $value ) use ( $basename ) {
				return is_array( $value ) && isset( $value[ $basename ] ) ? [ $basename => $value[ $basename ] ] : [];
			},
			0
		);
		wp_cache_delete( 'alloptions', 'options' );
		wp_cache_delete( 'active_plugins', 'options' );
		wp_cache_delete( 'notoptions', 'options' );
	}

	/**
	 * Contents of the must-use restore boot guard.
	 *
	 * The imported database activates the SOURCE site's plugins long before
	 * their files are published; a half-copied plugin (main file present,
	 * companion file missing) then fatals EVERY request — including the
	 * restore's own continuation ticks, which no DB-side trim can prevent
	 * because the trim itself only runs after the multi-tick import finishes.
	 * MU-plugins load before normal plugins, so filtering active_plugins in
	 * memory closes the race for every request type. Self-expires after an
	 * hour so an abandoned restore can never leave a site permanently gated.
	 */
	private function restore_boot_guard_contents( string $keep_basename ): string {
		$keep = var_export( $keep_basename, true );

		return '<?php'
			. "\n// WP PowerSuite Backup & Restore boot guard — removed automatically when the restore ends."
			. "\n// Only WP PowerSuite loads while a restore is publishing files; a half-copied plugin"
			. "\n// would otherwise fatal every request, including the restore's own progress ticks."
			. "\n\$wpps_marker = __DIR__ . '/wpps-restore-boot-guard.marker';"
			. "\nif ( ! is_file( \$wpps_marker ) ) { return; }"
			. "\n\$wpps_age = time() - (int) @filemtime( \$wpps_marker );"
			. "\nif ( \$wpps_age < 0 || \$wpps_age > 3600 ) { @unlink( \$wpps_marker ); return; }"
			. "\n\$wpps_keep = " . $keep . ';'
			. "\nadd_filter( 'option_active_plugins', static function ( \$value ) use ( \$wpps_keep ) {"
			. "\n\treturn array( \$wpps_keep );"
			. "\n}, 0 );"
			. "\nadd_filter( 'site_option_active_sitewide_plugins', static function ( \$value ) use ( \$wpps_keep ) {"
			. "\n\treturn is_array( \$value ) ? array_intersect_key( \$value, array( \$wpps_keep => true ) ) : \$value;"
			. "\n}, 0 );"
			. "\nunset( \$wpps_marker, \$wpps_age );\n";
	}

	/**
	 * Install (or refresh) the boot guard. Called at the start of every restore
	 * tick: rewriting the marker keeps its mtime fresh, so the 1-hour expiry
	 * only ever fires on abandoned restores.
	 */
	private function restore_install_boot_guard_mu_plugin(): bool {
		if ( ! defined( 'WPMU_PLUGIN_DIR' ) || ! is_string( WPMU_PLUGIN_DIR ) || '' === WPMU_PLUGIN_DIR ) {
			return false;
		}
		$main = wp_normalize_path( dirname( __DIR__, 3 ) . '/wp-powersuite.php' );
		if ( ! is_readable( $main ) ) {
			return false;
		}
		$keep = plugin_basename( $main );
		if ( '' === $keep ) {
			return false;
		}
		$dir = rtrim( wp_normalize_path( WPMU_PLUGIN_DIR ), '/' );
		if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
			return false; // Best-effort: hosts that forbid mu-plugins keep the DB-trim behaviour.
		}
		$file = $dir . '/wpps-restore-boot-guard.php';
		if ( ! is_file( $file ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === @file_put_contents( $file, $this->restore_boot_guard_contents( $keep ), LOCK_EX ) ) {
				return false;
			}
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if ( false === @file_put_contents( $dir . '/wpps-restore-boot-guard.marker', (string) time(), LOCK_EX ) ) {
			return false;
		}
		return is_file( $file ) && is_file( $dir . '/wpps-restore-boot-guard.marker' );
	}

	/**
	 * Remove the boot guard (restore finished, failed, or aborted).
	 */
	private function restore_remove_boot_guard_mu_plugin(): void {
		if ( ! defined( 'WPMU_PLUGIN_DIR' ) || ! is_string( WPMU_PLUGIN_DIR ) || '' === WPMU_PLUGIN_DIR ) {
			return;
		}
		$dir = rtrim( wp_normalize_path( WPMU_PLUGIN_DIR ), '/' );
		foreach ( [ '/wpps-restore-boot-guard.marker', '/wpps-restore-boot-guard.php' ] as $name ) {
			$p = $dir . $name;
			if ( is_file( $p ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $p );
			}
		}
	}

	/**
	 * After database import the active theme may not exist on disk yet.
	 * Switch to a safe default so WordPress can boot during file restoration.
	 */
	private function restore_snapshot_and_safe_theme( array &$state ): void {
		global $wpdb;

		$template   = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'template' ) );
		$stylesheet = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'stylesheet' ) );

		if ( '' === $template && '' === $stylesheet ) {
			return;
		}

		$state['restore_snap_template']   = $template;
		$state['restore_snap_stylesheet'] = $stylesheet;

		$theme_root = trailingslashit( wp_normalize_path( get_theme_root() ) );
		if ( is_dir( $theme_root . $stylesheet ) ) {
			return;
		}

		$safe = $this->find_safe_fallback_theme( $theme_root );
		if ( '' === $safe ) {
			$this->log_line( __( 'No fallback theme found — skipping temporary theme switch.', 'wppowersuite-lite' ) );
			return;
		}

		update_option( 'template', $safe );
		update_option( 'stylesheet', $safe );

		$this->log_line(
			sprintf( 'Theme "%s" is not on disk yet; temporarily switched to "%s" for safe booting.', $stylesheet, $safe )
		);
	}

	private function find_safe_fallback_theme( string $theme_root ): string {
		$candidates = [
			'twentytwentyfive', 'twentytwentyfour', 'twentytwentythree',
			'twentytwentytwo', 'twentytwentyone', 'twentytwenty',
			'twentynineteen', 'twentyseventeen',
		];
		foreach ( $candidates as $t ) {
			if ( is_dir( $theme_root . $t ) ) {
				return $t;
			}
		}
		$dirs = glob( $theme_root . '*', GLOB_ONLYDIR );
		if ( is_array( $dirs ) && count( $dirs ) > 0 ) {
			return basename( $dirs[0] );
		}
		return '';
	}

	/**
	 * @return list<string>
	 */
	private function restore_snapshot_state_keys(): array {
		return [
			'restore_snap_template',
			'restore_snap_stylesheet',
			'restore_snap_active_plugins',
			'restore_snap_active_sitewide_plugins',
		];
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function restore_state_has_snapshot_value( array $state, string $key ): bool {
		if ( ! array_key_exists( $key, $state ) ) {
			return false;
		}
		if ( 'restore_snap_active_sitewide_plugins' === $key ) {
			return is_array( $state[ $key ] );
		}

		return is_string( $state[ $key ] ) && $state[ $key ] !== '';
	}

	/**
	 * Restore runtime snapshots before abort/failure/stale cleanup destroys the only copy.
	 *
	 * @param array<string, mixed>|null $state
	 * @return array<string, mixed>|null
	 */
	private function restore_reapply_snapshots_before_teardown( string $backup_id, ?array $state = null ): ?array {
		if ( null === $state ) {
			$state = $this->get_restore_state( $backup_id );
		}
		if ( ! is_array( $state ) ) {
			return null;
		}

		$this->restore_reapply_snapshot_theme( $state );
		$this->restore_reapply_snapshot_active_plugins( $state );

		return $state;
	}

	/**
	 * After theme files are on disk, restore the original active theme from the backup.
	 */
	private function restore_reapply_snapshot_theme( array &$state ): void {
		$t_ok = isset( $state['restore_snap_template'] ) && is_string( $state['restore_snap_template'] ) && $state['restore_snap_template'] !== '';
		$s_ok = isset( $state['restore_snap_stylesheet'] ) && is_string( $state['restore_snap_stylesheet'] ) && $state['restore_snap_stylesheet'] !== '';
		if ( ! $t_ok && ! $s_ok ) {
			return;
		}

		$template   = $t_ok ? (string) $state['restore_snap_template'] : '';
		$stylesheet = $s_ok ? (string) $state['restore_snap_stylesheet'] : '';

		$theme_root = trailingslashit( wp_normalize_path( get_theme_root() ) );
		if ( '' !== $stylesheet && ! is_dir( $theme_root . $stylesheet ) ) {
			$this->log_line(
				sprintf(
					/* translators: %s: theme slug */
					__( 'Skipping theme reactivation: theme directory "%s" is still missing on disk.', 'wppowersuite-lite' ),
					$stylesheet
				)
			);
			unset( $state['restore_snap_template'], $state['restore_snap_stylesheet'] );
			return;
		}
		if ( '' !== $template && ! is_dir( $theme_root . $template ) ) {
			$this->log_line(
				sprintf(
					/* translators: %s: parent theme slug */
					__( 'Skipping theme reactivation: parent theme directory "%s" is still missing on disk.', 'wppowersuite-lite' ),
					$template
				)
			);
			unset( $state['restore_snap_template'], $state['restore_snap_stylesheet'] );
			return;
		}

		if ( '' !== $template ) {
			update_option( 'template', $template );
		}
		if ( '' !== $stylesheet ) {
			update_option( 'stylesheet', $stylesheet );
		}

		if ( function_exists( 'wp_cache_delete' ) ) {
			wp_cache_delete( 'themes', 'themes' );
		}

		unset( $state['restore_snap_template'], $state['restore_snap_stylesheet'] );

		$this->log_line(
			sprintf( 'Restored active theme: %s.', $stylesheet !== '' ? $stylesheet : $template )
		);
	}

	/**
	 * WordPress may strip active_plugins when any request runs while the DB is restored but plugin files are not copied yet.
	 * Re-apply the snapshot taken immediately after import so activations match the backup once files are on disk.
	 *
	 * @param array<string, mixed> $state
	 */
	private function restore_reapply_snapshot_active_plugins( array &$state ): void {
		global $wpdb;

		$restored_any = false;
		$blob         = isset( $state['restore_snap_active_plugins'] ) && is_string( $state['restore_snap_active_plugins'] )
			? (string) $state['restore_snap_active_plugins']
			: '';

		if ( $blob !== '' && is_array( maybe_unserialize( $blob ) ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = %s LIMIT 1", 'active_plugins' ), ARRAY_A );
			if ( is_array( $row ) ) {
				if ( (string) $row['option_value'] !== $blob ) {
					$ok = $wpdb->update(
						$wpdb->options,
						[ 'option_value' => $blob ],
						[ 'option_name' => 'active_plugins' ],
						[ '%s' ],
						[ '%s' ]
					);
					if ( false === $ok ) {
						$this->log_line( __( 'Restore finalize: could not update active_plugins.', 'wppowersuite-lite' ) );
					} else {
						$restored_any = true;
					}
				}
			} else {
				$ok = $wpdb->insert(
					$wpdb->options,
					[
						'option_name'  => 'active_plugins',
						'option_value' => $blob,
						'autoload'     => 'yes',
					],
					[ '%s', '%s', '%s' ]
				);
				if ( false === $ok ) {
					$this->log_line( __( 'Restore finalize: could not insert active_plugins.', 'wppowersuite-lite' ) );
				} else {
					$restored_any = true;
				}
			}
		}

		if ( is_multisite() && isset( $state['restore_snap_active_sitewide_plugins'] ) && is_array( $state['restore_snap_active_sitewide_plugins'] ) ) {
			update_site_option( 'active_sitewide_plugins', $state['restore_snap_active_sitewide_plugins'] );
			wp_cache_delete( 'active_sitewide_plugins', 'site-options' );
			$restored_any = true;
		}

		if ( ! $restored_any ) {
			return;
		}

		if ( function_exists( 'wp_cache_flush' ) ) {
			wp_cache_flush();
		}
		wp_cache_delete( 'alloptions', 'options' );
		wp_cache_delete( 'notoptions', 'options' );

		$this->log_line(
			__( 'Restore finalize: active_plugins was aligned with the snapshot from right after database import (avoids deactivated plugins from traffic during file restore).', 'wppowersuite-lite' )
		);
	}

	/**
	 * Serialized-safe cell migration (WordPress options, meta, etc.).
	 *
	 * @param list<array{0: string, 1: string}> $pairs
	 */
	private function restore_step_ajax_payload(
		string $backup_id,
		array $state,
		int $index_after_steps,
		int $total,
		string $ui_message,
		bool $done,
		bool $advance_after_complete,
		string $active_plan_step,
		int $restore_fully_done_steps
	): array {
		$plan_arr = array_values( (array) ( $state['plan'] ?? [] ) );

		if ( $done ) {
			$job_dir_done = trailingslashit( $this->get_backup_root() ) . $backup_id;
			$this->cleanup_restore_workdir( $backup_id );
			$this->delete_restore_manifests( $job_dir_done );
			$this->disable_restore_maintenance_mode( $state );
			$this->restore_remove_boot_guard_mu_plugin();
			$this->delete_restore_state( $backup_id );
			if ( $advance_after_complete ) {
				$this->release_restore_lock();
			}
		} else {
			$this->save_restore_state( $backup_id, $state );
		}

		$log_excerpt = '';
		if ( $done ) {
			$log_excerpt = $this->sanitize_log_excerpt_for_json( $this->read_log_tail( 200000 ) );
		} else {
			$log_excerpt = $this->sanitize_log_excerpt_for_json( $this->read_log_tail( 2000 ) );
		}
		$this->close_job_log();

		$fraction = $done ? 0.0 : $this->restore_step_progress_fraction( $active_plan_step, $state );
		$progress = $total > 0 ? (int) min( 100, round( ( ( $index_after_steps + $fraction ) / $total ) * 100 ) ) : 100;

		if ( ! $done ) {
			$ui_message = $this->restore_step_live_message( $active_plan_step, $state );
		}

		$plan_out = ( $done || $index_after_steps < 2 ) ? $plan_arr : [];

		$out = [
			'success'                  => true,
			'backup_id'                => $backup_id,
			'done'                     => $done,
			'progress'                 => $progress,
			'message'                  => $ui_message,
			'log_line'                 => $ui_message,
			'total_steps'              => $total,
			'current_step'             => $index_after_steps,
			'log_excerpt'              => $log_excerpt,
			'restore_plan'             => $plan_out,
			'restore_bootstrap'        => false,
			'restore_active_step'      => $active_plan_step,
			'restore_fully_done_steps' => $restore_fully_done_steps,
		];
		if ( $done ) {
			$log_token                       = $this->generate_restore_log_token( $backup_id );
			$out['restore_log_download_url'] = add_query_arg(
				[
					'page'              => 'powersuite-backup-restore',
					'dl_restore_log'    => $backup_id,
					'restore_log_token' => $log_token,
				],
				admin_url( 'admin.php' )
			);
			$out['message']                  = __( 'Restore completed successfully.', 'wppowersuite-lite' );
			$out['log_line']                 = $out['message'];
			$out['site_url']                 = home_url( '/' );
			$out['url_migration']            = ! empty( $state['url_migration'] );
			$out['elementor_active']         = $this->is_elementor_active();
		}

		return $out;
	}

	/**
	 * Drop the restore maintenance file when it is no longer protecting a live restore.
	 *
	 * @param array<string, mixed> $state
	 */
	private function release_restore_maintenance_after_database( array &$state ): void {
		if ( ! defined( 'ABSPATH' ) ) {
			unset( $state['maintenance_enabled'] );
			unset( $state['maintenance_retained_for_files'] );
			return;
		}

		$file          = ABSPATH . '.maintenance';
		$should_remove = ! empty( $state['maintenance_enabled'] );
		if ( ! $should_remove && is_file( $file ) ) {
			$lock = get_transient( self::TRANSIENT_RESTORE_LOCK );
			$bid  = isset( $state['restore_backup_id'] ) ? (string) $state['restore_backup_id'] : '';
			$should_remove = is_string( $lock ) && $lock !== '' && $bid !== '' && $lock === $bid;
		}

		if ( $should_remove && is_file( $file ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $file );
		}

		unset( $state['maintenance_enabled'] );
		unset( $state['maintenance_retained_for_files'] );
	}

	/**
	 * Keep restore maintenance mode while files are still being published.
	 *
	 * @param array<string, mixed> $state
	 */
	private function ensure_restore_maintenance_allows_continuation( array &$state, string $step ): void {
		if ( 'restore_database' === $step && empty( $state['db_sql_imported'] ) ) {
			return;
		}
		if ( ! empty( $state['maintenance_enabled'] ) && $this->restore_should_keep_maintenance_after_database( $state ) ) {
			$this->enable_restore_maintenance_mode( $state );
			return;
		}
		$this->release_restore_maintenance_after_database( $state );
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function restore_should_keep_maintenance_after_database( array $state ): bool {
		if ( empty( $state['maintenance_enabled'] ) ) {
			return false;
		}

		$plan = isset( $state['plan'] ) && is_array( $state['plan'] ) ? array_values( $state['plan'] ) : [];
		$idx  = isset( $state['step_index'] ) ? max( 0, (int) $state['step_index'] ) : 0;

		for ( $i = $idx, $n = count( $plan ); $i < $n; $i++ ) {
			$next = is_string( $plan[ $i ] ) ? $plan[ $i ] : '';
			if ( preg_match( '/^restore_(plugins|themes|uploads|others)_(extract|install)$/', $next ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Enable WordPress maintenance mode during restore (skipped if already active).
	 *
	 * Re-called at the start of every tick to refresh the timestamp: the file
	 * must stay valid across the whole multi-tick restore (WordPress
	 * ignores .maintenance older than 10 minutes, which doubles as the failsafe
	 * if the restore dies without cleaning up).
	 *
	 * @param array<string, mixed> $state
	 */
	private function enable_restore_maintenance_mode( array &$state ): void {
		if ( ! apply_filters( 'wpps_br_enable_maintenance_during_restore', true ) ) {
			return;
		}
		if ( ! defined( 'ABSPATH' ) ) {
			return;
		}
		$file = ABSPATH . '.maintenance';
		if ( empty( $state['maintenance_enabled'] ) && file_exists( $file ) ) {
			// Another process (core update, other tooling) owns it — leave it alone.
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if ( false !== file_put_contents( $file, $this->restore_maintenance_file_contents() ) ) {
			$state['maintenance_enabled'] = true;
		}
	}

	/**
	 * .maintenance payload that blocks visitors and cron for the protected restore
	 * window but lets this module's own restore continuation AJAX ticks through.
	 *
	 * wp_maintenance() includes this file before plugins load, so the exemption
	 * has to live inside the file itself: setting $upgrading to 0 makes core
	 * treat maintenance as expired for that request only. The exemption is
	 * restricted to admin-ajax.php with our restore actions; those handlers
	 * enforce their own nonce/cookie auth, so a forged request gets a JSON
	 * error instead of the site.
	 */
	private function restore_maintenance_file_contents(): string {
		$actions = "'wpps_br_restore_tick', 'wpps_br_restore_step', 'wpps_br_restore_abort'";

		return '<?php'
			. "\n// WP PowerSuite Backup & Restore: site paused while restore is publishing protected changes."
			. "\n// Removed automatically when protected restore work finishes; expires after 10 minutes regardless."
			. "\n\$upgrading = " . time() . ';'
			. "\n\$wpps_br_action = isset( \$_POST['action'] ) ? \$_POST['action'] : ( isset( \$_GET['action'] ) ? \$_GET['action'] : '' );"
			. "\n\$wpps_br_script = isset( \$_SERVER['SCRIPT_NAME'] ) ? (string) \$_SERVER['SCRIPT_NAME'] : '';"
			. "\nif ( is_string( \$wpps_br_action )"
			. "\n\t&& in_array( \$wpps_br_action, array( " . $actions . ' ), true )'
			. "\n\t&& substr( \$wpps_br_script, -15 ) === '/admin-ajax.php'"
			. "\n) {"
			. "\n\t\$upgrading = 0; // Restore continuation ticks must reach admin-ajax; everyone else waits."
			. "\n}"
			. "\nunset( \$wpps_br_action, \$wpps_br_script );\n";
	}

	/**
	 * Remove maintenance file only when this restore job created it.
	 *
	 * @param array<string, mixed> $state
	 */
	private function disable_restore_maintenance_mode( array $state ): void {
		$mutable = $state;
		$this->release_restore_maintenance_after_database( $mutable );
	}

	/**
	 * Generate a short-lived token file next to restore.log so the log can be downloaded
	 * without admin auth (session is dead after DB import).
	 *
	 * @return string 64-char hex token.
	 */
	private function generate_restore_log_token( string $backup_id ): string {
		$token    = bin2hex( random_bytes( 32 ) );
		$job_dir  = trailingslashit( $this->get_backup_root() ) . $backup_id;
		$tok_file = $job_dir . '/.restore-log-token';
		$payload  = wp_json_encode( [
			'token'     => hash( 'sha256', $token ),
			'expires'   => time() + HOUR_IN_SECONDS,
			'client_ip' => self::request_client_ip(),
		] );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $tok_file, $payload );
		return $token;
	}

	/**
	 * Whether Elementor is active after restore finalize.
	 */
	private function is_elementor_active(): bool {
		$active = get_option( 'active_plugins', [] );
		if ( ! is_array( $active ) ) {
			return false;
		}
		foreach ( $active as $plugin ) {
			if ( is_string( $plugin ) && str_contains( $plugin, 'elementor' ) ) {
				return true;
			}
		}
		return false;
	}

	public function verify_restore_log_token( string $backup_id, string $token ): bool {
		if ( '' === $token || '' === $backup_id ) {
			return false;
		}
		$tok_file = trailingslashit( $this->get_backup_root() ) . $backup_id . '/.restore-log-token';
		if ( ! is_readable( $tok_file ) ) {
			return false;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = @file_get_contents( $tok_file );
		if ( ! is_string( $raw ) || '' === $raw ) {
			return false;
		}
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) || empty( $data['token'] ) || empty( $data['expires'] ) ) {
			return false;
		}
		if ( (int) $data['expires'] < time() ) {
			@unlink( $tok_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			return false;
		}
		if ( ! self::request_ip_matches_stored( isset( $data['client_ip'] ) ? (string) $data['client_ip'] : '' ) ) {
			return false;
		}
		if ( ! hash_equals( (string) $data['token'], hash( 'sha256', $token ) ) ) {
			return false;
		}
		@unlink( $tok_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		return true;
	}
}
