<?php
/**
 * Backup creation, pre-checks, and log file handling.
 *
 * @package BackupRestore
 * @since 1.0.0
 */

namespace BackupRestore\Includes;

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

/**
 * Handles backup directory layout, environment checks, database export, and zip archives.
 */
class BackupService {

	/** Bumps when restore/lock behaviour changes; shown in AJAX + admin (proves which PHP file loaded). */
	public const ENGINE_BUILD = '1.11.7-20260717';

	/** Synthetic folder key for {@see self::UPLOAD_ACTIVITY_LOG_FILENAME} in the Logs UI and download handlers. */
	public const UPLOAD_ACTIVITY_LOG_BACKUP_ID = 'wpps-br-upload-log';

	/** Append-only log written during chunked (and other) uploads so progress/errors survive a page refresh. */
	public const UPLOAD_ACTIVITY_LOG_FILENAME = 'upload_activity.log';

	public const OPTION_HISTORY      = 'wp_powersuite_backup_restore_history';
	public const OPTION_LAST_MSG     = 'wp_powersuite_backup_restore_last_message';
	public const OPTION_SETTINGS     = 'wp_powersuite_backup_restore_settings';

	/**
	 * Backup run lock: Unix time until lease expires (stored value less than now means free). Same idea as Updraft_Semaphore_3_0.
	 */
	public const OPTION_BACKUP_LOCK_UNTIL = 'wpps_br_backup_lock_until';

	/** Backup job id holding the lock (folder name or bootstrap token while preparing). */
	public const OPTION_BACKUP_LOCK_JOB = 'wpps_br_backup_lock_job';

	/** @deprecated 1.4.2 Migrated to OPTION_BACKUP_LOCK_*; still deleted on release for upgrades. */
	public const TRANSIENT_LOCK       = 'wp_powersuite_backup_restore_running';

	/** @deprecated 1.4.2 */
	public const TRANSIENT_LOCK_TOUCH = 'wp_powersuite_backup_restore_lock_touch';
	public const TRANSIENT_JOB        = 'wpps_br_job_';

	/**
	 * Restore serialization lock (v2 key — v1 keys cleared in clear_legacy_lock_transients()).
	 *
	 * @var string
	 */
	public const TRANSIENT_RESTORE_LOCK = 'wpps_br_restore_lock_v2';

	/** @var string */
	public const TRANSIENT_RESTORE_TOUCH = 'wpps_br_restore_lock_touch_v2';

	/**
	 * Maps an unguessable continuation id to the active restore backup folder (UpdraftPlus-style).
	 * Lets restore ticks run after database.sql invalidates the admin session / nonces.
	 *
	 * @var string
	 */
	public const TRANSIENT_RESTORE_AJAX_JOB = 'wpps_br_restore_ajax_job_v1';

	/** @var string Pre-1.5.0 restore lock (must not block new installs / clones). */
	private const TRANSIENT_RESTORE_LOCK_LEGACY = 'wp_powersuite_restore_running';

	/** @var string */
	private const TRANSIENT_RESTORE_TOUCH_LEGACY = 'wp_powersuite_restore_lock_touch';

	private const RESTORE_JOB_PREFIX = 'wpps_br_restore_job_';

	/**
	 * Max files extracted before yielding to the next AJAX tick (time budget below still applies).
	 * Kept high so small files run in far fewer round-trips; slow/large zips exit on the time cap first.
	 */
	private const RESTORE_UNZIP_BATCH = 1200;

	/** Soft cap per restore HTTP tick (shared hosting / FastCGI safe). */
	private const RESTORE_UNZIP_MAX_SECONDS = 55;

	/** ZipArchive extract open/close cycles per HTTP tick when system unzip is unavailable. */
	private const RESTORE_UNZIP_INNER_FLUSHES = 4;

	/** Yield to the next AJAX tick after scanning this many central-directory entries (skipped or extracted). */
	private const RESTORE_UNZIP_MAX_INDEX_STEP = 20000;

	/** @var int Files copied live site per inner publish batch. */
	private const RESTORE_COPY_BATCH = 800;

	/** @var int Publish (copy) phase wall-clock budget per HTTP tick. */
	private const RESTORE_COPY_MAX_SECONDS = 55;

	/** @var int Publish copy batches per HTTP tick. */
	private const RESTORE_COPY_INNER_FLUSHES = 6;
	public const BACKUP_SUBDIR      = 'wp-powersuite-backups';
	public const PRIVATE_SUBDIR     = 'wp-powersuite-private';
	public const RESTORE_CONT_COOKIE = 'wpps_br_restore_cont';
	public const DL_STAGING_SUBDIR  = 'wp-powersuite-dl';
	public const HISTORY_MAX        = 80;
	public const JOB_TTL            = 3 * HOUR_IN_SECONDS;

	/** Relative paths under wp-content, one per line, used to chunk large “others” archives. */
	private const OTHERS_QUEUE_BASENAME = 'others-file-queue.txt';

	/** @var int Max files queued into one ZipArchive flush (soft cap; byte/time guards also apply). */
	private const ARCHIVE_CHUNK_MAX_FILES = 800;

	/** @var int Soft wall-clock cap per backup HTTP tick (shared hosting / FastCGI safe). */
	private const ARCHIVE_CHUNK_MAX_SECONDS = 55;

	/** @var int Uncompressed bytes per ZipArchive flush before close/reopen. */
	private const ARCHIVE_CHUNK_MAX_BYTES = 26214400; // 25 MiB.

	/** @var int ZipArchive open/close cycles per HTTP tick when chunked fallback runs. */
	private const ARCHIVE_CHUNK_INNER_FLUSHES = 8;

	/** @var int Files larger than this are ideally alone in a batch (reduces proxy timeouts while compressing). */
	private const ARCHIVE_LARGE_FILE_BYTES = 31457280; // 30 MiB.

	private const UPLOADS_QUEUE_BASENAME = 'uploads-file-queue.txt';

	/** Chunked plugins.zip / themes.zip — same caps as uploads to stay under max_execution_time per tick. */
	private const PLUGINS_QUEUE_BASENAME = 'plugins-file-queue.txt';
	private const THEMES_QUEUE_BASENAME  = 'themes-file-queue.txt';

	/** PHP database export: rows per SELECT and soft time budget per backup tick (matches uploads/others). */
	private const DB_PHP_EXPORT_ROW_CHUNK    = 200;
	private const DB_PHP_EXPORT_MAX_SECONDS  = 55;
	private const DB_PHP_EXPORT_MAX_INSERTS  = 1500;

	/** If background mysqldump produces no exit marker for this long, abort and fall back to PHP export. */
	private const MYSQLDUMP_ASYNC_STALL_SECONDS = 7200;

	/**
	 * Soft warning threshold for PHP mysqli import (no longer a hard fail — import is resumable).
	 */
	private const DB_PHP_IMPORT_WARN_BYTES = 52428800; // 52 MiB.

	/** PHP database import: statements and soft time budget per restore tick. */
	private const DB_PHP_IMPORT_MAX_SECONDS    = 55;
	private const DB_PHP_IMPORT_MAX_STATEMENTS = 400;

	/** Safety dump / queue-build wall clock per tick. */
	private const SAFETY_DUMP_MAX_SECONDS = 55;

	/** Restore continuation secret TTL (after DB import invalidates admin session). */
	private const RESTORE_AJAX_CONTINUATION_TTL = 600; // 10 minutes.

	/** Wall-clock budget per WP-Cron tick before scheduling a continuation run. */
	private const CRON_BACKUP_WALL_SECONDS = 240;

	/** In-progress scheduled backup resumed on the next cron tick. */
	public const TRANSIENT_CRON_BACKUP_JOB = 'wpps_br_cron_backup_job_v1';

	/** @var resource|null */
	private $log_stream = null;

	private string $log_file_path = '';

	/**
	 * Delete pre-1.5.0 restore lock transients (stale values break restore after DB clone or long-lived object cache).
	 */
	public static function clear_legacy_lock_transients(): void {
		delete_transient( self::TRANSIENT_RESTORE_LOCK_LEGACY );
		delete_transient( self::TRANSIENT_RESTORE_TOUCH_LEGACY );
	}

	/**
	 * Absolute path to root backup directory (private / non-web-accessible only).
	 *
	 * Returns an empty string when no safe path is available. Prefer
	 * {@see resolve_backup_root()} for structured errors.
	 */
	public function get_backup_root(): string {
		$resolved = $this->resolve_backup_root();
		return is_wp_error( $resolved ) ? '' : $resolved;
	}

	/**
	 * @return string|\WP_Error Trailing-slash absolute path or error.
	 */
	public function resolve_backup_root(): string|\WP_Error {
		/**
		 * Absolute path override (empty = use PrivateStorageResolver / WP_POWERSUITE_BACKUP_PATH).
		 * Must still pass private-path validation — web-mapped paths are rejected.
		 *
		 * @param string $root Absolute path or empty.
		 */
		$filtered = apply_filters( 'wpps_br_backup_root', '' );
		if ( is_string( $filtered ) && $filtered !== '' ) {
			return $this->prepare_explicit_private_path( $filtered );
		}

		return PrivateStorageResolver::resolve( self::BACKUP_SUBDIR );
	}

	/**
	 * @return string|\WP_Error
	 */
	private function prepare_explicit_private_path( string $path ): string|\WP_Error {
		$safe = PrivateStorageResolver::assert_safe_path( $path );
		if ( is_wp_error( $safe ) ) {
			return $safe;
		}
		$normalized = trailingslashit( PrivateStorageResolver::normalize_path( $path ) );
		if ( ! is_dir( $normalized ) && ! wp_mkdir_p( $normalized ) ) {
			return new \WP_Error( 'wpps_br_backup_root_mkdir', PrivateStorageResolver::configuration_help_message() );
		}
		$real = realpath( $normalized );
		if ( ! is_string( $real ) || $real === '' ) {
			return new \WP_Error( 'wpps_br_backup_root_realpath', PrivateStorageResolver::configuration_help_message() );
		}
		if ( is_link( $normalized ) ) {
			return new \WP_Error(
				'wpps_br_symlink_rejected',
				__( 'Private backup storage must not be a symbolic link.', 'wp-powersuite' )
			);
		}
		$safe_real = PrivateStorageResolver::assert_safe_path( $real );
		if ( is_wp_error( $safe_real ) ) {
			return $safe_real;
		}
		PrivateStorageResolver::harden_permissions( $real, true );

		return trailingslashit( PrivateStorageResolver::normalize_path( $real ) );
	}

	/**
	 * Locate an existing backup folder under the private root or legacy roots.
	 */
	public function locate_backup_dir( string $backup_id ): string {
		$backup_id = sanitize_file_name( $backup_id );
		if ( $backup_id === '' || ! self::is_valid_backup_folder_name( $backup_id ) ) {
			return '';
		}

		$roots = [];
		$primary = $this->get_backup_root();
		if ( $primary !== '' ) {
			$roots[] = $primary;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			$roots[] = $legacy;
		}

		foreach ( array_unique( $roots ) as $root ) {
			$dir = trailingslashit( wp_normalize_path( $root ) ) . $backup_id;
			if ( is_dir( $dir ) ) {
				return trailingslashit( wp_normalize_path( $dir ) );
			}
		}

		return '';
	}

	/**
	 * Private storage for restore state and secrets (never web-accessible).
	 */
	public function get_private_storage_root(): string {
		$resolved = $this->resolve_private_storage_root();
		return is_wp_error( $resolved ) ? '' : $resolved;
	}

	/**
	 * @return string|\WP_Error
	 */
	public function resolve_private_storage_root(): string|\WP_Error {
		/**
		 * @param string $root Absolute path or empty.
		 */
		$filtered = apply_filters( 'wpps_br_private_storage_root', '' );
		if ( is_string( $filtered ) && $filtered !== '' ) {
			return $this->prepare_explicit_private_path( $filtered );
		}

		return PrivateStorageResolver::resolve( self::PRIVATE_SUBDIR );
	}

	/**
	 * Ensure private storage exists and is denied direct HTTP access.
	 */
	public function ensure_private_storage_root(): bool {
		$root = $this->resolve_private_storage_root();
		if ( is_wp_error( $root ) || $root === '' ) {
			return false;
		}

		return $this->write_protected_directory( $root );
	}

	/**
	 * Last storage resolution error for admin UI / AJAX messages.
	 */
	public function get_storage_error_message(): string {
		$resolved = $this->resolve_backup_root();
		if ( is_wp_error( $resolved ) ) {
			return $resolved->get_error_message();
		}

		return '';
	}

	/**
	 * Whether a configured private backup root is available for new backups.
	 */
	public function has_safe_backup_storage(): bool {
		return ! is_wp_error( $this->resolve_backup_root() );
	}

	/**
	 * Absolute path for a backup folder under the private write root (new writes only).
	 * Empty when private storage is unavailable — never concatenates onto "/".
	 */
	public function get_primary_backup_dir( string $backup_id ): string {
		$root      = $this->get_backup_root();
		$backup_id = sanitize_file_name( $backup_id );
		if ( $root === '' || $backup_id === '' || ! self::is_valid_backup_folder_name( $backup_id ) ) {
			return '';
		}

		return trailingslashit( $root ) . $backup_id;
	}

	/**
	 * Legacy web-mapped roots that may still contain readable history (never for new writes).
	 *
	 * @return list<string>
	 */
	public function get_legacy_backup_roots(): array {
		return PrivateStorageResolver::legacy_backup_roots();
	}

	/**
	 * Tables included in a database backup.
	 *
	 * Single site: all tables in the database.
	 * Multisite: current blog tables + shared network globals (not other blogs' tables).
	 *
	 * @return list<string>
	 */
	public function get_backup_table_list(): array {
		global $wpdb;

		$col = $wpdb->get_col( 'SHOW TABLES', 0 );
		if ( ! is_array( $col ) ) {
			return [];
		}
		$all = array_values( array_filter( array_map( 'strval', $col ) ) );
		if ( ! is_multisite() ) {
			return $all;
		}

		$allowed = [];
		$blog_id = (int) get_current_blog_id();

		// Current blog tables (includes blog 1 when on main site).
		if ( method_exists( $wpdb, 'tables' ) ) {
			foreach ( (array) $wpdb->tables( 'blog' ) as $t ) {
				if ( is_string( $t ) && $t !== '' ) {
					$allowed[ $t ] = true;
				}
			}
		}

		// Shared / network tables needed for the site to boot.
		$global_keys = [ 'users', 'usermeta', 'site', 'sitemeta', 'blogs', 'blogmeta', 'signups', 'registration_log' ];
		if ( method_exists( $wpdb, 'tables' ) ) {
			foreach ( (array) $wpdb->tables( 'global' ) as $t ) {
				if ( is_string( $t ) && $t !== '' ) {
					$allowed[ $t ] = true;
				}
			}
			foreach ( (array) $wpdb->tables( 'ms_global' ) as $t ) {
				if ( is_string( $t ) && $t !== '' ) {
					$allowed[ $t ] = true;
				}
			}
		}
		foreach ( $global_keys as $key ) {
			if ( isset( $wpdb->$key ) && is_string( $wpdb->$key ) && $wpdb->$key !== '' ) {
				$allowed[ $wpdb->$key ] = true;
			}
		}

		// Also keep tables that match this blog's prefix pattern only.
		$blog_prefix = $wpdb->get_blog_prefix( $blog_id );
		$base        = $wpdb->base_prefix;
		$out         = [];
		foreach ( $all as $table ) {
			if ( isset( $allowed[ $table ] ) ) {
				$out[] = $table;
				continue;
			}
			// Include current blog: {base}{id}_* or base tables already in allowed.
			if ( $blog_id > 1 && str_starts_with( $table, $blog_prefix ) ) {
				$out[] = $table;
				continue;
			}
			if ( 1 === $blog_id && str_starts_with( $table, $base ) && ! preg_match( '/^' . preg_quote( $base, '/' ) . '\d+_/', $table ) ) {
				// Main site: base-prefixed tables that are NOT other-blog (baseN_).
				$out[] = $table;
			}
		}

		/**
		 * Filter the multisite/single-site table list for backups.
		 *
		 * @param list<string> $out Table names.
		 * @param list<string> $all All tables in the database.
		 */
		$filtered = apply_filters( 'wpps_br_backup_table_list', $out, $all );
		if ( ! is_array( $filtered ) ) {
			return array_values( array_unique( $out ) );
		}

		return array_values( array_unique( array_map( 'strval', $filtered ) ) );
	}

