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

namespace BackupRestore\Includes;

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

require_once __DIR__ . '/BackupArchiveTrait.php';
require_once __DIR__ . '/BackupBundleImportTrait.php';
require_once __DIR__ . '/BackupDatabaseImportTrait.php';
require_once __DIR__ . '/BackupDatabaseExportTrait.php';
require_once __DIR__ . '/BackupRestoreTrait.php';

/**
 * Handles backup directory layout, environment checks, database export, and zip archives.
 */
class BackupService {
	use BackupArchiveTrait;
	use BackupBundleImportTrait;
	use BackupDatabaseImportTrait;
	use BackupDatabaseExportTrait;
	use BackupRestoreTrait;

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

	/** 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';
	public const TRANSIENT_PRIVACY_NOTICE = 'wpps_br_privacy_notice_';

	/**
	 * 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    = 300;
	private const DB_PHP_EXPORT_MAX_SECONDS  = 55;
	/*
	 * Rows per tick. The 55s deadline is the real budget — this is only a runaway
	 * backstop. The old 1500-row cap made each tick finish in ~1s of work and pay a
	 * full HTTP roundtrip + WP bootstrap, so a 30 MB database took dozens of ticks.
	 */
	private const DB_PHP_EXPORT_MAX_INSERTS  = 20000;

	/** 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;
	/*
	 * Statements per tick: a backstop only — the 55s deadline governs. 400 was the
	 * bottleneck for whole restores: 400 INSERTs run in well under a second, so ticks
	 * were ~99% HTTP/bootstrap overhead. Imports run in transaction batches now
	 * (COMMIT at checkpoints), so single-row autocommit fsyncs are gone too.
	 */
	private const DB_PHP_IMPORT_MAX_STATEMENTS = 20000;

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

		/*
		 * Content-proof exports: any value beyond plain printable ASCII — or
		 * containing SQL-special characters — is written as a hex literal.
		 * Hex has no escaping rules, no connection-charset conversion, and
		 * nothing for the import statement parser to mis-split: the bytes
		 * land in the table exactly as they were read, whatever theme,
		 * page builder, or plugin produced them. Including "%" and "{" here
		 * also guarantees the importer's placeholder-token repair can never
		 * touch real data in dumps made by this build. Simple values (IDs,
		 * slugs, URLs, emails) stay human-readable; the archive compression
		 * absorbs the size cost of the rest.
		 */
		return 1 === preg_match( '/[^\x20-\x7E]|[\'"`\\\\%{]/', $value );
	}

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

		global $wpdb;
		/*
		 * Never esc_sql() data destined for a SQL dump file: since WP 4.8.3,
		 * wpdb::_real_escape() replaces every literal "%" with a per-request
		 * {sha256} placeholder token that only wpdb::query() reverts. Written
		 * to a dump and imported over raw mysqli, the token replaces "%" in
		 * the stored data and breaks the length prefixes of every serialized
		 * value containing "%" (page-builder content is full of CSS
		 * percentages, so those die first).
		 */
		if ( isset( $wpdb->dbh ) && $wpdb->dbh instanceof \mysqli ) {
			return "'" . $wpdb->dbh->real_escape_string( $s ) . "'";
		}

		return "'" . $wpdb->remove_placeholder_escape( 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 : '';
	}

	/**
	 * Show the archive-sensitivity notice on the next Backup & Restore page load.
	 */
	public static function queue_privacy_notice(): void {
		$uid = get_current_user_id();
		if ( $uid < 1 ) {
			return;
		}
		set_transient( self::TRANSIENT_PRIVACY_NOTICE . $uid, '1', DAY_IN_SECONDS );
	}

	/**
	 * Whether to render the archive-sensitivity notice (consumes the one-shot flag).
	 */
	public static function consume_privacy_notice(): bool {
		$uid = get_current_user_id();
		if ( $uid < 1 ) {
			return false;
		}
		$key = self::TRANSIENT_PRIVACY_NOTICE . $uid;
		if ( get_transient( $key ) === false ) {
			return false;
		}
		delete_transient( $key );
		return true;
	}

	/**
	 * @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->restore_reapply_snapshots_before_teardown( $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.', 'wppowersuite-lite' ),
			];
		}

		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.', 'wppowersuite-lite' ),
				'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.', 'wppowersuite-lite' ),
				'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.', 'wppowersuite-lite' ) ];
		}

		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', 'wppowersuite-lite' ),
				$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”.', 'wppowersuite-lite' ) );
			$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”.', 'wppowersuite-lite' ),
			];
		}
		$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.', 'wppowersuite-lite' ) );
			$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.', 'wppowersuite-lite' ),
				'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…', 'wppowersuite-lite' ),
			'log_line'      => __( 'Pre-check completed. Starting backup…', 'wppowersuite-lite' ),
			'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 ) {
			self::queue_privacy_notice();
			$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.', 'wppowersuite-lite' ),
				$label,
				size_format( $total, 1 )
			);
		}

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

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

	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.', 'wppowersuite-lite' ) ];
		}

		$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.', 'wppowersuite-lite' ) ];
		}

		$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.', 'wppowersuite-lite' ) ];
			}
		}

		$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.', 'wppowersuite-lite' ),
				'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: ', 'wppowersuite-lite' ) . $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…', 'wppowersuite-lite' );
			case 'zip_plugins':
				return __( 'Creating archive (Plugins)…', 'wppowersuite-lite' );
			case 'zip_themes':
				return __( 'Creating archive (Themes)…', 'wppowersuite-lite' );
			case 'zip_uploads':
				return __( 'Creating archive (Uploads)…', 'wppowersuite-lite' );
			case 'zip_others':
				return __( 'Creating archive (Other wp-content)…', 'wppowersuite-lite' );
			case 'finalize':
				return __( 'Finalizing backup…', 'wppowersuite-lite' );
			default:
				return __( 'Working…', 'wppowersuite-lite' );
		}
	}

	/**
	 * 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)…', 'wppowersuite-lite' );
						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)…', 'wppowersuite-lite' );
							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.', 'wppowersuite-lite' ) );
				}
				$this->log_line(
					sprintf(
						__( 'Database export complete (%s).', 'wppowersuite-lite' ),
						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.', 'wppowersuite-lite' ) );
				$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.', 'wppowersuite-lite' ) );
		}
	}

	/**
	 * 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).', 'wppowersuite-lite' ),
					$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', 'wppowersuite-lite' ),
					$name
				)
			);
		}
		$this->log_line(
			sprintf(
				/* translators: 1: name, 2: size */
				__( 'Archive finished: %1$s (%2$s)', 'wppowersuite-lite' ),
				$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;
	}

	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.', 'wppowersuite-lite' ) );
		}
		$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 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 );
	}

	/**
	 * Delete a directory only when it lives inside WP PowerSuite-managed storage.
	 */
	public function delete_managed_directory( string $dir ): void {
		if ( '' === $dir || ! $this->is_path_under_managed_storage( $dir ) ) {
			return;
		}
		$this->recursive_delete( $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.
	 */
	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)
	// -------------------------------------------------------------------------

}