	/**
	 * Whether a SQL value should be emitted as 0xHEX (binary-safe).
	 */
	private function sql_value_needs_hex( string $value ): bool {
		if ( $value === '' ) {
			return false;
		}
		if ( ! preg_match( '//u', $value ) ) {
			return true;
		}
		// phpcs:ignore WordPress.PHP.YodaConditions.NotYoda
		return strpos( $value, "\0" ) !== false || preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value ) === 1;
	}

	/**
	 * Format a cell for INSERT (NULL, hex, or escaped string).
	 *
	 * @param mixed $v Cell value.
	 */
	private function format_sql_insert_value( $v ): string {
		if ( null === $v ) {
			return 'NULL';
		}
		$s = (string) $v;
		if ( $this->sql_value_needs_hex( $s ) ) {
			return '0x' . bin2hex( $s );
		}

		return "'" . esc_sql( $s ) . "'";
	}

	/**
	 * Apache rules denying all direct HTTP access to a directory.
	 */
	public static function directory_deny_htaccess_rules(): string {
		$rules  = "Options -Indexes\n\n";
		$rules .= "<IfModule mod_authz_core.c>\n";
		$rules .= "  Require all denied\n";
		$rules .= "</IfModule>\n";
		$rules .= "<IfModule !mod_authz_core.c>\n";
		$rules .= "  Order deny,allow\n";
		$rules .= "  Deny from all\n";
		$rules .= "</IfModule>\n";

		return $rules;
	}

	/**
	 * IIS web.config denying all direct HTTP access to a directory.
	 */
	public static function directory_deny_web_config_rules(): string {
		return '<?xml version="1.0" encoding="UTF-8"?>'
			. '<configuration><system.webServer>'
			. '<security><authorization><remove users="*" roles="" verbs="" />'
			. '<add accessType="Deny" users="*" /></authorization></security>'
			. '</system.webServer></configuration>' . "\n";
	}

	/**
	 * Write deny rules, index.php silence stub, and return whether the directory is usable.
	 */
	public function write_protected_directory( string $dir ): bool {
		if ( $dir === '' || ! wp_mkdir_p( $dir ) ) {
			return false;
		}
		PrivateStorageResolver::harden_permissions( $dir, true );

		$ht_rules = self::directory_deny_htaccess_rules();
		$ht       = trailingslashit( $dir ) . '.htaccess';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$existing_ht = is_readable( $ht ) ? (string) file_get_contents( $ht ) : '';
		if ( $existing_ht !== $ht_rules ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $ht, $ht_rules ) ) {
				return false;
			}
			PrivateStorageResolver::harden_permissions( $ht, false );
		}
		$wc_rules = self::directory_deny_web_config_rules();
		$wc       = trailingslashit( $dir ) . 'web.config';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$existing_wc = is_readable( $wc ) ? (string) file_get_contents( $wc ) : '';
		if ( $existing_wc !== $wc_rules ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $wc, $wc_rules );
			PrivateStorageResolver::harden_permissions( $wc, false );
		}
		$idx = trailingslashit( $dir ) . 'index.php';
		if ( ! file_exists( $idx ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $idx, "<?php\n// Silence is golden.\n" );
			PrivateStorageResolver::harden_permissions( $idx, false );
		}

		$html = trailingslashit( $dir ) . 'index.html';
		if ( ! is_file( $html ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $html, '' );
			PrivateStorageResolver::harden_permissions( $html, false );
		}

		// Defense-in-depth only — private storage must not rely on web-server deny rules.
		self::maybe_write_nginx_deny_sample( $dir );

		return is_dir( $dir ) && is_writable( $dir );
	}

	/**
	 * Drop an nginx deny snippet for hosts that ignore Apache .htaccess (not loaded by WordPress).
	 */
	private static function maybe_write_nginx_deny_sample( string $dir ): void {
		$sample = trailingslashit( $dir ) . 'wp-powersuite-nginx-deny.conf.sample';
		if ( is_file( $sample ) ) {
			return;
		}
		$relative = str_replace( '\\', '/', $dir );
		$body     = "# Add inside your nginx server { } block (adjust the path to match this directory):\n"
			. "# location ~* ^" . preg_quote( $relative, '#' ) . "/ {\n"
			. "#     deny all;\n"
			. "#     return 404;\n"
			. "# }\n";
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		file_put_contents( $sample, $body );
	}

	/**
	 * Client IP for restore audit fields.
	 *
	 * Uses REMOTE_ADDR by default. X-Forwarded-For is honored only when REMOTE_ADDR
	 * matches {@see self::get_trusted_proxy_ips()} (filterable for load balancers).
	 */
	public static function request_client_ip(): string {
		$remote = '';
		if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			$remote = trim( (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
		}
		if ( ! filter_var( $remote, FILTER_VALIDATE_IP ) ) {
			$remote = '';
		}

		$ip = $remote;
		if ( $remote !== '' && self::is_trusted_proxy_ip( $remote ) && ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
			$parts  = explode( ',', (string) wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
			$client = trim( (string) ( $parts[0] ?? '' ) );
			if ( filter_var( $client, FILTER_VALIDATE_IP ) ) {
				$ip = $client;
			}
		}

		/**
		 * @param string $ip Resolved client IP.
		 */
		$ip = apply_filters( 'wpps_br_restore_client_ip', $ip );

		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	/**
	 * @return list<string>
	 */
	private static function get_trusted_proxy_ips(): array {
		/**
		 * IP addresses of reverse proxies allowed to supply X-Forwarded-For.
		 *
		 * @param list<string> $proxies Default empty (REMOTE_ADDR only).
		 */
		$proxies = apply_filters( 'wpps_br_trusted_proxy_ips', [] );
		if ( ! is_array( $proxies ) ) {
			return [];
		}

		$out = [];
		foreach ( $proxies as $proxy ) {
			if ( ! is_string( $proxy ) ) {
				continue;
			}
			$proxy = trim( $proxy );
			if ( $proxy !== '' && filter_var( $proxy, FILTER_VALIDATE_IP ) ) {
				$out[] = $proxy;
			}
		}

		return $out;
	}

	private static function is_trusted_proxy_ip( string $remote_addr ): bool {
		foreach ( self::get_trusted_proxy_ips() as $trusted ) {
			if ( hash_equals( $trusted, $remote_addr ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether the current request IP matches a stored value.
	 */
	public static function request_ip_matches_stored( $stored ): bool {
		if ( ! is_string( $stored ) || '' === $stored ) {
			return false;
		}
		$current = self::request_client_ip();
		if ( '' === $current ) {
			return false;
		}

		return hash_equals( $stored, $current );
	}

	/**
	 * Issue an HttpOnly cookie used to continue restore ticks after database import.
	 */
	public static function issue_restore_continuation_cookie( string $backup_id, string $secret ): void {
		$backup_id = sanitize_file_name( $backup_id );
		$secret    = trim( $secret );
		if ( '' === $backup_id || strlen( $secret ) < 32 ) {
			return;
		}

		$exp   = time() + self::RESTORE_AJAX_CONTINUATION_TTL;
		$ip    = self::request_client_ip();
		$sig   = hash_hmac( 'sha256', $backup_id . '|' . $exp . '|' . $ip, $secret . wp_salt( 'auth' ) );
		$value = base64_encode( $backup_id . '|' . $exp . '|' . $ip . '|' . $sig ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

		if ( headers_sent() ) {
			return;
		}

		setcookie(
			self::RESTORE_CONT_COOKIE,
			$value,
			[
				'expires'  => $exp,
				'path'     => COOKIEPATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Strict',
			]
		);
	}

	/**
	 * Verify restore continuation cookie for the requested backup (survives DB import / logged-out admin).
	 */
	public function verify_restore_continuation_cookie( string $backup_id ): bool {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || empty( $_COOKIE[ self::RESTORE_CONT_COOKIE ] ) ) {
			return false;
		}

		$raw = base64_decode( (string) wp_unslash( $_COOKIE[ self::RESTORE_CONT_COOKIE ] ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		if ( ! is_string( $raw ) || $raw === '' ) {
			return false;
		}

		$parts = explode( '|', $raw );
		// v2: backup_id|exp|ip|sig — v1 (3 parts) rejected so stolen cookies without IP bind fail closed.
		if ( count( $parts ) !== 4 ) {
			return false;
		}

		list( $cookie_backup_id, $exp, $cookie_ip, $sig ) = $parts;
		if ( sanitize_file_name( (string) $cookie_backup_id ) !== $backup_id ) {
			return false;
		}
		if ( ! is_numeric( $exp ) || time() > (int) $exp ) {
			return false;
		}

		$state = $this->read_restore_state_file( $backup_id );
		if ( ! is_array( $state ) || empty( $state['ajax_continuation_secret'] ) || ! is_string( $state['ajax_continuation_secret'] ) ) {
			return false;
		}

		$secret   = (string) $state['ajax_continuation_secret'];
		// HMAC is bound to the IP stored in the cookie (issued at start), not the current request IP.
		$expected = hash_hmac( 'sha256', $backup_id . '|' . $exp . '|' . $cookie_ip, $secret . wp_salt( 'auth' ) );
		if ( ! hash_equals( $expected, (string) $sig ) ) {
			return false;
		}

		$client_ip = self::request_client_ip();
		/**
		 * When true, restore continuation cookies require an exact client IP match (breaks VPN/CGNAT/proxies).
		 * Default false: soft IP — log mismatches but allow HMAC-valid cookies.
		 *
		 * @param bool $strict Default false.
		 */
		$strict_ip = (bool) apply_filters( 'wpps_br_restore_strict_ip', false );
		if ( $strict_ip ) {
			if ( $client_ip === '' || ! hash_equals( (string) $cookie_ip, $client_ip ) ) {
				return false;
			}
		} elseif ( $client_ip !== '' && is_string( $cookie_ip ) && $cookie_ip !== '' && ! hash_equals( (string) $cookie_ip, $client_ip ) ) {
			$this->log_line(
				sprintf(
					'Restore continuation: client IP changed (%s → %s); allowing soft IP match.',
					(string) $cookie_ip,
					$client_ip
				)
			);
		}

		set_transient(
			self::TRANSIENT_RESTORE_AJAX_JOB,
			[
				'secret'    => $secret,
				'backup_id' => $backup_id,
				'client_ip' => $client_ip !== '' ? $client_ip : (string) $cookie_ip,
			],
			self::RESTORE_AJAX_CONTINUATION_TTL
		);

		return true;
	}

	/**
	 * Clear restore continuation cookie when a restore finishes or is aborted.
	 */
	public static function clear_restore_continuation_cookie(): void {
		if ( headers_sent() ) {
			return;
		}

		setcookie(
			self::RESTORE_CONT_COOKIE,
			'',
			[
				'expires'  => time() - HOUR_IN_SECONDS,
				'path'     => COOKIEPATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Strict',
			]
		);
	}

	/**
	 * Skip symlinks and paths outside the scan root during backup archiving.
	 */
	private function backup_path_is_allowed_file( string $full_path, string $root_dir ): bool {
		if ( is_link( $full_path ) ) {
			return false;
		}
		$root = rtrim( wp_normalize_path( $root_dir ), '/' );
		$full = wp_normalize_path( $full_path );
		if ( $full === $root ) {
			return false;
		}

		return str_starts_with( $full, $root . '/' );
	}

	/**
	 * Ensure backup root exists and is protected from direct web access.
	 */
	public function ensure_backup_root(): bool {
		$root = $this->resolve_backup_root();
		if ( is_wp_error( $root ) || $root === '' ) {
			return false;
		}

		return $this->write_protected_directory( $root );
	}

	/**
	 * Internal staging directory for download bundles (private storage only).
	 */
	public function get_download_staging_dir(): string {
		$resolved = PrivateStorageResolver::resolve( self::DL_STAGING_SUBDIR );
		return is_wp_error( $resolved ) ? '' : $resolved;
	}

	/**
	 * Remove staged download files older than 2 hours (legacy staging copies from pre-1.11.6 engines).
	 */
	public function cleanup_stale_staged_downloads(): void {
		$dir = $this->get_download_staging_dir();
		if ( ! is_dir( $dir ) ) {
			return;
		}
		$cutoff = time() - 2 * HOUR_IN_SECONDS;
		$globs  = [
			trailingslashit( $dir ) . 'wpps-dl-*.zip',
			trailingslashit( $dir ) . 'bundle-*.zip',
		];
		foreach ( $globs as $pattern ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			$files = @glob( $pattern );
			if ( ! is_array( $files ) ) {
				continue;
			}
			foreach ( $files as $f ) {
				if ( ! is_string( $f ) || ! is_file( $f ) ) {
					continue;
				}
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				$m = (int) @filemtime( $f );
				if ( $m > 0 && $m < $cutoff ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $f );
				}
			}
		}
	}

	/**
	 * Format log line prefix like: Apr 02 12:44:23
	 */
	public function log_timestamp_prefix(): string {
		return gmdate( 'M d H:i:s' );
	}

	/**
	 * Human-readable one-line messages for the dashboard “last message” box.
	 */
	public static function set_last_message( string $message ): void {
		update_option( self::OPTION_LAST_MSG, $message, false );
	}

	public static function get_last_message(): string {
		$m = get_option( self::OPTION_LAST_MSG, '' );
		return is_string( $m ) ? $m : '';
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	public static function get_history(): array {
		$h = get_option( self::OPTION_HISTORY, [] );
		return is_array( $h ) ? $h : [];
	}

	/**
	 * @param array<string, array<string, mixed>> $history
	 */
	public static function save_history( array $history ): void {
		if ( count( $history ) > self::HISTORY_MAX ) {
			$history = array_slice( $history, -self::HISTORY_MAX, self::HISTORY_MAX, true );
		}
		update_option( self::OPTION_HISTORY, $history, false );
	}

	/* ─── Log file scanner (backup.log / restore.log on disk) ──── */

	/**
	 * Scan all backup directories for backup.log and restore.log files.
	 *
	 * @return list<array{type: string, backup_id: string, file: string, size: int, mtime: int}>
	 */
	public function get_log_files(): array {
		$scan_roots = [];
		$primary    = $this->get_backup_root();
		if ( $primary !== '' && is_dir( $primary ) ) {
			$scan_roots[] = $primary;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			if ( is_dir( $legacy ) ) {
				$scan_roots[] = $legacy;
			}
		}
		$scan_roots = array_values( array_unique( $scan_roots ) );
		if ( $scan_roots === [] ) {
			return [];
		}
		$logs = [];
		$seen = [];
		foreach ( $scan_roots as $root ) {
			$dirs = glob( trailingslashit( $root ) . '*', GLOB_ONLYDIR );
			if ( ! is_array( $dirs ) ) {
				continue;
			}
			foreach ( $dirs as $dir ) {
				$bid = basename( $dir );
				if ( str_starts_with( $bid, '.' ) ) {
					continue;
				}
				foreach ( [ 'backup.log' => 'backup', 'restore.log' => 'restore' ] as $fname => $type ) {
					$path = trailingslashit( $dir ) . $fname;
					$key  = $bid . '|' . $fname;
					if ( isset( $seen[ $key ] ) || ! is_file( $path ) || ! is_readable( $path ) ) {
						continue;
					}
					$seen[ $key ] = true;
					$logs[]       = [
						'type'      => $type,
						'backup_id' => $bid,
						'file'      => $fname,
						'size'      => (int) filesize( $path ),
						'mtime'     => (int) filemtime( $path ),
					];
				}
			}
		}
		$upload_act = $this->get_upload_activity_log_abs_path();
		if ( is_file( $upload_act ) && is_readable( $upload_act ) ) {
			$logs[] = [
				'type'      => 'upload',
				'backup_id' => self::UPLOAD_ACTIVITY_LOG_BACKUP_ID,
				'file'      => self::UPLOAD_ACTIVITY_LOG_FILENAME,
				'size'      => (int) filesize( $upload_act ),
				'mtime'     => (int) filemtime( $upload_act ),
			];
		}
		usort( $logs, static fn( $a, $b ) => $b['mtime'] <=> $a['mtime'] );
		return $logs;
	}

	/**
	 * Absolute path to chunked-upload activity log (under .upload-staging).
	 */
	public function get_upload_activity_log_abs_path(): string {
		$root = $this->get_backup_root();
		if ( $root === '' ) {
			return '';
		}

		return trailingslashit( wp_normalize_path( $root ) )
			. '.upload-staging/' . self::UPLOAD_ACTIVITY_LOG_FILENAME;
	}

	/**
	 * Append one UTF-8 line to the upload activity log (creates directory/file as needed).
	 */
	public function append_upload_activity_log( string $message ): void {
		$root = $this->get_backup_root();
		if ( ! is_string( $root ) || '' === $root ) {
			return;
		}
		$dir = trailingslashit( wp_normalize_path( $root ) ) . '.upload-staging';
		if ( ! is_dir( $dir ) ) {
			$this->write_protected_directory( $dir );
		}
		$path = $dir . '/' . self::UPLOAD_ACTIVITY_LOG_FILENAME;
		$line = '[' . gmdate( 'Y-m-d\TH:i:s\Z' ) . '] ' . preg_replace( "/\r\n|\r|\n/", ' ', $message ) . "\n";
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $path, $line, FILE_APPEND | LOCK_EX );
	}

	/**
	 * True if $path looks like a ZIP by local file header (handles missing/wrong .zip in the client filename).
	 */
	public static function file_is_zip_by_magic( string $path ): bool {
		if ( ! is_readable( $path ) ) {
			return false;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$h = fopen( $path, 'rb' );
		if ( false === $h ) {
			return false;
		}
		$hdr = fread( $h, 4 );
		fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		if ( ! is_string( $hdr ) || strlen( $hdr ) < 4 ) {
			return false;
		}
		return ( "\x50\x4b\x03\x04" === $hdr || "\x50\x4b\x05\x06" === $hdr || "\x50\x4b\x07\x08" === $hdr );
	}

	/**
	 * Whether to treat an uploaded file as a ZIP (extension or magic bytes).
	 */
	public function is_zip_upload_candidate( string $abs_path, string $display_name ): bool {
		$dn = strtolower( $display_name );
		if ( str_ends_with( $dn, '.zip' ) ) {
			return true;
		}
		if ( preg_match( '/[\x{FF0E}\x{3002}]zip$/u', $display_name ) ) {
			return true;
		}
		return self::file_is_zip_by_magic( $abs_path );
	}

	/**
	 * Delete log files older than $days from all backup directories.
	 */
	public function prune_old_log_files( int $days ): int {
		if ( $days <= 0 ) {
			return 0;
		}
		$cutoff  = time() - ( $days * DAY_IN_SECONDS );
		$root    = $this->get_backup_root();
		if ( ! is_dir( $root ) ) {
			return 0;
		}
		$count = 0;
		$dirs  = glob( trailingslashit( $root ) . '*', GLOB_ONLYDIR );
		if ( ! is_array( $dirs ) ) {
			return 0;
		}
		foreach ( $dirs as $dir ) {
			foreach ( [ 'backup.log', 'restore.log' ] as $fname ) {
				$path = trailingslashit( $dir ) . $fname;
				if ( is_file( $path ) && filemtime( $path ) < $cutoff ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					if ( @unlink( $path ) ) {
						++$count;
					}
				}
			}
		}
		$upl = $this->get_upload_activity_log_abs_path();
		if ( is_file( $upl ) && filemtime( $upl ) < $cutoff ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( @unlink( $upl ) ) {
				++$count;
			}
		}
		return $count;
	}

	/** Comma-separated exclusions inside uploads/ (Updraft-style). */
	public static function default_exclude_uploads(): string {
		// Avoid backup* — it matched any path segment starting with “backup” (too aggressive).
		return '*backups,backups,backwpup*,wp-clone,snapshots,wp-staging';
	}

	/** Comma-separated exclusions for “other” wp-content paths (Updraft-style). */
	public static function default_exclude_others(): string {
		// Avoid backup* — it skipped folders like backup-guard or custom “backup*” directory names under wp-content.
		return 'upgrade,cache,updraft,backwpup*,*backups,mysql.sql,debug.log,wp-powersuite-dl';
	}

	public static function sanitize_exclude_csv( string $csv ): string {
		$parts = array_filter( array_map( 'trim', explode( ',', $csv ) ) );
		$parts = array_slice( $parts, 0, 200 );
		return implode( ',', array_map( 'sanitize_text_field', $parts ) );
	}

	/**
	 * Persisted backup options (inclusions, exclusion tokens, retention).
	 *
	 * @return array{
	 *   include_plugins: bool,
	 *   include_themes: bool,
	 *   include_uploads: bool,
	 *   include_others: bool,
	 *   exclude_uploads: string,
	 *   exclude_others: string,
	 *   retain_completed: int,
	 *   log_retain_days: int
	 * }
	 */
	public static function get_settings(): array {
		$defaults = [
			'include_plugins'           => true,
			'include_themes'            => true,
			'include_uploads'           => true,
			'include_others'            => true,
			'exclude_uploads'           => self::default_exclude_uploads(),
			'exclude_others'            => self::default_exclude_others(),
			'retain_completed'          => 0,
			'log_retain_days'           => 30,
			'schedule_enabled'          => false,
			'schedule_frequency'        => 'daily',
			'schedule_time'             => '03:00',
			'schedule_day_of_week'      => 0,
			'schedule_day_of_month'     => 1,
			'schedule_backup_type'      => 'full',
			'schedule_email_enabled'            => false,
			'schedule_email_on'                 => 'both',
			'schedule_email_recipients'        => '',
			'manual_backup_email_enabled'       => false,
			'manual_backup_email_on'            => 'both',
			'manual_backup_email_recipients'    => '',
			'manual_restore_email_enabled'      => false,
			'manual_restore_email_on'           => 'both',
			'manual_restore_email_recipients'   => '',
		];
		$saved = get_option( self::OPTION_SETTINGS, [] );
		if ( ! is_array( $saved ) ) {
			$saved = [];
		}
		$out = $defaults;
		foreach ( [ 'include_plugins', 'include_themes', 'include_uploads', 'include_others' ] as $k ) {
			if ( array_key_exists( $k, $saved ) ) {
				$out[ $k ] = (bool) $saved[ $k ];
			}
		}
		if ( array_key_exists( 'exclude_uploads', $saved ) && is_string( $saved['exclude_uploads'] ) ) {
			$out['exclude_uploads'] = self::sanitize_exclude_csv( $saved['exclude_uploads'] );
		}
		if ( array_key_exists( 'exclude_others', $saved ) && is_string( $saved['exclude_others'] ) ) {
			$out['exclude_others'] = self::sanitize_exclude_csv( $saved['exclude_others'] );
		}
		if ( isset( $saved['retain_completed'] ) ) {
			$out['retain_completed'] = max( 0, min( 100, (int) $saved['retain_completed'] ) );
		}
		// One-time relax: legacy defaults used backup* which skipped any path segment starting with “backup”.
		if ( $out['exclude_others'] === 'upgrade,cache,updraft,backup*,*backups,mysql.sql,debug.log' ) {
			$out['exclude_others'] = self::default_exclude_others();
		}
		$prev_others_no_dl = 'upgrade,cache,updraft,backwpup*,*backups,mysql.sql,debug.log';
		if ( $out['exclude_others'] === $prev_others_no_dl ) {
			$out['exclude_others'] = self::default_exclude_others();
		}
		if ( $out['exclude_uploads'] === 'backup*,*backups,backups,backwpup*,wp-clone,snapshots,wp-staging' ) {
			$out['exclude_uploads'] = self::default_exclude_uploads();
		}

		foreach ( self::schedule_keys() as $sk ) {
			if ( array_key_exists( $sk, $saved ) ) {
				$out[ $sk ] = $saved[ $sk ];
			}
		}
		$out['schedule_enabled']       = ! empty( $out['schedule_enabled'] );
		$out['schedule_email_enabled'] = ! empty( $out['schedule_email_enabled'] );
		$out['schedule_day_of_week']   = (int) $out['schedule_day_of_week'];
		$out['schedule_day_of_month']  = (int) $out['schedule_day_of_month'];

		foreach ( self::manual_email_keys() as $mk ) {
			if ( array_key_exists( $mk, $saved ) ) {
				$out[ $mk ] = $saved[ $mk ];
			}
		}
		$out['manual_backup_email_enabled']  = ! empty( $out['manual_backup_email_enabled'] );
		$out['manual_restore_email_enabled'] = ! empty( $out['manual_restore_email_enabled'] );
		if ( ! in_array( $out['manual_backup_email_on'] ?? 'both', [ 'both', 'failure' ], true ) ) {
			$out['manual_backup_email_on'] = 'both';
		}
		if ( ! in_array( $out['manual_restore_email_on'] ?? 'both', [ 'both', 'failure' ], true ) ) {
			$out['manual_restore_email_on'] = 'both';
		}

		return $out;
	}

	/** @return list<string> */
	private static function schedule_keys(): array {
		return [
			'schedule_enabled',
			'schedule_frequency',
			'schedule_time',
			'schedule_day_of_week',
			'schedule_day_of_month',
			'schedule_backup_type',
			'schedule_email_enabled',
			'schedule_email_on',
			'schedule_email_recipients',
		];
	}

	/** @return list<string> */
	private static function manual_email_keys(): array {
		return [
			'manual_backup_email_enabled',
			'manual_backup_email_on',
			'manual_backup_email_recipients',
			'manual_restore_email_enabled',
			'manual_restore_email_on',
			'manual_restore_email_recipients',
		];
	}

	/**
	 * @param array<string, mixed> $settings Raw POST-shaped input.
	 */
	public static function save_settings_array( array $settings ): void {
		$existing = get_option( self::OPTION_SETTINGS, [] );
		if ( ! is_array( $existing ) ) {
			$existing = [];
		}
		$clean = [
			'include_plugins'  => ! empty( $settings['include_plugins'] ),
			'include_themes'   => ! empty( $settings['include_themes'] ),
			'include_uploads'  => ! empty( $settings['include_uploads'] ),
			'include_others'   => ! empty( $settings['include_others'] ),
			'exclude_uploads'  => isset( $settings['exclude_uploads'] )
				? self::sanitize_exclude_csv( (string) $settings['exclude_uploads'] )
				: self::default_exclude_uploads(),
			'exclude_others'   => isset( $settings['exclude_others'] )
				? self::sanitize_exclude_csv( (string) $settings['exclude_others'] )
				: self::default_exclude_others(),
			'retain_completed' => isset( $settings['retain_completed'] )
				? max( 0, min( 100, (int) $settings['retain_completed'] ) )
				: 0,
			'log_retain_days'  => isset( $settings['log_retain_days'] )
				? max( 0, min( 365, (int) $settings['log_retain_days'] ) )
				: 30,
			'manual_backup_email_enabled' => ! empty( $settings['manual_backup_email_enabled'] ),
			'manual_backup_email_on'      => in_array( $settings['manual_backup_email_on'] ?? '', [ 'both', 'failure' ], true )
				? (string) $settings['manual_backup_email_on'] : 'both',
			'manual_backup_email_recipients' => isset( $settings['manual_backup_email_recipients'] )
				? sanitize_textarea_field( (string) $settings['manual_backup_email_recipients'] ) : '',
			'manual_restore_email_enabled' => ! empty( $settings['manual_restore_email_enabled'] ),
			'manual_restore_email_on'      => in_array( $settings['manual_restore_email_on'] ?? '', [ 'both', 'failure' ], true )
				? (string) $settings['manual_restore_email_on'] : 'both',
			'manual_restore_email_recipients' => isset( $settings['manual_restore_email_recipients'] )
				? sanitize_textarea_field( (string) $settings['manual_restore_email_recipients'] ) : '',
		];
		foreach ( self::schedule_keys() as $sk ) {
			if ( array_key_exists( $sk, $existing ) ) {
				$clean[ $sk ] = $existing[ $sk ];
			}
		}
		update_option( self::OPTION_SETTINGS, $clean, false );
	}

	/**
	 * Merge schedule-only keys into the settings option without overwriting backup settings.
	 *
	 * @param array<string, mixed> $schedule
	 */
	public static function save_schedule_settings( array $schedule ): void {
		$existing = get_option( self::OPTION_SETTINGS, [] );
		if ( ! is_array( $existing ) ) {
			$existing = [];
		}
		foreach ( self::schedule_keys() as $sk ) {
			if ( array_key_exists( $sk, $schedule ) ) {
				$existing[ $sk ] = $schedule[ $sk ];
			}
		}
		update_option( self::OPTION_SETTINGS, $existing, false );
	}

	/**
	 * Seconds to extend backup lock lease on each tick (Updraft-style; avoids stale locks if PHP dies).
	 *
	 * @return int
	 */
	private function backup_lock_ttl(): int {
		return max( 120, (int) apply_filters( 'wpps_br_backup_lock_ttl_seconds', 30 * MINUTE_IN_SECONDS ) );
	}

	private function ensure_backup_lock_options(): void {
		if ( false === $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, false ) ) {
			add_option( self::OPTION_BACKUP_LOCK_UNTIL, '0', '', false );
			$this->bust_backup_lock_cache();
		}
		if ( false === $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, false ) ) {
			add_option( self::OPTION_BACKUP_LOCK_JOB, '', '', false );
			$this->bust_backup_lock_cache();
		}
	}

	/**
	 * Object-cache hosts keep stale option values after direct SQL UPDATEs — always bust first.
	 */
	private function bust_backup_lock_cache(): void {
		wp_cache_delete( self::OPTION_BACKUP_LOCK_UNTIL, 'options' );
		wp_cache_delete( self::OPTION_BACKUP_LOCK_JOB, 'options' );
		wp_cache_delete( 'notoptions', 'options' );
		wp_cache_delete( 'alloptions', 'options' );
	}

	/**
	 * @param mixed $default
	 * @return mixed
	 */
	private function get_lock_option( string $name, $default = false ) {
		$this->bust_backup_lock_cache();
		return get_option( $name, $default );
	}

	/**
	 * @param mixed $value
	 */
	private function set_lock_option( string $name, $value ): void {
		update_option( $name, $value, false );
		$this->bust_backup_lock_cache();
	}

	/**
	 * Move pre-1.4.2 transients into options once so in-flight backups are not stranded.
	 */
	private function migrate_legacy_transient_backup_lock(): void {
		$old = get_transient( self::TRANSIENT_LOCK );
		if ( ! $old ) {
			return;
		}
		$job = (string) $old;
		delete_transient( self::TRANSIENT_LOCK );
		delete_transient( self::TRANSIENT_LOCK_TOUCH );
		$this->ensure_backup_lock_options();
		$until = (int) $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, 0 );
		$cur   = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		if ( $until > time() && $cur !== '' ) {
			return;
		}
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_JOB, $job );
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, (string) ( time() + $this->backup_lock_ttl() ) );
	}

	/**
	 * @return string Active holder when lease valid, else ''.
	 */
	private function get_active_backup_lock_job(): string {
		$until = (int) $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, 0 );
		$job   = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		if ( $until <= time() || '' === $job ) {
			return '';
		}
		return $job;
	}

	public function acquire_lock( string $job_id = '' ): bool {
		$this->migrate_legacy_transient_backup_lock();
		$this->ensure_backup_lock_options();
		$now   = time();
		$ttl   = $this->backup_lock_ttl();
		$until = (int) $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, 0 );
		$held  = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );

		if ( $until > $now && '' !== $held ) {
			if ( '' !== $job_id && $held === $job_id ) {
				$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, (string) ( $now + $ttl ) );
				return true;
			}
			return false;
		}

		global $wpdb;
		$new_until = (string) ( $now + $ttl );
		$rows        = (int) $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) <= %d",
				$new_until,
				self::OPTION_BACKUP_LOCK_UNTIL,
				$now
			)
		);
		// Direct SQL does not update object cache — force a fresh read path.
		$this->bust_backup_lock_cache();

		if ( 1 !== $rows ) {
			$until = (int) $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, 0 );
			$held  = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
			if ( $until > $now && '' !== $held ) {
				if ( '' !== $job_id && $held === $job_id ) {
					$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, (string) ( $now + $ttl ) );
					return true;
				}
				return false;
			}
			return false;
		}
		$val = '' !== $job_id ? $job_id : (string) $now;
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_JOB, $val );
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, $new_until );
		return true;
	}

	/**
	 * Refresh lock lease (each backup AJAX tick), mirroring Updraft last-lock refresh.
	 */
	public function touch_backup_lock(): void {
		$this->migrate_legacy_transient_backup_lock();
		$held = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		if ( '' === $held ) {
			return;
		}
		$now = time();
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, (string) ( $now + $this->backup_lock_ttl() ) );
	}

	public function release_lock(): void {
		delete_transient( self::TRANSIENT_LOCK );
		delete_transient( self::TRANSIENT_LOCK_TOUCH );
		$this->ensure_backup_lock_options();
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, '0' );
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
	}

	/**
	 * True if the backup lock should be cleared (expired lease, bad holder, or missing job state).
	 */
	public function is_backup_lock_abandoned(): bool {
		$this->migrate_legacy_transient_backup_lock();
		$until = (int) $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, 0 );
		$held  = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		if ( '' === $held ) {
			return false;
		}
		$now    = time();
		$held_s = $held;

		if ( $until <= $now ) {
			return true;
		}
		if ( ctype_digit( $held_s ) ) {
			return ( $now - (int) $held_s ) > 15 * MINUTE_IN_SECONDS;
		}
		if ( ! self::is_valid_backup_folder_name( $held_s ) ) {
			return true;
		}
		if ( ! is_array( $this->get_job_state( $held_s ) ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Drop an abandoned lock and job state (partial files remain on disk).
	 */
	public function clear_abandoned_backup_lock(): bool {
		if ( ! $this->is_backup_lock_abandoned() ) {
			return false;
		}
		$held = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		$this->release_lock();
		if ( '' !== $held && self::is_valid_backup_folder_name( $held ) ) {
			$this->delete_job_state( $held );
		}
		return true;
	}

	/**
	 * Admin escape hatch: clear lock + job state regardless of activity (use after a stuck UI).
	 */
	public function force_clear_backup_lock(): void {
		$this->migrate_legacy_transient_backup_lock();
		$held = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		if ( '' === $held ) {
			$legacy = get_transient( self::TRANSIENT_LOCK );
			if ( is_string( $legacy ) && $legacy !== '' ) {
				$held = (string) $legacy;
			}
		}
		if ( '' !== $held && self::is_valid_backup_folder_name( $held ) ) {
			$this->delete_job_state( $held );
		}
		$this->release_lock();

		// Flush object cache first so get_transient reads from DB.
		wp_cache_delete( self::TRANSIENT_RESTORE_LOCK, 'transient' );
		wp_cache_delete( self::TRANSIENT_RESTORE_TOUCH, 'transient' );
		wp_cache_delete( self::TRANSIENT_RESTORE_AJAX_JOB, 'transient' );

		$rh = get_transient( self::TRANSIENT_RESTORE_LOCK );
		if ( is_string( $rh ) && $rh !== '' && self::is_valid_backup_folder_name( $rh ) ) {
			$this->delete_restore_state( $rh );
			$this->cleanup_restore_workdir( $rh );
			$this->delete_restore_manifests( trailingslashit( $this->get_backup_root() ) . $rh );
		}
		delete_transient( self::TRANSIENT_RESTORE_LOCK );
		delete_transient( self::TRANSIENT_RESTORE_TOUCH );
		delete_transient( self::TRANSIENT_RESTORE_AJAX_JOB );
		self::clear_legacy_lock_transients();

		// Belt-and-suspenders: directly remove rows from DB in case object cache prevented delete_transient from working.
		global $wpdb;
		$restore_keys = [
			'_transient_' . self::TRANSIENT_RESTORE_LOCK,
			'_transient_timeout_' . self::TRANSIENT_RESTORE_LOCK,
			'_transient_' . self::TRANSIENT_RESTORE_TOUCH,
			'_transient_timeout_' . self::TRANSIENT_RESTORE_TOUCH,
			'_transient_' . self::TRANSIENT_RESTORE_AJAX_JOB,
			'_transient_timeout_' . self::TRANSIENT_RESTORE_AJAX_JOB,
		];
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			"DELETE FROM `{$wpdb->options}` WHERE option_name IN ("
			. implode( ',', array_map( static function ( string $k ) use ( $wpdb ) { return $wpdb->prepare( '%s', $k ); }, $restore_keys ) )
			. ')'
		);
		// Final cache flush after direct DB delete.
		wp_cache_delete( self::TRANSIENT_RESTORE_LOCK, 'transient' );
		wp_cache_delete( self::TRANSIENT_RESTORE_TOUCH, 'transient' );
		wp_cache_delete( self::TRANSIENT_RESTORE_AJAX_JOB, 'transient' );

		// Clean up orphaned .restore-staging and .wpps-restore-job.json from ALL backup dirs.
		// After a site reset the transient is gone, so we don't know which backup was active.
		$root = $this->get_backup_root();
		if ( is_dir( $root ) ) {
			$dirs = glob( trailingslashit( $root ) . 'backup-*', GLOB_ONLYDIR );
			if ( is_array( $dirs ) ) {
				foreach ( $dirs as $dir ) {
					$bid = basename( $dir );
					if ( ! self::is_valid_backup_folder_name( $bid ) ) {
						continue;
					}
					$staging = wp_normalize_path( trailingslashit( $dir ) . '.restore-staging' );
					if ( is_dir( $staging ) ) {
						$this->recursive_delete( $staging );
					}
					$job_json = trailingslashit( $dir ) . '.wpps-restore-job.json';
					if ( is_file( $job_json ) ) {
						// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
						@unlink( $job_json );
					}
					$this->delete_restore_manifests( $dir );
				}
			}
		}
	}

	/**
	 * Snapshot of lock-related state for support / browser console (admin AJAX).
	 *
	 * @return array<string, mixed>
	 */
	public function get_lock_debug_snapshot( string $module_version, ?string $restore_backup_probe_id = null ): array {
		$now   = time();
		$until = (int) $this->get_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, 0 );
		$job   = (string) $this->get_lock_option( self::OPTION_BACKUP_LOCK_JOB, '' );
		$probe = null;
		if ( is_string( $restore_backup_probe_id ) && '' !== $restore_backup_probe_id && $this->is_allowed_backup_folder_id( $restore_backup_probe_id ) ) {
			$probe = [
				'backup_id'                  => $restore_backup_probe_id,
				'restore_state_in_transient' => is_array( $this->get_restore_state( $restore_backup_probe_id ) ),
			];
		}

		return [
			'server_time_unix'          => $now,
			'server_time_iso_utc'       => gmdate( 'c', $now ),
			'module_version'            => $module_version,
			'br_service_engine'         => self::ENGINE_BUILD,
			'backup_lock_options'       => [
				'until_unix'              => $until,
				'until_iso_utc'           => $until > 0 ? gmdate( 'c', $until ) : '',
				'job'                     => $job,
				'lease_seconds_remaining' => $until > $now ? ( $until - $now ) : 0,
				'active_holder'           => $this->get_active_backup_lock_job(),
			],
			'restore_lock_transients'   => [
				'TRANSIENT_RESTORE_LOCK'  => get_transient( self::TRANSIENT_RESTORE_LOCK ),
				'TRANSIENT_RESTORE_TOUCH' => get_transient( self::TRANSIENT_RESTORE_TOUCH ),
			],
			'legacy_backup_transients'  => [
				'TRANSIENT_LOCK'       => get_transient( self::TRANSIENT_LOCK ),
				'TRANSIENT_LOCK_TOUCH' => get_transient( self::TRANSIENT_LOCK_TOUCH ),
			],
			'legacy_restore_transients_v1' => [
				'lock'  => get_transient( self::TRANSIENT_RESTORE_LOCK_LEGACY ),
				'touch' => get_transient( self::TRANSIENT_RESTORE_TOUCH_LEGACY ),
			],
			'restore_backup_blocked_message_removed_in' => '1.4.2',
			'hint'                      => 'That English sentence is not returned by BackupService v1.4.2+. ENGINE_BUILD and br_service_engine in this JSON match the PHP on disk. If your alert shows the old sentence without br_engine in the Network response, a different PHP file or cached opcode is executing.',
			'probe'                     => $probe,
		];
	}

	private function job_transient_key( string $backup_id ): string {
		return self::TRANSIENT_JOB . $backup_id;
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function save_job_state( string $backup_id, array $state ): void {
		$backup_id = sanitize_file_name( $backup_id );
		$job_dir   = isset( $state['job_dir'] ) ? (string) $state['job_dir'] : '';
		if ( $job_dir === '' && $backup_id !== '' ) {
			$job_dir = $this->locate_backup_dir( $backup_id );
			if ( $job_dir === '' ) {
				$job_dir = $this->get_primary_backup_dir( $backup_id );
			}
			$state['job_dir'] = $job_dir;
		}
		if ( $job_dir !== '' && is_dir( $job_dir ) ) {
			$this->write_backup_job_state_file( $job_dir, $state );
		}

		$key = $this->job_transient_key( $backup_id );
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::setJobState( $key, $state, self::JOB_TTL );
			return;
		}
		set_transient( $key, $state, self::JOB_TTL );
	}

	/**
	 * @return array<string, mixed>|null
	 */
	public function get_job_state( string $backup_id ): ?array {
		$backup_id = sanitize_file_name( $backup_id );
		$from_disk = $this->read_backup_job_state_file( $backup_id );

		$key = $this->job_transient_key( $backup_id );
		$s   = class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class )
			? \WP_PowerSuite\Core\LongAjaxResponse::getJobState( $key )
			: 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 );
			return $disk_i >= $mem_i ? $from_disk : $s;
		}
		if ( is_array( $from_disk ) ) {
			return $from_disk;
		}

		return is_array( $s ) ? $s : null;
	}

	public function delete_job_state( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		$key       = $this->job_transient_key( $backup_id );
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::deleteJobState( $key );
		} else {
			delete_transient( $key );
		}
		$path = $this->backup_job_state_file_path( $backup_id );
		if ( is_file( $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $path );
		}
	}

	private function backup_job_state_file_path( string $backup_id ): string {
		$backup_id = sanitize_file_name( $backup_id );
		if ( $backup_id === '' ) {
			return '';
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' ) {
			$primary = $this->get_primary_backup_dir( $backup_id );
			$dir     = $primary !== '' ? trailingslashit( $primary ) : '';
		}
		if ( $dir === '' ) {
			return '';
		}

		return $dir . 'job-state.json';
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function write_backup_job_state_file( string $job_dir, array $state ): void {
		$path = trailingslashit( $job_dir ) . 'job-state.json';
		$json = wp_json_encode( $state );
		if ( ! is_string( $json ) || $json === '' ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $path, $json, LOCK_EX );
	}

	/**
	 * @return array<string, mixed>|null
	 */
	private function read_backup_job_state_file( string $backup_id ): ?array {
		$path = $this->backup_job_state_file_path( $backup_id );
		if ( $path === '' || ! 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;
		}
		$decoded = json_decode( $raw, true );

		return is_array( $decoded ) ? $decoded : null;
	}

	/**
	 * Abort in-progress job (admin action).
	 */
	public function abort_job( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return;
		}
		$this->migrate_legacy_transient_backup_lock();
		$held = $this->get_active_backup_lock_job();
		if ( $held !== $backup_id ) {
			return;
		}
		$this->delete_job_state( $backup_id );
		$this->release_lock();
		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( is_readable( $dir . '/manifest.json' ) ) {
			return;
		}
		if ( is_dir( $dir ) ) {
			$this->mysqldump_try_terminate_async_dump( $dir . '/database.sql' );
			$this->recursive_delete( $dir );
		}
	}

	/**
	 * @param array<string, mixed> $settings From get_settings().
	 * @return list<string>
	 */
	private function build_step_plan( string $type, array $settings ): array {
		$plan = [];
		if ( in_array( $type, [ 'full', 'database' ], true ) ) {
			$plan[] = 'database';
		}
		if ( in_array( $type, [ 'full', 'files' ], true ) ) {
			if ( ! empty( $settings['include_plugins'] ) ) {
				$plan[] = 'zip_plugins';
			}
			if ( ! empty( $settings['include_themes'] ) ) {
				$plan[] = 'zip_themes';
			}
			if ( ! empty( $settings['include_uploads'] ) ) {
				$plan[] = 'zip_uploads';
			}
			if ( ! empty( $settings['include_others'] ) ) {
				$plan[] = 'zip_others';
			}
		}
		$plan[] = 'finalize';
		return $plan;
	}

	/**
	 * Whether a normalized file path is inside an excluded directory (prefix-safe).
	 */
	private function path_is_excluded( string $file_path, string $exclude_dir_norm ): bool {
		$ex = rtrim( $exclude_dir_norm, '/' );
		$fp = rtrim( $file_path, '/' );
		if ( $fp === $ex ) {
			return true;
		}
		return str_starts_with( $fp, $ex . '/' );
	}

	/**
	 * One comma-separated token vs a single path segment (Updraft-style: backup*, *backups, exact).
	 */
	private function exclude_token_matches_segment( string $token_raw, string $segment ): bool {
		$seg = strtolower( $segment );
		$tok = strtolower( trim( $token_raw ) );
		if ( '' === $tok || '' === $seg || '.' === $seg || '..' === $seg ) {
			return false;
		}
		$has_star = str_contains( $tok, '*' );
		if ( ! $has_star ) {
			return $seg === $tok;
		}
		if ( str_ends_with( $tok, '*' ) && ! str_starts_with( $tok, '*' ) ) {
			$p = rtrim( $tok, '*' );
			return '' !== $p && str_starts_with( $seg, $p );
		}
		if ( str_starts_with( $tok, '*' ) && ! str_ends_with( $tok, '*' ) ) {
			$p = ltrim( $tok, '*' );
			return '' !== $p && str_ends_with( $seg, $p );
		}
		if ( str_starts_with( $tok, '*' ) && str_ends_with( $tok, '*' ) && strlen( $tok ) > 2 ) {
			$mid = substr( $tok, 1, -1 );
			return '' !== $mid && str_contains( $seg, $mid );
		}
		return false;
	}

	/**
	 * Match relative path (under uploads/ or under wp-content/) against comma-separated exclusions saved in Settings.
	 *
	 * @param string $rel_path   Relative path with forward slashes.
	 * @param string $scope      'uploads' or 'others' (file-basename-only rules apply to others for tokens with a dot and no '*').
	 * @param string $csv        Comma-separated list from settings.
	 */
	private function relative_path_matches_exclude_csv( string $rel_path, string $scope, string $csv ): bool {
		$rel_path = str_replace( '\\', '/', ltrim( $rel_path, '/' ) );
		if ( '' === $rel_path ) {
			return false;
		}
		$tokens = array_filter( array_map( 'trim', explode( ',', $csv ) ) );
		if ( count( $tokens ) === 0 ) {
			return false;
		}
		$segments = explode( '/', $rel_path );
		$basename = strtolower( basename( $rel_path ) );
		foreach ( $tokens as $t ) {
			if ( '' === $t ) {
				continue;
			}
			if ( 'others' === $scope && ! str_contains( $t, '/' ) && ! str_contains( $t, '*' ) && str_contains( strtolower( $t ), '.' ) ) {
				if ( $basename === strtolower( $t ) ) {
					return true;
				}
				continue;
			}
			foreach ( $segments as $seg_item ) {
				if ( '' === $seg_item ) {
					continue;
				}
				if ( $this->exclude_token_matches_segment( $t, $seg_item ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Use STORE (no compression) for entries where DEFLATE is slow and helps little (matches common backup plugin behaviour).
	 */
	private function zip_path_should_store_only( string $full_path ): bool {
		if ( ! is_readable( $full_path ) || ! is_file( $full_path ) ) {
			return false;
		}
		$lower = strtolower( $full_path );
		$no_compress_ext = [ '.zip', '.gz', '.gzip', '.rar', '.7z', '.mp4', '.webm', '.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.woff', '.woff2', '.eot', '.pdf', '.xz', '.br' ];
		foreach ( $no_compress_ext as $ext ) {
			if ( str_ends_with( $lower, $ext ) ) {
				return true;
			}
		}
		$sz = @filesize( $full_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		return is_int( $sz ) && $sz > 20971520; // 20 MiB
	}

	/**
	 * @param \ZipArchive $zip Archive open for writing; entry must exist (after addFile).
	 */
	private function zip_set_compression_for_file( \ZipArchive $zip, string $zip_internal_name, string $full_path ): void {
		if ( ! $this->zip_path_should_store_only( $full_path ) ) {
			return;
		}
		if ( ! method_exists( $zip, 'setCompressionName' ) ) {
			return;
		}
		// CM_STORE: much faster for .zip/.sql dumps / media; avoids multi-minute stalls on huge blobs.
		$zip->setCompressionName( $zip_internal_name, \ZipArchive::CM_STORE ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName -- API
	}

	/**
	 * Single AJAX tick: start a new job (backup_id empty) or run the next step.
	 *
	 * @return array<string, mixed>
	 */
	public function backup_tick( ?string $backup_id, string $type, string $label ): array {
		$type = strtolower( $type );
		if ( ! in_array( $type, [ 'full', 'database', 'files' ], true ) ) {
			return [
				'success' => false,
				'message' => __( 'Invalid backup type.', 'wp-powersuite' ),
			];
		}

		if ( null === $backup_id || '' === $backup_id ) {
			return $this->backup_job_start( $type, $label );
		}

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

	/**
	 * Run a complete backup synchronously (for WP-Cron scheduled backups).
	 * Loops through backup_tick() until done, same pipeline as the AJAX UI.
	 *
	 * @return array<string, mixed>
	 */
	public function run_scheduled_backup( string $type, string $label ): array {
		@set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged

		if ( function_exists( 'ignore_user_abort' ) ) {
			ignore_user_abort( true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.ignore_user_abort_ignore_user_abort
		}

		$start_time = microtime( true );
		$deadline   = $start_time + (float) self::CRON_BACKUP_WALL_SECONDS;
		$bid        = '';
		$result     = [];

		$continuation = get_transient( self::TRANSIENT_CRON_BACKUP_JOB );
		if ( is_array( $continuation ) && ! empty( $continuation['backup_id'] ) ) {
			$bid   = sanitize_file_name( (string) $continuation['backup_id'] );
			$type  = is_string( $continuation['type'] ?? null ) ? (string) $continuation['type'] : $type;
			$label = is_string( $continuation['label'] ?? null ) ? (string) $continuation['label'] : $label;
			delete_transient( self::TRANSIENT_CRON_BACKUP_JOB );
			$result = [
				'success'   => true,
				'backup_id' => $bid,
				'done'      => false,
			];
			$this->cron_owned_job = $bid;
		} else {
			$result = $this->backup_tick( null, $type, $label );
			if ( empty( $result['success'] ) ) {
				$result['duration_seconds'] = (int) round( microtime( true ) - $start_time );
				return $result;
			}
			$bid                  = $result['backup_id'] ?? '';
			$this->cron_owned_job = $bid;
		}

		while ( '' !== $bid && empty( $result['done'] ) ) {
			if ( microtime( true ) >= $deadline ) {
				set_transient(
					self::TRANSIENT_CRON_BACKUP_JOB,
					[
						'backup_id' => $bid,
						'type'      => $type,
						'label'     => $label,
					],
					2 * HOUR_IN_SECONDS
				);
				wp_schedule_single_event( time() + 60, 'wpps_br_scheduled_backup' );
				$result['cron_continued'] = true;
				break;
			}
			$this->touch_backup_lock();
			$result = $this->backup_tick( $bid, $type, $label );
			if ( empty( $result['success'] ) ) {
				delete_transient( self::TRANSIENT_CRON_BACKUP_JOB );
				break;
			}
		}

		if ( ! empty( $result['done'] ) ) {
			delete_transient( self::TRANSIENT_CRON_BACKUP_JOB );
		}

		$this->cron_owned_job       = '';
		$result['duration_seconds'] = (int) round( microtime( true ) - $start_time );

		if ( ! empty( $result['done'] ) && '' !== $bid ) {
			$result['backup_details'] = $this->gather_backup_details( $bid );
		}

		return $result;
	}

	/**
	 * Collect component file sizes and counts for a completed backup.
	 *
	 * @return array{components: array<string, int>, total_size: int, wp_version: string}
	 */
	private function gather_backup_details( string $backup_id ): array {
		$dir        = trailingslashit( $this->get_backup_root() ) . $backup_id;
		$components = [];
		$total      = 0;

		foreach ( self::storage_basename_map() as $comp => $filename ) {
			$path = $dir . '/' . $filename;
			if ( is_file( $path ) ) {
				$size = (int) filesize( $path );
				$components[ $comp ] = $size;
				$total += $size;
			}
		}

		return [
			'components' => $components,
			'total_size' => $total,
			'wp_version' => get_bloginfo( 'version' ),
		];
	}

	/**
	 * When set, backup_job_run_step() skips the external lock ownership check.
	 * Prevents a page refresh or WP object-cache flush from killing a cron backup.
	 */
	private string $cron_owned_job = '';

	// -------------------------------------------------------------------------
	// Backup job lifecycle
	// -------------------------------------------------------------------------

	/**
	 * Generate a non-guessable backup folder id.
	 *
	 * @param string $prefix Folder name prefix (e.g. backup, backup-upload).
	 */
	public function generate_unique_backup_id( string $prefix = 'backup' ): string {
		$prefix = sanitize_key( $prefix );
		if ( $prefix === '' ) {
			$prefix = 'backup';
		}

		do {
			$id = $prefix . '-' . strtolower( wp_generate_password( 12, false, false ) ) . '-' . gmdate( 'Ymd-His' );
		} while ( is_dir( trailingslashit( $this->get_backup_root() ) . $id ) );

		return $id;
	}

	/**
	 * @return array<string, mixed>
	 */
	private function backup_job_start( string $type, string $label ): array {
		$root = $this->resolve_backup_root();
		if ( is_wp_error( $root ) ) {
			return [
				'success' => false,
				'message' => $root->get_error_message(),
				'code'    => $root->get_error_code(),
			];
		}
		if ( ! $this->ensure_backup_root() ) {
			return [
				'success' => false,
				'message' => PrivateStorageResolver::configuration_help_message(),
				'code'    => 'wpps_br_no_private_storage',
			];
		}

		$this->clear_abandoned_backup_lock();
		self::clear_legacy_lock_transients();

		wp_cache_delete( self::TRANSIENT_RESTORE_LOCK, 'transient' );
		$restore_held = get_transient( self::TRANSIENT_RESTORE_LOCK );
		if ( $restore_held && ! $this->is_restore_lock_stale() ) {
			return [
				'success'       => false,
				'message'       => __( 'A restore is running. Stop or finish it before starting a backup.', 'wp-powersuite' ),
				'lock_conflict' => true,
			];
		}
		if ( $restore_held && $this->is_restore_lock_stale() ) {
			$this->release_restore_lock();
		}

		if ( ! $this->acquire_lock( '' ) ) {
			return [
				'success'        => false,
				'message'        => __( 'Another backup is already running. If you closed the tab during a backup, wait a few minutes or use “Clear stuck backup lock” below.', 'wp-powersuite' ),
				'lock_conflict'  => true,
			];
		}

		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( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Per-step budget; host may cap.
		}

		$backup_id = $this->generate_unique_backup_id();
		$job_dir   = trailingslashit( $this->get_backup_root() ) . $backup_id;

		if ( ! wp_mkdir_p( $job_dir ) ) {
			$this->release_lock();
			return [ 'success' => false, 'message' => __( 'Could not create backup job folder.', 'wp-powersuite' ) ];
		}

		try {
			$this->open_job_log( $job_dir );
		} catch ( \Throwable $e ) {
			$this->recursive_delete( $job_dir );
			$this->release_lock();
			return [ 'success' => false, 'message' => $e->getMessage() ];
		}

		$this->log_line(
			sprintf(
				/* translators: %s: backup identifier */
				__( 'Opened log for job %s', 'wp-powersuite' ),
				$backup_id
			)
		);

		$settings    = self::get_settings();
		$any_include = ! empty( $settings['include_plugins'] )
			|| ! empty( $settings['include_themes'] )
			|| ! empty( $settings['include_uploads'] )
			|| ! empty( $settings['include_others'] );
		if ( 'files' === $type && ! $any_include ) {
			$this->log_line( __( 'No file groups selected. Open Settings and enable at least one inclusion, or choose “Database only”.', 'wp-powersuite' ) );
			$this->close_job_log();
			$this->recursive_delete( $job_dir );
			$this->release_lock();
			return [
				'success' => false,
				'message' => __( 'No file backup groups are enabled. Use Settings or pick “Database only”.', 'wp-powersuite' ),
			];
		}
		$needs_files = ( 'files' === $type ) || ( 'full' === $type && $any_include );
		$check       = $this->run_precheck( $needs_files );
		foreach ( $check['lines'] as $ln ) {
			$this->log_line( $ln );
		}

		if ( ! $check['ok'] ) {
			$this->log_line( __( 'Pre-check failed; backup aborted.', 'wp-powersuite' ) );
			$this->close_job_log();
			$this->recursive_delete( $job_dir );
			$this->release_lock();
			return [
				'success'   => false,
				'message'   => __( 'Pre-check failed. See the log panel for details.', 'wp-powersuite' ),
				'backup_id' => $backup_id,
			];
		}

		$plan  = $this->build_step_plan( $type, $settings );
		$state = [
			'backup_id'         => $backup_id,
			'job_dir'           => $job_dir,
			'type'              => $type,
			'label'             => $label,
			'wall_started_at'   => time(),
			'step_index'        => 0,
			'plan'              => $plan,
			'check'             => $check,
			'components'        => [],
			'files_map'         => [],
			'exclude_uploads_csv' => (string) $settings['exclude_uploads'],
			'exclude_others_csv'  => (string) $settings['exclude_others'],
		];
		$this->save_job_state( $backup_id, $state );
		delete_transient( self::TRANSIENT_LOCK );
		delete_transient( self::TRANSIENT_LOCK_TOUCH );
		$this->ensure_backup_lock_options();
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_JOB, $backup_id );
		$this->set_lock_option( self::OPTION_BACKUP_LOCK_UNTIL, (string) ( time() + $this->backup_lock_ttl() ) );
		$excerpt = $this->read_log_tail( 8000 );
		$this->close_job_log();

		$total = count( $plan );
		return [
			'success'       => true,
			'backup_id'     => $backup_id,
			'done'          => false,
			'progress'      => 5,
			'message'       => __( 'Pre-check completed. Starting backup…', 'wp-powersuite' ),
			'log_line'      => __( 'Pre-check completed. Starting backup…', 'wp-powersuite' ),
			'total_steps'   => $total,
			'current_step'  => 0,
			'log_excerpt'   => $excerpt,
		];
	}

	/**
	 * Fraction of the current plan step that is done (0–1), for smooth overall progress.
	 *
	 * @param array<string, mixed> $state
	 */
	private function backup_step_internal_fraction( array $state, string $step ): float {
		switch ( $step ) {
			case 'database':
				if ( ! empty( $state['db_mysqldump_async'] ) ) {
					return 0.45;
				}
				$tables = isset( $state['db_php_tables'] ) && is_array( $state['db_php_tables'] ) ? $state['db_php_tables'] : [];
				$n      = count( $tables );
				if ( $n < 1 ) {
					return ! empty( $state['db_php_started'] ) ? 0.12 : 0.06;
				}
				$idx      = isset( $state['db_php_table_index'] ) ? (int) $state['db_php_table_index'] : 0;
				$offset   = isset( $state['db_php_row_offset'] ) ? (int) $state['db_php_row_offset'] : 0;
				$per      = 1.0 / $n;
				$base     = min( 1.0, $idx * $per );
				$row_bump = ( $offset > 0 ) ? min( $per * 0.9, 1.0 - $base ) : 0.0;

				return min( 1.0, $base + $row_bump );
			case 'zip_plugins':
				$t = isset( $state['plugins_queue_total'] ) ? (int) $state['plugins_queue_total'] : 0;
				$o = isset( $state['plugins_queue_offset'] ) ? (int) $state['plugins_queue_offset'] : 0;
				return ( $t > 0 ) ? min( 1.0, $o / $t ) : 0.0;
			case 'zip_themes':
				$t = isset( $state['themes_queue_total'] ) ? (int) $state['themes_queue_total'] : 0;
				$o = isset( $state['themes_queue_offset'] ) ? (int) $state['themes_queue_offset'] : 0;
				return ( $t > 0 ) ? min( 1.0, $o / $t ) : 0.0;
			case 'zip_uploads':
				$t = isset( $state['uploads_queue_total'] ) ? (int) $state['uploads_queue_total'] : 0;
				$o = isset( $state['uploads_queue_offset'] ) ? (int) $state['uploads_queue_offset'] : 0;
				return ( $t > 0 ) ? min( 1.0, $o / $t ) : 0.0;
			case 'zip_others':
				$t = isset( $state['others_queue_total'] ) ? (int) $state['others_queue_total'] : 0;
				$o = isset( $state['others_queue_offset'] ) ? (int) $state['others_queue_offset'] : 0;
				return ( $t > 0 ) ? min( 1.0, $o / $t ) : 0.0;
			case 'finalize':
				return 0.0;
			default:
				return 0.0;
		}
	}

	/**
	 * Overall backup progress 1–99 while running; 100 when the job has finished.
	 *
	 * @param array<string, mixed> $state
	 */
	private function compute_backup_progress_percent( array $state, int $plan_index, int $plan_total, bool $job_done ): int {
		if ( $job_done ) {
			return 100;
		}
		if ( $plan_total < 1 ) {
			return 99;
		}
		$plan_index = max( 0, $plan_index );
		if ( $plan_index >= $plan_total ) {
			return 99;
		}
		$plan = isset( $state['plan'] ) && is_array( $state['plan'] ) ? $state['plan'] : [];
		$step = (string) ( $plan[ $plan_index ] ?? '' );
		$f    = $this->backup_step_internal_fraction( $state, $step );
		$pct  = ( ( $plan_index + $f ) / $plan_total ) * 100;

		return (int) min( 99, max( 1, round( $pct ) ) );
	}

	/**
	 * @param array<string, mixed> $state
	 * @return array<string, mixed>
	 */
	private function step_ajax_payload(
		string $backup_id,
		array $state,
		int $index_after_steps,
		int $total,
		string $ui_message,
		bool $done,
		bool $release_lock
	): array {
		$type_snap    = isset( $state['type'] ) ? (string) $state['type'] : '';
		$label_snap   = isset( $state['label'] ) ? (string) $state['label'] : '';
		$wall_started = isset( $state['wall_started_at'] ) ? (int) $state['wall_started_at'] : 0;

		$backup_details = [];
		if ( $done ) {
			$backup_details = $this->gather_backup_details( $backup_id );
			$this->delete_job_state( $backup_id );
			if ( $release_lock ) {
				$this->release_lock();
			}
		} else {
			$this->save_job_state( $backup_id, $state );
		}

		$log_excerpt = $this->read_log_tail( 8000 );
		$this->close_job_log();

		$progress = $this->compute_backup_progress_percent( $state, $index_after_steps, $total, $done );

		$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,
		];
		if ( $done ) {
			$out['type']             = $type_snap;
			$out['label']            = $label_snap;
			$out['backup_details']   = $backup_details;
			$out['duration_seconds'] = $wall_started > 0 ? max( 0, time() - $wall_started ) : 0;
			$completion_message      = self::format_backup_success_message(
				[
					'label'          => $label_snap,
					'backup_details' => $backup_details,
				]
			);
			$out['message']  = $completion_message;
			$out['log_line'] = $completion_message;
		}

		return $out;
	}

	/**
	 * Human-readable summary when a backup job finishes successfully.
	 *
	 * @param array<string, mixed> $result
	 */
	public static function format_backup_success_message( array $result ): string {
		$label   = trim( (string) ( $result['label'] ?? '' ) );
		$details = isset( $result['backup_details'] ) && is_array( $result['backup_details'] ) ? $result['backup_details'] : [];
		$total   = (int) ( $details['total_size'] ?? 0 );

		if ( '' !== $label && $total > 0 ) {
			return sprintf(
				/* translators: 1: backup label 2: formatted archive size */
				__( 'Backup "%1$s" completed successfully. Total archive size: %2$s.', 'wp-powersuite' ),
				$label,
				size_format( $total, 1 )
			);
		}

		if ( $total > 0 ) {
			return sprintf(
				/* translators: %s: formatted total archive size */
				__( 'Backup completed successfully. Total archive size: %s.', 'wp-powersuite' ),
				size_format( $total, 1 )
			);
		}

		return __( 'Backup completed successfully. All selected components were archived without errors.', 'wp-powersuite' );
	}

	private function backup_job_run_step( string $backup_id ): array {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id ) {
			return [ 'success' => false, 'message' => __( 'Invalid job id.', 'wp-powersuite' ) ];
		}

		$state = $this->get_job_state( $backup_id );
		if ( ! is_array( $state ) ) {
			$this->release_lock();
			return [ 'success' => false, 'message' => __( 'Backup job expired or was cleared. Start again.', 'wp-powersuite' ) ];
		}

		$is_cron_owner = ( '' !== $this->cron_owned_job && $this->cron_owned_job === $backup_id );

		if ( ! $is_cron_owner ) {
			$this->migrate_legacy_transient_backup_lock();
			$held = $this->get_active_backup_lock_job();
			if ( $held !== $backup_id ) {
				// Object-cache hosts can briefly lose the lock lease while durable job state
				// (DB option / job-state.json) still exists. Reclaim when no other real job holds it.
				$can_reclaim = ( '' === $held || ctype_digit( $held ) );
				if ( $can_reclaim && $this->acquire_lock( $backup_id ) ) {
					$held = $backup_id;
				}
			}
			if ( $held !== $backup_id ) {
				return [ 'success' => false, 'message' => __( 'This backup job is no longer active.', 'wp-powersuite' ) ];
			}
		}

		$this->touch_backup_lock();

		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( 600 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		}

		$this->open_job_log( (string) $state['job_dir'] );

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

		if ( $index >= $total ) {
			$this->close_job_log();
			return [
				'success'         => true,
				'backup_id'       => $backup_id,
				'done'            => true,
				'progress'        => 100,
				'message'         => __( 'Backup already finished.', 'wp-powersuite' ),
				'type'            => isset( $state['type'] ) ? (string) $state['type'] : '',
				'label'           => isset( $state['label'] ) ? (string) $state['label'] : '',
				'backup_details'  => $this->gather_backup_details( $backup_id ),
				'duration_seconds' => 0,
			];
		}

		$step = (string) $plan[ $index ];
		$ui = $this->step_ui_message( $step );

		try {
			$step_complete = $this->execute_backup_step( $step, $state );
		} catch ( \Throwable $e ) {
			$this->log_line( __( 'Error: ', 'wp-powersuite' ) . $e->getMessage() );
			$log_excerpt = $this->read_log_tail( 8000 );
			$this->close_job_log();
			$this->delete_job_state( $backup_id );
			$this->release_lock();
			self::set_last_message( $e->getMessage() );
			return [
				'success'     => false,
				'message'     => $e->getMessage(),
				'backup_id'   => $backup_id,
				'log_line'    => $e->getMessage(),
				'log_excerpt' => $log_excerpt,
				'type'        => isset( $state['type'] ) ? (string) $state['type'] : '',
			];
		}

		if ( ! $step_complete ) {
			if ( isset( $state['uploads_progress_label'] ) && is_string( $state['uploads_progress_label'] ) && $state['uploads_progress_label'] !== '' ) {
				$ui = $state['uploads_progress_label'];
			} elseif ( isset( $state['others_progress_label'] ) && is_string( $state['others_progress_label'] ) && $state['others_progress_label'] !== '' ) {
				$ui = $state['others_progress_label'];
			} elseif ( isset( $state['plugins_progress_label'] ) && is_string( $state['plugins_progress_label'] ) && $state['plugins_progress_label'] !== '' ) {
				$ui = $state['plugins_progress_label'];
			} elseif ( isset( $state['themes_progress_label'] ) && is_string( $state['themes_progress_label'] ) && $state['themes_progress_label'] !== '' ) {
				$ui = $state['themes_progress_label'];
			} elseif ( isset( $state['db_export_progress_label'] ) && is_string( $state['db_export_progress_label'] ) && $state['db_export_progress_label'] !== '' ) {
				$ui = $state['db_export_progress_label'];
			}
			return $this->step_ajax_payload( $backup_id, $state, $index, $total, $ui, false, false );
		}

		$index++;
		$state['step_index'] = $index;
		$done                = $index >= $total;
		return $this->step_ajax_payload( $backup_id, $state, $index, $total, $ui, $done, true );
	}

	private function step_ui_message( string $step ): string {
		switch ( $step ) {
			case 'database':
				return __( 'Exporting database…', 'wp-powersuite' );
			case 'zip_plugins':
				return __( 'Creating archive (Plugins)…', 'wp-powersuite' );
			case 'zip_themes':
				return __( 'Creating archive (Themes)…', 'wp-powersuite' );
			case 'zip_uploads':
				return __( 'Creating archive (Uploads)…', 'wp-powersuite' );
			case 'zip_others':
				return __( 'Creating archive (Other wp-content)…', 'wp-powersuite' );
			case 'finalize':
				return __( 'Finalizing backup…', 'wp-powersuite' );
			default:
				return __( 'Working…', 'wp-powersuite' );
		}
	}

	/**
	 * Run one unit of work for the current plan step.
	 *
	 * @param array<string, mixed> $state
	 * @return bool True when this plan step is fully finished (advance plan). False when the same step needs another HTTP tick (chunked zip_others).
	 */
	private function execute_backup_step( string $step, array &$state ): bool {
		global $wpdb;
		$job_dir = (string) $state['job_dir'];
		$type    = (string) $state['type'];
		$label   = (string) $state['label'];
		/** @var array{lines: string[], zip_mode: string, db_mode: string, ok: bool} $check */
		$check = isset( $state['check'] ) && is_array( $state['check'] ) ? $state['check'] : [ 'db_mode' => 'php', 'ok' => true, 'lines' => [], 'zip_mode' => 'ziparchive' ];
		$components = isset( $state['components'] ) && is_array( $state['components'] ) ? $state['components'] : [];
		$files_map  = isset( $state['files_map'] ) && is_array( $state['files_map'] ) ? $state['files_map'] : [];

		switch ( $step ) {
			case 'database':
				$db_file = $job_dir . '/database.sql';
				$win     = strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN';
				$try_dump = ( 'mysqldump' === $check['db_mode'] )
					&& $this->find_mysqldump_binary()
					&& ! $win
					&& empty( $state['db_php_started'] );

				$dump_ok = false;
				if ( ! empty( $state['db_mysqldump_async'] ) ) {
					$poll = $this->mysqldump_poll_background_job( $db_file, $state );
					if ( null === $poll ) {
						$state['db_export_progress_label'] = __( 'Exporting database (mysqldump in background)…', 'wp-powersuite' );
						return false;
					}
					$dump_ok = ( true === $poll );
				} elseif ( $try_dump ) {
					if ( $this->mysqldump_shell_exec_available() ) {
						if ( $this->mysqldump_start_background_job( $job_dir, $db_file, $state ) ) {
							$state['db_export_progress_label'] = __( 'Exporting database (mysqldump in background)…', 'wp-powersuite' );
							return false;
						}
					}
					$dump_ok = $this->try_export_database_mysqldump( $db_file );
				}

				if ( ! $dump_ok ) {
					if ( $try_dump && is_file( $db_file ) ) {
						// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
						@unlink( $db_file );
					}
					$db_done = $this->export_database_php_resumable( $db_file, $state );
					if ( ! $db_done ) {
						return false;
					}
				}
				if ( ! file_exists( $db_file ) || ! filesize( $db_file ) ) {
					throw new \Exception( __( 'Database export produced no data.', 'wp-powersuite' ) );
				}
				$this->log_line(
					sprintf(
						__( 'Database export complete (%s).', 'wp-powersuite' ),
						size_format( filesize( $db_file ), 1 )
					)
				);
				$components[]          = 'database';
				$files_map['database']   = 'database.sql';
				$state['components']     = $components;
				$state['files_map']      = $files_map;
				return true;

			case 'zip_plugins':
				$plugins_done = $this->zip_plugins_chunked( $job_dir . '/plugins.zip', $job_dir, $state );
				if ( ! $plugins_done ) {
					return false;
				}
				$this->log_zip_done( 'plugins', $job_dir . '/plugins.zip' );
				$qp = trailingslashit( $job_dir ) . self::PLUGINS_QUEUE_BASENAME;
				if ( is_file( $qp ) ) {
					@unlink( $qp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				unset( $state['plugins_queue_offset'], $state['plugins_queue_total'], $state['plugins_progress_label'] );
				$components[]       = 'plugins';
				$files_map['plugins'] = 'plugins.zip';
				$state['components']  = $components;
				$state['files_map']   = $files_map;
				return true;

			case 'zip_themes':
				$themes_done = $this->zip_themes_chunked( $job_dir . '/themes.zip', $job_dir, $state );
				if ( ! $themes_done ) {
					return false;
				}
				$this->log_zip_done( 'themes', $job_dir . '/themes.zip' );
				$qt = trailingslashit( $job_dir ) . self::THEMES_QUEUE_BASENAME;
				if ( is_file( $qt ) ) {
					@unlink( $qt ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				unset( $state['themes_queue_offset'], $state['themes_queue_total'], $state['themes_progress_label'] );
				$components[]        = 'themes';
				$files_map['themes'] = 'themes.zip';
				$state['components'] = $components;
				$state['files_map']  = $files_map;
				return true;

			case 'zip_uploads':
				$uploads_done = $this->zip_uploads_chunked( $job_dir . '/uploads.zip', $job_dir, $state );
				if ( ! $uploads_done ) {
					return false;
				}
				$this->log_zip_done( 'uploads', $job_dir . '/uploads.zip' );
				$q = trailingslashit( $job_dir ) . self::UPLOADS_QUEUE_BASENAME;
				if ( is_file( $q ) ) {
					@unlink( $q ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				unset( $state['uploads_queue_offset'], $state['uploads_queue_total'], $state['uploads_progress_label'] );
				$components[]          = 'uploads';
				$files_map['uploads']  = 'uploads.zip';
				$state['components']   = $components;
				$state['files_map']    = $files_map;
				return true;

			case 'zip_others':
				$others_done = $this->zip_others_wp_content_chunked( $job_dir . '/others.zip', $job_dir, $state );
				if ( ! $others_done ) {
					return false;
				}
				$this->log_zip_done( 'others', $job_dir . '/others.zip' );
				$q = trailingslashit( $job_dir ) . self::OTHERS_QUEUE_BASENAME;
				if ( is_file( $q ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $q );
				}
				unset( $state['others_queue_offset'], $state['others_queue_total'], $state['others_progress_label'] );
				$components[]         = 'others';
				$files_map['others']  = 'others.zip';
				$state['components']  = $components;
				$state['files_map']   = $files_map;
				return true;

			case 'finalize':
				$manifest = [
					'backup_id'    => $state['backup_id'],
					'created'      => time(),
					'created_gmt'  => gmdate( 'c' ),
					'type'         => $type,
					'label'        => $label,
					'site_url'     => home_url( '/' ),
					'wp_version'   => get_bloginfo( 'version' ),
					'table_prefix' => $wpdb->prefix,
					'components'   => $state['components'],
					'files'        => $state['files_map'],
				];
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
				file_put_contents( $job_dir . '/manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
				$this->log_line( __( 'Backup completed successfully.', 'wp-powersuite' ) );
				$bid = (string) $state['backup_id'];
				$history = self::get_history();
				$history[ $bid ] = [
					'id'          => $bid,
					'created'     => time(),
					'label'       => $label,
					'type'        => $type,
					'components'  => $state['components'],
					'path'        => $bid,
				];
				self::save_history( $history );
				$this->apply_retention_policy();
				return true;

			default:
				throw new \Exception( __( 'Unknown backup step.', 'wp-powersuite' ) );
		}
	}

	/**
	 * Delete oldest complete backup sets beyond the “Retain” count (Updraft-style retain N).
	 */
	private function apply_retention_policy(): void {
		$keep = (int) ( self::get_settings()['retain_completed'] ?? 0 );
		if ( $keep < 1 ) {
			return;
		}
		$hist = self::get_history();
		if ( count( $hist ) <= $keep ) {
			return;
		}
		uasort(
			$hist,
			static function ( $a, $b ) {
				return ( (int) ( $b['created'] ?? 0 ) ) <=> ( (int) ( $a['created'] ?? 0 ) );
			}
		);
		$ids = array_keys( $hist );
		$drop     = array_slice( $ids, $keep );
		$removed  = 0;
		foreach ( $drop as $bid ) {
			if ( ! is_string( $bid ) || $bid === '' || ! str_starts_with( $bid, 'backup-' ) ) {
				continue;
			}
			// Uploaded/migrated sets (backup-upload-…) are user-supplied restore sources,
			// not products of this site's schedule — retention must never auto-delete them.
			if ( str_starts_with( $bid, 'backup-upload-' ) ) {
				continue;
			}
			if ( $this->delete_backup( $bid ) ) {
				$removed++;
			}
		}
		if ( $removed > 0 ) {
			$this->log_line(
				sprintf(
					/* translators: %d: number of deleted backup folders */
					__( 'Retention policy: deleted %d older backup set(s).', 'wp-powersuite' ),
					$removed
				)
			);
		}
	}

	private function log_zip_done( string $name, string $zip_path ): void {
		if ( ! file_exists( $zip_path ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: archive name */
					__( 'Failed to create archive: %s', 'wp-powersuite' ),
					$name
				)
			);
		}
		$this->log_line(
			sprintf(
				/* translators: 1: name, 2: size */
				__( 'Archive finished: %1$s (%2$s)', 'wp-powersuite' ),
				$name,
				size_format( filesize( $zip_path ), 1 )
			)
		);
	}

	/**
	 * Run environment checks (read-only). Returns lines suitable for logs / UI.
	 *
	 * @return array{lines: string[], zip_mode: string, db_mode: string, ok: bool}
	 */
	public function run_precheck( bool $require_zip = true ): array {
		$lines    = [];
		$ok       = true;
		$zip_mode = 'none';
		$db_mode  = 'php';

		if ( class_exists( 'ZipArchive' ) ) {
			$bin_zip = $this->find_system_zip_binary();
			if ( $bin_zip && $this->archive_shell_available() ) {
				$zip_mode = 'binzip';
				$lines[]  = "ZIP engine: system zip ({$bin_zip}) with ZipArchive chunked fallback.";
				$bin_unzip = $this->find_system_unzip_binary();
				if ( $bin_unzip ) {
					$lines[] = "Restore extract: system unzip ({$bin_unzip}) with ZipArchive chunked fallback.";
				}
			} else {
				$zip_mode = 'ziparchive';
				$lines[]  = 'ZIP engine: PHP ZipArchive extension is available.';
				if ( $bin_zip ) {
					$lines[] = "System zip binary detected ({$bin_zip}) but shell helpers are disabled; using ZipArchive only.";
				}
			}
		} elseif ( $require_zip ) {
			$ok       = false;
			$zip_mode = 'none';
			$lines[]  = 'PHP ZipArchive extension is required for file backups. Enable ext-zip or ask your host.';
		} else {
			$lines[] = 'ZIP extension not available; database-only backups can still run.';
		}

		$dump = $this->find_mysqldump_binary();
		if ( $dump ) {
			$db_mode = 'mysqldump';
			$lines[] = "Working mysqldump found: {$dump}";
		} else {
			$lines[] = 'mysqldump not found in standard locations; will use PHP database export if needed (can be slower on large sites).';
		}

		$mem = ini_get( 'memory_limit' ) ?: 'unknown';
		$lines[] = "PHP memory_limit: {$mem}";

		$root = $this->get_backup_root();
		if ( ! is_dir( $root ) ) {
			if ( ! wp_mkdir_p( $root ) ) {
				$ok      = false;
				$lines[] = "Cannot create backup directory: {$root}";
			} else {
				$lines[] = "Created backup directory: {$root}";
			}
		}
		if ( is_dir( $root ) && ! is_writable( $root ) ) {
			$ok      = false;
			$lines[] = "Backup directory is not writable: {$root}";
		} elseif ( is_dir( $root ) ) {
			$lines[] = "Backup directory writable: {$root}";
		}

		$free = @disk_free_space( $root ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( is_float( $free ) && $free > 0 ) {
			$lines[] = sprintf(
				'Free disk space (backup volume): ~%s',
				size_format( $free, 1 )
			);
		}

		$dbh = DB_HOST;
		$dbn = DB_NAME;
		$lines[] = "Database host: {$dbh}; database: {$dbn}";

		return [
			'lines'    => $lines,
			'zip_mode' => $zip_mode,
			'db_mode'  => $db_mode,
			'ok'       => $ok,
		];
	}

	/**
	 * Whether PHP exec() may be called (missing or disable_functions'd exec() is a fatal Error on PHP 8+).
	 */
	private static function exec_function_available(): bool {
		if ( ! function_exists( 'exec' ) ) {
			return false;
		}
		$dis = ini_get( 'disable_functions' );
		if ( is_string( $dis ) && $dis !== '' ) {
			$disabled = array_map( 'trim', explode( ',', strtolower( $dis ) ) );
			if ( in_array( 'exec', $disabled, true ) ) {
				return false;
			}
		}
		return true;
	}

	private function find_system_zip_binary(): ?string {
		$paths = [ '/usr/bin/zip', '/bin/zip', '/usr/local/bin/zip' ];
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			if ( ! self::exec_function_available() ) {
				return null;
			}
			$paths = [ 'zip' ];
			foreach ( $paths as $p ) {
				$out = [];
				$code = 0;
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec
				@exec( 'where ' . escapeshellarg( $p ) . ' 2>NUL', $out, $code );
				if ( $code === 0 && ! empty( $out[0] ) && is_string( $out[0] ) && file_exists( trim( $out[0] ) ) ) {
					return trim( $out[0] );
				}
			}
			return null;
		}
		foreach ( $paths as $p ) {
			if ( $p && @is_executable( $p ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				return $p;
			}
		}
		return null;
	}

	private function find_system_unzip_binary(): ?string {
		$paths = [ '/usr/bin/unzip', '/bin/unzip', '/usr/local/bin/unzip' ];
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			if ( ! self::exec_function_available() ) {
				return null;
			}
			$paths = [ 'unzip' ];
			foreach ( $paths as $p ) {
				$out  = [];
				$code = 0;
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec
				@exec( 'where ' . escapeshellarg( $p ) . ' 2>NUL', $out, $code );
				if ( $code === 0 && ! empty( $out[0] ) && is_string( $out[0] ) && file_exists( trim( $out[0] ) ) ) {
					return trim( $out[0] );
				}
			}
			return null;
		}
		foreach ( $paths as $p ) {
			if ( $p && @is_executable( $p ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				return $p;
			}
		}
		return null;
	}

	/**
	 * Restore extract/publish tuning (filterable; clamped for safety on shared hosts).
	 *
	 * @return array{unzip_max_files: int, unzip_max_seconds: int, unzip_inner_flushes: int, unzip_max_index_step: int, copy_batch: int, copy_max_seconds: int, copy_inner_flushes: int}
	 */
	private function restore_chunk_limits(): array {
		$unzip_files = (int) apply_filters( 'wpps_br_restore_unzip_batch', self::RESTORE_UNZIP_BATCH );
		$unzip_files = max( 200, min( 3000, $unzip_files ) );

		$unzip_seconds = (int) apply_filters( 'wpps_br_restore_unzip_max_seconds', self::RESTORE_UNZIP_MAX_SECONDS );
		$unzip_seconds = max( 20, min( 120, $unzip_seconds ) );

		$unzip_inner = (int) apply_filters( 'wpps_br_restore_unzip_inner_flushes', self::RESTORE_UNZIP_INNER_FLUSHES );
		$unzip_inner = max( 1, min( 12, $unzip_inner ) );

		$copy_batch = (int) apply_filters( 'wpps_br_restore_copy_batch', self::RESTORE_COPY_BATCH );
		$copy_batch = max( 100, min( 2000, $copy_batch ) );

		$copy_seconds = (int) apply_filters( 'wpps_br_restore_copy_max_seconds', self::RESTORE_COPY_MAX_SECONDS );
		$copy_seconds = max( 20, min( 120, $copy_seconds ) );

		$copy_inner = (int) apply_filters( 'wpps_br_restore_copy_inner_flushes', self::RESTORE_COPY_INNER_FLUSHES );
		$copy_inner = max( 1, min( 12, $copy_inner ) );

		return [
			'unzip_max_files'       => $unzip_files,
			'unzip_max_seconds'     => $unzip_seconds,
			'unzip_inner_flushes'   => $unzip_inner,
			'unzip_max_index_step'  => self::RESTORE_UNZIP_MAX_INDEX_STEP,
			'copy_batch'            => $copy_batch,
			'copy_max_seconds'      => $copy_seconds,
			'copy_inner_flushes'    => $copy_inner,
		];
	}

	/**
	 * Extract a component zip with system unzip into the restore staging tree (plugins/… under workdir).
	 */
	private function try_system_unzip_component( string $zip_path, string $zip_prefix, string $dest_root ): bool {
		// Default off: system unzip does not apply the Zip Slip checks used by ZipArchive.
		if ( ! apply_filters( 'wpps_br_use_system_unzip', false, $zip_prefix, $dest_root ) ) {
			return false;
		}
		if ( ! $this->archive_shell_available() ) {
			return false;
		}
		$bin = $this->find_system_unzip_binary();
		if ( ! $bin || ! is_readable( $zip_path ) ) {
			return false;
		}

		$dest_root  = rtrim( wp_normalize_path( $dest_root ), '/' );
		$extract_to = dirname( $dest_root );
		$prefix     = rtrim( str_replace( '\\', '/', $zip_prefix ), '/' );
		if ( $prefix === '' || ! wp_mkdir_p( $extract_to ) ) {
			return false;
		}

		// Validate every matching entry BEFORE shell unzip (post-extract scans miss escapes).
		if ( ! $this->zip_entries_safe_for_extract( $zip_path, $prefix, $dest_root, $extract_to ) ) {
			$this->log_line(
				sprintf(
					/* translators: %s: zip internal prefix */
					__( 'System unzip skipped for %s: archive contains unsafe paths.', 'wp-powersuite' ),
					$prefix
				)
			);

			return false;
		}

		$cmd_parts = [
			escapeshellarg( $bin ),
			'-q',
			'-o',
			escapeshellarg( wp_normalize_path( $zip_path ) ),
			escapeshellarg( $prefix . '/*' ),
			'-d',
			escapeshellarg( $extract_to ),
		];
		$cmd = implode( ' ', $cmd_parts );

		$stderr = '';
		$code   = $this->run_shell_command_in_directory( $cmd, $extract_to, 900, $stderr );
		if ( 0 !== $code || ! is_dir( $dest_root ) ) {
			$detail = $stderr !== '' ? ' ' . trim( $stderr ) : '';
			$this->log_line(
				sprintf(
					/* translators: 1: zip internal prefix, 2: optional error detail */
					__( 'System unzip failed for %1$s; falling back to ZipArchive.%2$s', 'wp-powersuite' ),
					$prefix,
					$detail
				)
			);

			return false;
		}

		if ( ! $this->assert_extracted_tree_inside_root( $dest_root ) ) {
			$this->log_line(
				sprintf(
					/* translators: %s: zip internal prefix */
					__( 'System unzip for %s wrote paths outside the staging root; falling back to ZipArchive.', 'wp-powersuite' ),
					$prefix
				)
			);
			$this->recursive_delete( $dest_root );

			return false;
		}

		$this->log_line(
			sprintf(
				/* translators: 1: zip internal prefix, 2: unzip binary path */
				__( 'Extracted %1$s with system unzip (%2$s).', 'wp-powersuite' ),
				$prefix,
				$bin
			)
		);

		return true;
	}

	/**
	 * Archive resource limits (filterable).
	 *
	 * @return array{
	 *   max_archive_bytes:int,
	 *   max_extracted_bytes:int,
	 *   max_files:int,
	 *   max_file_bytes:int,
	 *   max_ratio:float,
	 *   max_path_length:int,
	 *   max_depth:int
	 * }
	 */
	private function archive_extract_limits(): array {
		$max_archive = (int) apply_filters( 'wpps_br_archive_max_bytes', 2 * 1024 * 1024 * 1024 );
		$max_extract = (int) apply_filters( 'wpps_br_archive_max_extracted_bytes', 8 * 1024 * 1024 * 1024 );
		$max_files   = (int) apply_filters( 'wpps_br_archive_max_files', 100000 );
		$max_file    = (int) apply_filters( 'wpps_br_archive_max_file_bytes', 512 * 1024 * 1024 );
		$max_ratio   = (float) apply_filters( 'wpps_br_archive_max_compression_ratio', 100.0 );
		$max_path    = (int) apply_filters( 'wpps_br_archive_max_path_length', 512 );
		$max_depth   = (int) apply_filters( 'wpps_br_archive_max_depth', 40 );

		return [
			'max_archive_bytes'   => max( 1024 * 1024, $max_archive ),
			'max_extracted_bytes' => max( 1024 * 1024, $max_extract ),
			'max_files'           => max( 1, min( 500000, $max_files ) ),
			'max_file_bytes'      => max( 1024, $max_file ),
			'max_ratio'           => max( 1.0, $max_ratio ),
			'max_path_length'     => max( 64, min( 4096, $max_path ) ),
			'max_depth'           => max( 1, min( 128, $max_depth ) ),
		];
	}

	/**
	 * Whether a zip entry is a forbidden special type (symlink/device/etc.).
	 */
	private function zip_entry_is_special_file( \ZipArchive $z, int $index ): bool {
		$stat = $z->statIndex( $index );
		if ( ! is_array( $stat ) ) {
			return true;
		}
		if ( ! method_exists( $z, 'getExternalAttributesIndex' ) ) {
			return false;
		}
		$opsys = 0;
		$attr  = 0;
		if ( ! $z->getExternalAttributesIndex( $index, $opsys, $attr ) ) {
			return false;
		}
		$opsys = (int) $opsys;
		$attr  = (int) $attr;
		if ( ( defined( 'ZipArchive::OPSYS_UNIX' ) && $opsys === \ZipArchive::OPSYS_UNIX ) || $opsys === 3 ) {
			$mode = ( $attr >> 16 ) & 0xFFFF;
			$type = $mode & 0xF000;
			// 0xA000 symlink, 0x6000 block, 0x2000 char, 0x1000 fifo, 0xC000 socket.
			if ( in_array( $type, [ 0xA000, 0x6000, 0x2000, 0x1000, 0xC000 ], true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Public wrapper for regression tests / shared callers.
	 */
	public static function is_zip_entry_name_unsafe( string $name ): bool {
		$svc = new self();

		return $svc->zip_entry_path_is_unsafe( $name );
	}

	/**
	 * Reject unsafe zip entry path names before any extraction.
	 */
	private function zip_entry_path_is_unsafe( string $name ): bool {
		if ( $name === '' || str_contains( $name, "\0" ) ) {
			return true;
		}
		$norm = str_replace( '\\', '/', $name );
		if ( $norm === '' || $norm === '.' || $norm === '..' ) {
			return true;
		}
		if ( str_starts_with( $norm, '/' ) || str_starts_with( $norm, '//' ) ) {
			return true;
		}
		if ( preg_match( '#^[A-Za-z]:/#', $norm ) || preg_match( '#^[A-Za-z]:$#', $norm ) ) {
			return true;
		}
		if ( str_starts_with( $norm, '\\\\' ) || str_contains( $name, '\\\\' ) ) {
			return true;
		}
		if ( preg_match( '#(^|/)\.\.(/|$)#', $norm ) === 1 ) {
			return true;
		}
		if ( str_contains( $name, '..\\' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Pre-flight Zip Slip + resource check: every entry under $zip_prefix must
	 * resolve inside $dest_root when extracted to $extract_to. No extraction begins
	 * until this returns true.
	 */
	private function zip_entries_safe_for_extract( string $zip_path, string $zip_prefix, string $dest_root, string $extract_to ): bool {
		if ( ! class_exists( '\ZipArchive' ) ) {
			return false;
		}
		$limits = $this->archive_extract_limits();
		$archive_size = is_readable( $zip_path ) ? (int) filesize( $zip_path ) : 0;
		if ( $archive_size < 1 || $archive_size > $limits['max_archive_bytes'] ) {
			return false;
		}

		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_path, \ZipArchive::RDONLY ) ) {
			return false;
		}

		$prefix      = rtrim( str_replace( '\\', '/', $zip_prefix ), '/' );
		$prefix_slash = $prefix === '' ? '' : $prefix . '/';
		$dest_root   = rtrim( wp_normalize_path( $dest_root ), '/' );
		$extract_to  = rtrim( wp_normalize_path( $extract_to ), '/' );
		$prefix_len  = strlen( $prefix_slash );
		$entry_count = 0;
		$uncompressed_total = 0;

		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				$z->close();
				return false;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( $prefix_slash !== '' && ! str_starts_with( $norm, $prefix_slash ) && $norm !== $prefix ) {
				continue;
			}
			if ( $this->zip_entry_path_is_unsafe( $name ) || $this->zip_entry_path_is_unsafe( $norm ) ) {
				$z->close();
				return false;
			}
			if ( $this->zip_entry_is_special_file( $z, $i ) ) {
				$z->close();
				return false;
			}
			++$entry_count;
			if ( $entry_count > $limits['max_files'] ) {
				$z->close();
				return false;
			}
			if ( strlen( $norm ) > $limits['max_path_length'] ) {
				$z->close();
				return false;
			}
			$depth = substr_count( trim( $norm, '/' ), '/' );
			if ( $depth > $limits['max_depth'] ) {
				$z->close();
				return false;
			}

			$stat = $z->statIndex( $i );
			$size = is_array( $stat ) ? (int) ( $stat['size'] ?? 0 ) : 0;
			if ( $size < 0 || $size > $limits['max_file_bytes'] ) {
				$z->close();
				return false;
			}
			$uncompressed_total += $size;
			if ( $uncompressed_total > $limits['max_extracted_bytes'] ) {
				$z->close();
				return false;
			}

			$rel = $prefix_slash !== '' && str_starts_with( $norm, $prefix_slash )
				? substr( $norm, $prefix_len )
				: ltrim( $norm, '/' );
			$rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' );
			$target = $rel === '' ? $dest_root : wp_normalize_path( $dest_root . '/' . $rel );
			if ( ! str_starts_with( $target, $dest_root . '/' ) && $target !== $dest_root ) {
				$z->close();
				return false;
			}
			$abs = wp_normalize_path( $extract_to . '/' . $norm );
			if ( ! str_starts_with( $abs, $extract_to . '/' ) && $abs !== $extract_to ) {
				$z->close();
				return false;
			}
		}

		if ( $archive_size > 0 && $uncompressed_total > 0 ) {
			$ratio = $uncompressed_total / max( 1, $archive_size );
			if ( $ratio > $limits['max_ratio'] ) {
				$z->close();
				return false;
			}
		}

		$z->close();

		return $entry_count > 0;
	}

	/**
	 * Ensure every realpath under $root stays inside $root (Zip Slip guard after system unzip).
	 */
	private function assert_extracted_tree_inside_root( string $root ): bool {
		$root_real = realpath( $root );
		if ( ! is_string( $root_real ) || $root_real === '' || ! is_dir( $root_real ) ) {
			return false;
		}
		$root_norm = wp_normalize_path( $root_real );

		try {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $root_real, \FilesystemIterator::SKIP_DOTS ),
				\RecursiveIteratorIterator::SELF_FIRST
			);
		} catch ( \Throwable $e ) {
			return false;
		}

		foreach ( $iterator as $item ) {
			$path = $item->getPathname();
			$real = realpath( $path );
			if ( ! is_string( $real ) || $real === '' ) {
				return false;
			}
			$norm = wp_normalize_path( $real );
			if ( $norm !== $root_norm && ! str_starts_with( $norm, $root_norm . '/' ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Chunk/archive tuning (filterable; clamped for safety on shared hosts).
	 *
	 * @return array{max_files: int, max_seconds: int, max_bytes: int, inner_flushes: int, large_file_bytes: int}
	 */
	private function archive_chunk_limits(): array {
		$max_files = (int) apply_filters( 'wpps_br_archive_chunk_max_files', self::ARCHIVE_CHUNK_MAX_FILES );
		$max_files = max( 100, min( 2000, $max_files ) );

		$max_seconds = (int) apply_filters( 'wpps_br_archive_chunk_max_seconds', self::ARCHIVE_CHUNK_MAX_SECONDS );
		$max_seconds = max( 20, min( 120, $max_seconds ) );

		$max_bytes = (int) apply_filters( 'wpps_br_archive_chunk_max_bytes', self::ARCHIVE_CHUNK_MAX_BYTES );
		$max_bytes = max( 5242880, min( 104857600, $max_bytes ) );

		$inner = (int) apply_filters( 'wpps_br_archive_chunk_inner_flushes', self::ARCHIVE_CHUNK_INNER_FLUSHES );
		$inner = max( 1, min( 20, $inner ) );

		return [
			'max_files'        => $max_files,
			'max_seconds'      => $max_seconds,
			'max_bytes'        => $max_bytes,
			'inner_flushes'    => $inner,
			'large_file_bytes' => self::ARCHIVE_LARGE_FILE_BYTES,
		];
	}

	/**
	 * Whether shell helpers (proc_open / shell_exec) are usable for system zip.
	 */
	private function archive_shell_available(): bool {
		$dis = ini_get( 'disable_functions' );
		if ( ! is_string( $dis ) ) {
			$dis = '';
		}
		$dis_lower = strtolower( $dis );
		$proc_ok   = function_exists( 'proc_open' ) && ! str_contains( $dis_lower, 'proc_open' );
		$shell_ok  = function_exists( 'shell_exec' ) && ! str_contains( $dis_lower, 'shell_exec' );

		/**
		 * @param bool $allow Default true when proc_open or shell_exec is allowed.
		 */
		return (bool) apply_filters( 'wpps_br_allow_system_zip', $proc_ok || $shell_ok );
	}

	/**
	 * Skip dev/build trees that are not needed to run WordPress (vendor/ is kept for Composer plugins).
	 */
	private function relative_path_should_skip_archive_bloat( string $rel ): bool {
		if ( $this->relative_path_contains_vcs_metadata( $rel ) ) {
			return true;
		}
		$rel = str_replace( '\\', '/', $rel );
		$skip_segments = apply_filters(
			'wpps_br_archive_skip_path_segments',
			[
				'node_modules',
				'bower_components',
				'__macosx',
				'.npm',
				'.yarn',
				'.pnpm-store',
				'.cache',
				'.sass-cache',
				'.parcel-cache',
				'.nyc_output',
				'coverage',
			],
			$rel
		);
		foreach ( explode( '/', $rel ) as $seg ) {
			if ( $seg === '' ) {
				continue;
			}
			foreach ( $skip_segments as $skip ) {
				if ( is_string( $skip ) && strcasecmp( $seg, $skip ) === 0 ) {
					return true;
				}
			}
		}

		/**
		 * @param bool   $skip Default false.
		 * @param string $rel  Path relative to the archive root.
		 */
		return (bool) apply_filters( 'wpps_br_skip_archive_path', false, $rel );
	}

	/**
	 * System zip can only preserve plugins/ or themes/ prefixes when the source lives under wp-content/{folder}.
	 */
	private function can_system_zip_wp_content_tree( string $source_dir, string $wp_content_folder ): bool {
		$source   = rtrim( wp_normalize_path( $source_dir ), '/' );
		$content  = rtrim( wp_normalize_path( WP_CONTENT_DIR ), '/' );
		$expected = $content . '/' . trim( $wp_content_folder, '/' );

		return $source === $expected;
	}

	/**
	 * @return list<string>
	 */
	private function system_zip_exclude_globs(): array {
		$globs = [
			'*/node_modules/*',
			'*/bower_components/*',
			'*/.git/*',
			'*/.svn/*',
			'*/.hg/*',
			'*/__MACOSX/*',
			'*/.cache/*',
			'*/.sass-cache/*',
			'*/.parcel-cache/*',
			'*/.nyc_output/*',
			'*/coverage/*',
		];

		/** @var list<string> $globs */
		$globs = apply_filters( 'wpps_br_system_zip_exclude_globs', $globs );

		return array_values( array_filter( $globs, 'is_string' ) );
	}

	private function validate_zip_archive_file( string $zip_path ): bool {
		if ( ! is_file( $zip_path ) ) {
			return false;
		}
		$size = (int) filesize( $zip_path );
		if ( $size < 22 ) {
			return false;
		}
		if ( ! class_exists( 'ZipArchive' ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$head = file_get_contents( $zip_path, false, null, 0, 4 );

			return is_string( $head ) && str_starts_with( $head, "PK\x03\x04" );
		}
		$z = new \ZipArchive();
		$ok = $z->open( $zip_path, \ZipArchive::RDONLY );
		if ( true !== $ok ) {
			return false;
		}
		$z->close();

		return true;
	}

	/**
	 * Run Info-ZIP compatible binary from wp-content to produce plugins.zip or themes.zip.
	 */
	private function try_system_zip_wp_content_tree( string $zip_path, string $source_dir, string $wp_content_folder, string $log_label ): bool {
		if ( ! apply_filters( 'wpps_br_use_system_zip', true, $wp_content_folder, $source_dir ) ) {
			return false;
		}
		if ( ! $this->can_system_zip_wp_content_tree( $source_dir, $wp_content_folder ) ) {
			return false;
		}
		if ( ! $this->archive_shell_available() ) {
			return false;
		}
		$bin = $this->find_system_zip_binary();
		if ( ! $bin ) {
			return false;
		}

		$content = rtrim( wp_normalize_path( WP_CONTENT_DIR ), '/' );
		$folder  = trim( $wp_content_folder, '/' ) . '/';
		$zip_abs = wp_normalize_path( $zip_path );

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

		$cmd_parts = [
			escapeshellarg( $bin ),
			'-r',
			'-q',
			'-X',
			escapeshellarg( $zip_abs ),
			escapeshellarg( $folder ),
		];
		foreach ( $this->system_zip_exclude_globs() as $glob ) {
			$cmd_parts[] = '-x';
			$cmd_parts[] = escapeshellarg( $glob );
		}
		$cmd = implode( ' ', $cmd_parts );

		$this->log_line(
			sprintf(
				/* translators: 1: archive label (Plugins/Themes), 2: zip binary path */
				__( '%1$s archive: creating with system zip (%2$s).', 'wp-powersuite' ),
				$log_label,
				$bin
			)
		);

		$stderr = '';
		$code   = $this->run_shell_command_in_directory( $cmd, $content, 600, $stderr );
		if ( 0 !== $code || ! $this->validate_zip_archive_file( $zip_abs ) ) {
			if ( is_file( $zip_abs ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $zip_abs );
			}
			$detail = $stderr !== '' ? ' ' . trim( $stderr ) : '';
			$this->log_line(
				sprintf(
					/* translators: 1: archive label, 2: optional error detail */
					__( '%1$s archive: system zip failed; falling back to ZipArchive.%2$s', 'wp-powersuite' ),
					$log_label,
					$detail
				)
			);

			return false;
		}

		$this->log_line(
			sprintf(
				/* translators: 1: archive label, 2: archive size */
				__( '%1$s archive: system zip finished (%2$s).', 'wp-powersuite' ),
				$log_label,
				size_format( (int) filesize( $zip_abs ), 1 )
			)
		);

		return true;
	}

	/**
	 * @param-out string $stderr Combined stderr output.
	 */
	private function run_shell_command_in_directory( string $command, string $cwd, int $timeout_seconds, string &$stderr ): int {
		$stderr         = '';
		$timeout_seconds = max( 30, min( 900, $timeout_seconds ) );
		$cwd            = rtrim( wp_normalize_path( $cwd ), '/' );
		if ( ! is_dir( $cwd ) ) {
			return 127;
		}

		$dis = ini_get( 'disable_functions' );
		if ( ! is_string( $dis ) ) {
			$dis = '';
		}
		$dis_lower = strtolower( $dis );
		if ( function_exists( 'proc_open' ) && ! str_contains( $dis_lower, 'proc_open' ) ) {
			$descriptors = [
				0 => [ 'pipe', 'r' ],
				1 => [ 'pipe', 'w' ],
				2 => [ 'pipe', 'w' ],
			];
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_proc_open
			$proc = @proc_open( $command, $descriptors, $pipes, $cwd );
			if ( is_resource( $proc ) ) {
				fclose( $pipes[0] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				stream_set_blocking( $pipes[1], false );
				stream_set_blocking( $pipes[2], false );
				$stdout     = '';
				$deadline   = microtime( true ) + $timeout_seconds;
				$terminated = false;
				$exit_code  = 1;
				while ( true ) {
					$read   = [ $pipes[1], $pipes[2] ];
					$write  = null;
					$except = null;
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_stream_select
					$n = @stream_select( $read, $write, $except, 1 );
					if ( $n === false ) {
						break;
					}
					if ( $n > 0 ) {
						if ( in_array( $pipes[1], $read, true ) ) {
							$chunk = fread( $pipes[1], 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
							if ( is_string( $chunk ) && $chunk !== '' ) {
								$stdout .= $chunk;
							}
						}
						if ( in_array( $pipes[2], $read, true ) ) {
							$chunk = fread( $pipes[2], 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
							if ( is_string( $chunk ) && $chunk !== '' ) {
								$stderr .= $chunk;
							}
						}
					}
					$status = proc_get_status( $proc );
					if ( is_array( $status ) && empty( $status['running'] ) ) {
						$exit_code = isset( $status['exitcode'] ) ? (int) $status['exitcode'] : 0;
						break;
					}
					if ( microtime( true ) >= $deadline ) {
						// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_proc_terminate
						@proc_terminate( $proc );
						$terminated = true;
						break;
					}
				}
				fclose( $pipes[1] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				fclose( $pipes[2] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				proc_close( $proc );
				if ( $terminated ) {
					return 124;
				}

				return $exit_code ?? 0;
			}
		}

		if ( function_exists( 'shell_exec' ) && ! str_contains( $dis_lower, 'shell_exec' ) ) {
			$wrapped = 'cd ' . escapeshellarg( $cwd ) . ' && ' . $command;
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_shell_exec
			$out = @shell_exec( $wrapped . ' 2>&1' );
			if ( is_string( $out ) && $out !== '' ) {
				$stderr = $out;
			}

			return 0;
		}

		return 127;
	}

	/**
	 * One ZipArchive open/add/close cycle for a line-delimited file queue.
	 *
	 * @param array<string, mixed> $state
	 * @return array{added: int, done: bool}
	 */
	private function archive_zip_add_queue_batch(
		string $zip_path,
		string $queue_path,
		string $source_dir,
		string $zip_prefix,
		array &$state,
		string $offset_key,
		int $total_lines,
		string $log_component,
		array $limits
	): array {
		$offset      = isset( $state[ $offset_key ] ) ? (int) $state[ $offset_key ] : 0;
		$source_dir  = trailingslashit( wp_normalize_path( $source_dir ) );
		$source_root = rtrim( $source_dir, '/' );
		$zip_prefix  = trailingslashit( str_replace( '\\', '/', $zip_prefix ) );

		$z = new \ZipArchive();
		$flags = 0 === $offset
			? ( \ZipArchive::CREATE | \ZipArchive::OVERWRITE )
			: \ZipArchive::CREATE;
		if ( 0 !== $offset && ! is_file( $zip_path ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: archive component name */
					__( '%s archive was missing mid-job; please start the backup again.', 'wp-powersuite' ),
					$log_component
				)
			);
		}
		if ( true !== $z->open( $zip_path, $flags ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: archive component name */
					__( 'Could not open %s zip for writing.', 'wp-powersuite' ),
					$log_component
				)
			);
		}

		$batch_start  = microtime( true );
		$added        = 0;
		$batch_bytes  = 0;
		$line_index   = 0;
		$large_bytes  = (int) $limits['large_file_bytes'];
		$max_files    = (int) $limits['max_files'];
		$max_bytes    = (int) $limits['max_bytes'];
		$max_seconds  = (int) $limits['max_seconds'];

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$qh = fopen( $queue_path, 'rb' );
		if ( false === $qh ) {
			$z->close();
			throw new \Exception(
				sprintf(
					/* translators: %s: archive component name */
					__( 'Could not read %s file queue.', 'wp-powersuite' ),
					$log_component
				)
			);
		}

		try {
			while ( ( $line = fgets( $qh ) ) !== false ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets
				if ( $line_index < $offset ) {
					$line_index++;
					continue;
				}
				$rel = trim( (string) $line );
				if ( $rel === '' ) {
					$line_index++;
					$state[ $offset_key ] = $line_index;
					continue;
				}
				$full = wp_normalize_path( $source_dir . str_replace( '\\', '/', $rel ) );
				if ( ! str_starts_with( $full, $source_root ) ) {
					$line_index++;
					$state[ $offset_key ] = $line_index;
					continue;
				}
				$fsize = ( is_readable( $full ) && is_file( $full ) ) ? (int) filesize( $full ) : 0;
				if ( $added > 0 && $fsize > $large_bytes ) {
					break;
				}
				$zip_rel = $zip_prefix . ltrim( str_replace( '\\', '/', $rel ), '/' );
				if ( is_readable( $full ) && is_file( $full ) ) {
					if ( ! $z->addFile( $full, $zip_rel ) ) {
						$this->log_line( sprintf( 'Skipped file (not readable): %s', $full ) );
					} else {
						$this->zip_set_compression_for_file( $z, $zip_rel, $full );
						$batch_bytes += $fsize;
					}
				}
				$added++;
				$line_index++;
				$state[ $offset_key ] = $line_index;
				if ( $fsize > $large_bytes ) {
					break;
				}
				if ( $added >= $max_files ) {
					break;
				}
				if ( $batch_bytes >= $max_bytes ) {
					break;
				}
				if ( ( microtime( true ) - $batch_start ) >= min( 12, $max_seconds ) ) {
					break;
				}
			}
		} finally {
			fclose( $qh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$z->close();
		}

		$next_off = (int) $state[ $offset_key ];
		$done     = $next_off >= $total_lines;
		if ( $added > 0 ) {
			$this->log_line(
				sprintf(
					/* translators: 1: component, 2: batch size, 3: progress, 4: total */
					__( '%1$s archive: batch added %2$d file(s); progress %3$d / %4$d.', 'wp-powersuite' ),
					$log_component,
					$added,
					min( $next_off, $total_lines ),
					$total_lines
				)
			);
		}

		return [
			'added' => $added,
			'done'  => $done,
		];
	}

	/**
	 * Process multiple ZipArchive flushes in one HTTP tick when chunked fallback is used.
	 *
	 * @param array<string, mixed> $state
	 */
	private function archive_zip_queue_chunk_tick(
		string $zip_path,
		string $queue_path,
		string $source_dir,
		string $zip_prefix,
		array &$state,
		string $offset_key,
		string $total_key,
		string $progress_key,
		string $log_component,
		string $progress_template
	): bool {
		$limits        = $this->archive_chunk_limits();
		$total_lines   = (int) ( $state[ $total_key ] ?? 0 );
		$tick_deadline = microtime( true ) + $limits['max_seconds'];
		$flushes       = 0;
		$done          = false;

		while ( $flushes < $limits['inner_flushes'] && microtime( true ) < $tick_deadline ) {
			$batch = $this->archive_zip_add_queue_batch(
				$zip_path,
				$queue_path,
				$source_dir,
				$zip_prefix,
				$state,
				$offset_key,
				$total_lines,
				$log_component,
				$limits
			);
			$flushes++;
			$done = $batch['done'];
			if ( $done || $batch['added'] === 0 ) {
				break;
			}
		}

		$next_off = (int) ( $state[ $offset_key ] ?? 0 );
		$state[ $progress_key ] = sprintf(
			$progress_template,
			min( $next_off, max( 1, $total_lines ) ),
			max( 1, $total_lines )
		);

		return $done;
	}

	private function find_mysqldump_binary(): ?string {
		// Prefer mariadb-dump on MariaDB hosts (mysqldump name is deprecated there).
		$paths = [
			'/usr/bin/mariadb-dump',
			'/bin/mariadb-dump',
			'/usr/local/bin/mariadb-dump',
			'/usr/bin/mysqldump',
			'/bin/mysqldump',
			'/usr/local/bin/mysqldump',
			'/opt/homebrew/bin/mariadb-dump',
			'/opt/homebrew/bin/mysqldump',
		];
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			$candidates = [];
			foreach ( [ 'C', 'D', 'E' ] as $drive ) {
				foreach ( [ 'MariaDB*', 'MySQL*' ] as $vendor ) {
					$pattern = $drive . ':\\Program Files\\' . $vendor . '\\*\\bin\\*dump.exe';
					$found   = glob( $pattern );
					if ( is_array( $found ) ) {
						$candidates = array_merge( $candidates, $found );
					}
				}
			}
			foreach ( $candidates as $p ) {
				if ( is_string( $p ) && file_exists( $p ) ) {
					return $p;
				}
			}
			return null;
		}
		foreach ( $paths as $p ) {
			if ( $p && @is_executable( $p ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				return $p;
			}
		}
		return null;
	}

	/**
	 * Whether this dump client supports MySQL's --set-gtid-purged (MariaDB does not).
	 */
	private function mysqldump_supports_set_gtid_purged( string $bin ): bool {
		$disabled = array_filter( array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ) );
		if ( ! function_exists( 'exec' ) || in_array( 'exec', $disabled, true ) ) {
			// Fail open only for known MariaDB binary names.
			$base = strtolower( basename( $bin ) );
			return ! str_contains( $base, 'mariadb' );
		}
		$output = [];
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec,WordPress.PHP.NoSilencedErrors.Discouraged
		@exec( escapeshellarg( $bin ) . ' --version 2>&1', $output );
		$ver = strtolower( implode( ' ', $output ) );
		if ( $ver === '' ) {
			$base = strtolower( basename( $bin ) );
			return ! str_contains( $base, 'mariadb' );
		}
		if ( str_contains( $ver, 'mariadb' ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Open per-backup log file.
	 */
	public function open_job_log( string $job_dir, string $log_basename = 'backup.log' ): void {
		$this->close_job_log();
		$log_basename = sanitize_file_name( $log_basename );
		if ( '' === $log_basename || str_contains( $log_basename, '..' ) ) {
			$log_basename = 'backup.log';
		}
		$this->log_file_path = trailingslashit( $job_dir ) . $log_basename;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $this->log_file_path, 'ab' );
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not open backup log file for writing.', 'wp-powersuite' ) );
		}
		$this->log_stream = $fh;
	}

	public function close_job_log(): void {
		if ( is_resource( $this->log_stream ) ) {
			fclose( $this->log_stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$this->log_stream = null;
		}
	}

	public function log_line( string $message ): void {
		$line = '[' . $this->log_timestamp_prefix() . "] {$message}\n";
		if ( is_resource( $this->log_stream ) ) {
			fwrite( $this->log_stream, $line ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			fflush( $this->log_stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fflush -- Keep log readable for live panel.
		}
		self::set_last_message( trim( $message ) );
	}

	/**
	 * Read last N bytes of the current job log (for the admin live log panel).
	 */
	private function read_log_tail( int $max_bytes ): string {
		if ( '' === $this->log_file_path || ! is_readable( $this->log_file_path ) ) {
			return '';
		}
		if ( is_resource( $this->log_stream ) ) {
			fflush( $this->log_stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fflush
		}
		$sz = (int) filesize( $this->log_file_path );
		if ( $sz <= 0 ) {
			return '';
		}
		$len = min( $max_bytes, $sz );
		$start = $sz - $len;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		return (string) file_get_contents( $this->log_file_path, false, null, $start, $len );
	}

	/**
	 * Ensure restore.log tail is safe for wp_json_encode (no NULs / most control chars).
	 */
	private function sanitize_log_excerpt_for_json( string $excerpt ): string {
		if ( $excerpt === '' ) {
			return '';
		}
		$excerpt = str_replace( "\0", '', $excerpt );
		$excerpt = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $excerpt );
		if ( function_exists( 'wp_check_invalid_utf8' ) ) {
			$excerpt = wp_check_invalid_utf8( (string) $excerpt, true );
		}

		return is_string( $excerpt ) ? $excerpt : '';
	}

	/**
	 * Build mysqldump argv for non-Windows shells (credentials file + args). Null on Windows or if mysqldump missing.
	 *
	 * @return array{argv: list<string>, cnf_tmp: ?string}|null
	 */
	private function mysqldump_cnf_escape_value( string $value ): string {
		$escaped = str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $value );
		$escaped = str_replace( [ "\r\n", "\n", "\r" ], [ '\n', '\n', '\n' ], $escaped );
		return '"' . $escaped . '"';
	}

	private function mysqldump_build_argv_unix(): ?array {
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			return null;
		}
		$bin = $this->find_mysqldump_binary();
		if ( ! $bin ) {
			return null;
		}

		$user = DB_USER;
		$pass = DB_PASSWORD;
		$name = DB_NAME;
		$host = DB_HOST;

		$cnf_tmp  = null;
		$cnf_args = [];
		$cnf_path = tempnam( sys_get_temp_dir(), 'wpps_db_' );
		$u_q      = $this->mysqldump_cnf_escape_value( $user );
		$p_q      = $this->mysqldump_cnf_escape_value( $pass );
		// Quoted values + [mysqldump]: unquoted passwords break on # etc.; mysqli often still connects while CLI didn't.
		$cnf_body = "[client]\nuser={$u_q}\npassword={$p_q}\n[mysqldump]\nuser={$u_q}\npassword={$p_q}\n";
		if ( $cnf_path && false !== file_put_contents( $cnf_path, $cnf_body ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@chmod( $cnf_path, 0600 );
			$cnf_tmp  = $cnf_path;
			$cnf_args = [ '--defaults-extra-file=' . escapeshellarg( $cnf_path ) ];
		}

		$flags = [
			'--single-transaction',
			'--quick',
			'--skip-lock-tables',
			'--routines',
			'--triggers',
			'--events',
			'--hex-blob',
		];
		// MySQL only — MariaDB rejects this and aborts the whole dump.
		if ( $this->mysqldump_supports_set_gtid_purged( $bin ) ) {
			$flags[] = '--set-gtid-purged=OFF';
		}
		$args = array_merge(
			[ escapeshellarg( $bin ) ],
			$cnf_args,
			$flags
		);
		if ( ! $cnf_tmp ) {
			// Never put the DB password on argv (process-list leak). Fall back to PHP export.
			return null;
		}

		$socket_path = null;
		if ( str_starts_with( $host, 'localhost:' ) && str_contains( $host, '/' ) ) {
			$socket_path = substr( $host, strlen( 'localhost:' ) );
		}
		if ( $socket_path ) {
			$args[] = '--socket=' . escapeshellarg( $socket_path );
		} else {
			$h    = $host;
			$port = null;
			if ( preg_match( '/^([^:]+):(\d+)$/', $host, $hm ) ) {
				$h    = $hm[1];
				$port = (int) $hm[2];
			}
			$args[] = '-h' . escapeshellarg( $h );
			if ( $port ) {
				$args[] = '-P' . (string) $port;
			}
			// Align with typical mysqli TCP behaviour; avoids user@localhost (socket) vs user@127.0.0.1 (TCP) grant mismatches.
			$args[] = '--protocol=TCP';
		}

		$args[] = escapeshellarg( $name );

		// Multisite / filtered table list: append explicit tables (still dumps DB routines/events globally).
		$tables = $this->get_backup_table_list();
		if ( is_multisite() && $tables !== [] ) {
			foreach ( $tables as $table ) {
				if ( is_string( $table ) && $table !== '' && ! str_contains( $table, '`' ) ) {
					$args[] = escapeshellarg( $table );
				}
			}
		}

		return [ 'argv' => $args, 'cnf_tmp' => $cnf_tmp ];
	}

	private function mysqldump_shell_exec_available(): bool {
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			return false;
		}
		if ( ! function_exists( 'shell_exec' ) ) {
			return false;
		}
		$dis = ini_get( 'disable_functions' );
		if ( is_string( $dis ) && $dis !== '' && str_contains( strtolower( $dis ), 'shell_exec' ) ) {
			return false;
		}
		/**
		 * Set false to force synchronous mysqldump only (one HTTP request, may hit max_execution_time).
		 *
		 * @param bool $allow Default true on Unix when shell_exec is available.
		 */
		return (bool) apply_filters( 'wpps_br_allow_async_mysqldump', true );
	}

	/**
	 * @param list<string> $paths
	 */
	private function mysqldump_unlink_if_present( array $paths ): void {
		foreach ( $paths as $p ) {
			if ( is_string( $p ) && $p !== '' && is_file( $p ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $p );
			}
		}
	}

	/**
	 * Send SIGTERM to a background mysqldump if pid file exists (backup abort / stall cleanup).
	 */
	private function mysqldump_try_terminate_async_dump( string $dest_sql_path ): void {
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			return;
		}
		$pidf = $dest_sql_path . '.mysqldump.pid';
		if ( ! is_readable( $pidf ) || filesize( $pidf ) < 1 ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$pid = (int) trim( (string) file_get_contents( $pidf ) );
		if ( $pid < 2 ) {
			return;
		}
		if ( function_exists( 'posix_kill' ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@posix_kill( $pid, 15 );
			return;
		}
		$dis = ini_get( 'disable_functions' );
		if ( is_string( $dis ) && $dis !== '' && str_contains( strtolower( $dis ), 'exec' ) ) {
			return;
		}
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec,WordPress.PHP.NoSilencedErrors.Discouraged
		@exec( 'kill -TERM ' . $pid . ' 2>/dev/null' );
	}

	/**
	 * Spawn mysqldump in a detached shell so it is not tied to PHP max_execution_time; poll with mysqldump_poll_background_job().
	 *
	 * @param array<string, mixed> $state
	 */
	private function mysqldump_start_background_job( string $job_dir, string $dest_final, array &$state ): bool {
		$built = $this->mysqldump_build_argv_unix();
		if ( ! $built ) {
			return false;
		}

		$tmp  = $dest_final . '.mysqldump.part';
		$err  = $dest_final . '.mysqldump.stderr';
		$exf  = $dest_final . '.mysqldump.exit';
		$pidf = $dest_final . '.mysqldump.pid';
		$sh   = trailingslashit( $job_dir ) . '.wpps-mysqldump-async.sh';
		$this->mysqldump_unlink_if_present( [ $tmp, $err, $exf, $pidf, $dest_final, $sh ] );

		$argv_line = implode( ' ', $built['argv'] );
		$body      = "#!/bin/bash\n";
		$body     .= "set -u\n";
		$body     .= 'FINAL=' . escapeshellarg( $dest_final ) . "\n";
		$body     .= 'TMP=' . escapeshellarg( $tmp ) . "\n";
		$body     .= 'ERR=' . escapeshellarg( $err ) . "\n";
		$body     .= 'EXITF=' . escapeshellarg( $exf ) . "\n";
		$body     .= 'PIDF=' . escapeshellarg( $pidf ) . "\n";
		$body     .= 'rm -f "$EXITF" "$FINAL" "$PIDF"' . "\n";
		$body     .= $argv_line . ' >"$TMP" 2>"$ERR" &' . "\n";
		$body     .= 'mpid=$!' . "\n";
		$body     .= 'echo "$mpid" >"$PIDF"' . "\n";
		$body     .= 'wait $mpid' . "\n";
		$body     .= 'code=$?' . "\n";
		$body     .= 'rm -f "$PIDF"' . "\n";
		$body     .= 'echo "$code" >"$EXITF"' . "\n";
		$body     .= 'if [ "$code" -eq 0 ]; then mv -f "$TMP" "$FINAL"; else rm -f "$TMP"; fi' . "\n";

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if ( false === file_put_contents( $sh, $body ) ) {
			if ( $built['cnf_tmp'] ) {
				$this->mysqldump_unlink_if_present( [ $built['cnf_tmp'] ] );
			}
			return false;
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		@chmod( $sh, 0700 );

		$bash = '/bin/bash';
		if ( ! is_executable( $bash ) ) {
			$bash = 'bash';
		}
		$nohup = '';
		foreach ( [ '/usr/bin/nohup', '/bin/nohup' ] as $nh ) {
			if ( is_executable( $nh ) ) {
				$nohup = $nh . ' ';
				break;
			}
		}
		$launch = 'cd ' . escapeshellarg( $job_dir ) . ' && ' . $nohup . $bash . ' ' . escapeshellarg( $sh ) . ' </dev/null >/dev/null 2>&1 &';
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_shell_exec,WordPress.PHP.NoSilencedErrors.Discouraged
		@shell_exec( $launch );

		$state['db_mysqldump_async']      = true;
		$state['db_mysqldump_started']    = time();
		$state['db_mysqldump_sh']         = $sh;
		$state['db_mysqldump_cnf']        = $built['cnf_tmp'];
		$state['db_mysqldump_tmp']        = $tmp;
		$state['db_mysqldump_err']        = $err;
		$state['db_mysqldump_exit']       = $exf;
		$this->log_line( __( 'mysqldump started in background (avoids PHP max_execution_time for the dump itself).', 'wp-powersuite' ) );
		return true;
	}

	/**
	 * @param array<string, mixed> $state
	 * @return bool|null Null = still running, true = success, false = failure (use PHP export)
	 */
	private function mysqldump_poll_background_job( string $dest_final, array &$state ): ?bool {
		$exf = $dest_final . '.mysqldump.exit';
		if ( empty( $state['db_mysqldump_async'] ) ) {
			return false;
		}
		$started = isset( $state['db_mysqldump_started'] ) ? (int) $state['db_mysqldump_started'] : 0;
		if ( $started > 0 && ( time() - $started ) > self::MYSQLDUMP_ASYNC_STALL_SECONDS ) {
			$this->log_line( __( 'Background mysqldump timed out waiting for completion; falling back to PHP export (chunked).', 'wp-powersuite' ) );
			$this->mysqldump_try_terminate_async_dump( $dest_final );
			$this->mysqldump_clear_async_state( $state, $dest_final );
			return false;
		}

		if ( ! is_readable( $exf ) || filesize( $exf ) < 1 ) {
			return null;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = (string) file_get_contents( $exf );
		$code = (int) trim( $raw );

		$err = $dest_final . '.mysqldump.stderr';
		if ( is_readable( $err ) && filesize( $err ) > 0 ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$stderr = (string) file_get_contents( $err );
			$meaningful = preg_replace( '/.*Using a password on the command line.*\n?/i', '', $stderr );
			if ( trim( $meaningful ) !== '' ) {
				$this->log_line( 'mysqldump stderr: ' . trim( substr( $meaningful, 0, 500 ) ) );
			}
		}

		$this->mysqldump_clear_async_state( $state, $dest_final );

		if ( 0 !== $code || ! is_file( $dest_final ) || filesize( $dest_final ) < 32 ) {
			if ( is_file( $dest_final ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $dest_final );
			}
			$this->log_line( __( 'mysqldump failed; falling back to PHP export (chunked).', 'wp-powersuite' ) );
			return false;
		}

		$this->prepend_database_sql_headers_to_file( $dest_final );
		return true;
	}

	/**
	 * @param array<string, mixed> $state
	 */
	private function mysqldump_clear_async_state( array &$state, string $dest_final ): void {
		$paths = [
			isset( $state['db_mysqldump_sh'] ) ? (string) $state['db_mysqldump_sh'] : '',
			isset( $state['db_mysqldump_cnf'] ) && is_string( $state['db_mysqldump_cnf'] ) ? $state['db_mysqldump_cnf'] : '',
			$dest_final . '.mysqldump.part',
			$dest_final . '.mysqldump.stderr',
			$dest_final . '.mysqldump.exit',
			$dest_final . '.mysqldump.pid',
		];
		$this->mysqldump_unlink_if_present( $paths );

		unset(
			$state['db_mysqldump_async'],
			$state['db_mysqldump_started'],
			$state['db_mysqldump_sh'],
			$state['db_mysqldump_cnf'],
			$state['db_mysqldump_tmp'],
			$state['db_mysqldump_err'],
			$state['db_mysqldump_exit']
		);
	}

	/**
	 * Run mysqldump in one shot (same PHP request). Returns false → use background job or PHP export.
	 */
	private function try_export_database_mysqldump( string $dest_file ): bool {
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			$this->log_line( __( 'Using PHP database export on Windows (mysqldump shell redirect unreliable).', 'wp-powersuite' ) );
			return false;
		}
		if ( ! self::exec_function_available() ) {
			// Calling a disabled exec() is fatal on PHP 8+; fall back to the PHP export.
			$this->log_line( __( 'PHP exec() is disabled on this host; using PHP database export (chunked).', 'wp-powersuite' ) );
			return false;
		}
		$built = $this->mysqldump_build_argv_unix();
		if ( ! $built ) {
			return false;
		}

		$err_file = $dest_file . '.stderr';
		$cmd      = implode( ' ', $built['argv'] ) . ' > ' . escapeshellarg( $dest_file ) . ' 2>' . escapeshellarg( $err_file );

		$output = [];
		$code   = 0;
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec,WordPress.PHP.NoSilencedErrors.Discouraged
		@exec( $cmd, $output, $code );

		if ( $built['cnf_tmp'] ) {
			$this->mysqldump_unlink_if_present( [ $built['cnf_tmp'] ] );
		}

		if ( is_readable( $err_file ) && filesize( $err_file ) > 0 ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$stderr = (string) file_get_contents( $err_file );
			$meaningful = preg_replace( '/.*Using a password on the command line.*\n?/i', '', $stderr );
			if ( trim( $meaningful ) !== '' ) {
				$this->log_line( 'mysqldump stderr: ' . trim( substr( $meaningful, 0, 500 ) ) );
			}
		}
		$this->mysqldump_unlink_if_present( [ $err_file ] );

		if ( $code !== 0 || ! file_exists( $dest_file ) || filesize( $dest_file ) < 32 ) {
			$this->mysqldump_unlink_if_present( [ $dest_file ] );
			$this->log_line( __( 'mysqldump failed; falling back to PHP export (chunked).', 'wp-powersuite' ) );
			return false;
		}
		$this->prepend_database_sql_headers_to_file( $dest_file );
		return true;
	}

	/**
	 * Stream database to SQL using PHP across multiple backup ticks (survives low max_execution_time).
	 * Phases: tables → views → routines → triggers → events.
	 *
	 * @param array<string, mixed> $state Job state; uses keys db_php_*.
	 * @return bool True when export is complete.
	 */
	private function export_database_php_resumable( string $dest_file, array &$state ): bool {
		global $wpdb;

		$phase = isset( $state['db_php_phase'] ) && is_string( $state['db_php_phase'] ) ? $state['db_php_phase'] : 'tables';

		$tables = isset( $state['db_php_tables'] ) && is_array( $state['db_php_tables'] ) ? $state['db_php_tables'] : null;
		if ( ! is_array( $tables ) ) {
			$tables = $this->get_backup_table_list();
			if ( $tables === [] ) {
				throw new \Exception( __( 'Could not read database table list.', 'wp-powersuite' ) );
			}
			$state['db_php_tables']             = $tables;
			$state['db_php_table_index']        = 0;
			$state['db_php_row_offset']         = 0;
			$state['db_php_schema_written_idx'] = -1;
			$state['db_php_phase']              = 'tables';
			$phase                              = 'tables';
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$fh = fopen( $dest_file, 'wb' );
			if ( ! $fh ) {
				throw new \Exception( __( 'Could not open database export file for writing.', 'wp-powersuite' ) );
			}
			fwrite( $fh, "SET FOREIGN_KEY_CHECKS=0;\nSET SQL_MODE=\"NO_AUTO_VALUE_ON_ZERO\";\nSET NAMES utf8mb4;\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			fwrite( $fh, $this->get_database_dump_sql_header_lines() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$state['db_php_started'] = true;
		}

		if ( 'tables' === $phase ) {
			$tables_done = $this->export_database_php_tables_tick( $dest_file, $state, $tables );
			if ( ! $tables_done ) {
				return false;
			}
			$state['db_php_phase'] = 'views';
			$phase                 = 'views';
		}

		if ( 'views' === $phase ) {
			if ( ! $this->export_database_php_objects_tick( $dest_file, $state, 'views' ) ) {
				return false;
			}
			$state['db_php_phase'] = 'routines';
			$phase                 = 'routines';
		}
		if ( 'routines' === $phase ) {
			if ( ! $this->export_database_php_objects_tick( $dest_file, $state, 'routines' ) ) {
				return false;
			}
			$state['db_php_phase'] = 'triggers';
			$phase                 = 'triggers';
		}
		if ( 'triggers' === $phase ) {
			if ( ! $this->export_database_php_objects_tick( $dest_file, $state, 'triggers' ) ) {
				return false;
			}
			$state['db_php_phase'] = 'events';
			$phase                 = 'events';
		}
		if ( 'events' === $phase ) {
			if ( ! $this->export_database_php_objects_tick( $dest_file, $state, 'events' ) ) {
				return false;
			}
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $dest_file, 'ab' );
		if ( $fh ) {
			fwrite( $fh, "\nSET FOREIGN_KEY_CHECKS=1;\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		unset(
			$state['db_php_tables'],
			$state['db_php_table_index'],
			$state['db_php_row_offset'],
			$state['db_php_started'],
			$state['db_php_schema_written_idx'],
			$state['db_php_phase'],
			$state['db_php_order_by'],
			$state['db_php_obj_list'],
			$state['db_php_obj_index'],
			$state['db_export_progress_label']
		);

		return true;
	}

	/**
	 * @param list<string>         $tables
	 * @param array<string, mixed> $state
	 */
	private function export_database_php_tables_tick( string $dest_file, array &$state, array $tables ): bool {
		global $wpdb;

		$n_tables = count( $tables );
		$idx      = isset( $state['db_php_table_index'] ) ? (int) $state['db_php_table_index'] : 0;
		$offset   = isset( $state['db_php_row_offset'] ) ? (int) $state['db_php_row_offset'] : 0;
		$sch_idx  = isset( $state['db_php_schema_written_idx'] ) ? (int) $state['db_php_schema_written_idx'] : -1;

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $dest_file, 'ab' );
		if ( ! $fh ) {
			throw new \Exception( __( 'Could not open database export file for appending.', 'wp-powersuite' ) );
		}

		$deadline = microtime( true ) + self::DB_PHP_EXPORT_MAX_SECONDS;
		$inserts  = 0;
		$chunk    = self::DB_PHP_EXPORT_ROW_CHUNK;

		try {
			while ( $idx < $n_tables && microtime( true ) < $deadline && $inserts < self::DB_PHP_EXPORT_MAX_INSERTS ) {
				$table = $tables[ $idx ];
				if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
					// Never skip data silently — the backup would report success while incomplete.
					$this->log_line(
						sprintf(
							/* translators: %s: database table name */
							__( 'WARNING: table "%s" was NOT exported (unsupported characters in table name; PHP export supports letters, digits and underscores only). Use mysqldump or rename the table.', 'wp-powersuite' ),
							$table
						)
					);
					++$idx;
					$offset = 0;
					unset( $state['db_php_order_by'] );
					continue;
				}
				if ( $sch_idx !== $idx ) {
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name validated
					$create = $wpdb->get_row( "SHOW CREATE TABLE `{$table}`", ARRAY_N );
					if ( ! is_array( $create ) || empty( $create[1] ) ) {
						$this->log_line(
							sprintf(
								/* translators: %s: database table name */
								__( 'WARNING: table "%s" was NOT exported (SHOW CREATE TABLE failed — missing privilege or the table vanished mid-backup).', 'wp-powersuite' ),
								$table
							)
						);
						++$idx;
						$offset = 0;
						unset( $state['db_php_order_by'] );
						continue;
					}
					fwrite( $fh, "\nDROP TABLE IF EXISTS `{$table}`;\n" . $create[1] . ";\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
					$sch_idx                            = $idx;
					$state['db_php_schema_written_idx'] = $sch_idx;
					unset( $state['db_php_order_by'] );
				}

				// Stable pagination: without ORDER BY, LIMIT/OFFSET pages can shift while other
				// requests write to the table, silently skipping or duplicating rows.
				if ( ! isset( $state['db_php_order_by'] ) || ! is_string( $state['db_php_order_by'] ) ) {
					$state['db_php_order_by'] = $this->php_export_order_by_clause( $table );
				}
				$order_sql = '' !== $state['db_php_order_by'] ? ' ORDER BY ' . $state['db_php_order_by'] : '';

				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers validated
				$rows = $wpdb->get_results(
					$wpdb->prepare( "SELECT * FROM `{$table}`{$order_sql} LIMIT %d OFFSET %d", $chunk, $offset ),
					ARRAY_A
				);
				// phpcs:enable
				if ( ! is_array( $rows ) || count( $rows ) === 0 ) {
					++$idx;
					$offset = 0;
					unset( $state['db_php_order_by'] );
					continue;
				}

				$cols    = array_keys( $rows[0] );
				$col_sql = '`' . implode( '`,`', array_map( 'esc_sql', $cols ) ) . '`';
				foreach ( $rows as $row ) {
					$vals = [];
					foreach ( $row as $v ) {
						$vals[] = $this->format_sql_insert_value( $v );
					}
					fwrite( $fh, "INSERT INTO `{$table}` ({$col_sql}) VALUES (" . implode( ',', $vals ) . ");\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				}
				$row_n    = count( $rows );
				$inserts += $row_n;
				$offset  += $row_n;
				if ( $row_n < $chunk ) {
					++$idx;
					$offset = 0;
					unset( $state['db_php_order_by'] );
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		$state['db_php_table_index']        = $idx;
		$state['db_php_row_offset']         = $offset;
		$state['db_php_schema_written_idx'] = $sch_idx;
		if ( $idx >= $n_tables ) {
			unset( $state['db_php_order_by'] );
		}
		$state['db_export_progress_label']  = sprintf(
			/* translators: 1: table index (1-based), 2: total tables */
			__( 'Exporting database… table %1$d of %2$d (PHP)', 'wp-powersuite' ),
			min( $idx + 1, max( 1, $n_tables ) ),
			$n_tables
		);

		return $idx >= $n_tables;
	}

	/**
	 * ORDER BY clause on the primary key for stable LIMIT/OFFSET pagination ('' when no
	 * usable primary key exists — falls back to unordered pages, matching old behaviour).
	 *
	 * @param string $table Validated table name ([A-Za-z0-9_]+ only).
	 */
	private function php_export_order_by_clause( string $table ): string {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name validated by caller
		$rows = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'", ARRAY_A );
		if ( ! is_array( $rows ) || $rows === [] ) {
			return '';
		}
		usort(
			$rows,
			static function ( $a, $b ) {
				return ( (int) ( $a['Seq_in_index'] ?? 0 ) ) <=> ( (int) ( $b['Seq_in_index'] ?? 0 ) );
			}
		);
		$cols = [];
		foreach ( $rows as $row ) {
			$col = isset( $row['Column_name'] ) ? (string) $row['Column_name'] : '';
			if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $col ) ) {
				return '';
			}
			$cols[] = "`{$col}`";
		}

		return implode( ',', $cols );
	}

	/**
	 * Export views / routines / triggers / events (one phase list, resumable by index).
	 *
	 * @param array<string, mixed> $state
	 * @param 'views'|'routines'|'triggers'|'events' $kind
	 */
	private function export_database_php_objects_tick( string $dest_file, array &$state, string $kind ): bool {
		global $wpdb;

		$list_key = 'db_php_obj_list';
		$idx_key  = 'db_php_obj_index';
		$list     = isset( $state[ $list_key ] ) && is_array( $state[ $list_key ] ) ? $state[ $list_key ] : null;

		if ( ! is_array( $list ) ) {
			$list = $this->collect_database_objects_for_export( $kind );
			$state[ $list_key ] = $list;
			$state[ $idx_key ]  = 0;
			if ( $list === [] ) {
				unset( $state[ $list_key ], $state[ $idx_key ] );
				return true;
			}
		}

		$idx      = isset( $state[ $idx_key ] ) ? (int) $state[ $idx_key ] : 0;
		$n        = count( $list );
		$deadline = microtime( true ) + self::DB_PHP_EXPORT_MAX_SECONDS;

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $dest_file, 'ab' );
		if ( ! $fh ) {
			throw new \Exception( __( 'Could not open database export file for appending.', 'wp-powersuite' ) );
		}

		try {
			while ( $idx < $n && microtime( true ) < $deadline ) {
				$item = $list[ $idx ];
				if ( ! is_array( $item ) || empty( $item['sql'] ) ) {
					++$idx;
					continue;
				}
				$name = isset( $item['name'] ) ? (string) $item['name'] : '';
				$sql  = (string) $item['sql'];
				if ( $name !== '' ) {
					fwrite( $fh, "\n-- {$kind}: {$name}\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				}
				fwrite( $fh, $sql . ( str_ends_with( trim( $sql ), ';' ) ? "\n" : ";\n" ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				++$idx;
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		if ( $idx >= $n ) {
			unset( $state[ $list_key ], $state[ $idx_key ] );
			$state['db_export_progress_label'] = sprintf(
				/* translators: %s: object kind (views, routines, …) */
				__( 'Exported database %s (PHP)', 'wp-powersuite' ),
				$kind
			);
			return true;
		}

		$state[ $idx_key ]                 = $idx;
		$state['db_export_progress_label'] = sprintf(
			/* translators: 1: kind, 2: index, 3: total */
			__( 'Exporting database %1$s… %2$d / %3$d (PHP)', 'wp-powersuite' ),
			$kind,
			$idx + 1,
			$n
		);

		return false;
	}

	/**
	 * @param 'views'|'routines'|'triggers'|'events' $kind
	 * @return list<array{name: string, sql: string}>
	 */
	private function collect_database_objects_for_export( string $kind ): array {
		global $wpdb;

		$db_name = defined( 'DB_NAME' ) ? (string) DB_NAME : '';
		if ( $db_name === '' ) {
			return [];
		}
		$out = [];

		try {
			if ( 'views' === $kind ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = %s',
						$db_name
					),
					ARRAY_A
				);
				$allowed = array_fill_keys( $this->get_backup_table_list(), true );
				if ( is_array( $rows ) ) {
					foreach ( $rows as $row ) {
						$name = (string) ( $row['TABLE_NAME'] ?? '' );
						if ( $name === '' || ( is_multisite() && ! isset( $allowed[ $name ] ) ) ) {
							continue;
						}
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$create = $wpdb->get_row( "SHOW CREATE VIEW `{$name}`", ARRAY_N );
						if ( is_array( $create ) && ! empty( $create[1] ) ) {
							$out[] = [
								'name' => $name,
								'sql'  => "DROP VIEW IF EXISTS `{$name}`;\n" . $create[1],
							];
						}
					}
				}
				return $out;
			}

			if ( 'routines' === $kind ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = %s',
						$db_name
					),
					ARRAY_A
				);
				if ( is_array( $rows ) ) {
					foreach ( $rows as $row ) {
						$name = (string) ( $row['ROUTINE_NAME'] ?? '' );
						$type = strtoupper( (string) ( $row['ROUTINE_TYPE'] ?? '' ) );
						if ( $name === '' || ! in_array( $type, [ 'PROCEDURE', 'FUNCTION' ], true ) ) {
							continue;
						}
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$create = $wpdb->get_row( "SHOW CREATE {$type} `{$name}`", ARRAY_N );
						// SHOW CREATE PROCEDURE returns Create Procedure at index 2.
						$sql = '';
						if ( is_array( $create ) ) {
							$sql = (string) ( $create[2] ?? $create[1] ?? '' );
						}
						if ( $sql !== '' ) {
							$drop = 'PROCEDURE' === $type ? "DROP PROCEDURE IF EXISTS `{$name}`" : "DROP FUNCTION IF EXISTS `{$name}`";
							$out[] = [
								'name' => $name,
								'sql'  => "DELIMITER ;;\n{$drop};;\n{$sql};;\nDELIMITER ;",
							];
						}
					}
				}
				return $out;
			}

			if ( 'triggers' === $kind ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = %s',
						$db_name
					),
					ARRAY_A
				);
				$allowed = array_fill_keys( $this->get_backup_table_list(), true );
				if ( is_array( $rows ) ) {
					foreach ( $rows as $row ) {
						$name  = (string) ( $row['TRIGGER_NAME'] ?? '' );
						$table = (string) ( $row['EVENT_OBJECT_TABLE'] ?? '' );
						if ( $name === '' || ( is_multisite() && $table !== '' && ! isset( $allowed[ $table ] ) ) ) {
							continue;
						}
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$create = $wpdb->get_row( "SHOW CREATE TRIGGER `{$name}`", ARRAY_N );
						$sql    = is_array( $create ) ? (string) ( $create[2] ?? $create[1] ?? '' ) : '';
						if ( $sql !== '' ) {
							$out[] = [
								'name' => $name,
								'sql'  => "DROP TRIGGER IF EXISTS `{$name}`;\n{$sql}",
							];
						}
					}
				}
				return $out;
			}

			if ( 'events' === $kind ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT EVENT_NAME FROM information_schema.EVENTS WHERE EVENT_SCHEMA = %s',
						$db_name
					),
					ARRAY_A
				);
				if ( is_array( $rows ) ) {
					foreach ( $rows as $row ) {
						$name = (string) ( $row['EVENT_NAME'] ?? '' );
						if ( $name === '' ) {
							continue;
						}
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$create = $wpdb->get_row( "SHOW CREATE EVENT `{$name}`", ARRAY_N );
						$sql    = is_array( $create ) ? (string) ( $create[3] ?? $create[1] ?? '' ) : '';
						if ( $sql !== '' ) {
							$out[] = [
								'name' => $name,
								'sql'  => "DROP EVENT IF EXISTS `{$name}`;\n{$sql}",
							];
						}
					}
				}
			}
		} catch ( \Throwable $e ) {
			$this->log_line( sprintf( 'Skipping %s export (privilege or schema error): %s', $kind, $e->getMessage() ) );
			return [];
		}

		return $out;
	}

	/**
	 * Whether to omit this path (VCS dirs should not be deployed; .git/objects is often read-only for the web user).
	 */
	private function relative_path_contains_vcs_metadata( string $rel ): bool {
		$rel = str_replace( '\\', '/', $rel );
		foreach ( explode( '/', $rel ) as $seg ) {
			$lower = strtolower( $seg );
			if ( in_array( $lower, [ '.git', '.svn', '.hg', '.bzr' ], true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param string      $zip_mode     Unused; ZipArchive is always used when this method runs.
	 * @param string|null $exclude_csv  Comma-separated exclusion tokens (Settings → uploads); empty = skip filtering.
	 */
	private function zip_directory( string $source, string $zip_path, string $zip_prefix, string $zip_mode, ?string $exclude_csv = null ): void {
		$source = trailingslashit( wp_normalize_path( $source ) );
		if ( ! is_dir( $source ) ) {
			if ( ! class_exists( 'ZipArchive' ) ) {
				throw new \Exception( __( 'PHP ZipArchive is not available.', 'wp-powersuite' ) );
			}
			$z = new \ZipArchive();
			if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
				throw new \Exception( __( 'Could not create empty archive.', 'wp-powersuite' ) );
			}
			$z->addFromString( 'readme.txt', "This component's source directory was not found during backup.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- false positive
			$z->close();
			return;
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is not available.', 'wp-powersuite' ) );
		}
		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
			throw new \Exception( __( 'Could not open zip file for writing.', 'wp-powersuite' ) );
		}
		try {
			$rit_flags        = \FilesystemIterator::SKIP_DOTS;
			$zip_file_count   = 0;
			$zip_file_bytes   = 0;
			$excl_file_count  = 0;
			$excl_file_bytes  = 0;
			$excl_dir_count   = 0;
			if ( is_string( $exclude_csv ) && $exclude_csv !== '' ) {
				$source_root = rtrim( $source, '/' );
				$inner       = new \RecursiveDirectoryIterator( $source, $rit_flags );
				$filtered    = new \RecursiveCallbackFilterIterator(
					$inner,
					function ( $current ) use ( $source_root, $exclude_csv, &$excl_dir_count ) {
						$full = wp_normalize_path( $current->getPathname() );
						if ( ! str_starts_with( $full, $source_root ) ) {
							return false;
						}
						$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
						if ( $this->relative_path_contains_vcs_metadata( $rel ) ) {
							return false;
						}
						if ( ! $current->isDir() ) {
							return true;
						}
						if ( $this->relative_path_matches_exclude_csv( $rel, 'uploads', $exclude_csv ) ) {
							++$excl_dir_count;
							return false;
						}
						return true;
					}
				);
				$it = new \RecursiveIteratorIterator( $filtered, \RecursiveIteratorIterator::SELF_FIRST );
			} else {
				$inner         = new \RecursiveDirectoryIterator( $source, $rit_flags );
				$archive_root = rtrim( $source, '/' );
				$filtered      = new \RecursiveCallbackFilterIterator(
					$inner,
					function ( $current ) use ( $archive_root ) {
						$full = wp_normalize_path( $current->getPathname() );
						if ( ! str_starts_with( $full, $archive_root ) ) {
							return false;
						}
						$rel = ltrim( substr( $full, strlen( $archive_root ) ), '/' );
						return ! $this->relative_path_contains_vcs_metadata( $rel );
					}
				);
				$it = new \RecursiveIteratorIterator( $filtered, \RecursiveIteratorIterator::SELF_FIRST );
			}
			foreach ( $it as $file ) {
				if ( ! $file->isFile() ) {
					continue;
				}
				$full = wp_normalize_path( $file->getPathname() );
				if ( ! $this->backup_path_is_allowed_file( $full, rtrim( $source, '/' ) ) ) {
					continue;
				}
				if ( ! str_starts_with( $full, $source ) ) {
					continue;
				}
				$rel_under = ltrim( substr( $full, strlen( rtrim( $source, '/' ) ) ), '/' );
				if ( $this->relative_path_contains_vcs_metadata( $rel_under ) ) {
					continue;
				}
				if ( is_string( $exclude_csv ) && $exclude_csv !== '' && $this->relative_path_matches_exclude_csv( $rel_under, 'uploads', $exclude_csv ) ) {
					$excl_file_bytes += (int) $file->getSize();
					++$excl_file_count;
					continue;
				}
				$rel = $zip_prefix . $rel_under;
				if ( ! $z->addFile( $full, $rel ) ) {
					$this->log_line( sprintf( 'Skipped file (not readable): %s', $full ) );
					continue;
				}
				$zip_file_bytes += (int) $file->getSize();
				++$zip_file_count;
				$this->zip_set_compression_for_file( $z, $rel, $full );
			}
			$this->log_line(
				sprintf(
					'Zipped %d files (%s). Skipped by exclusion rules: %d files + %d directories (%s).',
					$zip_file_count,
					size_format( $zip_file_bytes, 1 ),
					$excl_file_count,
					$excl_dir_count,
					size_format( $excl_file_bytes, 1 )
				)
			);
		} catch ( \Throwable $e ) {
			$z->close();
			throw new \Exception( __( 'Could not read directory for archiving.', 'wp-powersuite' ) . ' ' . $e->getMessage() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- wrapped when logged
		}
		if ( ! $z->close() ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: zip file path */
					__( 'Failed to finalize ZIP archive: %s (disk may be full).', 'wp-powersuite' ),
					basename( $zip_path )
				)
			);
		}
	}

	/**
	 * Directory prefixes to skip when archiving “other” wp-content (plugins/themes/uploads/backup dir + common junk).
	 *
	 * @return list<string> Normalized paths without trailing slash.
	 */
	private function others_wp_content_exclude_directories( string $content ): array {
		$content_root = rtrim( wp_normalize_path( trailingslashit( $content ) ), '/' );
		$paths        = [
			wp_normalize_path( trailingslashit( WP_PLUGIN_DIR ) ),
			wp_normalize_path( trailingslashit( get_theme_root() ) ),
			wp_normalize_path( trailingslashit( wp_upload_dir()['basedir'] ) ),
			wp_normalize_path( trailingslashit( $this->get_backup_root() ) ),
			wp_normalize_path( trailingslashit( $content ) . PrivateStorageResolver::BACKUP_LEAF ),
			wp_normalize_path( trailingslashit( $content ) . PrivateStorageResolver::PRIVATE_LEAF ),
			wp_normalize_path( trailingslashit( $content ) . PrivateStorageResolver::STAGING_LEAF ),
		];
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			$paths[] = wp_normalize_path( trailingslashit( $legacy ) );
		}
		$private = $this->get_private_storage_root();
		if ( $private !== '' ) {
			$paths[] = wp_normalize_path( trailingslashit( $private ) );
		}
		$exclude = [];
		foreach ( $paths as $p ) {
			$p = rtrim( wp_normalize_path( $p ), '/' );
			if ( $p && str_starts_with( $p, $content_root ) ) {
				$exclude[] = $p;
			}
		}
		return array_values( array_unique( $exclude ) );
	}

	/**
	 * Write a line-delimited list of paths relative to wp-content.
	 *
	 * @return int Number of files queued.
	 */
	/**
	 * Resumable others queue build (BFS directory walk with time budget).
	 *
	 * @param array<string, mixed> $state
	 * @return bool True when the queue file is complete.
	 */
	private function build_others_file_queue_tick( string $job_dir, string $content, string $exclude_others_csv, array &$state ): bool {
		$content      = wp_normalize_path( trailingslashit( $content ) );
		$content_root = rtrim( $content, '/' );
		$queue_path   = trailingslashit( $job_dir ) . self::OTHERS_QUEUE_BASENAME;

		if ( empty( $state['others_queue_building'] ) ) {
			$this->log_line( __( 'Others: building file queue using your Settings exclusion list.', 'wp-powersuite' ) );
			if ( $exclude_others_csv !== '' ) {
				$this->log_line( sprintf( 'Others exclusion tokens: %s', $exclude_others_csv ) );
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$fh = fopen( $queue_path, 'wb' );
			if ( false === $fh ) {
				throw new \Exception( __( 'Could not write others file queue.', 'wp-powersuite' ) );
			}
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$state['others_queue_building']   = true;
			$state['others_queue_build_stack'] = [ $content_root ];
			$state['others_queue_build_count'] = 0;
			$state['others_queue_build_bytes'] = 0;
		}

		$stack   = isset( $state['others_queue_build_stack'] ) && is_array( $state['others_queue_build_stack'] ) ? $state['others_queue_build_stack'] : [];
		$count   = isset( $state['others_queue_build_count'] ) ? (int) $state['others_queue_build_count'] : 0;
		$qbytes  = isset( $state['others_queue_build_bytes'] ) ? (int) $state['others_queue_build_bytes'] : 0;
		$exclude = $this->others_wp_content_exclude_directories( $content );
		$deadline = microtime( true ) + self::ARCHIVE_CHUNK_MAX_SECONDS;

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $queue_path, 'ab' );
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not write others file queue.', 'wp-powersuite' ) );
		}

		try {
			while ( $stack !== [] && microtime( true ) < $deadline ) {
				$dir = array_pop( $stack );
				if ( ! is_string( $dir ) || $dir === '' || ! is_dir( $dir ) ) {
					continue;
				}
				$dir_n = rtrim( wp_normalize_path( $dir ), '/' );
				foreach ( $exclude as $ex_dir ) {
					if ( $this->path_is_excluded( $dir_n, wp_normalize_path( $ex_dir ) ) ) {
						continue 2;
					}
				}
				$rel_dir = ltrim( substr( $dir_n, strlen( $content_root ) ), '/' );
				if ( $rel_dir !== '' && $this->relative_path_contains_vcs_metadata( $rel_dir ) ) {
					continue;
				}
				if ( $rel_dir !== '' && $exclude_others_csv !== '' && $this->relative_path_matches_exclude_csv( $rel_dir, 'others', $exclude_others_csv ) ) {
					continue;
				}

				$entries = @scandir( $dir_n ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( ! is_array( $entries ) ) {
					continue;
				}
				foreach ( $entries as $entry ) {
					if ( '.' === $entry || '..' === $entry ) {
						continue;
					}
					$full = $dir_n . '/' . $entry;
					$full = wp_normalize_path( $full );
					if ( is_dir( $full ) && ! is_link( $full ) ) {
						$stack[] = $full;
						continue;
					}
					if ( ! is_file( $full ) || is_link( $full ) ) {
						continue;
					}
					if ( ! $this->backup_path_is_allowed_file( $full, $content_root ) ) {
						continue;
					}
					$rel = ltrim( substr( $full, strlen( $content_root ) ), '/' );
					if ( $rel === '' || $this->relative_path_contains_vcs_metadata( $rel ) ) {
						continue;
					}
					if ( $exclude_others_csv !== '' && $this->relative_path_matches_exclude_csv( $rel, 'others', $exclude_others_csv ) ) {
						continue;
					}
					$qbytes += (int) @filesize( $full ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					fwrite( $fh, $rel . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
					++$count;
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		$state['others_queue_build_stack'] = $stack;
		$state['others_queue_build_count'] = $count;
		$state['others_queue_build_bytes'] = $qbytes;

		if ( $stack !== [] ) {
			$state['others_progress_label'] = sprintf(
				/* translators: %d: files queued so far */
				__( 'Scanning other wp-content files… %d queued', 'wp-powersuite' ),
				$count
			);
			return false;
		}

		$state['others_queue_total']  = $count;
		$state['others_queue_offset'] = 0;
		unset( $state['others_queue_building'], $state['others_queue_build_stack'], $state['others_queue_build_count'], $state['others_queue_build_bytes'] );
		$this->log_line(
			sprintf(
				'Others: queued %d files (%s).',
				$count,
				size_format( $qbytes, 1 )
			)
		);

		return true;
	}

	/**
	 * Add the next slice of “others” files so each AJAX tick stays within proxy / PHP-friendly bounds.
	 *
	 * @param array<string, mixed> $state
	 */
	private function zip_others_wp_content_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required to archive “other” wp-content items. Please enable the zip extension.', 'wp-powersuite' ) );
		}

		$content    = wp_normalize_path( trailingslashit( WP_CONTENT_DIR ) );
		$queue_path = trailingslashit( $job_dir ) . self::OTHERS_QUEUE_BASENAME;

		$total_lines = isset( $state['others_queue_total'] ) ? (int) $state['others_queue_total'] : 0;

		$needs_queue = ! empty( $state['others_queue_building'] )
			|| ! is_file( $queue_path )
			|| ( ! isset( $state['others_queue_total'] ) && ! isset( $state['others_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( empty( $state['others_queue_building'] ) ) {
				if ( is_file( $queue_path ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $queue_path );
				}
				if ( is_file( $zip_path ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $zip_path );
				}
			}
			$ex_oth = isset( $state['exclude_others_csv'] ) && is_string( $state['exclude_others_csv'] )
				? $state['exclude_others_csv']
				: self::default_exclude_others();
			if ( ! $this->build_others_file_queue_tick( $job_dir, $content, $ex_oth, $state ) ) {
				return false;
			}
			$total_lines = isset( $state['others_queue_total'] ) ? (int) $state['others_queue_total'] : 0;

			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Others archive: nothing to include (all paths excluded).', 'wp-powersuite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open others.zip for writing.', 'wp-powersuite' ) );
				}
				$z->addFromString(
					'others/readme.txt',
					"No additional files under wp-content were archived (exclusions only).\n"
				);
				$z->close();
				return true;
			}

			$this->log_line(
				sprintf(
					/* translators: %d: file count */
					__( 'Others archive: queued %d file(s) under wp-content.', 'wp-powersuite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$content,
			'others/',
			$state,
			'others_queue_offset',
			'others_queue_total',
			'others_progress_label',
			'Others',
			/* translators: 1: processed lines, 2: total queued files */
			__( 'Creating archive (Other wp-content)… %1$d / %2$d files', 'wp-powersuite' )
		);
	}

	/**
	 * Chunked uploads ZIP — mirrors the others chunking to survive PHP-FPM timeouts.
	 */
	private function zip_uploads_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required.', 'wp-powersuite' ) );
		}

		$uploads_dir = trailingslashit( wp_normalize_path( wp_upload_dir()['basedir'] ) );
		$queue_path  = trailingslashit( $job_dir ) . self::UPLOADS_QUEUE_BASENAME;

		$total_lines = isset( $state['uploads_queue_total'] ) ? (int) $state['uploads_queue_total'] : 0;

		$needs_queue = ! empty( $state['uploads_queue_building'] )
			|| ! is_file( $queue_path )
			|| ( ! isset( $state['uploads_queue_total'] ) && ! isset( $state['uploads_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( empty( $state['uploads_queue_building'] ) ) {
				if ( is_file( $queue_path ) ) {
					@unlink( $queue_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				if ( is_file( $zip_path ) ) {
					@unlink( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
			}
			$ex_up = isset( $state['exclude_uploads_csv'] ) && is_string( $state['exclude_uploads_csv'] )
				? $state['exclude_uploads_csv']
				: self::default_exclude_uploads();
			if ( ! $this->build_uploads_file_queue_tick( $queue_path, $uploads_dir, $ex_up, $state ) ) {
				return false;
			}
			$total_lines = isset( $state['uploads_queue_total'] ) ? (int) $state['uploads_queue_total'] : 0;
			$state['uploads_queue_total']  = $total_lines;
			$state['uploads_queue_offset'] = 0;

			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Uploads archive: nothing to include.', 'wp-powersuite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open uploads.zip for writing.', 'wp-powersuite' ) );
				}
				$z->addFromString( 'uploads/readme.txt', "No uploads files were archived.\n" );
				$z->close();
				return true;
			}

			$this->log_line(
				sprintf(
					__( 'Uploads archive: queued %d file(s).', 'wp-powersuite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$uploads_dir,
			'uploads/',
			$state,
			'uploads_queue_offset',
			'uploads_queue_total',
			'uploads_progress_label',
			'Uploads',
			__( 'Creating archive (Uploads)… %1$d / %2$d files', 'wp-powersuite' )
		);
	}

	/**
	 * Line-delimited paths relative to $root_dir (VCS + dev bloat skipped). Shared by chunked plugins/themes archives.
	 */
	private function build_vcs_filtered_file_queue( string $queue_path, string $root_dir ): int {
		$root_dir    = trailingslashit( wp_normalize_path( $root_dir ) );
		$source_root = rtrim( $root_dir, '/' );
		$count       = 0;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $queue_path, 'wb' );
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not create file queue for archive.', 'wp-powersuite' ) );
		}
		$rit_flags = \FilesystemIterator::SKIP_DOTS;
		$inner     = new \RecursiveDirectoryIterator( $root_dir, $rit_flags );
		$filtered  = new \RecursiveCallbackFilterIterator(
			$inner,
			function ( $current ) use ( $source_root ) {
				$full = wp_normalize_path( $current->getPathname() );
				if ( ! str_starts_with( $full, $source_root ) ) {
					return false;
				}
				$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
				return ! $this->relative_path_should_skip_archive_bloat( $rel );
			}
		);
		$it = new \RecursiveIteratorIterator( $filtered, \RecursiveIteratorIterator::SELF_FIRST );
		try {
			foreach ( $it as $file ) {
				if ( ! $file->isFile() ) {
					continue;
				}
				$full = wp_normalize_path( $file->getPathname() );
				if ( ! $this->backup_path_is_allowed_file( $full, $source_root ) ) {
					continue;
				}
				if ( ! str_starts_with( $full, $source_root ) ) {
					continue;
				}
				$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
				if ( $this->relative_path_should_skip_archive_bloat( $rel ) ) {
					continue;
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				fwrite( $fh, $rel . "\n" );
				$count++;
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}
		return $count;
	}

	/**
	 * Chunked plugins.zip — avoids one long Zip pass hitting max_execution_time on huge wp-content/plugins trees.
	 *
	 * @param array<string, mixed> $state
	 */
	private function zip_plugins_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required.', 'wp-powersuite' ) );
		}
		$plugins_dir = trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) );
		$queue_path  = trailingslashit( $job_dir ) . self::PLUGINS_QUEUE_BASENAME;
		$needs_queue = ! is_file( $queue_path )
			|| ( ! isset( $state['plugins_queue_total'] ) && ! isset( $state['plugins_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( is_file( $queue_path ) ) {
				@unlink( $queue_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( is_file( $zip_path ) ) {
				@unlink( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( ! is_dir( $plugins_dir ) ) {
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not create plugins.zip.', 'wp-powersuite' ) );
				}
				$z->addFromString( 'plugins/readme.txt', "Plugin directory was not found during backup.\n" );
				$z->close();
				return true;
			}
			if ( $this->try_system_zip_wp_content_tree( $zip_path, $plugins_dir, 'plugins', 'Plugins' ) ) {
				return true;
			}
			$total_lines = $this->build_vcs_filtered_file_queue( $queue_path, $plugins_dir );
			$state['plugins_queue_total']  = $total_lines;
			$state['plugins_queue_offset'] = 0;
			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Plugins archive: nothing to include.', 'wp-powersuite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open plugins.zip for writing.', 'wp-powersuite' ) );
				}
				$z->addFromString( 'plugins/readme.txt', "No plugin files were archived.\n" );
				$z->close();
				return true;
			}
			$this->log_line(
				sprintf(
					/* translators: %d: file count */
					__( 'Plugins archive: queued %d file(s).', 'wp-powersuite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$plugins_dir,
			'plugins/',
			$state,
			'plugins_queue_offset',
			'plugins_queue_total',
			'plugins_progress_label',
			'Plugins',
			/* translators: 1: current file index, 2: total files */
			__( 'Creating archive (Plugins)… %1$d / %2$d files', 'wp-powersuite' )
		);
	}

	/**
	 * Chunked themes.zip — same pattern as zip_plugins_chunked.
	 *
	 * @param array<string, mixed> $state
	 */
	private function zip_themes_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required.', 'wp-powersuite' ) );
		}
		$themes_root = trailingslashit( wp_normalize_path( get_theme_root() ) );
		$queue_path  = trailingslashit( $job_dir ) . self::THEMES_QUEUE_BASENAME;
		$needs_queue = ! is_file( $queue_path )
			|| ( ! isset( $state['themes_queue_total'] ) && ! isset( $state['themes_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( is_file( $queue_path ) ) {
				@unlink( $queue_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( is_file( $zip_path ) ) {
				@unlink( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( ! is_dir( $themes_root ) ) {
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not create themes.zip.', 'wp-powersuite' ) );
				}
				$z->addFromString( 'themes/readme.txt', "Theme directory was not found during backup.\n" );
				$z->close();
				return true;
			}
			if ( $this->try_system_zip_wp_content_tree( $zip_path, $themes_root, 'themes', 'Themes' ) ) {
				return true;
			}
			$total_lines = $this->build_vcs_filtered_file_queue( $queue_path, $themes_root );
			$state['themes_queue_total']  = $total_lines;
			$state['themes_queue_offset'] = 0;
			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Themes archive: nothing to include.', 'wp-powersuite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open themes.zip for writing.', 'wp-powersuite' ) );
				}
				$z->addFromString( 'themes/readme.txt', "No theme files were archived.\n" );
				$z->close();
				return true;
			}
			$this->log_line(
				sprintf(
					/* translators: %d: file count */
					__( 'Themes archive: queued %d file(s).', 'wp-powersuite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$themes_root,
			'themes/',
			$state,
			'themes_queue_offset',
			'themes_queue_total',
			'themes_progress_label',
			'Themes',
			/* translators: 1: current file index, 2: total files */
			__( 'Creating archive (Themes)… %1$d / %2$d files', 'wp-powersuite' )
		);
	}

	/**
	 * Build the file queue for chunked uploads archiving.
	 */
	/**
	 * Resumable uploads queue build.
	 *
	 * @param array<string, mixed> $state
	 * @return bool True when complete.
	 */
	private function build_uploads_file_queue_tick( string $queue_path, string $uploads_dir, string $exclude_csv, array &$state ): bool {
		$source_root = rtrim( wp_normalize_path( $uploads_dir ), '/' );

		if ( empty( $state['uploads_queue_building'] ) ) {
			$fh = fopen( $queue_path, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			if ( false === $fh ) {
				throw new \Exception( __( 'Could not create uploads file queue.', 'wp-powersuite' ) );
			}
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$state['uploads_queue_building']    = true;
			$state['uploads_queue_build_stack'] = [ $source_root ];
			$state['uploads_queue_build_count'] = 0;
		}

		$stack    = isset( $state['uploads_queue_build_stack'] ) && is_array( $state['uploads_queue_build_stack'] ) ? $state['uploads_queue_build_stack'] : [];
		$count    = isset( $state['uploads_queue_build_count'] ) ? (int) $state['uploads_queue_build_count'] : 0;
		$deadline = microtime( true ) + self::ARCHIVE_CHUNK_MAX_SECONDS;

		$fh = fopen( $queue_path, 'ab' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not create uploads file queue.', 'wp-powersuite' ) );
		}

		try {
			while ( $stack !== [] && microtime( true ) < $deadline ) {
				$dir = array_pop( $stack );
				if ( ! is_string( $dir ) || ! is_dir( $dir ) ) {
					continue;
				}
				$dir_n   = rtrim( wp_normalize_path( $dir ), '/' );
				$rel_dir = ltrim( substr( $dir_n, strlen( $source_root ) ), '/' );
				if ( $rel_dir !== '' && $this->relative_path_contains_vcs_metadata( $rel_dir ) ) {
					continue;
				}
				if ( $rel_dir !== '' && $exclude_csv !== '' && $this->relative_path_matches_exclude_csv( $rel_dir, 'uploads', $exclude_csv ) ) {
					continue;
				}
				$entries = @scandir( $dir_n ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( ! is_array( $entries ) ) {
					continue;
				}
				foreach ( $entries as $entry ) {
					if ( '.' === $entry || '..' === $entry ) {
						continue;
					}
					$full = wp_normalize_path( $dir_n . '/' . $entry );
					if ( is_dir( $full ) && ! is_link( $full ) ) {
						$stack[] = $full;
						continue;
					}
					if ( ! is_file( $full ) || is_link( $full ) ) {
						continue;
					}
					$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
					if ( $rel === '' || $this->relative_path_contains_vcs_metadata( $rel ) ) {
						continue;
					}
					if ( $exclude_csv !== '' && $this->relative_path_matches_exclude_csv( $rel, 'uploads', $exclude_csv ) ) {
						continue;
					}
					fwrite( $fh, $rel . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
					++$count;
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		$state['uploads_queue_build_stack'] = $stack;
		$state['uploads_queue_build_count'] = $count;
		if ( $stack !== [] ) {
			$state['uploads_progress_label'] = sprintf(
				/* translators: %d: files queued */
				__( 'Scanning uploads… %d queued', 'wp-powersuite' ),
				$count
			);
			return false;
		}

		$state['uploads_queue_total']  = $count;
		$state['uploads_queue_offset'] = 0;
		unset( $state['uploads_queue_building'], $state['uploads_queue_build_stack'], $state['uploads_queue_build_count'] );

		return true;
	}

	private function recursive_delete( string $dir ): void {
		if ( ! is_dir( $dir ) ) {
			return;
		}
		$it = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::CHILD_FIRST
		);
		foreach ( $it as $file ) {
			$p = $file->getPathname();
			if ( $file->isDir() ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
				@rmdir( $p );
			} else {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				@unlink( $p );
			}
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
		@rmdir( $dir );
	}

	/**
	 * Total size of backups on disk (private root + legacy locations).
	 */
	public function get_total_backup_bytes(): int {
		$roots = [];
		$primary = $this->get_backup_root();
		if ( $primary !== '' && is_dir( $primary ) ) {
			$roots[] = $primary;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			if ( is_dir( $legacy ) ) {
				$roots[] = $legacy;
			}
		}
		$total = 0;
		foreach ( array_unique( $roots ) as $root ) {
			try {
				$it = new \RecursiveIteratorIterator(
					new \RecursiveDirectoryIterator( $root, \RecursiveDirectoryIterator::SKIP_DOTS )
				);
				foreach ( $it as $file ) {
					if ( $file->isFile() ) {
						$total += (int) $file->getSize();
					}
				}
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Skip unreadable trees.
			}
		}
		return $total;
	}

	/**
	 * @return array{path: string, filename: string}|null
	 */
	public function get_component_download( string $backup_id, string $part ):
	?array {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || strpos( $backup_id, '..' ) !== false || ! self::is_valid_backup_folder_name( $backup_id ) ) {
			return null;
		}
		$allowed = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! in_array( $part, $allowed, true ) ) {
			return null;
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) ) {
			return null;
		}
		$map  = self::storage_basename_map();
		$file = $dir . $map[ $part ];
		if ( ! is_readable( $file ) ) {
			return null;
		}
		$hist = self::get_history();
		$ts   = isset( $hist[ $backup_id ]['created'] ) ? (int) $hist[ $backup_id ]['created'] : 0;
		if ( $ts < 1 ) {
			$ts = (int) @filemtime( $file );
		}
		if ( $ts < 1 ) {
			$ts = time();
		}
		$download_name = self::build_branded_download_basename( $backup_id, $part, $ts );
		return [ 'path' => $file, 'filename' => $download_name ];
	}

	/**
	 * Components that exist on disk for this backup folder.
	 *
	 * @return list<string>
	 */
	public function list_components_on_disk( string $backup_id ): array {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || strpos( $backup_id, '..' ) !== false || ! self::is_valid_backup_folder_name( $backup_id ) ) {
			return [];
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) ) {
			return [];
		}
		$out = [];
		foreach ( self::storage_basename_map() as $c => $fn ) {
			if ( is_readable( $dir . $fn ) ) {
				$out[] = $c;
			}
		}
		return $out;
	}

	/**
	 * After adding/removing a part file, sync history + manifest "components" with disk.
	 */
	public function sync_backup_metadata_from_disk( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || strpos( $backup_id, '..' ) !== false ) {
			return;
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) || ! $this->is_path_under_managed_storage( $dir ) ) {
			return;
		}
		$comps = $this->list_components_on_disk( $backup_id );
		$h     = self::get_history();
		if ( isset( $h[ $backup_id ] ) ) {
			if ( count( $comps ) === 0 ) {
				unset( $h[ $backup_id ] );
			} else {
				$h[ $backup_id ]['components'] = $comps;
			}
			self::save_history( $h );
		}
		$man_path = $dir . 'manifest.json';
		if ( is_readable( $man_path ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$raw = file_get_contents( $man_path );
			$m   = is_string( $raw ) ? json_decode( $raw, true ) : null;
			if ( is_array( $m ) ) {
				$m['components'] = $comps;
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents
				file_put_contents( $man_path, wp_json_encode( $m, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
			}
		}
	}

	/**
	 * Remove one component file from a backup set (Updraft-style).
	 */
	public function delete_backup_component( string $backup_id, string $part ): bool {
		$info = $this->get_component_download( $backup_id, $part );
		if ( ! is_array( $info ) ) {
			return false;
		}
		$path = $info['path'];
		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		if ( ! @unlink( $path ) ) {
			return false;
		}
		$this->sync_backup_metadata_from_disk( $backup_id );
		return true;
	}

	/**
	 * List zip member names and uncompressed sizes (for browse UI).
	 *
	 * @return array{entries: list<array{name: string, size: int}>, truncated: bool}|null
	 */
	public function list_zip_contents( string $absolute_path, int $max_entries = 500 ): ?array {
		if ( ! is_readable( $absolute_path ) || ! class_exists( 'ZipArchive' ) ) {
			return null;
		}
		$zip = new \ZipArchive();
		if ( true !== $zip->open( $absolute_path, \ZipArchive::RDONLY ) ) {
			return null;
		}
		$entries   = [];
		$truncated = false;
		for ( $i = 0; $i < $zip->numFiles; $i++ ) {
			if ( count( $entries ) >= $max_entries ) {
				$truncated = true;
				break;
			}
			$name = $zip->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$size = 0;
			$st   = $zip->statIndex( $i );
			if ( is_array( $st ) && isset( $st['size'] ) ) {
				$size = (int) $st['size'];
			}
			$entries[] = [ 'name' => $name, 'size' => $size ];
		}
		$zip->close();
		usort(
			$entries,
			static function ( array $a, array $b ): int {
				return strcmp( $a['name'], $b['name'] );
			}
		);
		return [ 'entries' => $entries, 'truncated' => $truncated ];
	}

	/**
	 * List immediate children of a path inside a zip by scanning every entry (no truncation).
	 * Used by the browse UI so large archives still show all top-level folders (e.g. every plugin).
	 *
	 * @return list<array{name: string, is_dir: bool, rel: string, size: int}>|null
	 */
	public function list_zip_children_at_path( string $absolute_path, string $subpath ): ?array {
		if ( ! is_readable( $absolute_path ) || ! class_exists( 'ZipArchive' ) ) {
			return null;
		}
		$subpath = str_replace( '\\', '/', $subpath );
		$subpath = trim( $subpath, '/' );
		if ( str_contains( $subpath, '..' ) ) {
			return null;
		}
		$prefix = ( '' === $subpath ) ? '' : $subpath . '/';
		$plen   = strlen( $prefix );

		$z = new \ZipArchive();
		if ( true !== $z->open( $absolute_path, \ZipArchive::RDONLY ) ) {
			return null;
		}

		/** @var array<string, array{is_dir: bool, size: int, rel: string}> $kids */
		$kids = [];

		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$norm              = str_replace( '\\', '/', $name );
			$is_zip_dir_marker = str_ends_with( $norm, '/' );
			if ( $is_zip_dir_marker ) {
				$norm = rtrim( $norm, '/' );
			}
			if ( $norm === '' || str_contains( $norm, '..' ) ) {
				continue;
			}
			if ( '' !== $prefix ) {
				if ( ! str_starts_with( $norm, $prefix ) ) {
					continue;
				}
				$pdir = rtrim( $prefix, '/' );
				if ( $norm === $pdir ) {
					continue;
				}
			}
			$rest = '' === $prefix ? $norm : substr( $norm, $plen );
			$rest = ltrim( $rest, '/' );
			if ( '' === $rest ) {
				continue;
			}
			$slash_at = strpos( $rest, '/' );
			if ( false === $slash_at ) {
				$seg      = $rest;
				$rel_full = '' === $prefix ? $seg : $prefix . $seg;
				if ( $is_zip_dir_marker ) {
					if ( ! isset( $kids[ $seg ] ) ) {
						$kids[ $seg ] = [ 'is_dir' => true, 'size' => 0, 'rel' => $rel_full ];
					} else {
						$kids[ $seg ]['is_dir'] = true;
					}
					continue;
				}
				$st = $z->statIndex( $i );
				$sz = ( is_array( $st ) && isset( $st['size'] ) ) ? (int) $st['size'] : 0;
				if ( ! isset( $kids[ $seg ] ) ) {
					$kids[ $seg ] = [ 'is_dir' => false, 'size' => $sz, 'rel' => $rel_full ];
				} elseif ( empty( $kids[ $seg ]['is_dir'] ) ) {
					$kids[ $seg ]['size'] = $sz;
					$kids[ $seg ]['rel']  = $rel_full;
				}
			} else {
				$seg = substr( $rest, 0, $slash_at );
				if ( '' === $seg ) {
					continue;
				}
				$rel_full = '' === $prefix ? $seg : $prefix . $seg;
				if ( ! isset( $kids[ $seg ] ) ) {
					$kids[ $seg ] = [ 'is_dir' => true, 'size' => 0, 'rel' => $rel_full ];
				} else {
					$kids[ $seg ]['is_dir'] = true;
				}
			}
		}

		$z->close();

		$out = [];
		foreach ( $kids as $seg => $meta ) {
			$out[] = [
				'name'   => $seg,
				'is_dir' => ! empty( $meta['is_dir'] ),
				'rel'    => $meta['rel'],
				'size'   => (int) ( $meta['size'] ?? 0 ),
			];
		}

		usort(
			$out,
			static function ( array $a, array $b ): int {
				if ( $a['is_dir'] !== $b['is_dir'] ) {
					return $a['is_dir'] ? -1 : 1;
				}
				return strcasecmp( $a['name'], $b['name'] );
			}
		);

		return $out;
	}

	/**
	 * Stream one member from a backup component zip (plugins.zip, themes.zip, …).
	 */
	public function stream_zip_entry( string $backup_id, string $part, string $entry_name ): bool {
		$backup_id = sanitize_file_name( $backup_id );
		$part      = sanitize_key( $part );
		if ( '' === $backup_id || '' === $part ) {
			return false;
		}
		$entry_name = str_replace( '\\', '/', $entry_name );
		$entry_name = ltrim( $entry_name, '/' );
		if ( $entry_name === '' || str_contains( $entry_name, '..' ) ) {
			return false;
		}
		if ( str_ends_with( $entry_name, '/' ) ) {
			return false;
		}
		$info = $this->get_component_download( $backup_id, $part );
		if ( ! is_array( $info ) ) {
			return false;
		}
		$zip_path = $info['path'];
		if ( ! is_readable( $zip_path ) || ! str_ends_with( strtolower( $zip_path ), '.zip' ) ) {
			return false;
		}
		if ( ! class_exists( 'ZipArchive' ) ) {
			return false;
		}
		$zip = new \ZipArchive();
		if ( true !== $zip->open( $zip_path, \ZipArchive::RDONLY ) ) {
			return false;
		}
		$index = $zip->locateName( $entry_name, 0 );
		if ( false === $index ) {
			$index = $zip->locateName( $entry_name, \ZipArchive::FL_NOCASE );
		}
		if ( false === $index ) {
			$zip->close();
			return false;
		}
		$stat = $zip->statIndex( $index );
		if ( ! is_array( $stat ) || ! isset( $stat['name'] ) || ! is_string( $stat['name'] ) ) {
			$zip->close();
			return false;
		}
		if ( str_ends_with( $stat['name'], '/' ) ) {
			$zip->close();
			return false;
		}
		$internal = $stat['name'];
		$stream   = $zip->getStream( $internal );
		if ( ! is_resource( $stream ) ) {
			$zip->close();
			return false;
		}
		$basename = basename( str_replace( '\\', '/', $internal ) );
		nocache_headers();
		header( 'Content-Type: application/octet-stream' );
		header( 'Content-Disposition: attachment; filename="' . rawurlencode( $basename ) . '"' );
		if ( isset( $stat['size'] ) && (int) $stat['size'] > 0 ) {
			header( 'Content-Length: ' . (string) (int) $stat['size'] );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
		while ( ! feof( $stream ) ) {
			$chunk = fread( $stream, 8192 );
			if ( is_string( $chunk ) && $chunk !== '' ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo $chunk;
			}
		}
		fclose( $stream );
		$zip->close();
		return true;
	}

	public function delete_backup( string $backup_id ): bool {
		$backup_id = sanitize_file_name( $backup_id );
		$dir       = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) ) {
			return false;
		}
		$dir_real = rtrim( wp_normalize_path( $dir ), '/' );
		$allowed  = false;
		$primary  = $this->get_backup_root();
		if ( $primary !== '' && str_starts_with( $dir_real, rtrim( wp_normalize_path( $primary ), '/' ) ) ) {
			$allowed = true;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			if ( str_starts_with( $dir_real, rtrim( wp_normalize_path( $legacy ), '/' ) ) ) {
				$allowed = true;
				break;
			}
		}
		if ( ! $allowed ) {
			return false;
		}
		$this->recursive_delete( $dir );
		$h = self::get_history();
		unset( $h[ $backup_id ] );
		self::save_history( $h );
		return ! is_dir( $dir );
	}

	/**
	 * @return array<string, mixed>|null
	 */
	public function get_manifest( string $backup_id ): ?array {
		$backup_id = sanitize_file_name( $backup_id );
		$dir       = $this->locate_backup_dir( $backup_id );
		$path      = $dir !== '' ? $dir . 'manifest.json' : '';
		if ( $path === '' || ! is_readable( $path ) ) {
			return null;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$json = file_get_contents( $path );
		if ( ! is_string( $json ) ) {
			return null;
		}
		$data = json_decode( $json, true );
		return is_array( $data ) ? $data : null;
	}

	/**
	 * Standard filenames for each backup component (matches downloads / archives).
	 *
	 * @return array<string, string>
	 */
	public static function storage_basename_map(): array {
		return [
			'database' => 'database.sql',
			'plugins'  => 'plugins.zip',
			'themes'   => 'themes.zip',
			'uploads'  => 'uploads.zip',
			'others'   => 'others.zip',
		];
	}

	/**
	 * Safe backup-* folder id (matches on-disk job directories).
	 */
	public static function is_valid_backup_folder_name( string $id ): bool {
		$id = sanitize_file_name( $id );
		if ( '' === $id || str_contains( $id, '..' ) ) {
			return false;
		}
		return (bool) preg_match( '/^backup-[A-Za-z0-9_-]+$/', $id );
	}

	/**
	 * Public download basename: wpps-backup__{backup_id}__{part}.ext (short; parseable on upload).
	 *
	 * @param int $created_ts Unused; kept for callers that already pass backup timestamp.
	 */
	public static function build_branded_download_basename( string $backup_id, string $part, int $created_ts ): string {
		$allowed = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! in_array( $part, $allowed, true ) ) {
			$part = 'plugins';
		}
		$ext = ( 'database' === $part ) ? 'sql' : 'zip';
		return sprintf(
			'wpps-backup__%s__%s.%s',
			sanitize_file_name( $backup_id ),
			$part,
			$ext
		);
	}

	/**
	 * Parse our branded download filename (returns backup folder + component when valid).
	 *
	 * @return array{backup_id: string, component: string}|null
	 */
	public static function parse_branded_upload_filename( string $filename ): ?array {
		$base = basename( str_replace( '\\', '/', $filename ) );

		$backup_id_raw = null;
		$tail          = null;

		if ( str_starts_with( $base, 'wpps-backup__' ) ) {
			$segments = explode( '__', $base );
			if ( 'wpps-backup' !== ( $segments[0] ?? '' ) ) {
				return null;
			}

			// Compact: wpps-backup__backup-…__plugins.zip
			if ( 3 === count( $segments ) ) {
				$backup_id_raw = $segments[1];
				$tail          = $segments[2];
			} elseif ( 5 === count( $segments ) ) {
				// Legacy: wpps-backup__UTC__site__backup-…__plugins.zip
				if ( ! preg_match( '/^\d{8}T\d{6}Z$/', $segments[1] ) ) {
					return null;
				}
				$backup_id_raw = $segments[3];
				$tail          = $segments[4];
			} else {
				return null;
			}
		} elseif (
			preg_match(
				'/^wpps-backup_((?:backup-)[A-Za-z0-9_-]+)_(database\.sql|plugins\.zip|themes\.zip|uploads\.zip|others\.zip)$/i',
				$base,
				$m0
			)
		) {
			// After sanitize_file_name, "__" can become "_" (wpps-backup_backup-id_database.sql).
			$backup_id_raw = $m0[1];
			$tail          = $m0[2];
		} elseif ( preg_match( '/^wpps-backup_((?:backup-)[A-Za-z0-9_-]+)_bundle\.zip$/i', $base, $mbu ) ) {
			$backup_id_raw = $mbu[1];
			$tail          = 'bundle.zip';
		} else {
			return null;
		}

		if ( ! is_string( $backup_id_raw ) || ! self::is_valid_backup_folder_name( $backup_id_raw ) ) {
			return null;
		}
		if ( ! is_string( $tail ) || ! preg_match( '/^([a-z]+)\.(sql|zip)$/i', $tail, $m ) ) {
			return null;
		}
		$stem = strtolower( $m[1] );
		$ext  = strtolower( $m[2] );
		if ( 'bundle' === $stem && 'zip' === $ext ) {
			return [
				'backup_id' => sanitize_file_name( $backup_id_raw ),
				'component' => 'bundle',
			];
		}
		$ok   = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! in_array( $stem, $ok, true ) ) {
			return null;
		}
		if ( 'database' === $stem && 'sql' !== $ext ) {
			return null;
		}
		if ( 'database' !== $stem && 'zip' !== $ext ) {
			return null;
		}
		return [
			'backup_id' => sanitize_file_name( $backup_id_raw ),
			'component' => $stem,
		];
	}

	/**
	 * Branded full-backup download: wpps-backup__{id}__bundle.zip
	 */
	public static function build_branded_bundle_download_basename( string $backup_id ): string {
		return 'wpps-backup__' . sanitize_file_name( $backup_id ) . '__bundle.zip';
	}

	/**
	 * Stable on-disk bundle cache (STORE zip of backup parts). Reused when parts are unchanged.
	 */
	private function bundle_cache_basename(): string {
		return '.wpps-br-bundle-ready.zip';
	}

	/**
	 * In-progress bundle zip while tick packing runs (promoted to the ready cache when complete).
	 */
	private function bundle_building_basename(): string {
		return '.wpps-br-bundle-building.zip';
	}

	/**
	 * Backup parts (+ manifest.json) that belong in the full-ZIP bundle.
	 *
	 * @return array{
	 *   queue: list<array{abs: string, entry: string}>,
	 *   parts: list<array{key: string, name: string, size: int}>,
	 *   total_size: int
	 * }
	 */
	private function scan_bundle_sources( string $dir ): array {
		$queue      = [];
		$parts      = [];
		$total_size = 0;
		foreach ( self::storage_basename_map() as $comp => $fn ) {
			$p = $dir . $fn;
			if ( is_readable( $p ) && is_file( $p ) ) {
				$sz          = (int) filesize( $p );
				$parts[]     = [ 'key' => $comp, 'name' => $fn, 'size' => $sz ];
				$total_size += $sz;
				$queue[]     = [ 'abs' => $p, 'entry' => $fn ];
			}
		}
		$man = $dir . 'manifest.json';
		if ( is_readable( $man ) && is_file( $man ) ) {
			$queue[] = [ 'abs' => $man, 'entry' => 'manifest.json' ];
		}

		return [ 'queue' => $queue, 'parts' => $parts, 'total_size' => $total_size ];
	}

	/**
	 * Timeline payload rendered by the bundle modal.
	 *
	 * @param list<array{key: string, name: string, size: int}> $parts Scanned parts.
	 * @param string $pack_status 'active' or 'done'.
	 * @param string $label_extra Optional suffix for the pack step (current file n/m).
	 * @return array<string, mixed>
	 */
	private function bundle_progress_payload( array $parts, int $total_size, string $pack_status, string $label_extra = '' ): array {
		$pack = [ 'key' => 'pack', 'status' => $pack_status, 'total_size' => $total_size ];
		if ( '' !== $label_extra ) {
			$pack['label_extra'] = $label_extra;
		}

		return [
			'steps' => [
				[ 'key' => 'scan', 'status' => 'done', 'parts' => $parts, 'total_size' => $total_size ],
				$pack,
				[ 'key' => 'stage', 'status' => 'done' === $pack_status ? 'done' : 'pending' ],
			],
		];
	}

	/**
	 * @return array{path: string, filename: string}|null
	 */
	private function find_cached_bundle_download_file( string $dir, string $backup_id ): ?array {
		$cached = $dir . $this->bundle_cache_basename();
		if ( ! is_readable( $cached ) || ! is_file( $cached ) ) {
			return null;
		}
		$bundle_mtime = (int) @filemtime( $cached ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( $bundle_mtime < 1 ) {
			return null;
		}
		foreach ( self::storage_basename_map() as $fn ) {
			$p = $dir . $fn;
			if ( is_readable( $p ) && is_file( $p ) ) {
				$part_mtime = (int) @filemtime( $p ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( $part_mtime > $bundle_mtime ) {
					return null;
				}
			}
		}
		$man = $dir . 'manifest.json';
		if ( is_readable( $man ) && is_file( $man ) ) {
			$man_mtime = (int) @filemtime( $man ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( $man_mtime > $bundle_mtime ) {
				return null;
			}
		}
		$sz = (int) filesize( $cached );
		if ( $sz < 64 ) {
			return null;
		}

		return [
			'path'     => $cached,
			'filename' => self::build_branded_bundle_download_basename( $backup_id ),
		];
	}

	/**
	 * One AJAX-sized step of full-ZIP preparation (avoids host 503 on large packs).
	 *
	 * Stateless resume: progress lives in the building zip itself (entries already added),
	 * so there is no job file that can go stale or desync. Each call opens the building
	 * zip with CREATE, appends the first missing part, and closes. When every part is in,
	 * the building zip is promoted to the reusable ready cache and its path is returned.
	 *
	 * Uses CM_STORE (no compression) because the parts are already compressed archives.
	 *
	 * @return array{
	 *   success: bool,
	 *   done?: bool,
	 *   message?: string,
	 *   path?: string,
	 *   filename?: string,
	 *   progress?: array<string, mixed>|null
	 * }
	 */
	public function tick_bundle_prepare( string $backup_id ): 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 id.', 'wp-powersuite' ) ];
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) || ! $this->is_path_under_managed_storage( $dir ) ) {
			return [ 'success' => false, 'message' => __( 'Backup folder not found.', 'wp-powersuite' ) ];
		}
		if ( ! class_exists( 'ZipArchive' ) ) {
			return [ 'success' => false, 'message' => __( 'ZipArchive is not available on this server.', 'wp-powersuite' ) ];
		}

		// Remove leftovers from the retired pack-job/progress-file engines (pre-1.11.6).
		foreach ( [ '.wpps-br-bundle-pack-job.json', '.wpps-bundle-progress.json' ] as $legacy ) {
			if ( is_file( $dir . $legacy ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $dir . $legacy );
			}
		}

		$scan       = $this->scan_bundle_sources( $dir );
		$queue      = $scan['queue'];
		$parts      = $scan['parts'];
		$total_size = $scan['total_size'];
		if ( empty( $queue ) ) {
			return [ 'success' => false, 'message' => __( 'No backup parts found to pack.', 'wp-powersuite' ) ];
		}

		$cached = $this->find_cached_bundle_download_file( $dir, $backup_id );
		if ( is_array( $cached ) ) {
			return [
				'success'  => true,
				'done'     => true,
				'path'     => $cached['path'],
				'filename' => $cached['filename'],
				'progress' => $this->bundle_progress_payload( $parts, $total_size, 'done' ),
			];
		}

		$building = $dir . $this->bundle_building_basename();

		// Restart when a part changed after packing began (otherwise the zip would mix generations).
		if ( is_file( $building ) ) {
			$building_mtime = (int) @filemtime( $building ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			foreach ( $queue as $item ) {
				$part_mtime = (int) @filemtime( $item['abs'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( $building_mtime > 0 && $part_mtime > $building_mtime ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $building );
					break;
				}
			}
		}

		$z = new \ZipArchive();
		if ( true !== $z->open( $building, \ZipArchive::CREATE ) ) {
			return [ 'success' => false, 'message' => __( 'Could not create the ZIP bundle.', 'wp-powersuite' ) ];
		}

		$have = [];
		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$n = $z->getNameIndex( $i );
			if ( is_string( $n ) ) {
				$have[ $n ] = true;
			}
		}

		$next = null;
		foreach ( $queue as $item ) {
			if ( ! isset( $have[ $item['entry'] ] ) ) {
				$next = $item;
				break;
			}
		}

		if ( null === $next ) {
			// All parts are in: promote the building zip to the reusable ready cache.
			if ( ! $z->close() ) {
				return [ 'success' => false, 'message' => __( 'Could not save the ZIP bundle while packing.', 'wp-powersuite' ) ];
			}
			$cached_path = $dir . $this->bundle_cache_basename();
			if ( is_file( $cached_path ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $cached_path );
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( ! @rename( $building, $cached_path ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( ! @copy( $building, $cached_path ) ) {
					return [ 'success' => false, 'message' => __( 'Could not finalize the ZIP bundle.', 'wp-powersuite' ) ];
				}
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $building );
			}

			return [
				'success'  => true,
				'done'     => true,
				'path'     => $cached_path,
				'filename' => self::build_branded_bundle_download_basename( $backup_id ),
				'progress' => $this->bundle_progress_payload( $parts, $total_size, 'done' ),
			];
		}

		$real = realpath( $next['abs'] );
		if ( false === $real || ! $z->addFile( $real, $next['entry'] ) ) {
			$z->close();
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $building );
			return [
				'success' => false,
				/* translators: %s: file name */
				'message' => sprintf( __( 'Could not add %s to the ZIP bundle.', 'wp-powersuite' ), $next['entry'] ),
			];
		}
		$z->setCompressionName( $next['entry'], \ZipArchive::CM_STORE );
		if ( ! $z->close() ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $building );
			return [ 'success' => false, 'message' => __( 'Could not save the ZIP bundle while packing.', 'wp-powersuite' ) ];
		}

		$done_count  = count( $have ) + 1;
		$label_extra = sprintf( '%1$s (%2$d/%3$d)', $next['entry'], $done_count, count( $queue ) );

		return [
			'success'  => true,
			'done'     => false,
			'progress' => $this->bundle_progress_payload( $parts, $total_size, 'active', $label_extra ),
		];
	}

	/**
	 * True if archive has at least two known backup basenames at zip root.
	 */
	public function zip_archive_root_has_multiple_backup_parts( string $zip_abs ): bool {
		return $this->count_backup_parts_in_zip( $zip_abs ) >= 2;
	}

	/**
	 * Count how many known backup basenames exist at zip root (also counts manifest.json).
	 */
	public function count_backup_parts_in_zip( string $zip_abs ): int {
		if ( ! is_readable( $zip_abs ) || ! class_exists( 'ZipArchive' ) ) {
			return 0;
		}
		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_abs, \ZipArchive::RDONLY ) ) {
			return 0;
		}
		$want = array_flip( array_values( self::storage_basename_map() ) );
		$want['manifest.json'] = true;
		$hits = 0;
		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( str_contains( $norm, '..' ) || str_ends_with( $norm, '/' ) ) {
				continue;
			}
			$leaf = basename( $norm );
			if ( isset( $want[ $leaf ] ) ) {
				++$hits;
			}
		}
		$z->close();

		return $hits;
	}

	/**
	 * Import a single .zip that contains database.sql / plugins.zip / … at archive root.
	 *
	 * @return array{success: bool, message: string, imported?: int}
	 */
	public function import_bundle_zip_from_path( string $backup_id, string $zip_abs, bool $unlink_after ): array {
		$zip_abs = wp_normalize_path( $zip_abs );
		if ( ! is_readable( $zip_abs ) || ! class_exists( 'ZipArchive' ) ) {
			return [ 'success' => false, 'message' => __( 'Could not read the bundle.', 'wp-powersuite' ) ];
		}
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid backup folder name.', 'wp-powersuite' ) ];
		}
		if ( ! $this->ensure_backup_root() ) {
			return [ 'success' => false, 'message' => __( 'Backup directory is not writable.', 'wp-powersuite' ) ];
		}
		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( ! wp_mkdir_p( $dir ) ) {
			return [ 'success' => false, 'message' => __( 'Could not create backup folder.', 'wp-powersuite' ) ];
		}
		$dir_real  = wp_normalize_path( $dir );
		$root_real = wp_normalize_path( $this->get_backup_root() );
		if ( ! str_starts_with( $dir_real, $root_real ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid path.', 'wp-powersuite' ) ];
		}

		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_abs, \ZipArchive::RDONLY ) ) {
			return [ 'success' => false, 'message' => __( 'Could not open bundle archive.', 'wp-powersuite' ) ];
		}
		$allowed = array_flip( array_merge( array_values( self::storage_basename_map() ), [ 'manifest.json' ] ) );
		$imported = 0;

		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( str_contains( $norm, '..' ) || str_ends_with( $norm, '/' ) ) {
				continue;
			}
			$base = basename( $norm );
			if ( ! isset( $allowed[ $base ] ) ) {
				continue;
			}
			$dest = $dir . '/' . $base;
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( is_file( $dest ) ) {
				@unlink( $dest );
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$in = $z->getStream( $name );
			if ( false !== $in && is_resource( $in ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
				$out = fopen( $dest, 'wb' );
				if ( false !== $out ) {
					stream_copy_to_stream( $in, $out );
					fclose( $in );
					fclose( $out );
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
					@chmod( $dest, 0644 );
					++$imported;
				} else {
					fclose( $in );
				}
			} else {
				$data = $z->getFromIndex( $i );
				if ( false !== $data && '' !== $data ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
					if ( false !== file_put_contents( $dest, $data ) ) {
						// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
						@chmod( $dest, 0644 );
						++$imported;
					}
				}
			}
		}
		$z->close();

		if ( $imported < 1 ) {
			if ( $unlink_after ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $zip_abs );
			}
			return [
				'success' => false,
				'message' => __( 'The bundle did not contain any recognized backup files (database.sql, plugins.zip, themes.zip, uploads.zip, others.zip, or manifest.json).', 'wp-powersuite' ),
			];
		}

		if ( $unlink_after ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $zip_abs );
		}

		return [
			'success'   => true,
			/* translators: %d: number of files written */
			'message'   => sprintf( __( 'Imported %d file(s) from the bundle.', 'wp-powersuite' ), $imported ),
			'imported'  => $imported,
		];
	}

	/**
	 * Whether $id is a safe folder name under the backup root.
	 */
	public function is_allowed_backup_folder_id( string $id ): bool {
		return self::is_valid_backup_folder_name( $id );
	}

	/**
	 * Existing backup-* directories (for upload target dropdown).
	 *
	 * @return list<array{id: string, has_manifest: bool}>
	 */
	public function list_backup_dirs_for_upload(): array {
		$out   = [];
		$seen  = [];
		$roots = [];
		$primary = $this->get_backup_root();
		if ( $primary !== '' ) {
			$roots[] = $primary;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			$roots[] = $legacy;
		}
		foreach ( array_unique( $roots ) as $root ) {
			if ( ! is_dir( $root ) ) {
				continue;
			}
			$dirs = glob( trailingslashit( $root ) . 'backup-*', GLOB_ONLYDIR );
			if ( ! is_array( $dirs ) ) {
				continue;
			}
			foreach ( $dirs as $dir ) {
				$id = basename( $dir );
				if ( ! $this->is_allowed_backup_folder_id( $id ) || isset( $seen[ $id ] ) ) {
					continue;
				}
				$seen[ $id ] = true;
				$out[]       = [
					'id'           => $id,
					'has_manifest' => is_readable( trailingslashit( $dir ) . 'manifest.json' ),
					'legacy'       => $primary === '' || ! str_starts_with( wp_normalize_path( $dir ), wp_normalize_path( $primary ) ),
				];
			}
		}
		usort(
			$out,
			static function ( $a, $b ) {
				return strcmp( (string) ( $b['id'] ?? '' ), (string) ( $a['id'] ?? '' ) );
			}
		);
		return $out;
	}

	/**
	 * Map an uploaded filename to a component key (same layout as our backups).
	 */
	public static function detect_component_from_upload_filename( string $filename ): ?string {
		$branded = self::parse_branded_upload_filename( $filename );
		if ( is_array( $branded ) ) {
			return $branded['component'];
		}
		$base = strtolower( basename( $filename ) );
		if ( preg_match( '/\.sql$/', $base ) ) {
			return 'database';
		}
		if ( str_ends_with( $base, 'others.zip' ) ) {
			return 'others';
		}
		if ( str_ends_with( $base, 'uploads.zip' ) ) {
			return 'uploads';
		}
		if ( str_ends_with( $base, 'themes.zip' ) ) {
			return 'themes';
		}
		if ( str_ends_with( $base, 'plugins.zip' ) ) {
			return 'plugins';
		}
		if ( str_ends_with( $base, '.zip' ) && str_contains( $base, 'bundle' ) ) {
			return 'bundle';
		}
		return null;
	}

	/**
	 * First bytes look like a MySQL / WordPress SQL dump (handles chunked uploads named "blob" with no extension).
	 */
	private static function file_head_looks_like_sql_dump( string $abs_path ): bool {
		$sz = is_readable( $abs_path ) ? (int) filesize( $abs_path ) : 0;
		if ( $sz < 16 ) {
			return false;
		}
		$read = (int) min( 65536, $sz );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$head = (string) file_get_contents( $abs_path, false, null, 0, $read );
		if ( $head === '' ) {
			return false;
		}
		if ( str_starts_with( $head, "\xEF\xBB\xBF" ) ) {
			$head = substr( $head, 3 );
		}
		$trim = ltrim( $head );
		if ( str_starts_with( $trim, "PK\x03\x04" ) || str_starts_with( $trim, "PK\x05\x06" ) ) {
			return false;
		}
		if ( preg_match( '/^([\s\r\n]*)(--|#|\/\*)/', $trim ) ) {
			return true;
		}

		return (bool) preg_match(
			'/\b(CREATE TABLE|INSERT INTO|DROP TABLE|LOCK TABLES|UNLOCK TABLES|SET (?:SQL_MODE|NAMES|FOREIGN_KEY_CHECKS)|DUMPFILE:|MYSQL DUMP)\b/i',
			$trim
		);
	}

	/**
	 * Detect plugins.zip / themes.zip / uploads.zip layout (archive root is that folder, not database.sql at root).
	 *
	 * @return ?string One of plugins|themes|uploads or null.
	 */
	private function infer_component_from_component_zip_layout( string $zip_abs ): ?string {
		if ( ! is_readable( $zip_abs ) || ! class_exists( 'ZipArchive' ) ) {
			return null;
		}
		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_abs, \ZipArchive::RDONLY ) ) {
			return null;
		}
		$plug = 0;
		$thm  = 0;
		$upl  = 0;
		$oth  = 0;
		$max  = min( 2000, $z->numFiles );
		for ( $i = 0; $i < $max; $i++ ) {
			$n = $z->getNameIndex( $i );
			if ( ! is_string( $n ) || $n === '' || str_ends_with( $n, '/' ) ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $n );
			if ( str_contains( $norm, '..' ) ) {
				continue;
			}
			$ln = strtolower( $norm );
			if ( str_starts_with( $ln, 'plugins/' ) ) {
				++$plug;
			} elseif ( str_starts_with( $ln, 'themes/' ) ) {
				++$thm;
			} elseif ( str_starts_with( $ln, 'uploads/' ) ) {
				++$upl;
			} else {
				++$oth;
			}
		}
		$z->close();
		if ( $plug === 0 && $thm === 0 && $upl === 0 ) {
			return null;
		}
		if ( $plug > 0 && $thm === 0 && $upl === 0 && $oth === 0 ) {
			return 'plugins';
		}
		if ( $thm > 0 && $plug === 0 && $upl === 0 && $oth === 0 ) {
			return 'themes';
		}
		if ( $upl > 0 && $plug === 0 && $thm === 0 && $oth === 0 ) {
			return 'uploads';
		}

		return null;
	}

	/**
	 * When the filename is useless (e.g. "blob"), infer database / component ZIP from contents.
	 *
	 * @return ?string database|plugins|themes|uploads or null (not bundle).
	 */
	private function infer_upload_component_from_file_contents( string $abs_path ): ?string {
		if ( ! is_readable( $abs_path ) ) {
			return null;
		}
		if ( self::file_is_zip_by_magic( $abs_path ) ) {
			return $this->infer_component_from_component_zip_layout( $abs_path );
		}
		if ( self::file_head_looks_like_sql_dump( $abs_path ) ) {
			return 'database';
		}

		return null;
	}

	/**
	 * Filename + branded + forced + content sniff → component key.
	 *
	 * @param array{backup_id: string, component: string}|null $branded
	 * @return ?string database|plugins|themes|uploads|others|bundle|null
	 */
	private function resolve_upload_component_key( string $abs_path, string $display_name, ?array $branded, ?string $forced_component ): ?string {
		if ( is_string( $forced_component ) && $forced_component !== '' ) {
			$k = sanitize_key( $forced_component );
			if ( in_array( $k, [ 'database', 'plugins', 'themes', 'uploads', 'others', 'bundle' ], true ) ) {
				return $k;
			}
		}
		if ( is_array( $branded ) && ! empty( $branded['component'] ) ) {
			$k = sanitize_key( (string) $branded['component'] );
			if ( in_array( $k, [ 'database', 'plugins', 'themes', 'uploads', 'others', 'bundle' ], true ) ) {
				return $k;
			}
		}
		$from = self::detect_component_from_upload_filename( $display_name );
		if ( null !== $from ) {
			return $from;
		}

		return $this->infer_upload_component_from_file_contents( $abs_path );
	}

	/**
	 * Whether to run bundle extraction: never when we already resolved a single concrete part.
	 *
	 * @param string|null $resolved_key From resolve_upload_component_key().
	 */
	private function should_attempt_bundle_zip_import( string $abs_path, string $display_name, ?string $resolved_key ): bool {
		if ( 'bundle' === $resolved_key ) {
			return true;
		}
		if ( is_string( $resolved_key ) && in_array( $resolved_key, [ 'database', 'plugins', 'themes', 'uploads', 'others' ], true ) ) {
			return false;
		}
		if ( ! $this->is_zip_upload_candidate( $abs_path, $display_name ) ) {
			return false;
		}
		$low = strtolower( basename( str_replace( '\\', '/', $display_name ) ) );
		$n   = $this->count_backup_parts_in_zip( $abs_path );

		return str_contains( $low, 'bundle' ) || $n >= 1;
	}

	/**
	 * Move one uploaded file into a backup set folder using standard names (Updraft-style manual upload).
	 *
	 * @param array<string, mixed> $file `$_FILES['backup_file']` single file entry.
	 * @return array{success: bool, message: string, component?: string}
	 */
	public function ingest_uploaded_backup_part( string $backup_id, array $file, ?string $forced_component ): array {
		$err = isset( $file['error'] ) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE;
		if ( UPLOAD_ERR_OK !== $err ) {
			$msg = __( 'No file was uploaded.', 'wp-powersuite' );
			if ( UPLOAD_ERR_INI_SIZE === $err || UPLOAD_ERR_FORM_SIZE === $err ) {
				$msg = __( 'The uploaded file exceeds the server upload limit.', 'wp-powersuite' );
			}
			if ( UPLOAD_ERR_PARTIAL === $err ) {
				$msg = __( 'The file was only partially uploaded.', 'wp-powersuite' );
			}
			return [ 'success' => false, 'message' => $msg ];
		}

		$tmp = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
		if ( '' === $tmp || ! is_uploaded_file( $tmp ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid upload.', 'wp-powersuite' ) ];
		}

		$max  = (int) wp_max_upload_size();
		$size = isset( $file['size'] ) ? (int) $file['size'] : 0;
		if ( $max > 0 && $size > $max ) {
			return [ 'success' => false, 'message' => __( 'File is larger than the allowed upload size.', 'wp-powersuite' ) ];
		}

		$name    = isset( $file['name'] ) ? (string) $file['name'] : '';
		$branded = self::parse_branded_upload_filename( $name );
		if ( is_array( $branded ) && self::is_valid_backup_folder_name( $branded['backup_id'] ) ) {
			$backup_id = $branded['backup_id'];
		}

		if ( ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid backup folder name.', 'wp-powersuite' ) ];
		}

		$key = $this->resolve_upload_component_key( $tmp, $name, $branded, $forced_component );

		if ( $this->should_attempt_bundle_zip_import( $tmp, $name, $key ) ) {
			if ( ! $this->is_zip_upload_candidate( $tmp, $name ) ) {
				return [ 'success' => false, 'message' => __( 'A full backup bundle must be a .zip file.', 'wp-powersuite' ) ];
			}
			$r = $this->import_bundle_zip_from_path( $backup_id, $tmp, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wp-powersuite' ) ) ];
			}

			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$comp = $key;

		if ( 'bundle' === $comp && $this->is_zip_upload_candidate( $tmp, $name ) ) {
			$r = $this->import_bundle_zip_from_path( $backup_id, $tmp, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wp-powersuite' ) ) ];
			}
			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$allowed = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! is_string( $comp ) || ! in_array( $comp, $allowed, true ) ) {
			if ( $this->is_zip_upload_candidate( $tmp, $name ) ) {
				$r = $this->import_bundle_zip_from_path( $backup_id, $tmp, true );
				if ( ! empty( $r['success'] ) ) {
					return [
						'success'   => true,
						'message'   => (string) ( $r['message'] ?? '' ),
						'component' => 'bundle',
						'backup_id' => $backup_id,
					];
				}
				return [
					'success' => false,
					'message' => (string) (
						$r['message'] ?? __(
							'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.',
							'wp-powersuite'
						)
					),
				];
			}
			return [
				'success' => false,
				'message' => __( 'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.', 'wp-powersuite' ),
			];
		}

		$map  = self::storage_basename_map();
		$dest = trailingslashit( $this->get_backup_root() ) . $backup_id . '/' . $map[ $comp ];

		if ( ! $this->ensure_backup_root() ) {
			return [ 'success' => false, 'message' => __( 'Backup directory is not writable.', 'wp-powersuite' ) ];
		}

		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( ! wp_mkdir_p( $dir ) ) {
			return [ 'success' => false, 'message' => __( 'Could not create backup folder.', 'wp-powersuite' ) ];
		}

		$dir_real  = wp_normalize_path( $dir );
		$root_real = wp_normalize_path( $this->get_backup_root() );
		if ( ! str_starts_with( $dir_real, $root_real ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid path.', 'wp-powersuite' ) ];
		}

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

		if ( ! move_uploaded_file( $tmp, $dest ) ) {
			return [ 'success' => false, 'message' => __( 'Could not save the uploaded file.', 'wp-powersuite' ) ];
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
		@chmod( $dest, 0644 );

		return [
			'success'    => true,
			'message'    => '',
			'component'  => $comp,
			'backup_id'  => $backup_id,
		];
	}

	/**
	 * Ingest a file already on disk (e.g. after wp_handle_upload to staging — Updraft-style plupload path).
	 *
	 * @param string $staged_abs_path Absolute path from wp_handle_upload()['file'].
	 * @return array{success: bool, message: string, component?: string, backup_id?: string}
	 */
	public function ingest_staged_backup_part( string $backup_id, string $staged_abs_path, string $original_filename, ?string $forced_component ): array {
		$staged_abs_path = wp_normalize_path( $staged_abs_path );
		if ( '' === $staged_abs_path || ! is_file( $staged_abs_path ) || ! is_readable( $staged_abs_path ) ) {
			return [ 'success' => false, 'message' => __( 'Could not read the uploaded file.', 'wp-powersuite' ) ];
		}

		$root_real = wp_normalize_path( $this->get_backup_root() );
		// Global namespace: wp_realpath() lives in WordPress global scope (not BackupRestore\Includes).
		if ( function_exists( 'wp_realpath' ) ) {
			$resolved = \wp_realpath( $staged_abs_path );
		} else {
			$tmp = \realpath( $staged_abs_path );
			$resolved = ( false !== $tmp ) ? $tmp : false;
		}
		if ( ! is_string( $resolved ) || '' === $resolved ) {
			$staged_real = $staged_abs_path;
		} else {
			$staged_real = wp_normalize_path( $resolved );
		}
		$root_slash = trailingslashit( $root_real );
		if ( ! str_starts_with( $staged_real, $root_slash ) && $staged_real !== $root_real ) {
			return [ 'success' => false, 'message' => __( 'Invalid staged file path.', 'wp-powersuite' ) ];
		}

		$name    = $original_filename !== '' ? $original_filename : basename( $staged_abs_path );
		$branded = self::parse_branded_upload_filename( $name );
		if ( is_array( $branded ) && self::is_valid_backup_folder_name( $branded['backup_id'] ) ) {
			$backup_id = $branded['backup_id'];
		}

		if ( ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Invalid backup folder name.', 'wp-powersuite' ) ];
		}

		$key = $this->resolve_upload_component_key( $staged_abs_path, $name, $branded, $forced_component );

		if ( $this->should_attempt_bundle_zip_import( $staged_abs_path, $name, $key ) ) {
			if ( ! $this->is_zip_upload_candidate( $staged_abs_path, $name ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $staged_abs_path );
				return [ 'success' => false, 'message' => __( 'A full backup bundle must be a .zip file.', 'wp-powersuite' ) ];
			}
			$r = $this->import_bundle_zip_from_path( $backup_id, $staged_abs_path, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wp-powersuite' ) ) ];
			}

			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$comp = $key;

		if ( 'bundle' === $comp && $this->is_zip_upload_candidate( $staged_abs_path, $name ) ) {
			$r = $this->import_bundle_zip_from_path( $backup_id, $staged_abs_path, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wp-powersuite' ) ) ];
			}
			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$allowed = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! is_string( $comp ) || ! in_array( $comp, $allowed, true ) ) {
			if ( $this->is_zip_upload_candidate( $staged_abs_path, $name ) ) {
				$r = $this->import_bundle_zip_from_path( $backup_id, $staged_abs_path, true );
				if ( ! empty( $r['success'] ) ) {
					return [
						'success'   => true,
						'message'   => (string) ( $r['message'] ?? '' ),
						'component' => 'bundle',
						'backup_id' => $backup_id,
					];
				}
				if ( is_file( $staged_abs_path ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $staged_abs_path );
				}
				return [
					'success' => false,
					'message' => (string) (
						$r['message'] ?? __(
							'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.',
							'wp-powersuite'
						)
					),
				];
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [
				'success' => false,
				'message' => __( 'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.', 'wp-powersuite' ),
			];
		}

		$map  = self::storage_basename_map();
		$dest = trailingslashit( $this->get_backup_root() ) . $backup_id . '/' . $map[ $comp ];

		if ( ! $this->ensure_backup_root() ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Backup directory is not writable.', 'wp-powersuite' ) ];
		}

		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( ! wp_mkdir_p( $dir ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Could not create backup folder.', 'wp-powersuite' ) ];
		}

		$dir_real  = wp_normalize_path( $dir );
		$root_real = wp_normalize_path( $this->get_backup_root() );
		if ( ! str_starts_with( $dir_real, $root_real ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Invalid path.', 'wp-powersuite' ) ];
		}

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

		if ( ! @rename( $staged_abs_path, $dest ) ) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
			if ( ! @copy( $staged_abs_path, $dest ) ) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $staged_abs_path );
				return [ 'success' => false, 'message' => __( 'Could not save the uploaded file.', 'wp-powersuite' ) ];
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
		@chmod( $dest, 0644 );

		return [
			'success'   => true,
			'message'   => '',
			'component' => $comp,
			'backup_id' => $backup_id,
		];
	}

	/**
	 * Ensure a backup-* folder that has component files on disk appears in saved history (imported sets, uploads).
	 */
	public function ensure_backup_in_history_from_disk( string $backup_id ): void {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || strpos( $backup_id, '..' ) !== false ) {
			return;
		}
		if ( ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return;
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) ) {
			return;
		}
		$comps = $this->list_components_on_disk( $backup_id );
		if ( count( $comps ) === 0 ) {
			return;
		}
		$h = self::get_history();
		if ( isset( $h[ $backup_id ] ) ) {
			return;
		}
		$manifest = $this->get_manifest( $backup_id );
		if ( is_array( $manifest ) ) {
			$h[ $backup_id ] = [
				'id'           => $backup_id,
				'created'      => (int) ( $manifest['created'] ?? filemtime( $dir ) ),
				'label'        => (string) ( $manifest['label'] ?? '' ),
				'type'         => (string) ( $manifest['type'] ?? 'full' ),
				'components'   => (array) ( $manifest['components'] ?? $comps ),
				'path'         => $backup_id,
			];
		} else {
			$h[ $backup_id ] = [
				'id'           => $backup_id,
				'created'      => (int) filemtime( $dir ),
				'label'        => '',
				'type'         => 'imported',
				'components'   => $comps,
				'path'         => $backup_id,
			];
		}
		self::save_history( $h );
	}

	// -------------------------------------------------------------------------
	// Restore (Updraft-style: stepwise AJAX, database + chunked zip extract)
	// -------------------------------------------------------------------------

	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 snapshot keys must survive every tick or the active theme stays
			 * on the temporary fallback after restore.
			 */
			foreach ( [ 'restore_snap_template', 'restore_snap_stylesheet' ] as $snap_key ) {
				$have = isset( $pick[ $snap_key ] ) && is_string( $pick[ $snap_key ] ) && $pick[ $snap_key ] !== '';
				if ( $have ) {
					continue;
				}
				if ( isset( $from_disk[ $snap_key ] ) && is_string( $from_disk[ $snap_key ] ) && $from_disk[ $snap_key ] !== '' ) {
					$pick[ $snap_key ] = $from_disk[ $snap_key ];
				} elseif ( isset( $s[ $snap_key ] ) && is_string( $s[ $snap_key ] ) && $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 );
		$this->delete_restore_state( $backup_id );
		$this->release_restore_lock();
		$this->cleanup_restore_workdir( $backup_id );
		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)', 'wp-powersuite' ),
				'ok'       => $db_ext_ok,
				'severity' => $db_ext_ok ? 'ok' : 'error',
				'detail'   => $db_ext_ok
					? __( 'Required for database restore — extension is available.', 'wp-powersuite' )
					: __( 'Required for database restore — enable mysqli or mysqlnd.', 'wp-powersuite' ),
			];
		}

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

		// 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', 'wp-powersuite' ),
				'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.', 'wp-powersuite' ),
					PHP_VERSION,
					$min_php
				),
			];
		}

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

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

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

		$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.', 'wp-powersuite' ),
					$mem_raw
				);
			} elseif ( $mem_warn ) {
				$mem_detail = sprintf(
					/* translators: %s: php.ini value */
					__( '%s — consider 256M+ for large file or database restores.', 'wp-powersuite' ),
					$mem_raw
				);
			} else {
				$mem_detail = __( 'Could not read memory_limit — confirm php.ini for this restore.', 'wp-powersuite' );
			}
			$checks[] = [
				'id'       => 'memory_limit',
				'label'    => __( 'PHP memory_limit', 'wp-powersuite' ),
				'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', 'wp-powersuite' ),
				'ok'       => false,
				'severity' => 'warning',
				'detail'   => sprintf(
					/* translators: %d: seconds */
					__( '%d s per request — very low; slow hosts may hit timeouts even with AJAX steps.', 'wp-powersuite' ),
					$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)', 'wp-powersuite' ),
				'ok'       => false,
				'severity' => 'warning',
				'detail'   => sprintf(
					/* translators: %s: formatted size */
					__( 'Only about %s free — restores need extra space while unpacking.', 'wp-powersuite' ),
					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()', 'wp-powersuite' ),
					'ok'       => false,
					'severity' => 'warning',
					'detail'   => __(
						'Disabled for database restore — very large SQL may import slower via PHP only (mysql CLI unavailable).',
						'wp-powersuite'
					),
				];
			}
		}

		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.', 'wp-powersuite' );
			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.', 'wp-powersuite' ),
				$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.', 'wp-powersuite' ),
					$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.', 'wp-powersuite' );
		}

		$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.', 'wp-powersuite' );
		}

		$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', 'wp-powersuite' ),
					'ok'       => true,
					'severity' => 'info',
					'detail'   => sprintf(
						'Backup uses "%s", this site uses "%s" — table names will be rewritten automatically during import.',
						$bp,
						$wpdb->prefix
					),
				];
			}
		}

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

		$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.', 'wp-powersuite' );
					$items[ $c ] = [
						'ok'     => false,
						'label'  => $label,
						'detail' => __( 'database.sql was not found in the backup folder.', 'wp-powersuite' ),
					];
					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.', 'wp-powersuite' );
					}
				}
				$items[ $c ] = [
					'ok'     => true,
					'label'  => $label,
					'detail' => sprintf(
						/* translators: %s: file size */
						__( '%s — ready on server (local backup).', 'wp-powersuite' ),
						size_format( $sz, 1 )
					),
				];
				continue;
			}

			if ( ! class_exists( 'ZipArchive' ) ) {
				$items[ $c ] = [
					'ok'     => false,
					'label'  => $label,
					'detail' => __( 'PHP ZipArchive is required for this part.', 'wp-powersuite' ),
				];
				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', 'wp-powersuite' ),
					$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).', 'wp-powersuite' ),
				];
				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.', 'wp-powersuite' ),
					$c,
					$bn
				);
				$items[ $c ] = [
					'ok'     => false,
					'label'  => $label,
					'detail' => __( 'The ZIP archive failed integrity validation.', 'wp-powersuite' ),
				];
				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).', 'wp-powersuite' ),
					$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', 'wp-powersuite' ),
						$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.', 'wp-powersuite' ),
				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.',
				'wp-powersuite'
			);
		}

		$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.', 'wp-powersuite' );
		}

		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.',
				'wp-powersuite'
			);
		}

		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.',
				'wp-powersuite'
			);
		} elseif ( $site_url_from_sql ) {
			$warnings[] = __(
				'manifest.json had no site URL; the original address was taken from database.sql (header or wp_options).',
				'wp-powersuite'
			);
		}

		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.', 'wp-powersuite' ) ];
		}

		$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”.', 'wp-powersuite' )
						: __( 'Another restore is already running.', 'wp-powersuite' ),
					'lock_conflict' => true,
				];
			}
			$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.', 'wp-powersuite' ) ];
			}
			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.', 'wp-powersuite' ),
						'restore_session_lost' => true,
					];
				}
				if ( $held ) {
					return [
						'success'       => false,
						'message'       => __( 'Another restore is already running.', 'wp-powersuite' ),
						'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.', 'wp-powersuite' ),
					'restore_session_lost' => true,
				];
			}
			if ( ! $this->acquire_restore_lock( $backup_id ) ) {
				return [
					'success'       => false,
					'message'       => __( 'Another restore is already running.', 'wp-powersuite' ),
					'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.', 'wp-powersuite' ) ];
			}

			// 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.', 'wp-powersuite' ),
				];
			}

			// 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', 'wp-powersuite' ),
					$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', 'wp-powersuite' ),
						$m_from,
						$m_to
					)
				);
			} elseif ( in_array( 'database', $pick, true ) && ! empty( $opts['skip_url_migration'] ) ) {
				$this->log_line( __( 'URL migration skipped (advanced option).', 'wp-powersuite' ) );
			}
			$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…', 'wp-powersuite' ),
				'log_line'                 => __( 'Restore initialized. Working…', 'wp-powersuite' ),
				'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.', 'wp-powersuite' ),
				'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.', 'wp-powersuite' ),
				'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.', 'wp-powersuite' ),
				'restore_session_lost' => true,
			];
		}

		$this->touch_restore_lock();

		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->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.', 'wp-powersuite' ),
				'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'];

		$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', 'wp-powersuite' ),
					$state['safety_dump_path']
				);
			}
			$this->log_line( __( 'Restore error: ', 'wp-powersuite' ) . $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->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', 'wp-powersuite' ),
						$state['safety_dump_path']
					);
				}
				$this->log_line( __( 'Restore error: ', 'wp-powersuite' ) . $e->getMessage() . $safety_note );
				$this->close_job_log();
				$this->disable_restore_maintenance_mode( $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 );
	}

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

	/**
	 * @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.', 'wp-powersuite' ) );
				}

				$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 ) {
						/*
						 * Multi-tick continuation: a fresh .maintenance file makes WordPress
						 * answer the NEXT admin-ajax tick with a 503 for up to 10 minutes and
						 * the restore stalls. Drop it between ticks; it is re-armed above on
						 * the next tick while work is actually running.
						 */
						$this->release_restore_maintenance_after_database( $state );
						$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 ) {
						// See safety-dump note above: keep .maintenance off between ticks so
						// the next admin-ajax continuation request is not served a 503.
						$this->release_restore_maintenance_after_database( $state );
						$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_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.', 'wp-powersuite' ),
							$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', 'wp-powersuite' ),
							$state['safety_dump_path']
						)
					);
				}
				$this->log_line( __( 'Database restore completed.', 'wp-powersuite' ) );
				$this->ensure_restore_ajax_continuation( $bid, $state );
				$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.', 'wp-powersuite' ) );
				return true;

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

	/**
	 * @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', 'wp-powersuite' ),
					$component
				)
			);
		}
		if ( ! is_array( $map ) ) {
			$map = $this->restore_zip_prefix_and_dest( $component );
		}
		if ( ! is_array( $map ) ) {
			throw new \Exception( __( 'Invalid restore target.', 'wp-powersuite' ) );
		}
		$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', 'wp-powersuite' ),
					$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', 'wp-powersuite' ),
					$wd
				)
			);
		}
		$map = $this->restore_zip_prefix_and_workdir( $component, $wd );
		if ( ! is_array( $map ) ) {
			throw new \Exception( __( 'Invalid restore staging layout.', 'wp-powersuite' ) );
		}
		$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.', 'wp-powersuite' ),
						$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.', 'wp-powersuite' ) );
		}
		$source_dir = rtrim( wp_normalize_path( $source_dir ), '/' );
		try {
			if ( ! is_dir( $source_dir ) ) {
				return;
			}
			$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;
				}
				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
		}
	}

	/**
	 * 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.', 'wp-powersuite' ),
					$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;
	}

	/**
	 * 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.', 'wp-powersuite' ) );
		}
		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 ( ! 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', 'wp-powersuite' ),
						$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', 'wp-powersuite' ),
						$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 ] );
			if ( 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', 'wp-powersuite' ),
					$component
				)
			);
			return true;
		}

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

	/**
	 * 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;
				}
				$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.', 'wp-powersuite' ),
					$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', 'wp-powersuite' ),
					$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.)', 'wp-powersuite' );
			}
			throw new \Exception(
				sprintf(
					/* translators: %s: absolute path */
					__( 'Could not write restored file: %s', 'wp-powersuite' ),
					$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', 'wp-powersuite' ),
					$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.', 'wp-powersuite' ) );
		}
		if ( ! is_readable( $zip_path ) ) {
			throw new \Exception( __( 'Cannot read zip archive.', 'wp-powersuite' ) );
		}

		$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 (unsafe paths or resource limits). Restore stopped before extraction.', 'wp-powersuite' ) );
			}
		}
		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.', 'wp-powersuite' ) );
		}

		$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…', 'wp-powersuite' ),
						$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…', 'wp-powersuite' ),
						$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', 'wp-powersuite' ),
						$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.', 'wp-powersuite' ),
						$batch,
						$i + 1,
						$n
					)
				);
				$z->close();
				return false;
			}
		}

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

	private function find_mysql_binary(): ?string {
		$paths = [ '/usr/bin/mysql', '/bin/mysql', '/usr/local/bin/mysql', '/opt/homebrew/bin/mysql' ];
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			$candidates = [];
			foreach ( [ 'C', 'D', 'E' ] as $drive ) {
				$pattern = $drive . ':\\Program Files\\MySQL\\*\\bin\\mysql.exe';
				$found   = glob( $pattern );
				if ( is_array( $found ) ) {
					$candidates = array_merge( $candidates, $found );
				}
			}
			foreach ( $candidates as $p ) {
				if ( is_string( $p ) && file_exists( $p ) ) {
					return $p;
				}
			}
			$disabled_exec = array_filter( array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ) );
			if ( function_exists( 'exec' ) && ! in_array( 'exec', $disabled_exec, true ) ) {
				$out = [];
				$rc  = -1;
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec,WordPress.PHP.NoSilencedErrors.Discouraged
				@exec( 'where mysql 2>NUL', $out, $rc );
				if ( 0 === $rc && isset( $out[0] ) && is_string( $out[0] ) ) {
					$first = trim( $out[0] );
					if ( $first !== '' && is_file( $first ) ) {
						return $first;
					}
				}
			}
			return null;
		}
		foreach ( $paths as $p ) {
			if ( $p && @is_executable( $p ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				return $p;
			}
		}
		return null;
	}

	/**
	 * Build mysql CLI argv using a defaults-extra-file (never put the password on argv).
	 *
	 * @return array{argv: list<string>, cnf_tmp: string}|null
	 */
	private function build_mysql_cli_argv( string $bin ): ?array {
		$user = DB_USER;
		$pass = DB_PASSWORD;
		$name = DB_NAME;
		$host = DB_HOST;

		$cnf_path = tempnam( sys_get_temp_dir(), 'wpps_my_' );
		if ( ! $cnf_path ) {
			return null;
		}
		$u_q      = $this->mysqldump_cnf_escape_value( $user );
		$p_q      = $this->mysqldump_cnf_escape_value( $pass );
		$cnf_body = "[client]\nuser={$u_q}\npassword={$p_q}\n";
		if ( false === file_put_contents( $cnf_path, $cnf_body ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $cnf_path );
			return null;
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		@chmod( $cnf_path, 0600 );

		$argv = [
			$bin,
			'--defaults-extra-file=' . $cnf_path,
			'--binary-mode',
		];

		$socket_path = null;
		if ( str_starts_with( $host, 'localhost:' ) && str_contains( $host, '/' ) ) {
			$socket_path = substr( $host, strlen( 'localhost:' ) );
		}
		if ( $socket_path ) {
			$argv[] = '--socket=' . $socket_path;
		} else {
			$h    = $host;
			$port = null;
			if ( preg_match( '/^([^:]+):(\d+)$/', $host, $hm ) ) {
				$h    = $hm[1];
				$port = (int) $hm[2];
			}
			if ( strtolower( $h ) === 'localhost' ) {
				$argv[] = '--protocol=TCP';
				$h       = '127.0.0.1';
			}
			$argv[] = '-h';
			$argv[] = $h;
			if ( $port ) {
				$argv[] = '-P';
				$argv[] = (string) $port;
			}
		}

		$argv[] = $name;

		return [ 'argv' => $argv, 'cnf_tmp' => $cnf_path ];
	}

	/**
	 * @throws \Exception On failure.
	 */
	private function import_database_mysql_cli( string $sql_path ): void {
		if ( ! is_readable( $sql_path ) ) {
			throw new \Exception( __( 'Cannot read SQL dump.', 'wp-powersuite' ) );
		}
		$bin = $this->find_mysql_binary();
		if ( ! $bin ) {
			throw new \Exception( __( 'mysql client not found.', 'wp-powersuite' ) );
		}

		$built = $this->build_mysql_cli_argv( $bin );
		if ( null === $built ) {
			throw new \Exception( __( 'Could not prepare mysql credentials file.', 'wp-powersuite' ) );
		}
		$argv    = $built['argv'];
		$cnf_tmp = $built['cnf_tmp'];

		try {
			$disabled_cli = array_filter( array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ) );
			$proc_allowed = function_exists( 'proc_open' ) && ! in_array( 'proc_open', $disabled_cli, true );
			$use_proc     = $proc_allowed && PHP_VERSION_ID >= 70400;
			if ( $use_proc ) {
				$desc = [
					0 => [ 'file', $sql_path, 'rb' ],
					1 => [ 'pipe', 'w' ],
					2 => [ 'pipe', 'w' ],
				];
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				$proc = @proc_open(
					$argv,
					$desc,
					$pipes,
					null,
					null,
					[ 'bypass_shell' => true ]
				);
				if ( is_resource( $proc ) ) {
					$stdout = '';
					$stderr = '';
					if ( isset( $pipes[1] ) && is_resource( $pipes[1] ) ) {
						$stdout = (string) stream_get_contents( $pipes[1] );
						fclose( $pipes[1] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
					}
					if ( isset( $pipes[2] ) && is_resource( $pipes[2] ) ) {
						$stderr = (string) stream_get_contents( $pipes[2] );
						fclose( $pipes[2] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
					}
					$code = proc_close( $proc );
					if ( 0 === $code ) {
						return;
					}
					$hint = trim( $stderr !== '' ? $stderr : $stdout );
					if ( $hint === '' ) {
						$hint = __( '(mysql exited non-zero with no output; check DB credentials and that mysql matches this WordPress server)', 'wp-powersuite' );
					}
					throw new \Exception( __( 'mysql import failed: ', 'wp-powersuite' ) . $hint );
				}
			}

			$shell_args = array_map( 'escapeshellarg', $argv );
			$cmd        = implode( ' ', $shell_args ) . ' < ' . escapeshellarg( $sql_path ) . ' 2>&1';
			$output     = [];
			$code       = -1;
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec,WordPress.PHP.NoSilencedErrors.Discouraged
			@exec( $cmd, $output, $code );

			if ( 0 !== $code ) {
				$hint = implode( "\n", array_slice( $output, 0, 15 ) );
				if ( $hint === '' ) {
					$hint = __( '(no output; check DB host: try 127.0.0.1 instead of localhost in wp-config if CLI cannot connect)', 'wp-powersuite' );
				}
				throw new \Exception( __( 'mysql import failed: ', 'wp-powersuite' ) . $hint );
			}
		} finally {
			if ( is_string( $cnf_tmp ) && is_file( $cnf_tmp ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $cnf_tmp );
			}
		}
	}

	/**
	 * Detect the table prefix used inside a SQL dump file.
	 *
	 * Strategy:
	 *  1. Look for a `# Table Prefix: xxx` header comment (our own backups).
	 *  2. Look for `CREATE TABLE \`xxx_options\`` to infer the prefix from a core WP table.
	 *
	 * @return string|null The detected prefix (e.g. 'wp_'), or null if undetermined.
	 */
	private function detect_table_prefix_from_sql( string $sql_path ): ?string {
		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;
		}
		$prefix       = null;
		$bytes_read   = 0;
		$scan_limit   = 512 * 1024;
		try {
			while ( ( $line = fgets( $h ) ) !== false ) {
				$bytes_read += strlen( $line );
				$trimmed     = ltrim( $line );
				if ( preg_match( '/^#\s*Table\s+Prefix:\s*(\S+)/i', $trimmed, $m ) ) {
					$prefix = $m[1];
					break;
				}
				if ( preg_match( '/CREATE\s+TABLE\s+`([A-Za-z0-9_]*)options`/i', $trimmed, $m ) ) {
					$prefix = $m[1];
					break;
				}
				if ( $bytes_read > $scan_limit ) {
					break;
				}
			}
		} finally {
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

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

	/**
	 * Create a temporary copy of the SQL dump with all table names rewritten from $old_prefix to $new_prefix.
	 *
	 * Only rewrites backtick-quoted table names in DDL/DML statement keywords
	 * (DROP TABLE, CREATE TABLE, INSERT INTO, LOCK TABLES, UNLOCK TABLES)
	 * so that data values are left intact.
	 *
	 * @return string Absolute path to the temp file (caller must unlink).
	 * @throws \Exception On I/O failure.
	 */
	private function rewrite_sql_table_prefix( string $sql_path, string $old_prefix, string $new_prefix ): string {
		$dir = dirname( $sql_path );
		$tmp = $dir . '/.wpps-prefix-rewrite-' . wp_generate_password( 10, false, false ) . '.sql';

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$in = fopen( $sql_path, 'rb' );
		if ( false === $in ) {
			throw new \Exception( __( 'Could not open SQL dump for prefix rewriting.', 'wp-powersuite' ) );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$out = fopen( $tmp, 'wb' );
		if ( false === $out ) {
			fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			throw new \Exception( __( 'Could not create temp SQL file for prefix rewriting.', 'wp-powersuite' ) );
		}

		$old_q = preg_quote( $old_prefix, '/' );

		$patterns = [
			'/^(DROP\s+TABLE\s+IF\s+EXISTS\s+`)' . $old_q . '/i',
			'/^(DROP\s+TABLE\s+`)' . $old_q . '/i',
			'/^(CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`)' . $old_q . '/i',
			'/^(INSERT\s+INTO\s+`)' . $old_q . '/i',
			'/^(LOCK\s+TABLES\s+`)' . $old_q . '/i',
			'/^(REPLACE\s+INTO\s+`)' . $old_q . '/i',
			'/^(ALTER\s+TABLE\s+`)' . $old_q . '/i',
			'/^(TRUNCATE\s+(?:TABLE\s+)?`)' . $old_q . '/i',
			'/^(\/\*!\d+\s+ALTER\s+TABLE\s+`)' . $old_q . '/i',
		];
		$replacements = array_map(
			static function ( string $p ) use ( $new_prefix ): string {
				return '${1}' . $new_prefix;
			},
			$patterns
		);

		try {
			while ( ( $line = fgets( $in ) ) !== false ) {
				$rewritten = preg_replace( $patterns, $replacements, $line );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				fwrite( $out, is_string( $rewritten ) ? $rewritten : $line );
			}
		} finally {
			fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		return $tmp;
	}

	/**
	 * Absolute path for the pre-restore safety SQL dump (private storage when available).
	 */
	public function get_pre_restore_safety_dump_path( string $backup_id ): string {
		$backup_id = sanitize_file_name( $backup_id );
		if ( $backup_id === '' ) {
			return '';
		}
		if ( $this->ensure_private_storage_root() ) {
			$root = $this->get_private_storage_root();
			if ( $root !== '' ) {
				return trailingslashit( $root ) . 'pre-restore-safety-' . $backup_id . '.sql';
			}
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir !== '' ) {
			return $dir . 'pre-restore-safety.sql';
		}
		$primary = $this->get_primary_backup_dir( $backup_id );
		return $primary !== '' ? trailingslashit( $primary ) . 'pre-restore-safety.sql' : '';
	}

	/**
	 * Dump the live database before restore import (resumable). Returns true when the safety file is ready.
	 *
	 * @param array<string, mixed> $state
	 */
	private function run_pre_restore_safety_dump_tick( string $backup_id, array &$state ): bool {
		$path = isset( $state['safety_dump_path'] ) && is_string( $state['safety_dump_path'] ) && $state['safety_dump_path'] !== ''
			? (string) $state['safety_dump_path']
			: $this->get_pre_restore_safety_dump_path( $backup_id );
		if ( $path === '' ) {
			$this->log_line( __( 'Skipping pre-restore safety dump (no writable private storage).', 'wp-powersuite' ) );
			return true;
		}
		$state['safety_dump_path'] = $path;

		if ( empty( $state['safety_dump_started'] ) ) {
			$this->log_line( __( 'Creating pre-restore safety database dump…', 'wp-powersuite' ) );
			if ( is_file( $path ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $path );
			}
			if ( $this->try_export_database_mysqldump( $path ) && is_readable( $path ) && (int) filesize( $path ) > 64 ) {
				$state['safety_dump_started'] = true;
				$this->log_line(
					sprintf(
						/* translators: %s: path */
						__( 'Pre-restore safety dump ready (mysqldump): %s', 'wp-powersuite' ),
						$path
					)
				);
				return true;
			}
			$state['safety_dump_started'] = true;
			$state['safety_dump_php']     = true;
		}

		if ( ! empty( $state['safety_dump_php'] ) ) {
			$done = $this->export_database_php_resumable( $path, $state );
			if ( ! $done ) {
				return false;
			}
			$this->log_line(
				sprintf(
					/* translators: %s: path */
					__( 'Pre-restore safety dump ready (PHP): %s', 'wp-powersuite' ),
					$path
				)
			);
		}

		return is_readable( $path ) && (int) filesize( $path ) > 0;
	}

	/**
	 * Import a SQL dump (CLI one-shot or resumable PHP). Returns false when another tick is needed.
	 *
	 * @param array<string, mixed> $state Restore job state (db_php_import_* keys).
	 * @throws \Exception On failure.
	 */
	private function import_database_from_sql_file( string $sql_path, array &$state ): bool {
		if ( ! is_readable( $sql_path ) ) {
			throw new \Exception( __( 'Cannot read SQL dump.', 'wp-powersuite' ) );
		}

		global $wpdb;

		if ( empty( $state['db_import_path'] ) ) {
			$import_path = $sql_path;
			$dump_prefix = $this->detect_table_prefix_from_sql( $sql_path );
			$state['db_import_dump_prefix'] = is_string( $dump_prefix ) ? $dump_prefix : '';

			if ( is_string( $dump_prefix ) && $dump_prefix !== '' && $dump_prefix !== $wpdb->prefix ) {
				$this->log_line(
					sprintf(
						'Table prefix mismatch: dump uses "%s", this site uses "%s". Rewriting SQL table names before import.',
						$dump_prefix,
						$wpdb->prefix
					)
				);
				try {
					$import_path = $this->rewrite_sql_table_prefix( $sql_path, $dump_prefix, $wpdb->prefix );
					$state['db_import_prefix_tmp'] = $import_path;
				} catch ( \Exception $e ) {
					$this->log_line( 'Prefix rewrite failed: ' . $e->getMessage() . ' — importing with original prefix.' );
				}
			}
			$state['db_import_path'] = $import_path;
		}

		$import_path = (string) $state['db_import_path'];
		$done        = $this->import_database_from_sql_file_inner( $import_path, $state );
		if ( ! $done ) {
			return false;
		}

		$dump_prefix = isset( $state['db_import_dump_prefix'] ) ? (string) $state['db_import_dump_prefix'] : '';
		if ( $dump_prefix !== '' && $dump_prefix !== $wpdb->prefix ) {
			$this->fix_prefix_in_meta_data( $dump_prefix, $wpdb->prefix );
		}

		if ( ! empty( $state['db_import_prefix_tmp'] ) && is_string( $state['db_import_prefix_tmp'] ) && is_file( $state['db_import_prefix_tmp'] ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $state['db_import_prefix_tmp'] );
		}
		unset(
			$state['db_import_path'],
			$state['db_import_prefix_tmp'],
			$state['db_import_dump_prefix'],
			$state['db_php_import_offset'],
			$state['db_php_import_buffer'],
			$state['db_php_import_delimiter'],
			$state['db_php_import_stmt_count'],
			$state['db_php_import_in_string'],
			$state['db_php_import_string_quote'],
			$state['db_php_import_escape']
		);

		return true;
	}

	/**
	 * After a cross-prefix import the table NAMES are correct, but WordPress
	 * also stores the prefix inside option_name / meta_key values.
	 * Without this fix the admin loses all capabilities ("Sorry, you are not allowed…").
	 */
	private function fix_prefix_in_meta_data( string $old_prefix, string $new_prefix ): void {
		global $wpdb;

		$this->log_line( sprintf( 'Fixing prefix-dependent meta data: "%s" → "%s"', $old_prefix, $new_prefix ) );

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

		$option_keys = [
			'user_roles',
		];
		foreach ( $option_keys as $key ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->query( $wpdb->prepare(
				"UPDATE `{$options_table}` SET option_name = %s WHERE option_name = %s",
				$new_prefix . $key,
				$old_prefix . $key
			) );
		}

		$meta_keys = [
			'capabilities',
			'user_level',
			'dashboard_quick_press_last_post_id',
			'user-settings',
			'user-settings-time',
		];
		foreach ( $meta_keys as $key ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->query( $wpdb->prepare(
				"UPDATE `{$usermeta_table}` SET meta_key = %s WHERE meta_key = %s",
				$new_prefix . $key,
				$old_prefix . $key
			) );
		}

		$this->log_line( 'Prefix-dependent meta data fixed.' );
	}

	/**
	 * Low-level SQL import (CLI one-shot or resumable PHP mysqli).
	 *
	 * @param array<string, mixed> $state
	 * @return bool True when import finished.
	 * @throws \Exception On failure.
	 */
	private function import_database_from_sql_file_inner( string $sql_path, array &$state ): bool {
		if ( ! is_readable( $sql_path ) ) {
			throw new \Exception( __( 'Cannot read SQL dump.', 'wp-powersuite' ) );
		}

		// Prefer mysql CLI once (large dumps); skip retry once PHP import has started.
		if ( empty( $state['db_php_import_started'] ) ) {
			$disabled_exec    = array_filter( array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ) );
			$exec_ok          = function_exists( 'exec' ) && ! in_array( 'exec', $disabled_exec, true );
			$proc_ok          = function_exists( 'proc_open' ) && ! in_array( 'proc_open', $disabled_exec, true );
			$mysql_cli_usable = (bool) $this->find_mysql_binary() && ( $exec_ok || $proc_ok );

			if ( $mysql_cli_usable ) {
				try {
					$this->import_database_mysql_cli( $sql_path );
					return true;
				} catch ( \Exception $e ) {
					$this->log_line( sprintf( 'mysql CLI failed, falling back to PHP import: %s', $e->getMessage() ) );
				}
			}
		}

		global $wpdb;

		$sql_size = (int) filesize( $sql_path );
		if ( empty( $state['db_php_import_started'] ) && $sql_size > self::DB_PHP_IMPORT_WARN_BYTES ) {
			$this->log_line(
				sprintf(
					/* translators: 1: file size */
					__( 'Large SQL dump (%1$s): importing via resumable PHP (install mysql client for faster restores).', 'wp-powersuite' ),
					size_format( $sql_size, 1 )
				)
			);
		}

		if ( ! $wpdb->use_mysqli || ! ( $wpdb->dbh instanceof \mysqli ) ) {
			throw new \Exception( __( 'mysqli is required for PHP-driven import on this host.', 'wp-powersuite' ) );
		}

		mysqli_report( MYSQLI_REPORT_OFF );

		/** @var \mysqli $dbh */
		$dbh = $wpdb->dbh;
		$this->import_db_session_init( $dbh );

		$offset    = isset( $state['db_php_import_offset'] ) ? (int) $state['db_php_import_offset'] : 0;
		$buffer    = isset( $state['db_php_import_buffer'] ) && is_string( $state['db_php_import_buffer'] ) ? $state['db_php_import_buffer'] : '';
		$delimiter = isset( $state['db_php_import_delimiter'] ) && is_string( $state['db_php_import_delimiter'] ) ? $state['db_php_import_delimiter'] : ';';
		$stmt_count = isset( $state['db_php_import_stmt_count'] ) ? (int) $state['db_php_import_stmt_count'] : 0;
		$in_string = ! empty( $state['db_php_import_in_string'] );
		$quote     = isset( $state['db_php_import_string_quote'] ) ? (string) $state['db_php_import_string_quote'] : '';
		$escape    = ! empty( $state['db_php_import_escape'] );

		$h = fopen( $sql_path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( false === $h ) {
			throw new \Exception( __( 'Could not open SQL dump for reading.', 'wp-powersuite' ) );
		}
		if ( $offset > 0 ) {
			fseek( $h, $offset );
		}

		$deadline      = microtime( true ) + self::DB_PHP_IMPORT_MAX_SECONDS;
		$stmts_this_tick = 0;
		$state['db_php_import_started'] = true;
		$eof = false;

		try {
			while ( microtime( true ) < $deadline && $stmts_this_tick < self::DB_PHP_IMPORT_MAX_STATEMENTS ) {
				$line = fgets( $h );
				if ( false === $line ) {
					$eof = true;
					break;
				}

				$trimmed_l = ltrim( $line );
				if ( ! $in_string && ( $trimmed_l === '' || str_starts_with( $trimmed_l, '--' ) || str_starts_with( $trimmed_l, '#' ) ) ) {
					continue;
				}

				// DELIMITER directive (mysqldump / our PHP routine dumps).
				if ( ! $in_string && preg_match( '/^DELIMITER\s+(\S+)\s*$/i', trim( $line ), $dm ) ) {
					$delimiter = $dm[1];
					continue;
				}

				$len = strlen( $line );
				for ( $i = 0; $i < $len; $i++ ) {
					$ch = $line[ $i ];
					if ( $escape ) {
						$buffer .= $ch;
						$escape  = false;
						continue;
					}
					if ( $in_string ) {
						if ( '\\' === $ch ) {
							$buffer .= $ch;
							$escape  = true;
							continue;
						}
						$buffer .= $ch;
						if ( $ch === $quote ) {
							$in_string = false;
							$quote     = '';
						}
						continue;
					}
					if ( "'" === $ch || '"' === $ch || '`' === $ch ) {
						$in_string = true;
						$quote     = $ch;
						$buffer   .= $ch;
						continue;
					}
					$buffer .= $ch;
				}

				$buf_rtrim = rtrim( $buffer );
				if ( ! $in_string && $delimiter !== '' && str_ends_with( $buf_rtrim, $delimiter ) ) {
					$stmt = trim( substr( $buf_rtrim, 0, -strlen( $delimiter ) ) );
					$buffer = '';
					if ( $stmt !== '' ) {
						$dbh = $this->import_db_execute_with_reconnect( $dbh, $stmt, $stmt_count, 0 );
						++$stmt_count;
						++$stmts_this_tick;
					}
				}
			}

			if ( $eof && ! $in_string && trim( $buffer ) !== '' ) {
				$dbh = $this->import_db_execute_with_reconnect( $dbh, trim( $buffer ), $stmt_count, 0 );
				++$stmt_count;
				$buffer = '';
			}
		} finally {
			$new_offset = (int) ftell( $h );
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		$state['db_php_import_offset']       = $new_offset;
		$state['db_php_import_buffer']       = $buffer;
		$state['db_php_import_delimiter']    = $delimiter;
		$state['db_php_import_stmt_count']   = $stmt_count;
		$state['db_php_import_in_string']    = $in_string;
		$state['db_php_import_string_quote'] = $quote;
		$state['db_php_import_escape']       = $escape;

		if ( $eof && ! $in_string && trim( $buffer ) === '' ) {
			$dbh->query( 'SET SESSION foreign_key_checks = 1' );
			$this->log_line( sprintf( __( 'Database import complete: %d statements executed.', 'wp-powersuite' ), $stmt_count ) );
			unset( $state['db_php_import_started'] );
			return true;
		}

		$this->log_line(
			sprintf(
				/* translators: 1: statements, 2: byte offset */
				__( 'Database import progress: %1$d statements (offset %2$d)…', 'wp-powersuite' ),
				$stmt_count,
				$new_offset
			)
		);

		return false;
	}

	private function import_db_session_init( \mysqli $dbh ): void {
		$dbh->query( "SET SESSION sql_mode = ''" );
		$dbh->query( 'SET SESSION foreign_key_checks = 0' );
		$dbh->query( 'SET SESSION wait_timeout = 28800' );
		$dbh->query( 'SET SESSION net_read_timeout = 600' );
		$dbh->query( 'SET SESSION net_write_timeout = 600' );
		$packet = (int) ini_get( 'memory_limit' );
		if ( $packet > 0 ) {
			$dbh->query( 'SET SESSION max_allowed_packet = ' . min( $packet, 512 * 1024 * 1024 ) );
		}
	}

	/**
	 * Execute a SQL statement, reconnecting once on MySQL 2006/2013 errors.
	 *
	 * @return \mysqli The (possibly reconnected) database handle.
	 */
	private function import_db_execute_with_reconnect( \mysqli $dbh, string $stmt, int $stmt_index, int $line_num ): \mysqli {
		$result = $dbh->query( $stmt );

		if ( false === $result && in_array( $dbh->errno, [ 2006, 2013 ], true ) ) {
			$this->log_line(
				sprintf( 'MySQL connection lost at statement #%d (line %d), reconnecting…', $stmt_index + 1, $line_num )
			);

			$dbh = $this->import_db_reconnect();
			$this->import_db_session_init( $dbh );

			$result = $dbh->query( $stmt );
		}

		if ( false === $result && $dbh->errno ) {
			$eno = (int) $dbh->errno;
			$emg = (string) $dbh->error;
			throw new \Exception(
				sprintf(
					__( 'Database import failed at statement #%1$d (line %2$d, MySQL %3$d): %4$s', 'wp-powersuite' ),
					$stmt_index + 1,
					$line_num,
					$eno,
					$emg
				)
			);
		}

		if ( $result instanceof \mysqli_result ) {
			$result->free();
		}

		return $dbh;
	}

	/**
	 * Re-establish the WordPress mysqli connection.
	 *
	 * @return \mysqli Fresh connection handle.
	 * @throws \Exception If reconnection fails.
	 */
	private function import_db_reconnect(): \mysqli {
		global $wpdb;

		$wpdb->check_connection( false );

		if ( $wpdb->dbh instanceof \mysqli && @$wpdb->dbh->ping() ) {
			return $wpdb->dbh;
		}

		$dbh = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME );
		if ( $dbh->connect_errno ) {
			throw new \Exception(
				sprintf(
					__( 'MySQL reconnection failed (%1$d): %2$s', 'wp-powersuite' ),
					$dbh->connect_errno,
					$dbh->connect_error
				)
			);
		}
		if ( defined( 'DB_CHARSET' ) && DB_CHARSET ) {
			$dbh->set_charset( DB_CHARSET );
		}
		$wpdb->dbh = $dbh;
		return $dbh;
	}

	/**
	 * Build search/replace pairs (longest "from" first) for site URL migration.
	 *
	 * @return list<array{0: string, 1: string}>
	 */
	private function build_url_migration_pairs( string $from_raw, string $to_raw ): array {
		$from = esc_url_raw( rtrim( trim( $from_raw ), '/' ) );
		$to   = esc_url_raw( rtrim( trim( $to_raw ), '/' ) );
		if ( $from === '' || $to === '' || $from === $to ) {
			return [];
		}
		$pairs   = [];
		$pairs[] = [ $from, $to ];
		$pairs[] = [ $from . '/', $to . '/' ];
		$pu      = wp_parse_url( $from );
		if ( is_array( $pu ) && ! empty( $pu['scheme'] ) && ! empty( $pu['host'] ) ) {
			$alt_scheme = 'https' === $pu['scheme'] ? 'http' : 'https';
			$alt        = $alt_scheme . '://' . $pu['host'];
			if ( ! empty( $pu['port'] ) ) {
				$alt .= ':' . $pu['port'];
			}
			if ( ! empty( $pu['path'] ) && '/' !== $pu['path'] ) {
				$alt .= untrailingslashit( $pu['path'] );
			}
			$alt = esc_url_raw( rtrim( $alt, '/' ) );
			if ( $alt !== '' && $alt !== $from ) {
				$pairs[] = [ $alt, $to ];
				$pairs[] = [ $alt . '/', $to . '/' ];
			}
		}

		/*
		 * JSON-escaped variants: PHP json_encode() escapes "/" → "\/", so Elementor,
		 * Gutenberg, WPBakery, and many plugins store URLs like https:\/\/site.com\/path.
		 * Without these pairs, thousands of rows in postmeta stay unrewritten.
		 */
		$json_pairs = [];
		foreach ( $pairs as $pr ) {
			$jf = str_replace( '/', '\\/', $pr[0] );
			$jt = str_replace( '/', '\\/', $pr[1] );
			if ( $jf !== $pr[0] ) {
				$json_pairs[] = [ $jf, $jt ];
			}
		}
		foreach ( $json_pairs as $jp ) {
			$pairs[] = $jp;
		}

		$seen = [];
		$out  = [];
		foreach ( $pairs as $pr ) {
			if ( ! is_array( $pr ) || count( $pr ) < 2 ) {
				continue;
			}
			$a = (string) $pr[0];
			$b = (string) $pr[1];
			if ( $a === '' || $a === $b ) {
				continue;
			}
			$key = $a . "\0" . $b;
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$out[]        = [ $a, $b ];
		}
		usort(
			$out,
			static function ( array $x, array $y ): int {
				return strlen( $y[0] ) <=> strlen( $x[0] );
			}
		);

		return $out;
	}

	/**
	 * Replace strings in a scalar/array tree (for serialized PHP values).
	 *
	 * @param list<array{0: string, 1: string}> $pairs
	 * @param mixed                              $data
	 * @return mixed
	 */
	private function migrate_replace_deep( array $pairs, $data ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.dataFound
		if ( is_string( $data ) ) {
			return $this->migrate_plain_replace( $pairs, $data );
		}
		if ( is_array( $data ) ) {
			$next = [];
			foreach ( $data as $k => $v ) {
				$nk         = is_string( $k ) ? $this->migrate_plain_replace( $pairs, $k ) : $k;
				$next[ $nk ] = $this->migrate_replace_deep( $pairs, $v );
			}

			return $next;
		}

		return $data;
	}

	/**
	 * @param list<array{0: string, 1: string}> $pairs
	 */
	private function migrate_plain_replace( array $pairs, string $value ): string {
		foreach ( $pairs as $pr ) {
			$f = $pr[0];
			$t = $pr[1];
			if ( $f !== '' && str_contains( $value, $f ) ) {
				$value = str_replace( $f, $t, $value );
			}
		}

		return $value;
	}

	/**
	 * Whether this table is a WordPress options table ({prefix}options or multisite blog N options).
	 */
	private function is_wordpress_options_table( string $table ): bool {
		global $wpdb;

		return $table === $wpdb->options || ( str_ends_with( $table, '_options' ) && strlen( $table ) > strlen( '_options' ) );
	}

	/**
	 * Never run URL migration on these option rows — serialized lists must stay intact.
	 *
	 * @param string $option_name Value from option_name column.
	 */
	private function migrate_urls_skip_option_value_for_name( string $option_name ): bool {
		static $skip = [ 'active_plugins' => true, 'recently_activated' => true, 'uninstall_plugins' => true ];

		return isset( $skip[ $option_name ] );
	}

	/**
	 * Keep this plugin loadable for follow-up restore AJAX ticks after database.sql replaces options.
	 *
	 * If active_plugins from the backup omits PowerSuite (or lists only paths that do not match this
	 * install), the next admin-ajax request will not load our handlers — restore appears to stall and
	 * WordPress may show a login/session overlay while the browser still polls.
	 */
	private function restore_ensure_powersuite_in_active_plugins(): void {
		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 || str_contains( $basename, '..' ) ) {
			return;
		}
		$active = get_option( 'active_plugins', [] );
		if ( ! is_array( $active ) ) {
			$active = [];
		}
		if ( in_array( $basename, $active, true ) ) {
			return;
		}
		$active[] = $basename;
		sort( $active );
		update_option( 'active_plugins', $active, true );
	}

	/**
	 * After SQL import + optional URL migration, warn if the plugin activation list is unusable.
	 */
	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.',
				'wp-powersuite'
			);
		}
		$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.',
				'wp-powersuite'
			);
		}

		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 === '' ) {
			return;
		}
		if ( ! is_array( maybe_unserialize( $raw ) ) ) {
			return;
		}
		$state['restore_snap_active_plugins'] = $raw;
	}

	/**
	 * 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 ];
		// 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' ]
		);
		wp_cache_delete( 'alloptions', 'options' );
		wp_cache_delete( 'active_plugins', 'options' );
	}

	/**
	 * 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.', 'wp-powersuite' ) );
			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 '';
	}

	/**
	 * 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.', 'wp-powersuite' ),
					$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.', 'wp-powersuite' ),
					$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;

		if ( empty( $state['restore_snap_active_plugins'] ) || ! is_string( $state['restore_snap_active_plugins'] ) ) {
			return;
		}
		$blob = (string) $state['restore_snap_active_plugins'];
		if ( $blob === '' || ! is_array( maybe_unserialize( $blob ) ) ) {
			return;
		}

		// 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 ) {
				return;
			}
			$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.', 'wp-powersuite' ) );

				return;
			}
		} 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.', 'wp-powersuite' ) );

				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).', 'wp-powersuite' )
		);
	}

	/**
	 * Serialized-safe cell migration (WordPress options, meta, etc.).
	 *
	 * @param list<array{0: string, 1: string}> $pairs
	 */
	private function migrate_cell_value( array $pairs, string $value ): string {
		if ( $value === '' || $pairs === [] ) {
			return $value;
		}
		if ( function_exists( 'is_serialized' ) && is_serialized( $value ) ) {
			if ( $value === serialize( false ) ) {
				return $value;
			}
			$decoded = @unserialize( $value, [ 'allowed_classes' => false ] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize_unserialize
			if ( false === $decoded && $value !== serialize( false ) ) {
				// Plain str_replace on a blob that looks serialized would break length prefixes — leave unchanged.
				return $value;
			}
			$replaced = $this->migrate_replace_deep( $pairs, $decoded );

			return serialize( $replaced ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
		}

		if ( preg_match( '/^[aOsibdN]:/', $value ) ) {
			return $value;
		}

		return $this->migrate_plain_replace( $pairs, $value );
	}

	/**
	 * @return list<string>
	 */
	private function get_primary_key_columns_for_table( string $table ): array {
		global $wpdb;
		if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
			return [];
		}
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifier validated above
		$rows = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY' ORDER BY Seq_in_index ASC", ARRAY_A );
		if ( ! is_array( $rows ) || count( $rows ) === 0 ) {
			return [];
		}
		$cols = [];
		foreach ( $rows as $row ) {
			if ( ! empty( $row['Column_name'] ) && is_string( $row['Column_name'] ) ) {
				$cols[] = $row['Column_name'];
			}
		}

		return $cols;
	}

	/**
	 * Rewrite URLs after SQL import so the site works on a new domain (Updraft-style migration).
	 */
	private function migrate_database_urls_after_import( string $from_raw, string $to_raw ): int {
		global $wpdb;

		$pairs = $this->build_url_migration_pairs( $from_raw, $to_raw );
		if ( $pairs === [] ) {
			return 0;
		}

		$text_types = [ 'varchar', 'char', 'text', 'mediumtext', 'longtext', 'tinytext' ];
		$updated    = 0;
		$batch      = 150;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$tables = $wpdb->get_col( "SHOW TABLES LIKE '" . esc_sql( $wpdb->prefix ) . "%'" );
		if ( ! is_array( $tables ) ) {
			return 0;
		}

		foreach ( $tables as $table ) {
			if ( ! is_string( $table ) || ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
				continue;
			}
			$pk_cols = $this->get_primary_key_columns_for_table( $table );
			if ( count( $pk_cols ) !== 1 ) {
				continue;
			}
			$pk = $pk_cols[0];

			$columns = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
					DB_NAME,
					$table
				),
				ARRAY_A
			);
			if ( ! is_array( $columns ) ) {
				continue;
			}
			$text_cols = [];
			foreach ( $columns as $colrow ) {
				if ( ! is_array( $colrow ) || empty( $colrow['COLUMN_NAME'] ) || empty( $colrow['DATA_TYPE'] ) ) {
					continue;
				}
				$cname = (string) $colrow['COLUMN_NAME'];
				$ctype = strtolower( (string) $colrow['DATA_TYPE'] );
				if ( $cname === $pk || ! in_array( $ctype, $text_types, true ) ) {
					continue;
				}
				if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $cname ) ) {
					continue;
				}
				$text_cols[] = $cname;
			}
			if ( count( $text_cols ) === 0 ) {
				continue;
			}

			foreach ( $text_cols as $col ) {
				$offset                = 0;
				$options_val_with_name = $this->is_wordpress_options_table( $table ) && $col === 'option_value';
				while ( true ) {
					if ( $options_val_with_name ) {
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers whitelisted
						$sql = "SELECT `" . $pk . "`, `option_name`, `" . $col . "` FROM `" . $table . "` LIMIT %d OFFSET %d";
					} else {
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers whitelisted
						$sql = "SELECT `" . $pk . "`, `" . $col . "` FROM `" . $table . "` LIMIT %d OFFSET %d";
					}
					$rows = $wpdb->get_results( $wpdb->prepare( $sql, $batch, $offset ), ARRAY_A );
					if ( ! is_array( $rows ) || count( $rows ) === 0 ) {
						break;
					}
					foreach ( $rows as $row ) {
						if ( ! is_array( $row ) || ! isset( $row[ $pk ], $row[ $col ] ) ) {
							continue;
						}
						if ( $options_val_with_name && isset( $row['option_name'] ) && $this->migrate_urls_skip_option_value_for_name( (string) $row['option_name'] ) ) {
							continue;
						}
						$old = $row[ $col ];
						if ( ! is_string( $old ) || $old === '' ) {
							continue;
						}
						$hit = false;
						foreach ( $pairs as $pr ) {
							if ( $pr[0] !== '' && str_contains( $old, $pr[0] ) ) {
								$hit = true;
								break;
							}
						}
						if ( ! $hit ) {
							continue;
						}
						$new = $this->migrate_cell_value( $pairs, $old );
						if ( $new === $old ) {
							continue;
						}
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$ok = $wpdb->update( $table, [ $col => $new ], [ $pk => $row[ $pk ] ], [ '%s' ], [ '%s' ] );
						if ( false !== $ok ) {
							++$updated;
						}
					}
					$offset += $batch;
				}
			}
		}

		if ( function_exists( 'wp_cache_flush' ) ) {
			wp_cache_flush();
		}

		return $updated;
	}

	/**
	 * Ensure siteurl/home point at the migration target when they still match the source URL.
	 * Guards against edge cases where table scans miss rows or replace pairs differ slightly.
	 */
	private function restore_ensure_core_site_urls( string $target_to, string $source_from ): void {
		global $wpdb;

		$to   = untrailingslashit( esc_url_raw( $target_to ) );
		$from = untrailingslashit( esc_url_raw( $source_from ) );
		if ( $to === '' || $from === '' || $to === $from ) {
			return;
		}

		foreach ( [ 'siteurl', 'home' ] as $opt ) {
			$cur = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $opt ) );
			if ( ! is_string( $cur ) || $cur === '' ) {
				continue;
			}
			if ( function_exists( 'is_serialized' ) && is_serialized( $cur ) ) {
				continue;
			}
			$cur_norm = untrailingslashit( esc_url_raw( $cur ) );
			$new      = null;
			if ( $cur_norm === $from ) {
				$new = $to;
			} elseif ( str_contains( $cur, $from ) ) {
				$new = str_replace( $from, $to, $cur );
			}
			if ( null !== $new && $new !== $cur && $new !== '' ) {
				$wpdb->update( $wpdb->options, [ 'option_value' => $new ], [ 'option_name' => $opt ], [ '%s' ], [ '%s' ] );
			}
		}

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

	/**
	 * Flush third-party plugin caches that may contain hardcoded old URLs.
	 * Must be called after URL migration so regenerated caches pick up the new domain.
	 */
	private function flush_third_party_caches_after_url_migration(): void {
		global $wpdb;

		$items = 0;

		// ── Elementor ──────────────────────────────────────────────────
		// _elementor_css postmeta = per-post compiled CSS cache
		$r = $wpdb->query( "DELETE FROM `{$wpdb->postmeta}` WHERE `meta_key` = '_elementor_css'" );
		if ( is_int( $r ) && $r > 0 ) {
			$items += $r;
		}

		// Global Elementor CSS option + breakpoint files registry
		$wpdb->query(
			"DELETE FROM `{$wpdb->options}` WHERE `option_name` IN (
				'_elementor_global_css',
				'elementor-custom-breakpoints-files'
			)"
		);

		// Compiled CSS files on disk  (wp-content/uploads/elementor/css/)
		$css_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) . 'uploads/elementor/css/';
		if ( is_dir( $css_dir ) ) {
			$files = glob( $css_dir . '*' );
			if ( is_array( $files ) ) {
				foreach ( $files as $f ) {
					if ( is_file( $f ) ) {
						@unlink( $f ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
						++$items;
					}
				}
			}
		}

		if ( $items > 0 ) {
			$this->log_line(
				sprintf(
					/* translators: %d: number of cache items removed */
					__( 'Elementor CSS cache cleared (%d items). Styles will regenerate on next page load.', 'wp-powersuite' ),
					$items
				)
			);
		}

		// ── Transients ─────────────────────────────────────────────────
		// After DB replacement, transients may still cache stale/old-domain data.
		$wpdb->query( "DELETE FROM `{$wpdb->options}` WHERE `option_name` LIKE '\\_transient\\_%' OR `option_name` LIKE '\\_site_transient\\_%'" );
		$this->log_line( __( 'Transient cache flushed after URL migration.', 'wp-powersuite' ) );

		// ── Object cache ───────────────────────────────────────────────
		if ( function_exists( 'wp_cache_flush' ) ) {
			wp_cache_flush();
		}
	}

	/**
	 * @param array<string, mixed> $state
	 * @return array<string, mixed>
	 */
	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->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();

		$progress = $total > 0 ? (int) min( 100, round( ( $index_after_steps / $total ) * 100 ) ) : 100;

		$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.', 'wp-powersuite' );
			$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 .maintenance once SQL import is finished so restore AJAX ticks can continue.
	 *
	 * @param array<string, mixed> $state
	 */
	private function release_restore_maintenance_after_database( array &$state ): void {
		if ( ! defined( 'ABSPATH' ) ) {
			unset( $state['maintenance_enabled'] );
			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'] );
	}

	/**
	 * WordPress maintenance mode blocks admin-ajax; clear it after the database step.
	 *
	 * @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;
		}
		$this->release_restore_maintenance_after_database( $state );
	}

	/**
	 * Enable WordPress maintenance mode during restore (skipped if already active).
	 *
	 * @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 ( ! empty( $state['maintenance_enabled'] ) ) {
			return;
		}
		if ( ! defined( 'ABSPATH' ) ) {
			return;
		}
		$file = ABSPATH . '.maintenance';
		if ( file_exists( $file ) ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if ( false !== file_put_contents( $file, '<?php $upgrading = ' . time() . "; ?>\n" ) ) {
			$state['maintenance_enabled'] = true;
		}
	}

	/**
	 * 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;
	}

	/**
	 * Verify a restore-log download token (file-based, survives DB import).
	 */
	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;
	}
}
