<?php
/**
 * Backup & Restore module — local backups with pre-checks and logs.
 *
 * @package BackupRestore
 * @since 1.0.0
 */

namespace BackupRestore;

use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\AbstractModule;
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class Module extends AbstractModule implements DataLocationsInterface {

	public const MODULE_ID = 'backup-restore';

	private const VERSION = '1.11.5';

	/**
	 * Option names registered for uninstall/data export. Must match BackupService::OPTION_*.
	 */
	private const DATA_OPTION_HISTORY   = 'wp_powersuite_backup_restore_history';
	private const DATA_OPTION_LAST_MSG  = 'wp_powersuite_backup_restore_last_message';
	private const DATA_OPTION_SETTINGS  = 'wp_powersuite_backup_restore_settings';

public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Backup & Restore', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::VERSION;
	}

	public function getDescription(): string {
		return __( 'Local backups and restores for database and files, with checks, logs, and history in PowerSuite.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'ajax', 'cron' ];
	}

	public function getDependencies(): array {
		return [];
	}

	public function getDataLocations(): array {
		return [
			'options' => [
				self::DATA_OPTION_HISTORY,
				self::DATA_OPTION_LAST_MSG,
				self::DATA_OPTION_SETTINGS,
			],
		];
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	/**
	 * Capability gate for every Backup & Restore operation.
	 *
	 * Backups contain network-shared data on multisite (users/usermeta tables, plugin and
	 * theme code used by every site), and restores write to network-shared directories.
	 * A per-site administrator must therefore not be able to create, download, or restore
	 * backups on multisite — only network super admins can.
	 */
	private function current_user_can_manage_backups(): bool {
		if ( ! current_user_can( 'manage_options' ) ) {
			return false;
		}
		if ( is_multisite() && ! is_super_admin() ) {
			return false;
		}
		return true;
	}

	/**
	 * Menu capability matching {@see current_user_can_manage_backups()}.
	 */
	private function menu_capability(): string {
		return is_multisite() ? 'manage_network_options' : 'manage_options';
	}


	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		require_once __DIR__ . '/includes/PrivateStorageResolver.php';
		require_once __DIR__ . '/includes/BackupService.php';
		require_once __DIR__ . '/includes/BackupEmailBuilder.php';



		add_filter( 'cron_schedules', [ $this, 'add_cron_schedules' ] );
		add_action( 'wpps_br_scheduled_backup', [ $this, 'handle_scheduled_backup' ] );

		if ( ! is_admin() ) {
		return;
		}

		add_action( 'admin_menu', [ $this, 'register_menu' ], 25 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue' ] );
		add_action( 'wp_ajax_wpps_br_precheck', [ $this, 'ajax_precheck' ], 1 );
		add_action( 'wp_ajax_wpps_br_backup_step', [ $this, 'ajax_backup_step' ], 1 );
		add_action( 'wp_ajax_wpps_br_backup_abort', [ $this, 'ajax_backup_abort' ], 1 );
		// Primary restore tick + legacy action name (same handler). Priority 1 runs before stray hooks on default 10.
		add_action( 'wp_ajax_wpps_br_restore_tick', [ $this, 'ajax_restore_step' ], 1 );
		add_action( 'wp_ajax_wpps_br_restore_step', [ $this, 'ajax_restore_step' ], 1 );
		// Logged-out continuation after database import: authorized only via HttpOnly restore cookie.
		add_action( 'wp_ajax_nopriv_wpps_br_restore_tick', [ $this, 'ajax_restore_step' ], 1 );
		add_action( 'wp_ajax_nopriv_wpps_br_restore_step', [ $this, 'ajax_restore_step' ], 1 );
		add_action( 'wp_ajax_wpps_br_restore_precheck', [ $this, 'ajax_restore_precheck' ], 1 );
		add_action( 'wp_ajax_wpps_br_restore_abort', [ $this, 'ajax_restore_abort' ], 1 );
		add_action( 'wp_ajax_nopriv_wpps_br_restore_abort', [ $this, 'ajax_restore_abort' ], 1 );
		add_action( 'wp_ajax_wpps_br_delete_backup', [ $this, 'ajax_delete_backup' ], 1 );
		add_action( 'wp_ajax_wpps_br_rescan', [ $this, 'ajax_rescan' ], 1 );
		add_action( 'wp_ajax_wpps_br_clear_stuck_lock', [ $this, 'ajax_clear_stuck_lock' ], 1 );
		add_action( 'wp_ajax_wpps_br_lock_debug', [ $this, 'ajax_lock_debug' ], 1 );
		add_action( 'wp_ajax_wpps_br_part_panel', [ $this, 'ajax_part_panel' ], 1 );
		add_action( 'wp_ajax_wpps_br_delete_part', [ $this, 'ajax_delete_part' ], 1 );
		add_action( 'wp_ajax_wpps_br_browse_part', [ $this, 'ajax_browse_part' ], 1 );
		add_action( 'wp_ajax_wpps_br_upload_backup', [ $this, 'ajax_upload_backup' ], 1 );
		add_action( 'wp_ajax_wpps_br_plupload', [ $this, 'ajax_plupload_backup' ], 1 );
		add_action( 'wp_ajax_wpps_br_finalize_upload', [ $this, 'ajax_finalize_upload' ], 1 );
		add_action( 'wp_ajax_wpps_br_bundle_prepare', [ $this, 'ajax_bundle_prepare' ], 1 );
		add_action( 'wp_ajax_wpps_br_clear_activity_log', [ $this, 'ajax_clear_activity_log' ], 1 );
		add_action( 'wp_ajax_wpps_br_delete_log_file', [ $this, 'ajax_delete_log_file' ], 1 );
		add_action( 'admin_post_wpps_br_download', [ $this, 'handle_download' ] );
		add_action( 'admin_post_wpps_br_download_bundle_ready', [ $this, 'handle_download_bundle_ready' ] );
		// Prefer admin-ajax for fetch/Range chunk downloads (fewer WAF/proxy empty-body issues than admin-post).
		add_action( 'wp_ajax_wpps_br_download_bundle_ready', [ $this, 'handle_download_bundle_ready' ] );
		add_action( 'admin_post_wpps_br_browse_zip_entry', [ $this, 'handle_browse_zip_entry' ] );
		add_action( 'admin_post_wpps_br_save_settings', [ $this, 'handle_save_settings' ] );
		add_action( 'admin_post_wpps_br_save_schedule', [ $this, 'handle_save_schedule' ] );
		add_action( 'admin_post_wpps_br_upload_backup', [ $this, 'handle_upload_backup' ] );
		add_action( 'admin_init', [ $this, 'maybe_serve_log_download' ], 1 );
		foreach ( [ 'load-wp-powersuite_page_powersuite-backup-restore', 'load-powersuite_page_powersuite-backup-restore' ] as $ld_hook ) {
		add_action( $ld_hook, [ $this, 'run_backup_screen_maintenance' ], 5 );
		}
	}

	/**
	 * Throttled housekeeping (upload temp files, log retention) — only when the Backup & Restore screen loads.
	 */
	public function run_backup_screen_maintenance(): void {
		$this->cleanup_upload_staging();
		$this->maybe_prune_activity_log();
	}

	public function register_menu(): void {
		add_submenu_page(
			'powersuite',
			__( 'Backup & Restore', 'wp-powersuite' ),
			__( 'Backup & Restore', 'wp-powersuite' ),
			$this->menu_capability(),
			'powersuite-backup-restore',
			[ $this, 'render_page' ]
		);
	}

	public function enqueue( string $hook ): void {
		$hooks = [
			'wp-powersuite_page_powersuite-backup-restore',
			'powersuite_page_powersuite-backup-restore',
		];
		if ( ! in_array( $hook, $hooks, true ) ) {
			return;
		}

		/*
		 * On the restore screen, disable the WP auth-check (heartbeat session monitor).
		 * The database import replaces wp_usermeta which invalidates the admin's session
		 * tokens — WP then pops up "Your session has expired" even though the restore
		 * continues fine via its own continuation secret. Suppressing this prevents the
		 * confusing login modal from appearing mid-restore.
		 */
		if ( isset( $_GET['br_restore'] ) && is_string( $_GET['br_restore'] ) ) {
			wp_dequeue_script( 'wp-auth-check' );
			wp_deregister_script( 'wp-auth-check' );
			wp_dequeue_style( 'wp-auth-check' );
			wp_deregister_style( 'wp-auth-check' );
			remove_action( 'admin_enqueue_scripts', 'wp_auth_check_load' );
		}

		wp_enqueue_style( 'dashicons' );
		$base = defined( 'WP_POWERSUITE_PLUGIN_URL' ) ? WP_POWERSUITE_PLUGIN_URL : '';
		$version = defined( 'WP_POWERSUITE_VERSION' ) ? WP_POWERSUITE_VERSION : self::VERSION;
		if ( $base !== '' && ! wp_style_is( 'wp-powersuite-admin', 'registered' ) ) {
			wp_register_style(
				'wp-powersuite-admin',
				$base . 'src/Admin/Assets/app.css',
				[ 'wp-admin' ],
				$version
			);
		}
		if ( wp_style_is( 'wp-powersuite-admin', 'registered' ) ) {
			wp_enqueue_style( 'wp-powersuite-admin' );
		}
		$deps = wp_style_is( 'wp-powersuite-admin', 'registered' ) ? [ 'wp-powersuite-admin' ] : [ 'wp-admin' ];
		wp_enqueue_style(
			'wpps-backup-restore',
			$base . 'modules/backup-restore/assets/backup-restore-admin.css',
			$deps,
			self::VERSION
		);
		wp_enqueue_script(
			'wpps-backup-restore',
			$base . 'modules/backup-restore/assets/backup-restore-admin.js',
			[ 'jquery', 'plupload-all' ],
			self::VERSION,
			true
		);
		wp_localize_script(
			'wpps-backup-restore',
			'wppsBackupRestore',
			[
				'ajaxUrl'       => admin_url( 'admin-ajax.php' ),
				'adminPostUrl'  => admin_url( 'admin-post.php' ),
				'moduleVersion' => self::VERSION,
				'nonce'         => wp_create_nonce( 'wpps_br' ),
				'backupRestoreMainUrl' => admin_url( 'admin.php?page=powersuite-backup-restore' ),
				'isRestoreScreen' => isset( $_GET['page'], $_GET['br_restore'] ) && 'powersuite-backup-restore' === $_GET['page'] && is_string( $_GET['br_restore'] ),
				'restoreParts'  => [
					'database' => __( 'Database', 'wp-powersuite' ),
					'plugins'  => __( 'Plugins', 'wp-powersuite' ),
					'themes'   => __( 'Themes', 'wp-powersuite' ),
					'uploads'  => __( 'Uploads', 'wp-powersuite' ),
					'others'   => __( 'Other wp-content files', 'wp-powersuite' ),
				],
				'uploadNonce'   => wp_create_nonce( 'wpps_br_uploader' ),
				'uploadMaxBytes' => (int) wp_max_upload_size(),
				'startedPrefix' => __( 'Started:', 'wp-powersuite' ),
				'strings'    => [
					'working'       => __( 'Creating backup…', 'wp-powersuite' ),
					'errorGeneric'  => __( 'Something went wrong.', 'wp-powersuite' ),
					'timeoutHint'   => __( 'The server stopped the request (timeout or connection). Try “Database only” first, or ask your host to raise max_execution_time.', 'wp-powersuite' ),
					'confirmDelete' => __( 'Delete this backup and all its files?', 'wp-powersuite' ),
					'bulkDelete'    => __( 'Delete selected backups?', 'wp-powersuite' ),
					'confirmStop'   => __( 'Stop this backup and discard the partial files for this job?', 'wp-powersuite' ),
					'confirmDeletePart' => __( 'Delete this file from the server only? (You can upload it again later.)', 'wp-powersuite' ),
					'lastActivity'  => __( 'Last activity', 'wp-powersuite' ),
					'showLog'       => __( 'Show log', 'wp-powersuite' ),
					'hideLog'       => __( 'Hide log', 'wp-powersuite' ),
					'backupLogEmpty' => __( 'No log lines yet for this job. Status messages will appear here as the backup runs.', 'wp-powersuite' ),
					'restoreLogEmpty' => __( 'No log lines yet for this restore. Status messages will appear here as the restore runs.', 'wp-powersuite' ),
					'partPanelTitle' => __( 'Backup part', 'wp-powersuite' ),
					'downloadComputer' => __( 'Download to your computer', 'wp-powersuite' ),
					'deleteFromServer' => __( 'Delete from your web server', 'wp-powersuite' ),
					'browseContents'   => __( 'Browse contents', 'wp-powersuite' ),
					'closePanel'       => __( 'Close', 'wp-powersuite' ),
					'browseLoading'    => __( 'Loading…', 'wp-powersuite' ),
					'browseSqlHint'    => __( 'This is a SQL dump file. Download it to open in a text editor or import with phpMyAdmin.', 'wp-powersuite' ),
					'browseZipFail'    => __( 'Could not read this archive.', 'wp-powersuite' ),
					'browseTruncated'  => __( 'List truncated; download the archive to see everything.', 'wp-powersuite' ),
					'browseEmptyArchive'    => __( 'This archive has no file listing (empty or unreadable).', 'wp-powersuite' ),
					'fmUp'             => __( 'Up', 'wp-powersuite' ),
					'fmRoot'           => __( 'Archive root', 'wp-powersuite' ),
					'fmColName'        => __( 'Name', 'wp-powersuite' ),
					'fmColSize'        => __( 'Size', 'wp-powersuite' ),
					'fmColType'        => __( 'Type', 'wp-powersuite' ),
					'fmTypeFolder'     => __( 'Folder', 'wp-powersuite' ),
					'fmTypeFile'       => __( 'File', 'wp-powersuite' ),
					'fmSelected'       => __( 'Selected', 'wp-powersuite' ),
					'fmOpenFolder'     => __( 'Open folder', 'wp-powersuite' ),
					'fmDownloadSql'    => __( 'Download SQL file', 'wp-powersuite' ),
					'fmEmptyFolder'    => __( 'This folder is empty.', 'wp-powersuite' ),
					'confirmClearStuckLock' => __( 'Clear backup and restore locks and in-progress job state? Partial files in the backup folder will stay on the server until you delete that folder.', 'wp-powersuite' ),
					'clearStuckLock'        => __( 'Clear stuck backup / restore lock', 'wp-powersuite' ),
					'bulkSelectAll'        => __( 'Select all', 'wp-powersuite' ),
					'bulkDeselectAll'      => __( 'Deselect', 'wp-powersuite' ),
					'uploadSending'        => __( 'Sending file to server…', 'wp-powersuite' ),
					'uploadServerProcessing' => __( 'Upload complete — finishing on server (unpack / import). Large bundles can take several minutes.', 'wp-powersuite' ),
					'uploadUploading'      => __( 'Uploading backup…', 'wp-powersuite' ),
					'uploadMultiTitle'     => __( 'Uploading backup (file %1$s of %2$s)…', 'wp-powersuite' ),
					'uploadNFilesQueued'   => __( '%1$d files queued: %2$s', 'wp-powersuite' ),
					'uploadFinishing'      => __( 'Finishing on server…', 'wp-powersuite' ),
					'uploadPercent'        => __( '%s% sent', 'wp-powersuite' ),
					'restoreRailPrepare'  => __( 'Prepare', 'wp-powersuite' ),
					'restoreRailRun'      => __( 'Run', 'wp-powersuite' ),
					'restoreVerifyContinue' => __( 'Verify selected files', 'wp-powersuite' ),
					'restoreChangeSelection' => __( 'Change selection', 'wp-powersuite' ),
					'uploadDropHint'       => __( 'Drag one or more backup files here, or click to browse (multi-select).', 'wp-powersuite' ),
					'uploadBrowse'         => __( 'Select files', 'wp-powersuite' ),
					'uploadPluploadErr'    => __( 'Upload failed.', 'wp-powersuite' ),
					'uploadQueue'          => __( 'Queue', 'wp-powersuite' ),
					'uploadChunkUnsupported' => __( 'Very large uploads may need a higher PHP upload_max_filesize / post_max_size on the server.', 'wp-powersuite' ),
					'privacyDownloadWarning' => __( 'Backup files contain your full database (including user data, API keys, and passwords stored in wp_options), uploads, and site files. Store and share them securely.', 'wp-powersuite' ),
					'privacyRestoreWarning'  => __( 'Restoring overwrites live site data. Keep a separate backup elsewhere before you continue.', 'wp-powersuite' ),
					'restoreTitle'        => __( 'Restore from backup', 'wp-powersuite' ),
					'restoreIntro'        => __( 'This will overwrite the selected parts of the current site. Make sure you have a fresh backup elsewhere if something goes wrong.', 'wp-powersuite' ),
					'restorePick'         => __( 'Choose what to restore:', 'wp-powersuite' ),
					'restoreStart'        => __( 'Restore now', 'wp-powersuite' ),
					'restoreWorking'      => __( 'Restoring…', 'wp-powersuite' ),
					'confirmRestore'      => __( 'Restore these items into the live site now? This cannot be undone.', 'wp-powersuite' ),
					'precheckProceed'     => __( 'Continue anyway?', 'wp-powersuite' ),
					'restorePrecheckOk'   => __( 'The selected backup files are on this server and validated. Press Restore to begin.', 'wp-powersuite' ),
					'restoreModalLead'    => __( 'The restore operation has begun (%s). Do not close this page until it reports completion.', 'wp-powersuite' ),
					'restorePhaseVerify'  => __( 'Verifying', 'wp-powersuite' ),
					'restorePhaseDatabase' => __( 'Database', 'wp-powersuite' ),
					'restorePhaseFiles'  => __( 'Files', 'wp-powersuite' ),
					'restorePhaseCleaning' => __( 'Cleaning', 'wp-powersuite' ),
					'restorePhaseFinished' => __( 'Finished', 'wp-powersuite' ),
					'confirmStopRestore'  => __( 'Stop this restore? Partial files or database changes may already have been applied.', 'wp-powersuite' ),
					'restoreComplete'        => __( 'Restore completed successfully.', 'wp-powersuite' ),
					'restoreReloginHint'     => __( 'If the database was replaced, log in again. Download the restore log if you need a record.', 'wp-powersuite' ),
					'restoreDownloadLog'     => __( 'Download restore log', 'wp-powersuite' ),
					'restoreReloadAdmin'     => __( 'Reload this page', 'wp-powersuite' ),
					'restoreMalformedJson'   => __( 'The server returned invalid JSON (often PHP warnings or zlib compression breaking long restore responses). Check restore.log in the backup folder and PHP error_log.', 'wp-powersuite' ),
					'restoreZlibHint'        => __( 'If you see zlib warnings, disable PHP zlib.output_compression for this site (php.ini / .user.ini / host panel). That fixes many “Something went wrong” mid-restore errors.', 'wp-powersuite' ),
					'restorePartialHint'     => __( 'If the database ran but file restore stopped, WordPress may have deactivated plugins that were missing on disk. Run the same full restore again with Database + all file parts selected so the database matches the files.', 'wp-powersuite' ),
					'bundleModalTitle'       => __( 'Preparing full backup ZIP', 'wp-powersuite' ),
					'bundleModalLead'        => __( 'The zip file is being created on the server. Please keep this page open. When it is ready, your browser will start the download.', 'wp-powersuite' ),
					'bundleDownloadTitle'    => __( 'Download started', 'wp-powersuite' ),
					'bundleDownloadLead'     => __( 'Your browser is downloading the file now. Check the downloads bar for progress — you can close this dialog.', 'wp-powersuite' ),
					'bundleDownloadLeadSized' => __( 'Your browser is downloading %1$s now. Check the downloads bar for progress — you can close this dialog.', 'wp-powersuite' ),
					'bundleCancel'           => __( 'Cancel', 'wp-powersuite' ),
					'bundleClose'            => __( 'Close', 'wp-powersuite' ),
					'gatewayTimeoutHint'     => __( 'Server timeout or gateway error (%s). The host stopped a long request. Try again, use “Download all parts”, or ask your host to raise max_execution_time / FastCGI read timeout.', 'wp-powersuite' ),
				],
				'plupload'          => [
					'runtimes'         => 'html5,html4',
					'browse_button'    => 'wpps-br-plupload-browse',
					'container'        => 'wpps-br-plupload-ui',
					'drop_element'     => 'wpps-br-plupload-drop',
					'file_data_name'   => 'async-upload',
					'url'              => admin_url( 'admin-ajax.php', 'relative' ),
					'multipart'        => true,
					'multi_selection'  => true,
					'max_file_size'    => 0,
					// Cap chunks ~2 MiB. Using upload_max/8 produced 32–64 MiB chunks on many hosts → proxy/FPM 503.
					'chunk_size'       => (string) self::plupload_chunk_bytes() . 'b',
					'max_retries'      => 3,
					'filters'          => [
						[
							'title'      => __( 'Backups', 'wp-powersuite' ),
							'extensions' => 'zip,sql',
						],
					],
					'multipart_params' => [
						'_ajax_nonce' => wp_create_nonce( 'wpps_br_uploader' ),
						'action'      => 'wpps_br_plupload',
					],
				],
			]
		);
	}

	/**
	 * Safe Plupload chunk size for shared hosting / nginx / Cloudflare (bytes).
	 */
	private static function plupload_chunk_bytes(): int {
		$php_max = (int) wp_max_upload_size();
		$chunk   = (int) floor( $php_max / 4 );
		if ( $chunk < 256 * 1024 ) {
			$chunk = 256 * 1024;
		}
		// Hard cap: large chunks commonly trigger HTTP 503 from reverse proxies.
		$chunk = min( $chunk, 2 * 1024 * 1024 );
		/**
		 * Filter Plupload chunk size in bytes (clamped 256 KiB–8 MiB).
		 *
		 * @param int $chunk Chunk size in bytes.
		 */
		$filtered = (int) apply_filters( 'wpps_br_plupload_chunk_bytes', $chunk );
		return max( 256 * 1024, min( 8 * 1024 * 1024, $filtered > 0 ? $filtered : $chunk ) );
	}

	public function render_page(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}
		if ( ! $this->isLicenseActive() ) {
			?>
			<div class="wrap">
				<h1><?php esc_html_e( 'Backup & Restore', 'wp-powersuite' ); ?></h1>
				<div class="notice notice-warning inline" style="margin:0;">
					<p>
						<?php esc_html_e( 'Backup & Restore requires an active license. Activate your license to create and restore backups.', 'wp-powersuite' ); ?>
						<a href="<?php echo esc_url( admin_url( 'admin.php?page=powersuite-license' ) ); ?>"><?php esc_html_e( 'Activate license', 'wp-powersuite' ); ?></a>
					</p>
				</div>
			</div>
			<?php
			return;
		}

		$service   = new Includes\BackupService();
		$tab       = isset( $_GET['br_tab'] ) ? sanitize_key( wp_unslash( $_GET['br_tab'] ) ) : 'main';
		if ( ! in_array( $tab, [ 'settings', 'schedule' ], true ) ) {
			$tab = 'main';
		}
		$br_settings       = Includes\BackupService::get_settings();
		$settings_updated  = isset( $_GET['updated'] ) && '1' === sanitize_key( wp_unslash( $_GET['updated'] ) );
		$history            = Includes\BackupService::get_history();
		$storage_ok         = $service->has_safe_backup_storage();
		$storage_error      = $storage_ok ? '' : $service->get_storage_error_message();
		$legacy_roots       = $service->get_legacy_backup_roots();
		$disk_use  = $service->get_total_backup_bytes();
		$view_log  = isset( $_GET['view_log'] ) ? sanitize_file_name( wp_unslash( $_GET['view_log'] ) ) : '';
		$log_text  = '';
		if ( $view_log && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'wpps_br_view_log_' . $view_log ) ) {
			if ( Includes\BackupService::UPLOAD_ACTIVITY_LOG_BACKUP_ID === $view_log ) {
				$lf = $service->get_upload_activity_log_abs_path();
			} else {
				$dir = $service->locate_backup_dir( $view_log );
				$lf  = $dir !== '' ? $dir . 'backup.log' : '';
			}
			if ( $lf !== '' && is_readable( $lf ) && $service->is_path_under_managed_storage( $lf ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
				$log_text = (string) file_get_contents( $lf );
			}
		}

		$uid        = get_current_user_id();
		$br_flash   = ( $uid > 0 ) ? get_transient( 'wpps_br_flash_' . $uid ) : false;
		if ( is_array( $br_flash ) && $uid > 0 ) {
			delete_transient( 'wpps_br_flash_' . $uid );
		} else {
			$br_flash = null;
		}
		$br_upload_sets    = $service->list_backup_dirs_for_upload();
		$upload_max_bytes  = (int) wp_max_upload_size();
		$br_module_version = self::VERSION;
		$br_engine_build   = Includes\BackupService::ENGINE_BUILD;
		$br_log_files      = $service->get_log_files();

		$br_restore_ctx    = null;
		$br_restore_error  = '';
		$br_restore_screen = false;
		if ( 'main' === $tab && isset( $_GET['br_restore'] ) && is_string( $_GET['br_restore'] ) ) {
			$br_restore_screen = true;
			$rid               = sanitize_file_name( wp_unslash( $_GET['br_restore'] ) );
			$nonce_val         = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
			if ( ! $rid || ! wp_verify_nonce( $nonce_val, 'wpps_br_open_restore_' . $rid ) ) {
				$br_restore_error = __( 'This restore link is invalid or has expired. Go back and choose Restore again.', 'wp-powersuite' );
			} else {
				$disk = $service->list_components_on_disk( $rid );
				if ( count( $disk ) === 0 ) {
					$br_restore_error = __( 'No backup files were found on the server for this set.', 'wp-powersuite' );
				} else {
					$hist_lbl = $rid;
					foreach ( $history as $row ) {
						if ( isset( $row['id'] ) && (string) $row['id'] === $rid ) {
							$ts = (int) ( $row['created'] ?? 0 );
							$hist_lbl = $ts ? date_i18n( 'M d, Y H:i', $ts ) : $rid;
							break;
						}
					}
					$br_restore_ctx = [
						'id'         => $rid,
						'label'      => $hist_lbl,
						'components' => $disk,
					];
				}
			}
		}

		include __DIR__ . '/views/admin-page.php';
	}

	/**
	 * Resolve target backup folder + part from POST (shared by form upload + plupload).
	 *
	 * @return array{success: true, srv: Includes\BackupService, backup_id: string, forced: ?string}|array{success: false, message: string}
	 */
	private function prepare_backup_upload_job( array $post ): array {
		$raw_set = isset( $post['backup_set'] ) ? trim( (string) $post['backup_set'] ) : '__new__';
		$srv     = new Includes\BackupService();

		if ( '__new__' === $raw_set || '' === $raw_set ) {
			$backup_id = $srv->generate_unique_backup_id( 'backup-upload' );
		} else {
			$choice = sanitize_file_name( $raw_set );
			if ( ! $srv->is_allowed_backup_folder_id( $choice ) ) {
				return [ 'success' => false, 'message' => __( 'Invalid backup folder selected.', 'wp-powersuite' ) ];
			}
			$backup_id = $choice;
		}

		// Respect Advanced → "Backup part" (Plupload multipart_params.component + form uploads).
		$forced = null;
		$raw_comp = isset( $post['component'] ) ? sanitize_key( (string) $post['component'] ) : '';
		if ( $raw_comp !== '' && 'auto' !== $raw_comp ) {
			if ( in_array( $raw_comp, [ 'database', 'plugins', 'themes', 'uploads', 'others', 'bundle' ], true ) ) {
				$forced = $raw_comp;
			}
		}
		/**
		 * Allow overriding forced upload component (rare). Return null for automatic detection.
		 *
		 * @param null|string            $forced Forced component: bundle|database|plugins|themes|uploads|others|null.
		 * @param array<string, mixed>   $post   Sanitized POST.
		 * @param Includes\BackupService $srv  Service instance.
		 */
		$forced = \apply_filters( 'wpps_br_upload_forced_component', $forced, $post, $srv );
		if ( is_string( $forced ) && '' !== $forced ) {
			$forced = sanitize_key( $forced );
			if ( 'auto' === $forced ) {
				$forced = null;
			} elseif (
				'bundle' !== $forced
				&& ! in_array( $forced, [ 'database', 'plugins', 'themes', 'uploads', 'others' ], true )
			) {
				$forced = null;
			}
		} else {
			$forced = null;
		}

		return [
			'success'   => true,
			'srv'       => $srv,
			'backup_id' => $backup_id,
			'forced'    => $forced,
		];
	}

	/**
	 * Allow .sql / .zip through wp_handle_upload (some hosts / WP builds are strict).
	 *
	 * @param array<string, string> $mimes Mime types.
	 * @return array<string, string>
	 */
	public function filter_br_plupload_mimes( array $mimes ): array {
		$mimes['sql'] = 'application/sql';
		$mimes['zip'] = 'application/zip';
		return $mimes;
	}

	/**
	 * Point wp_handle_upload at a staging folder under the backup root (Updraft-style).
	 *
	 * @param array<string, string> $uploads Upload dir array from WordPress.
	 * @return array<string, string>
	 */
	public function filter_br_plupload_upload_dir( array $uploads ): array {
		$srv = new Includes\BackupService();
		if ( ! $srv->ensure_backup_root() ) {
			// Fail closed: never stage backup uploads into the public uploads directory.
			$uploads['error'] = $srv->get_storage_error_message();

			return $uploads;
		}
		$root = trailingslashit( wp_normalize_path( $srv->get_backup_root() ) );
		$dir  = $root . '.wpps-br-staging';
		if ( ! wp_mkdir_p( $dir ) ) {
			return $uploads;
		}
		$dir              = wp_normalize_path( $dir );
		$uploads['path']  = $dir;
		$uploads['url']   = '';
		$uploads['subdir'] = '';
		$uploads['basedir'] = $dir;
		$uploads['baseurl'] = '';
		return $uploads;
	}

	/**
	 * Admin-ajax upload via Plupload + wp_handle_upload (matches UpdraftPlus pattern more closely than raw $_FILES).
	 */
	public function ajax_plupload_backup(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_send_json_error( [ 'message' => __( 'You do not have permission to upload.', 'wp-powersuite' ) ] );
		}
		if ( ! check_ajax_referer( 'wpps_br_uploader', '_ajax_nonce', false ) ) {
			wp_send_json_error(
				[
					'message' => __( 'Security check failed. Reload this page and try the upload again.', 'wp-powersuite' ),
				]
			);
		}

		if ( empty( $_FILES['async-upload'] ) || ! is_array( $_FILES['async-upload'] ) ) {
			wp_send_json_error( [ 'message' => __( 'No file was received. Try again or use the fallback form below.', 'wp-powersuite' ) ] );
		}

		$post       = wp_unslash( $_POST );
		$chunks     = isset( $post['chunks'] ) ? (int) $post['chunks'] : 0;
		$chunk      = isset( $post['chunk'] ) ? (int) $post['chunk'] : 0;
		// Plupload sends the real client filename in POST `name`; $_FILES['name'] is often "blob" in some browsers.
		$orig_name = '';
		if ( ! empty( $post['name'] ) && is_string( $post['name'] ) ) {
			$orig_name = trim( wp_unslash( $post['name'] ) );
		}
		if ( '' === $orig_name && isset( $_FILES['async-upload']['name'] ) ) {
			$orig_name = (string) $_FILES['async-upload']['name'];
		}
		$is_chunked = $chunks > 1;

		if ( function_exists( 'set_time_limit' ) ) {
			// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
			@set_time_limit( 300 );
		}

		if ( $is_chunked ) {
			$this->handle_chunked_plupload( $post, $chunks, $chunk, $orig_name );
			return;
		}

		$prep = $this->prepare_backup_upload_job( $post );
		if ( empty( $prep['success'] ) ) {
			wp_send_json_error( [ 'message' => (string) ( $prep['message'] ?? '' ) ] );
		}

		/** @var Includes\BackupService $srv */
		$srv       = $prep['srv'];
		$backup_id = (string) $prep['backup_id'];
		$forced    = $prep['forced'];

		add_filter( 'upload_dir', [ $this, 'filter_br_plupload_upload_dir' ] );
		add_filter( 'upload_mimes', [ $this, 'filter_br_plupload_mimes' ] );
		$overrides = [
			'test_form' => false,
			'test_type' => false,
		];
		$status    = wp_handle_upload( $_FILES['async-upload'], $overrides );
		remove_filter( 'upload_mimes', [ $this, 'filter_br_plupload_mimes' ] );
		remove_filter( 'upload_dir', [ $this, 'filter_br_plupload_upload_dir' ] );

		if ( isset( $status['error'] ) ) {
			wp_send_json_error( [ 'message' => (string) $status['error'] ] );
		}

		if ( empty( $status['file'] ) ) {
			wp_send_json_error( [ 'message' => __( 'Upload did not return a file path.', 'wp-powersuite' ) ] );
		}

		$result = $srv->ingest_staged_backup_part( $backup_id, (string) $status['file'], $orig_name, $forced );
		if ( empty( $result['success'] ) ) {
			$srv->append_upload_activity_log(
				'FAILED ingest (single request): ' . (string) ( $result['message'] ?? '' ) . ' file=' . $orig_name
			);
			wp_send_json_error( [ 'message' => (string) ( $result['message'] ?? __( 'Upload failed.', 'wp-powersuite' ) ) ] );
		}
		$srv->append_upload_activity_log(
			'OK ingest (single request): ' . (string) ( $result['component'] ?? '' ) . ' -> ' . (string) ( $result['backup_id'] ?? $backup_id ) . ' file=' . $orig_name
		);

		$final_id = isset( $result['backup_id'] ) ? (string) $result['backup_id'] : $backup_id;
		$srv->ensure_backup_in_history_from_disk( $final_id );
		$srv->sync_backup_metadata_from_disk( $final_id );

		$comp   = (string) ( $result['component'] ?? '' );
		$labels = [
			'database' => __( 'Database', 'wp-powersuite' ),
			'plugins'  => __( 'Plugins', 'wp-powersuite' ),
			'themes'   => __( 'Themes', 'wp-powersuite' ),
			'uploads'  => __( 'Uploads', 'wp-powersuite' ),
			'others'   => __( 'Others', 'wp-powersuite' ),
			'bundle'   => __( 'Full backup bundle', 'wp-powersuite' ),
		];
		$label = $labels[ $comp ] ?? $comp;
		/* translators: 1: part name, 2: backup folder id */
		$msg = sprintf( __( 'Uploaded %1$s into %2$s.', 'wp-powersuite' ), $label, $final_id );

		$uid = get_current_user_id();
		if ( $uid > 0 ) {
			set_transient( 'wpps_br_flash_' . $uid, [ 'type' => 'success', 'message' => $msg ], 90 );
		}

		wp_send_json_success(
			[
				'message'   => $msg,
				'backup_id' => $final_id,
				'component' => $comp,
			]
		);
	}

	/**
	 * Handle a single chunk from a plupload chunked upload.
	 *
	 * @param array<string, mixed> $post   Unserialized POST data.
	 * @param int                  $chunks Total chunks.
	 * @param int                  $chunk  Current chunk index (0-based).
	 * @param string               $orig   Original filename.
	 */
	private function handle_chunked_plupload( array $post, int $chunks, int $chunk, string $orig ): void {
		$service = new Includes\BackupService();
		if ( ! $service->ensure_backup_root() ) {
			wp_send_json_error(
				[
					'message' => $service->get_storage_error_message() !== ''
						? $service->get_storage_error_message()
						: __( 'Backup directory is not writable.', 'wp-powersuite' ),
				]
			);
		}
		$staging   = trailingslashit( $service->get_backup_root() ) . '.upload-staging';
		$safe_name = sanitize_file_name( $orig );
		if ( '' === $safe_name ) {
			wp_send_json_error( [ 'message' => __( 'Invalid file name.', 'wp-powersuite' ) ] );
		}

		if ( ! is_dir( $staging ) ) {
			wp_mkdir_p( $staging );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $staging . '/.htaccess', "Deny from all\n" );
		}

		$part_path  = $staging . '/' . $safe_name . '.part';
		$tmp_upload = isset( $_FILES['async-upload']['tmp_name'] ) ? (string) $_FILES['async-upload']['tmp_name'] : '';
		$err_code   = isset( $_FILES['async-upload']['error'] ) ? (int) $_FILES['async-upload']['error'] : UPLOAD_ERR_NO_FILE;

		if ( UPLOAD_ERR_OK !== $err_code || '' === $tmp_upload || ! is_uploaded_file( $tmp_upload ) ) {
			wp_send_json_error( [
				'message' => sprintf(
					/* translators: 1: chunk number, 2: total chunks */
					__( 'Chunk %1$d/%2$d was not received correctly. Please retry.', 'wp-powersuite' ),
					$chunk + 1,
					$chunks
				),
			] );
		}

		if ( 0 === $chunk ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $part_path );
		} elseif ( ! is_file( $part_path ) ) {
			wp_send_json_error( [ 'message' => __( 'Earlier chunk data is missing. Please retry the whole file.', 'wp-powersuite' ) ] );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$out = fopen( $part_path, 'ab' );
		if ( false === $out ) {
			wp_send_json_error( [ 'message' => __( 'Could not write chunk to staging.', 'wp-powersuite' ) ] );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$in = fopen( $tmp_upload, 'rb' );
		if ( false === $in ) {
			fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			wp_send_json_error( [ 'message' => __( 'Could not read uploaded chunk.', 'wp-powersuite' ) ] );
		}
		while ( ! feof( $in ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			fwrite( $out, fread( $in, 1024 * 1024 ) );
		}
		fclose( $in );  // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose

		$assembled = is_file( $part_path ) ? (int) filesize( $part_path ) : 0;
		$service->append_upload_activity_log(
			sprintf(
				'Chunk %1$d/%2$d for "%3$s" (assembled %4$s bytes).',
				$chunk + 1,
				$chunks,
				$orig,
				number_format_i18n( $assembled )
			)
		);

		if ( $chunk < $chunks - 1 ) {
			wp_send_json_success( [
				'message' => sprintf(
					/* translators: 1: chunk number, 2: total chunks */
					__( 'Chunk %1$d/%2$d received.', 'wp-powersuite' ),
					$chunk + 1,
					$chunks
				),
			] );
			return;
		}

		$final_path = $staging . '/' . $safe_name;
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! @rename( $part_path, $final_path ) ) {
			wp_send_json_error( [ 'message' => __( 'Could not finalize assembled file.', 'wp-powersuite' ) ] );
		}

		/*
		 * Do not unpack/import on this request — it shared the last chunk POST and commonly hits
		 * proxy/FPM 503 on large bundles. Hand off to a dedicated finalize AJAX call instead.
		 */
		$ftoken  = strtolower( wp_generate_password( 24, false, false ) );
		$payload = [
			'user_id'   => get_current_user_id(),
			'path'      => $final_path,
			'orig'      => $orig,
			'post'      => [
				'backup_set' => isset( $post['backup_set'] ) ? (string) $post['backup_set'] : '__new__',
				'component'  => isset( $post['component'] ) ? (string) $post['component'] : 'auto',
			],
		];
		set_transient( 'wpps_br_upfinal_' . $ftoken, $payload, HOUR_IN_SECONDS );
		$service->append_upload_activity_log(
			sprintf(
				'Assembled "%1$s" (%2$s bytes) — waiting for finalize request.',
				$orig,
				number_format_i18n( is_file( $final_path ) ? (int) filesize( $final_path ) : 0 )
			)
		);

		wp_send_json_success(
			[
				'message'        => __( 'Upload complete — finishing on server…', 'wp-powersuite' ),
				'needs_finalize' => true,
				'finalize_token' => $ftoken,
			]
		);
	}

	/**
	 * Second-step import after chunked upload assembly (avoids 503 on the last chunk request).
	 */
	public function ajax_finalize_upload(): void {
		$this->prepare_large_ajax_json_response();
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_send_json_error( [ 'message' => __( 'You do not have permission to upload.', 'wp-powersuite' ) ], 403 );
		}
		if ( ! check_ajax_referer( 'wpps_br_uploader', '_ajax_nonce', false ) && ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Security check failed. Reload this page and try again.', 'wp-powersuite' ) ], 403 );
		}
		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( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		}

		$token = isset( $_POST['finalize_token'] ) ? sanitize_key( wp_unslash( $_POST['finalize_token'] ) ) : '';
		if ( '' === $token ) {
			wp_send_json_error( [ 'message' => __( 'Missing finalize token.', 'wp-powersuite' ) ] );
		}
		$row = get_transient( 'wpps_br_upfinal_' . $token );
		delete_transient( 'wpps_br_upfinal_' . $token );
		if ( ! is_array( $row ) ) {
			wp_send_json_error( [ 'message' => __( 'Upload session expired. Please upload the file again.', 'wp-powersuite' ) ] );
		}
		if ( (int) ( $row['user_id'] ?? 0 ) !== get_current_user_id() ) {
			wp_send_json_error( [ 'message' => __( 'Forbidden.', 'wp-powersuite' ) ], 403 );
		}

		$final_path = isset( $row['path'] ) ? (string) $row['path'] : '';
		$orig       = isset( $row['orig'] ) ? (string) $row['orig'] : basename( $final_path );
		$post_meta  = isset( $row['post'] ) && is_array( $row['post'] ) ? $row['post'] : [];
		if ( '' === $final_path || ! is_readable( $final_path ) ) {
			wp_send_json_error( [ 'message' => __( 'Assembled upload file is missing. Please retry.', 'wp-powersuite' ) ] );
		}

		$service = new Includes\BackupService();
		if ( ! $service->is_path_under_managed_storage( $final_path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $final_path );
			wp_send_json_error( [ 'message' => __( 'Invalid upload path.', 'wp-powersuite' ) ] );
		}

		$prep = $this->prepare_backup_upload_job( $post_meta );
		if ( empty( $prep['success'] ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $final_path );
			wp_send_json_error( [ 'message' => (string) ( $prep['message'] ?? '' ) ] );
		}

		/** @var Includes\BackupService $srv */
		$srv       = $prep['srv'];
		$backup_id = (string) $prep['backup_id'];
		$forced    = $prep['forced'];

		$srv->append_upload_activity_log(
			sprintf(
				'Finalizing "%1$s" (%2$s bytes) — importing…',
				$orig,
				number_format_i18n( is_file( $final_path ) ? (int) filesize( $final_path ) : 0 )
			)
		);

		$result = $srv->ingest_staged_backup_part( $backup_id, $final_path, $orig, $forced );
		if ( empty( $result['success'] ) ) {
			$srv->append_upload_activity_log(
				'FAILED ingest (chunked): ' . (string) ( $result['message'] ?? '' ) . ' file=' . $orig
			);
			wp_send_json_error( [ 'message' => (string) ( $result['message'] ?? __( 'Upload failed.', 'wp-powersuite' ) ) ] );
		}

		$final_id = isset( $result['backup_id'] ) ? (string) $result['backup_id'] : $backup_id;
		$srv->ensure_backup_in_history_from_disk( $final_id );
		$srv->sync_backup_metadata_from_disk( $final_id );

		$comp   = (string) ( $result['component'] ?? '' );
		$labels = [
			'database' => __( 'Database', 'wp-powersuite' ),
			'plugins'  => __( 'Plugins', 'wp-powersuite' ),
			'themes'   => __( 'Themes', 'wp-powersuite' ),
			'uploads'  => __( 'Uploads', 'wp-powersuite' ),
			'others'   => __( 'Others', 'wp-powersuite' ),
			'bundle'   => __( 'Full backup bundle', 'wp-powersuite' ),
		];
		$label = $labels[ $comp ] ?? $comp;
		/* translators: 1: part name, 2: backup folder id */
		$msg = sprintf( __( 'Uploaded %1$s into %2$s.', 'wp-powersuite' ), $label, $final_id );

		$srv->append_upload_activity_log( 'OK ingest (chunked): ' . $comp . ' -> ' . $final_id . ' file=' . $orig );

		$uid = get_current_user_id();
		if ( $uid > 0 ) {
			set_transient( 'wpps_br_flash_' . $uid, [ 'type' => 'success', 'message' => $msg ], 90 );
		}

		wp_send_json_success( [
			'message'   => $msg,
			'backup_id' => $final_id,
			'component' => $comp,
		] );
	}

	/**
	 * Clean up abandoned chunked upload staging files older than 2 hours.
	 */
	public function cleanup_upload_staging(): void {
		if ( false !== get_transient( 'wpps_br_upload_staging_cleaned' ) ) {
			return;
		}
		set_transient( 'wpps_br_upload_staging_cleaned', '1', HOUR_IN_SECONDS );
		$service = new Includes\BackupService();
		$root    = $service->get_backup_root();
		if ( $root === '' ) {
			return;
		}
		$staging = trailingslashit( $root ) . '.upload-staging';
		if ( ! is_dir( $staging ) ) {
			return;
		}
		$cutoff = time() - 2 * HOUR_IN_SECONDS;
		$items  = glob( $staging . '/*.part' );
		if ( ! is_array( $items ) ) {
			return;
		}
		foreach ( $items as $f ) {
			if ( is_file( $f ) && filemtime( $f ) < $cutoff ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $f );
			}
		}
	}

	/**
	 * Prune old log files from backup directories (throttled: once per 6 hours).
	 */
	public function maybe_prune_activity_log(): void {
		if ( false !== get_transient( 'wpps_br_activity_log_pruned' ) ) {
			return;
		}
		set_transient( 'wpps_br_activity_log_pruned', '1', 6 * HOUR_IN_SECONDS );
		$settings = Includes\BackupService::get_settings();
		$days     = (int) ( $settings['log_retain_days'] ?? 30 );
		if ( $days > 0 ) {
			$service = new Includes\BackupService();
			$service->prune_old_log_files( $days );
		}
	}

	/**
	 * Re-index $_FILES['backup_file'] for HTML multiple uploads.
	 *
	 * @return list<array{name: string, type: string, tmp_name: string, error: int, size: int}>
	 */
	private static function normalize_backup_files_field( array $field ): array {
		if ( ! isset( $field['name'] ) ) {
			return [];
		}
		if ( ! is_array( $field['name'] ) ) {
			return [ $field ];
		}
		$out   = [];
		$count = count( $field['name'] );
		for ( $i = 0; $i < $count; $i++ ) {
			$out[] = [
				'name'     => isset( $field['name'][ $i ] ) ? (string) $field['name'][ $i ] : '',
				'type'     => isset( $field['type'][ $i ] ) ? (string) $field['type'][ $i ] : '',
				'tmp_name' => isset( $field['tmp_name'][ $i ] ) ? (string) $field['tmp_name'][ $i ] : '',
				'error'    => isset( $field['error'][ $i ] ) ? (int) $field['error'][ $i ] : UPLOAD_ERR_NO_FILE,
				'size'     => isset( $field['size'][ $i ] ) ? (int) $field['size'][ $i ] : 0,
			];
		}

		return $out;
	}

	/**
	 * Shared upload pipeline (admin-post + legacy admin-ajax form).
	 *
	 * @return array{success: bool, message: string, backup_id?: string, component?: string}
	 */
	private function run_backup_upload_pipeline( array $post, array $files ): array {
		if ( ! isset( $files['backup_file'] ) || ! is_array( $files['backup_file'] ) ) {
			return [
				'success' => false,
				'message' => __(
					'No file was received by the server. Try the upload again. If it keeps failing, check PHP upload_max_filesize and post_max_size, or ask your host whether admin AJAX multipart uploads are blocked.',
					'wp-powersuite'
				),
			];
		}

		$file_list = self::normalize_backup_files_field( $files['backup_file'] );
		$file_list = array_values(
			array_filter(
				$file_list,
				static function ( array $f ): bool {
					return UPLOAD_ERR_NO_FILE !== (int) ( $f['error'] ?? UPLOAD_ERR_NO_FILE );
				}
			)
		);
		if ( count( $file_list ) === 0 ) {
			return [
				'success' => false,
				'message' => __(
					'No file was uploaded. The browser may not have attached the file to the request—try disabling browser extensions or use the same upload form without collapsing advanced options.',
					'wp-powersuite'
				),
			];
		}

		$prep = $this->prepare_backup_upload_job( $post );
		if ( empty( $prep['success'] ) ) {
			return [
				'success' => false,
				'message' => (string) ( $prep['message'] ?? __( 'Upload failed.', 'wp-powersuite' ) ),
			];
		}

		/** @var Includes\BackupService $srv */
		$srv       = $prep['srv'];
		$backup_id = (string) $prep['backup_id'];
		$forced    = $prep['forced'];

		$final_id   = $backup_id;
		$last_comp  = '';
		$upload_idx = 0;
		foreach ( $file_list as $one ) {
			++$upload_idx;
			$result = $srv->ingest_uploaded_backup_part( $final_id, $one, $forced );
			if ( empty( $result['success'] ) ) {
				return [
					'success' => false,
					'message' => sprintf(
						/* translators: 1: original filename, 2: error message */
						__( '“%1$s”: %2$s', 'wp-powersuite' ),
						isset( $one['name'] ) ? (string) $one['name'] : ( '#' . (string) $upload_idx ),
						(string) ( $result['message'] ?? __( 'Upload failed.', 'wp-powersuite' ) )
					),
				];
			}
			if ( ! empty( $result['backup_id'] ) ) {
				$final_id = (string) $result['backup_id'];
			}
			$last_comp = isset( $result['component'] ) ? (string) $result['component'] : '';
		}

		$srv->ensure_backup_in_history_from_disk( $final_id );
		$srv->sync_backup_metadata_from_disk( $final_id );

		return [
			'success'    => true,
			'message'    => '',
			'backup_id'  => $final_id,
			'component'  => $last_comp,
			'file_count' => count( $file_list ),
		];
	}

	public function ajax_upload_backup(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br_upload', '_wpnonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}

		$post  = wp_unslash( $_POST );
		$pipe  = $this->run_backup_upload_pipeline( $post, $_FILES );
		if ( empty( $pipe['success'] ) ) {
			wp_send_json_error( [ 'message' => $pipe['message'] ] );
		}

		$comp   = (string) ( $pipe['component'] ?? '' );
		$bid    = (string) ( $pipe['backup_id'] ?? '' );
		$labels = [
			'database' => __( 'Database', 'wp-powersuite' ),
			'plugins'  => __( 'Plugins', 'wp-powersuite' ),
			'themes'   => __( 'Themes', 'wp-powersuite' ),
			'uploads'  => __( 'Uploads', 'wp-powersuite' ),
			'others'   => __( 'Others', 'wp-powersuite' ),
			'bundle'   => __( 'Full backup bundle', 'wp-powersuite' ),
		];
		$label = $labels[ $comp ] ?? $comp;
		$n     = (int) ( $pipe['file_count'] ?? 1 );
		if ( $n > 1 ) {
			$msg = sprintf(
				/* translators: 1: number of files, 2: backup folder id */
				__( 'Uploaded %1$d file(s) into %2$s.', 'wp-powersuite' ),
				$n,
				$bid
			);
		} else {
			/* translators: 1: part name, 2: backup folder id */
			$msg = sprintf( __( 'Uploaded %1$s into %2$s.', 'wp-powersuite' ), $label, $bid );
		}

		$uid = get_current_user_id();
		if ( $uid > 0 ) {
			set_transient( 'wpps_br_flash_' . $uid, [ 'type' => 'success', 'message' => $msg ], 90 );
		}

		wp_send_json_success(
			[
				'message'   => $msg,
				'backup_id' => $bid,
			]
		);
	}

	public function handle_upload_backup(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}
		check_admin_referer( 'wpps_br_upload' );

		$redirect = admin_url( 'admin.php?page=powersuite-backup-restore' );
		$uid      = get_current_user_id();
		$flash    = static function ( string $type, string $message ) use ( $uid, $redirect ): void {
			if ( $uid > 0 ) {
				set_transient( 'wpps_br_flash_' . $uid, [ 'type' => $type, 'message' => $message ], 90 );
			}
			wp_safe_redirect( $redirect );
			exit;
		};

		$post = wp_unslash( $_POST );
		$pipe = $this->run_backup_upload_pipeline( $post, $_FILES );
		if ( empty( $pipe['success'] ) ) {
			$flash( 'error', $pipe['message'] );
		}

		$comp   = (string) ( $pipe['component'] ?? '' );
		$bid    = (string) ( $pipe['backup_id'] ?? '' );
		$labels = [
			'database' => __( 'Database', 'wp-powersuite' ),
			'plugins'  => __( 'Plugins', 'wp-powersuite' ),
			'themes'   => __( 'Themes', 'wp-powersuite' ),
			'uploads'  => __( 'Uploads', 'wp-powersuite' ),
			'others'   => __( 'Others', 'wp-powersuite' ),
			'bundle'   => __( 'Full backup bundle', 'wp-powersuite' ),
		];
		$label = $labels[ $comp ] ?? $comp;
		$n     = (int) ( $pipe['file_count'] ?? 1 );
		if ( $n > 1 ) {
			$msg = sprintf(
				/* translators: 1: number of files, 2: backup folder id */
				__( 'Uploaded %1$d file(s) into %2$s.', 'wp-powersuite' ),
				$n,
				$bid
			);
		} else {
			/* translators: 1: part name, 2: backup folder id */
			$msg = sprintf( __( 'Uploaded %1$s into %2$s.', 'wp-powersuite' ), $label, $bid );
		}
		$flash( 'success', $msg );
	}

	public function handle_save_settings(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}
		check_admin_referer( 'wpps_br_settings' );
		$post = wp_unslash( $_POST );
		Includes\BackupService::save_settings_array(
			[
				'include_plugins'   => ! empty( $post['include_plugins'] ),
				'include_themes'    => ! empty( $post['include_themes'] ),
				'include_uploads'   => ! empty( $post['include_uploads'] ),
				'include_others'    => ! empty( $post['include_others'] ),
				'exclude_uploads'  => isset( $post['exclude_uploads'] ) ? (string) $post['exclude_uploads'] : '',
				'exclude_others'   => isset( $post['exclude_others'] ) ? (string) $post['exclude_others'] : '',
				'retain_completed' => isset( $post['retain_completed'] ) ? (int) $post['retain_completed'] : 0,
				'log_retain_days'  => isset( $post['log_retain_days'] ) ? (int) $post['log_retain_days'] : 30,
				'manual_backup_email_enabled'     => ! empty( $post['manual_backup_email_enabled'] ),
				'manual_backup_email_on'            => in_array( $post['manual_backup_email_on'] ?? '', [ 'both', 'failure' ], true )
					? (string) $post['manual_backup_email_on'] : 'both',
				'manual_backup_email_recipients'    => isset( $post['manual_backup_email_recipients'] ) ? sanitize_textarea_field( (string) $post['manual_backup_email_recipients'] ) : '',
				'manual_restore_email_enabled'      => ! empty( $post['manual_restore_email_enabled'] ),
				'manual_restore_email_on'           => in_array( $post['manual_restore_email_on'] ?? '', [ 'both', 'failure' ], true )
					? (string) $post['manual_restore_email_on'] : 'both',
				'manual_restore_email_recipients'   => isset( $post['manual_restore_email_recipients'] ) ? sanitize_textarea_field( (string) $post['manual_restore_email_recipients'] ) : '',
			]
		);
		wp_safe_redirect(
			add_query_arg(
				[
					'page'     => 'powersuite-backup-restore',
					'br_tab'   => 'settings',
					'updated'  => '1',
				],
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * @param array<string, array<string, int>> $schedules
	 * @return array<string, array<string, int|string>>
	 */
	public function add_cron_schedules( array $schedules ): array {
		if ( ! isset( $schedules['weekly'] ) ) {
			$schedules['weekly'] = [
				'interval' => WEEK_IN_SECONDS,
				'display'  => __( 'Once Weekly', 'wp-powersuite' ),
			];
		}
		if ( ! isset( $schedules['monthly'] ) ) {
			$schedules['monthly'] = [
				'interval' => 30 * DAY_IN_SECONDS,
				'display'  => __( 'Once Monthly', 'wp-powersuite' ),
			];
		}
		return $schedules;
	}

	public function handle_scheduled_backup(): void {
		$settings = Includes\BackupService::get_settings();
		if ( empty( $settings['schedule_enabled'] ) ) {
			return;
		}

		$type  = $settings['schedule_backup_type'] ?? 'full';
		$label = __( 'Scheduled backup', 'wp-powersuite' );

		$service = new Includes\BackupService();
		$result  = $service->run_scheduled_backup( $type, $label );

		$result['type'] = $type;

		// Multi-tick cron continuation is in-progress, not a failure.
		if ( ! empty( $result['cron_continued'] ) ) {
			$message = __( 'Scheduled backup is continuing in the background…', 'wp-powersuite' );
			Includes\BackupService::set_last_message( $message );
			return;
		}

		$success = ! empty( $result['done'] ) && ! empty( $result['success'] );

		if ( $success ) {
			$message = Includes\BackupService::format_backup_success_message( $result );
		} else {
			$message = $result['message'] ?? __( 'Backup failed.', 'wp-powersuite' );
		}

		Includes\BackupService::set_last_message( $message );

		if ( ! empty( $settings['schedule_email_enabled'] ) ) {
			$notify_on = $settings['schedule_email_on'] ?? 'both';
			if ( 'both' === $notify_on || ( 'failure' === $notify_on && ! $success ) ) {
				$recipients = $this->get_email_recipients_list( $settings, 'schedule_email_recipients' );
				$this->defer_job_email(
					'backup',
					[
						'success'           => $success,
						'message'           => $message,
						'result'            => $result,
						'recipients'        => $recipients,
						'subject_job_label' => __( 'Scheduled Backup', 'wp-powersuite' ),
					]
				);
			}
		}
	}

	/**
	 * @param array<string, mixed> $result
	 */
	private function maybe_send_manual_backup_notification( bool $success, array $result ): void {
		$settings = Includes\BackupService::get_settings();
		if ( empty( $settings['manual_backup_email_enabled'] ) ) {
			return;
		}
		$notify_on = $settings['manual_backup_email_on'] ?? 'both';
		if ( ! ( 'both' === $notify_on || ( 'failure' === $notify_on && ! $success ) ) ) {
			return;
		}
		$recipients = $this->get_email_recipients_list( $settings, 'manual_backup_email_recipients' );
		if ( [] === $recipients ) {
			return;
		}
		$message = $success
			? Includes\BackupService::format_backup_success_message( $result )
			: ( $result['message'] ?? __( 'Backup failed.', 'wp-powersuite' ) );
		$this->defer_job_email(
			'backup',
			[
				'success'           => $success,
				'message'           => $message,
				'result'            => $result,
				'recipients'        => $recipients,
				'subject_job_label' => __( 'Manual Backup', 'wp-powersuite' ),
			]
		);
	}

	/**
	 * @param array<string, mixed> $result
	 */
	private function maybe_send_manual_restore_notification( bool $success, array $result ): void {
		$settings = Includes\BackupService::get_settings();
		if ( empty( $settings['manual_restore_email_enabled'] ) ) {
			return;
		}
		$notify_on = $settings['manual_restore_email_on'] ?? 'both';
		if ( ! ( 'both' === $notify_on || ( 'failure' === $notify_on && ! $success ) ) ) {
			return;
		}
		$recipients = $this->get_email_recipients_list( $settings, 'manual_restore_email_recipients' );
		if ( [] === $recipients ) {
			return;
		}
		$message = $success
			? ( $result['message'] ?? __( 'Restore completed successfully.', 'wp-powersuite' ) )
			: ( $result['message'] ?? __( 'Restore failed.', 'wp-powersuite' ) );
		$this->defer_job_email(
			'restore',
			[
				'success'    => $success,
				'message'    => $message,
				'result'     => $result,
				'recipients' => $recipients,
			]
		);
	}

	/**
	 * Send job emails on shutdown so long backup/restore AJAX ticks are not blocked by wp_mail().
	 *
	 * @param 'backup'|'restore'     $kind
	 * @param array<string, mixed> $payload
	 */
	private function defer_job_email( string $kind, array $payload ): void {
		add_action(
			'shutdown',
			function () use ( $kind, $payload ): void {
				try {
					if ( 'backup' === $kind ) {
						$this->send_backup_job_email(
							(bool) ( $payload['success'] ?? false ),
							(string) ( $payload['message'] ?? '' ),
							is_array( $payload['result'] ?? null ) ? $payload['result'] : [],
							is_array( $payload['recipients'] ?? null ) ? $payload['recipients'] : [],
							(string) ( $payload['subject_job_label'] ?? __( 'Backup', 'wp-powersuite' ) )
						);
						return;
					}

					$this->send_restore_job_email(
						(bool) ( $payload['success'] ?? false ),
						(string) ( $payload['message'] ?? '' ),
						is_array( $payload['result'] ?? null ) ? $payload['result'] : [],
						is_array( $payload['recipients'] ?? null ) ? $payload['recipients'] : []
					);
				} catch ( \Throwable $e ) {
					if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
						error_log( 'WP PowerSuite Backup & Restore email failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
					}
				}
			},
			20
		);
	}

	/**
	 * @param list<string>         $recipients
	 * @param array<string, mixed> $result
	 */
	private function send_backup_job_email(
		bool $success,
		string $message,
		array $result,
		array $recipients,
		string $subject_job_label
	): void {
		if ( [] === $recipients ) {
			return;
		}

		$site_name = wp_specialchars_decode( get_option( 'blogname', '' ), ENT_QUOTES );
		$status    = $success ? __( 'Success', 'wp-powersuite' ) : __( 'Failed', 'wp-powersuite' );
		$subject   = sprintf(
			/* translators: 1: site name 2: job label (e.g. Scheduled Backup) 3: status */
			__( '[%1$s] %2$s — %3$s', 'wp-powersuite' ),
			$site_name,
			$subject_job_label,
			$status
		);

		$settings_fb = Includes\BackupService::get_settings();
		$backup_type = ucfirst( (string) ( $result['type'] ?? $settings_fb['schedule_backup_type'] ?? 'full' ) );
		$timestamp   = wp_date( 'D, M j, Y H:i T' );
		$site_url    = home_url( '/' );
		$logo_url    = $this->get_notification_logo_url();

		$status_label = $success
			? __( 'Backup Completed Successfully', 'wp-powersuite' )
			: __( 'Backup Failed', 'wp-powersuite' );

		$duration_sec = isset( $result['duration_seconds'] ) ? (int) $result['duration_seconds'] : 0;
		$duration_str = $this->format_duration( $duration_sec );

		$backup_id    = $result['backup_id'] ?? '';
		$backup_label = trim( (string) ( $result['label'] ?? '' ) );
		$details      = $result['backup_details'] ?? [];
		$components   = $details['components'] ?? [];
		$total_size   = $details['total_size'] ?? 0;
		$wp_version   = $details['wp_version'] ?? get_bloginfo( 'version' );

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

		$components_plain = '';
		if ( ! empty( $components ) ) {
			foreach ( $components as $comp => $size ) {
				$label             = $comp_labels[ $comp ] ?? ucfirst( (string) $comp );
				$components_plain .= '  ' . $label . ': ' . size_format( (int) $size, 1 ) . "\n";
			}
		}

		$extra_html = Includes\BackupEmailBuilder::componentsTableHtml( $components, $comp_labels, (int) $total_size );
		if ( ! $success && ! empty( $result['log_excerpt'] ) ) {
			$extra_html .= Includes\BackupEmailBuilder::logExcerptHtml( (string) $result['log_excerpt'] );
		}

		$rows = [
			[
				'label'      => __( 'Site', 'wp-powersuite' ),
				'value_html' => '<a href="' . esc_url( $site_url ) . '" style="color:' . esc_attr( \WP_PowerSuite\Email\EmailRenderer::accentColor() ) . ';text-decoration:none;">' . esc_html( $site_url ) . '</a>',
			],
			[ 'label' => __( 'Backup Type', 'wp-powersuite' ), 'value' => $backup_type ],
		];
		if ( '' !== $backup_label ) {
			$rows[] = [ 'label' => __( 'Label', 'wp-powersuite' ), 'value' => $backup_label ];
		}
		$rows[] = [ 'label' => __( 'Completed At', 'wp-powersuite' ), 'value' => $timestamp ];
		$rows[] = [ 'label' => __( 'Duration', 'wp-powersuite' ), 'value' => $duration_str ];
		$rows[] = [ 'label' => __( 'WordPress', 'wp-powersuite' ), 'value' => (string) $wp_version ];
		if ( '' !== $backup_id ) {
			$rows[] = [ 'label' => __( 'Backup ID', 'wp-powersuite' ), 'value' => (string) $backup_id ];
		}
		$rows[] = [ 'label' => __( 'Status', 'wp-powersuite' ), 'value' => $status ];

		$html = Includes\BackupEmailBuilder::render( $success, $status_label, $message, $rows, $extra_html, $logo_url );

		$plain_lines   = [];
		$plain_lines[] = $status_label;
		$plain_lines[] = '';
		$plain_lines[] = sprintf( __( 'Site: %s', 'wp-powersuite' ), $site_url );
		$plain_lines[] = sprintf( __( 'Backup Type: %s', 'wp-powersuite' ), $backup_type );
		if ( '' !== $backup_label ) {
			$plain_lines[] = sprintf( __( 'Label: %s', 'wp-powersuite' ), $backup_label );
		}
		$plain_lines[] = sprintf( __( 'Completed At: %s', 'wp-powersuite' ), $timestamp );
		$plain_lines[] = sprintf( __( 'Duration: %s', 'wp-powersuite' ), $duration_str );
		$plain_lines[] = sprintf( __( 'WordPress: %s', 'wp-powersuite' ), $wp_version );
		if ( '' !== $backup_id ) {
			$plain_lines[] = sprintf( __( 'Backup ID: %s', 'wp-powersuite' ), $backup_id );
		}
		$plain_lines[] = sprintf( __( 'Status: %s', 'wp-powersuite' ), $status );
		if ( ! empty( $components ) ) {
			$plain_lines[] = '';
			$plain_lines[] = __( 'Backup Contents:', 'wp-powersuite' );
			$plain_lines[] = $components_plain;
			$plain_lines[] = sprintf( __( 'Total: %s', 'wp-powersuite' ), size_format( $total_size, 1 ) );
		}
		$plain_lines[] = '';
		$plain_lines[] = $message;
		if ( ! $success && ! empty( $result['log_excerpt'] ) ) {
			$plain_lines[] = '';
			$plain_lines[] = __( '--- Error Log ---', 'wp-powersuite' );
			$plain_lines[] = (string) $result['log_excerpt'];
		}

		$plain_body = implode( "\n", $plain_lines );
		$this->deliver_job_mail( $recipients, $subject, $html, $plain_body );
	}

	/**
	 * Email for manual (UI) restore completion or failure.
	 *
	 * @param list<string>         $recipients
	 * @param array<string, mixed> $result
	 */
	private function send_restore_job_email( bool $success, string $message, array $result, array $recipients ): void {
		if ( [] === $recipients ) {
			return;
		}

		$site_name = wp_specialchars_decode( get_option( 'blogname', '' ), ENT_QUOTES );
		$status    = $success ? __( 'Success', 'wp-powersuite' ) : __( 'Failed', 'wp-powersuite' );
		$subject   = sprintf(
			/* translators: 1: site name 2: status */
			__( '[%1$s] Manual Restore — %2$s', 'wp-powersuite' ),
			$site_name,
			$status
		);

		$timestamp = wp_date( 'D, M j, Y H:i T' );
		$site_url  = home_url( '/' );
		$logo_url  = $this->get_notification_logo_url();
		$backup_id = isset( $result['backup_id'] ) ? (string) $result['backup_id'] : '';

		$status_label = $success
			? __( 'Restore Completed Successfully', 'wp-powersuite' )
			: __( 'Restore Failed', 'wp-powersuite' );

		$extra_html = '';
		if ( ! $success && ! empty( $result['log_excerpt'] ) ) {
			$extra_html .= Includes\BackupEmailBuilder::logExcerptHtml( (string) $result['log_excerpt'] );
		}
		if ( $success && ! empty( $result['restore_log_download_url'] ) && is_string( $result['restore_log_download_url'] ) ) {
			$extra_html .= \WP_PowerSuite\Email\EmailComponents::paragraph(
				'<a href="' . esc_url( $result['restore_log_download_url'] ) . '" style="color:' . esc_attr( \WP_PowerSuite\Email\EmailRenderer::accentColor() ) . ';">' . esc_html__( 'Download restore log', 'wp-powersuite' ) . '</a>',
				[ 'margin' => '12px 0 0' ]
			);
		}

		$rows = [
			[
				'label'      => __( 'Site', 'wp-powersuite' ),
				'value_html' => '<a href="' . esc_url( $site_url ) . '" style="color:' . esc_attr( \WP_PowerSuite\Email\EmailRenderer::accentColor() ) . ';text-decoration:none;">' . esc_html( $site_url ) . '</a>',
			],
			[ 'label' => __( 'Finished At', 'wp-powersuite' ), 'value' => $timestamp ],
		];
		if ( '' !== $backup_id ) {
			$rows[] = [ 'label' => __( 'Backup set', 'wp-powersuite' ), 'value' => $backup_id ];
		}
		$rows[] = [ 'label' => __( 'Status', 'wp-powersuite' ), 'value' => $status ];

		$html = Includes\BackupEmailBuilder::render( $success, $status_label, $message, $rows, $extra_html, $logo_url );

		$plain_lines   = [ $status_label, '', sprintf( __( 'Site: %s', 'wp-powersuite' ), $site_url ), sprintf( __( 'Finished At: %s', 'wp-powersuite' ), $timestamp ) ];
		if ( '' !== $backup_id ) {
			$plain_lines[] = sprintf( __( 'Backup set: %s', 'wp-powersuite' ), $backup_id );
		}
		$plain_lines[] = sprintf( __( 'Status: %s', 'wp-powersuite' ), $status );
		$plain_lines[] = '';
		$plain_lines[] = $message;
		if ( $success && ! empty( $result['restore_log_download_url'] ) && is_string( $result['restore_log_download_url'] ) ) {
			$plain_lines[] = '';
			$plain_lines[] = __( 'Restore log:', 'wp-powersuite' ) . ' ' . $result['restore_log_download_url'];
		}
		if ( ! $success && ! empty( $result['log_excerpt'] ) ) {
			$plain_lines[] = '';
			$plain_lines[] = __( '--- Error Log ---', 'wp-powersuite' );
			$plain_lines[] = (string) $result['log_excerpt'];
		}

		$plain_body = implode( "\n", $plain_lines );
		$this->deliver_job_mail( $recipients, $subject, $html, $plain_body );
	}

	private function get_notification_logo_url(): string {
		$logo = \WP_PowerSuite\Email\EmailRenderer::brandingLogoUrl();
		return (string) apply_filters( 'wpps_br_notification_email_logo_url', $logo );
	}

	/**
	 * @param list<string> $recipients
	 */
	private function deliver_job_mail( array $recipients, string $subject, string $html, string $plain_body ): void {
		$recipients = array_values( array_filter( $recipients, 'is_email' ) );
		if ( [] === $recipients ) {
			return;
		}

		$html = trim( $html );
		if ( $html === '' ) {
			wp_mail( $recipients, $subject, $plain_body );
			return;
		}

		$headers = \WP_PowerSuite\Email\EmailRenderer::headers();
		\WP_PowerSuite\Email\EmailRenderer::withPlainAltBody(
			$plain_body,
			static function () use ( $recipients, $subject, $html, $headers ): bool {
				return wp_mail( $recipients, $subject, $html, $headers );
			}
		);
	}

	private function format_duration( int $seconds ): string {
		if ( $seconds < 1 ) {
			return __( '< 1 second', 'wp-powersuite' );
		}
		if ( $seconds < 60 ) {
			/* translators: %d: number of seconds */
			return sprintf( _n( '%d second', '%d seconds', $seconds, 'wp-powersuite' ), $seconds );
		}
		$min = (int) floor( $seconds / 60 );
		$sec = $seconds % 60;
		if ( $min < 60 ) {
			$parts = sprintf( _n( '%d minute', '%d minutes', $min, 'wp-powersuite' ), $min );
			if ( $sec > 0 ) {
				$parts .= ' ' . sprintf( _n( '%d second', '%d seconds', $sec, 'wp-powersuite' ), $sec );
			}
			return $parts;
		}
		$hrs = (int) floor( $min / 60 );
		$min = $min % 60;
		$parts = sprintf( _n( '%d hour', '%d hours', $hrs, 'wp-powersuite' ), $hrs );
		if ( $min > 0 ) {
			$parts .= ' ' . sprintf( _n( '%d minute', '%d minutes', $min, 'wp-powersuite' ), $min );
		}
		return $parts;
	}

	/**
	 * @return list<string>
	 */
	private function get_email_recipients_list( array $settings, string $recipients_setting_key ): array {
		$raw = trim( (string) ( $settings[ $recipients_setting_key ] ?? '' ) );
		if ( '' !== $raw ) {
			$lines  = preg_split( '/\r\n|\r|\n/', $raw );
			$emails = array_filter( array_map( 'trim', $lines ?: [] ), 'is_email' );
			if ( [] !== $emails ) {
				return array_values( $emails );
			}
		}
		$admin = get_option( 'admin_email', '' );
		return $admin ? [ $admin ] : [];
	}

	public function handle_save_schedule(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}
		check_admin_referer( 'wpps_br_schedule' );
		$post = wp_unslash( $_POST );

		$schedule = [
			'schedule_enabled'          => ! empty( $post['schedule_enabled'] ),
			'schedule_frequency'        => in_array( $post['schedule_frequency'] ?? '', [ 'daily', 'weekly', 'monthly' ], true )
				? (string) $post['schedule_frequency'] : 'daily',
			'schedule_time'             => isset( $post['schedule_time'] ) && preg_match( '/^\d{2}:\d{2}$/', (string) $post['schedule_time'] )
				? (string) $post['schedule_time'] : '03:00',
			'schedule_day_of_week'      => isset( $post['schedule_day_of_week'] ) ? max( 0, min( 6, (int) $post['schedule_day_of_week'] ) ) : 0,
			'schedule_day_of_month'     => isset( $post['schedule_day_of_month'] ) ? max( 1, min( 28, (int) $post['schedule_day_of_month'] ) ) : 1,
			'schedule_backup_type'      => in_array( $post['schedule_backup_type'] ?? '', [ 'full', 'database', 'files' ], true )
				? (string) $post['schedule_backup_type'] : 'full',
			'schedule_email_enabled'    => ! empty( $post['schedule_email_enabled'] ),
			'schedule_email_on'         => in_array( $post['schedule_email_on'] ?? '', [ 'both', 'failure' ], true )
				? (string) $post['schedule_email_on'] : 'both',
			'schedule_email_recipients' => isset( $post['schedule_email_recipients'] ) ? sanitize_textarea_field( (string) $post['schedule_email_recipients'] ) : '',
		];

		Includes\BackupService::save_schedule_settings( $schedule );
		self::reschedule_backup_cron();

		wp_safe_redirect(
			add_query_arg(
				[
					'page'    => 'powersuite-backup-restore',
					'br_tab'  => 'schedule',
					'updated' => '1',
				],
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	public static function reschedule_backup_cron(): void {
		wp_clear_scheduled_hook( 'wpps_br_scheduled_backup' );

		$settings = Includes\BackupService::get_settings();
		if ( empty( $settings['schedule_enabled'] ) ) {
			return;
		}

		$frequency = $settings['schedule_frequency'] ?? 'daily';
		$next      = self::calculate_next_scheduled_run( $settings );

		wp_schedule_event( $next, $frequency, 'wpps_br_scheduled_backup', [], true );
	}

	private static function calculate_next_scheduled_run( array $s ): int {
		$time  = $s['schedule_time'] ?? '03:00';
		$parts = explode( ':', $time );
		$hour  = max( 0, min( 23, (int) ( $parts[0] ?? 3 ) ) );
		$min   = max( 0, min( 59, (int) ( $parts[1] ?? 0 ) ) );
		$tz    = wp_timezone();
		$now   = new \DateTimeImmutable( 'now', $tz );
		$freq  = $s['schedule_frequency'] ?? 'daily';

		if ( 'daily' === $freq ) {
			$target = $now->setTime( $hour, $min, 0 );
			if ( $now >= $target ) {
				$target = $target->modify( '+1 day' );
			}
			return $target->getTimestamp();
		}

		if ( 'weekly' === $freq ) {
			$dow         = max( 0, min( 6, (int) ( $s['schedule_day_of_week'] ?? 0 ) ) );
			$current_dow = (int) $now->format( 'w' );
			$days_until  = ( $dow - $current_dow + 7 ) % 7;
			$target      = $now->modify( "+{$days_until} days" )->setTime( $hour, $min, 0 );
			if ( 0 === $days_until && $now >= $target ) {
				$target = $target->modify( '+7 days' );
			}
			return $target->getTimestamp();
		}

		// monthly
		$dom = max( 1, min( 28, (int) ( $s['schedule_day_of_month'] ?? 1 ) ) );
		$y   = (int) $now->format( 'Y' );
		$m   = (int) $now->format( 'n' );
		try {
			$target = ( new \DateTimeImmutable( 'now', $tz ) )
				->setDate( $y, $m, $dom )
				->setTime( $hour, $min, 0 );
		} catch ( \Exception $e ) {
			$target = $now->setTime( $hour, $min, 0 )->modify( '+1 day' );
		}
		if ( $now >= $target ) {
			$target = $target->modify( '+1 month' );
		}
		return $target->getTimestamp();
	}

	public function ajax_precheck(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$service = new Includes\BackupService();
		$check   = $service->run_precheck( false );
		if ( ! empty( $check['lines'] ) ) {
			$last = end( $check['lines'] );
			if ( is_string( $last ) ) {
				Includes\BackupService::set_last_message( $last );
			}
		}
		wp_send_json_success(
			[
				'lines' => $check['lines'],
				'ok'    => $check['ok'],
			]
		);
	}

	public function ajax_backup_step(): void {
		$this->prepare_large_ajax_json_response();
		$this->discard_output_buffers_before_json();
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$raw_id = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		$bid    = ( '' !== $raw_id ) ? $raw_id : null;
		$type   = isset( $_POST['backup_type'] ) ? sanitize_key( wp_unslash( $_POST['backup_type'] ) ) : 'full';
		$label  = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';

		try {
			$service = new Includes\BackupService();
			$result  = $service->backup_tick( $bid, $type, $label );
			if ( ! empty( $result['success'] ) ) {
				if ( ! empty( $result['done'] ) ) {
					$this->maybe_send_manual_backup_notification( true, $result );
				}
				wp_send_json_success( $result );
			}
			$fail_payload = [
				'message'   => $result['message'] ?? __( 'Backup failed.', 'wp-powersuite' ),
				'backup_id' => $result['backup_id'] ?? '',
				'type'      => $type,
			];
			if ( isset( $result['log_excerpt'] ) ) {
				$fail_payload['log_excerpt'] = $result['log_excerpt'];
			}
			$this->maybe_send_manual_backup_notification( false, $fail_payload );
			wp_send_json_error( $fail_payload );
		} catch ( \Throwable $e ) {
			$this->maybe_send_manual_backup_notification(
				false,
				[
					'message' => $e->getMessage(),
					'type'    => $type,
				]
			);
			wp_send_json_error( [ 'message' => __( 'Backup error: ', 'wp-powersuite' ) . $e->getMessage() ], 500 );
		}
	}

	public function ajax_backup_abort(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		if ( '' === $bid ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup id.', 'wp-powersuite' ) ] );
		}
		try {
			$service = new Includes\BackupService();
			$service->abort_job( $bid );
			wp_send_json_success();
		} catch ( \Throwable $e ) {
			wp_send_json_error( [ 'message' => $e->getMessage() ], 500 );
		}
	}

	/**
	 * Proof-of-deploy fields on restore AJAX (see Network → response).
	 *
	 * @return array<string, int|string>
	 */
	private function backup_restore_ajax_meta(): array {
		$path = __DIR__ . '/includes/BackupService.php';
		return [
			'br_engine'          => self::VERSION,
			'br_service_engine'  => Includes\BackupService::ENGINE_BUILD,
			'br_service_mtime'   => is_readable( $path ) ? (string) filemtime( $path ) : '0',
		];
	}

	/**
	 * Restore tick authorization: admin + nonce to start, or signed HttpOnly cookie after DB import.
	 */
	private function backup_restore_restore_tick_authorized( string $backup_id ): bool {
		if ( '' === $backup_id ) {
			return false;
		}

		$svc = new Includes\BackupService();

		if ( $this->current_user_can_manage_backups() && check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			$state = $svc->get_restore_state( $backup_id );
			if ( is_array( $state ) && ! empty( $state['ajax_continuation_secret'] ) && is_string( $state['ajax_continuation_secret'] ) ) {
				Includes\BackupService::issue_restore_continuation_cookie( $backup_id, (string) $state['ajax_continuation_secret'] );
			}
			return true;
		}

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

	/**
	 * Long backup/restore ticks emit multi-KB JSON. With zlib.output_compression on, PHP can emit
	 * "failed to send buffer of zlib output compression" and corrupt valid JSON (browser sees generic errors / logouts).
	 */
	private function prepare_large_ajax_json_response(): void {
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::prepare();
			return;
		}

		if ( function_exists( 'ini_set' ) ) {
			// phpcs:ignore WordPress.PHP.IniSet.Risking -- Needed for uncorrupted admin-ajax JSON on compressed hosts.
			@ini_set( 'zlib.output_compression', '0' );
		}
		if ( function_exists( 'apache_setenv' ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@apache_setenv( 'no-gzip', '1' );
		}
		if ( function_exists( 'header' ) && ! headers_sent() ) {
			header( 'X-Accel-Buffering: no' );
			header( 'Content-Encoding: none' );
		}
	}

	/**
	 * Notices/HTML from other code must not appear before JSON or jQuery fails to parse and the UI
	 * shows a false “restore failed” even when the server finished the step.
	 */
	private function discard_output_buffers_before_json(): void {
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::discardBuffers();
			return;
		}

		while ( ob_get_level() > 0 ) {
			// phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
			if ( ! @ob_end_clean() ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				break;
			}
		}
	}

	/**
	 * Binary downloads (ZIP/SQL) must not go through zlib.output_compression or buffered notices — file corrupts.
	 */
	private function prepare_admin_binary_download(): void {
		if ( function_exists( 'ini_set' ) ) {
			// phpcs:ignore WordPress.PHP.IniSet.Risking
			@ini_set( 'zlib.output_compression', '0' );
		}
		if ( function_exists( 'apache_setenv' ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@apache_setenv( 'no-gzip', '1' );
		}
		while ( ob_get_level() > 0 ) {
			// phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
			if ( ! @ob_end_clean() ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				break;
			}
		}
	}

	/**
	 * Large ZIP/SQL downloads: avoid PHP max_execution_time and full-response buffering cutting off around ~500MB on some hosts.
	 */
	private function prepare_large_file_download(): void {
		$this->prepare_admin_binary_download();
		if ( function_exists( 'header' ) && ! headers_sent() ) {
			// nginx reverse-proxy: avoid buffering the entire response (can truncate ~500MB bodies).
			header( 'X-Accel-Buffering: no' );
		}
		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( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		}
	}

	/**
	 * Stream a file to output in chunks so the connection stays active on long downloads.
	 *
	 * @param string $path Absolute path; must already be validated as readable.
	 */
	private function stream_readfile_path( string $path ): void {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$h = fopen( $path, 'rb' );
		if ( false === $h ) {
			return;
		}
		$chunk = 1024 * 1024 * 2; // 2 MiB — smaller chunks flush sooner and survive proxy buffering better.
		try {
			while ( ! feof( $h ) ) {
				if ( connection_aborted() ) {
					break;
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
				$buf = fread( $h, $chunk );
				if ( false === $buf || $buf === '' ) {
					break;
				}
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary stream
				echo $buf;
				if ( ob_get_level() > 0 ) {
					ob_flush();
				}
				flush();
			}
		} finally {
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}
	}

	/**
	 * Parse first Range spec (bytes=…). Full file when null; '416' when unsatisfiable.
	 *
	 * @return array{start:int,end:int,length:int}|null|string
	 */
	private function resolve_http_byte_range( int $file_size ) {
		if ( $file_size <= 0 || empty( $_SERVER['HTTP_RANGE'] ) ) {
			return null;
		}
		$range = trim( (string) wp_unslash( $_SERVER['HTTP_RANGE'] ) );
		if ( ! preg_match( '/bytes=([^,]+)/i', $range, $outer ) ) {
			return null;
		}
		$spec = trim( (string) $outer[1] );
		if ( ! preg_match( '/^(\d+)-(\d*)$/', $spec, $m ) ) {
			return null;
		}
		$start = (int) $m[1];
		$end   = $m[2] === '' ? $file_size - 1 : (int) $m[2];
		if ( $start < 0 || $start >= $file_size ) {
			return '416';
		}
		$end = min( $end, $file_size - 1 );
		if ( $end < $start ) {
			return '416';
		}

		return [
			'start'  => $start,
			'end'    => $end,
			'length' => $end - $start + 1,
		];
	}

	/**
	 * Stream an exact byte range from a file (used for HTTP 206 / WAF-friendly chunk downloads).
	 */
	private function stream_file_path_range( string $path, int $start, int $length ): void {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$h = fopen( $path, 'rb' );
		if ( false === $h ) {
			return;
		}
		if ( $start > 0 ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fseek
			fseek( $h, $start );
		}
		$chunk     = 1024 * 1024 * 2;
		$remaining = $length;
		try {
			while ( $remaining > 0 ) {
				if ( connection_aborted() ) {
					break;
				}
				$read = min( $chunk, $remaining );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
				$buf = fread( $h, $read );
				if ( false === $buf || $buf === '' ) {
					break;
				}
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary stream
				echo $buf;
				$remaining -= strlen( $buf );
				if ( ob_get_level() > 0 ) {
					ob_flush();
				}
				flush();
			}
		} finally {
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}
	}

	/**
	 * Whether an absolute file path is inside the PowerSuite backup root (resolved).
	 */
	private function is_path_inside_backup_root( string $abs_path ): bool {
		$service = new Includes\BackupService();
		$root    = $service->get_backup_root();
		if ( $root !== '' && $this->is_real_path_under( $abs_path, $root ) ) {
			return true;
		}
		// Authenticated admin downloads may stream from legacy backup locations.
		foreach ( $service->get_legacy_backup_roots() as $legacy ) {
			if ( $this->is_real_path_under( $abs_path, $legacy ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * True if the file is inside private backup storage, legacy backup roots, or download staging.
	 */
	private function is_path_inside_allowed_dir( string $abs_path ): bool {
		$service = new Includes\BackupService();

		return $service->is_path_under_managed_storage( $abs_path );
	}

	private function is_real_path_under( string $abs_path, string $dir ): bool {
		$root_r = realpath( rtrim( $dir, '/\\' ) );
		$file_r = realpath( $abs_path );
		if ( false === $root_r || false === $file_r || ! is_file( $file_r ) || ! is_readable( $file_r ) ) {
			return false;
		}
		$rn = wp_normalize_path( $root_r );
		$fn = wp_normalize_path( $file_r );

		return $fn === $rn || str_starts_with( $fn, trailingslashit( $rn ) );
	}

	/**
	 * Offload the file body to the web server when supported (avoids PHP/proxy response-body limits ~500MB on some hosts).
	 *
	 * Apache: define WPPS_BR_X_SENDFILE as true and enable mod_xsendfile; sends X-Sendfile after auth in PHP.
	 * Nginx: use filter wpps_br_x_accel_redirect — return the internal URI for X-Accel-Redirect (see nginx internal location).
	 *
	 * @param string $path     Absolute path (validated).
	 * @param string $filename Suggested download name.
	 * @param string $mime     Content-Type.
	 * @return bool True if headers were sent and the caller should exit without streaming or unlinking the path.
	 */
	private function try_offload_file_download( string $path, string $filename, string $mime ): bool {
		if ( ! $this->is_path_inside_allowed_dir( $path ) ) {
			return false;
		}
		$file_r = realpath( $path );
		if ( false === $file_r ) {
			return false;
		}
		$file_n = wp_normalize_path( $file_r );

		/*
		 * Private backup roots live outside the document root. X-Sendfile / X-Accel-Redirect
		 * without a matching server alias returns an empty 0-byte download in the browser.
		 * Only offload when an explicit Accel mapping is provided, or X-Sendfile is enabled
		 * AND the file is under a known web root (legacy paths).
		 */
		$service = new Includes\BackupService();
		$root    = $service->get_backup_root();
		$root_r  = $root !== '' ? realpath( rtrim( $root, '/\\' ) ) : false;
		$root_n  = false !== $root_r ? wp_normalize_path( $root_r ) : '';
		$under_primary = $root_n !== '' && ( $file_n === $root_n || str_starts_with( $file_n, trailingslashit( $root_n ) ) );

		$accel = $under_primary
			? (string) apply_filters( 'wpps_br_x_accel_redirect', '', $file_n, $root_n )
			: '';
		if ( $accel !== '' ) {
			header( 'Content-Type: ' . $mime );
			header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' );
			header( 'X-Accel-Redirect: ' . $accel );
			return true;
		}

		if ( defined( 'WPPS_BR_X_SENDFILE' ) && WPPS_BR_X_SENDFILE ) {
			// Refuse X-Sendfile for private (non-web) paths unless the site opts in.
			$allow_private = (bool) apply_filters( 'wpps_br_x_sendfile_private_paths', false, $file_n );
			$web_ok        = false;
			if ( class_exists( Includes\PrivateStorageResolver::class ) ) {
				foreach ( Includes\PrivateStorageResolver::web_roots() as $web_root ) {
					if ( Includes\PrivateStorageResolver::path_is_inside( $file_n, $web_root ) ) {
						$web_ok = true;
						break;
					}
				}
			}
			if ( ! $web_ok && ! $allow_private ) {
				return false;
			}
			header( 'Content-Type: ' . $mime );
			header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' );
			header( 'X-Sendfile: ' . $file_r );
			return true;
		}

		return false;
	}

	public function ajax_restore_step(): void {
		$this->prepare_large_ajax_json_response();
		$this->discard_output_buffers_before_json();
		$meta = $this->backup_restore_ajax_meta();
		$bid    = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		if ( '' === $bid ) {
			$this->discard_output_buffers_before_json();
			wp_send_json_error( array_merge( [ 'message' => __( 'Missing backup id.', 'wp-powersuite' ) ], $meta ) );
		}
		if ( ! $this->backup_restore_restore_tick_authorized( $bid ) ) {
			$this->discard_output_buffers_before_json();
			// HTTP 200 so jQuery always parses JSON in .done (403 + dirty buffers surfaces as generic .fail alerts).
			wp_send_json_error( array_merge( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], $meta ) );
		}
		$components = null;
		if ( isset( $_POST['components'] ) && is_string( $_POST['components'] ) && $_POST['components'] !== '' ) {
			$raw = json_decode( wp_unslash( $_POST['components'] ), true );
			$components = is_array( $raw ) ? $raw : null;
		}
		$restore_opts = null;
		if ( null !== $components ) {
			$restore_opts = [
				'migration_from'       => isset( $_POST['migration_from'] ) ? sanitize_text_field( wp_unslash( $_POST['migration_from'] ) ) : '',
				'skip_url_migration'   => isset( $_POST['skip_url_migration'] ) && '1' === (string) wp_unslash( $_POST['skip_url_migration'] ),
			];
		}
		$json_utf8_safe = static function ( $options ) {
			if ( ! is_int( $options ) ) {
				return $options;
			}
			if ( defined( 'JSON_INVALID_UTF8_SUBSTITUTE' ) ) {
				return $options | JSON_INVALID_UTF8_SUBSTITUTE;
			}
			return $options;
		};
		add_filter( 'wp_json_encode_options', $json_utf8_safe, 999 );
		try {
			$service = new Includes\BackupService();
			$result  = $service->restore_tick( $bid, $components, $restore_opts );
			$this->discard_output_buffers_before_json();
			if ( ! empty( $result['success'] ) ) {
				if ( ! empty( $result['done'] ) ) {
					$this->maybe_send_manual_restore_notification( true, $result );
				}
				wp_send_json_success( array_merge( $result, $meta ) );
			}
			$err_merge = array_merge(
				[
					'message'               => isset( $result['message'] ) && is_string( $result['message'] ) && $result['message'] !== ''
						? $result['message']
						: __( 'Restore failed.', 'wp-powersuite' ),
					'lock_conflict'          => ! empty( $result['lock_conflict'] ),
					'restore_session_lost'   => ! empty( $result['restore_session_lost'] ),
				],
				$meta
			);
			if ( isset( $result['log_excerpt'] ) ) {
				$err_merge['log_excerpt'] = $result['log_excerpt'];
			}
			if ( $bid !== '' ) {
				$err_merge['backup_id'] = $bid;
			}
			$this->maybe_send_manual_restore_notification( false, $err_merge );
			wp_send_json_error( $err_merge );
		} catch ( \Throwable $e ) {
			$this->discard_output_buffers_before_json();
			$safe_msg = ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || $this->current_user_can_manage_backups()
				? $e->getMessage()
				: __( 'An internal error occurred during restore.', 'wp-powersuite' );
			$this->maybe_send_manual_restore_notification(
				false,
				array_merge(
					[
						'message'   => __( 'Restore error: ', 'wp-powersuite' ) . $safe_msg,
						'backup_id' => $bid,
					],
					$meta
				)
			);
			wp_send_json_error(
				array_merge(
					[
						'message' => __( 'Restore error: ', 'wp-powersuite' ) . $safe_msg,
					],
					$meta
				),
				500
			);
		} finally {
			remove_filter( 'wp_json_encode_options', $json_utf8_safe, 999 );
		}
	}

	public function ajax_restore_precheck(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		if ( '' === $bid ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup id.', 'wp-powersuite' ) ] );
		}
		$components = [];
		if ( isset( $_POST['components'] ) && is_string( $_POST['components'] ) && $_POST['components'] !== '' ) {
			$raw = json_decode( wp_unslash( $_POST['components'] ), true );
			$components = is_array( $raw ) ? $raw : [];
		}
		try {
			$service = new Includes\BackupService();
			$result  = $service->restore_precheck( $bid, $components );
			wp_send_json_success( $result );
		} catch ( \Throwable $e ) {
			wp_send_json_error( [ 'message' => $e->getMessage() ], 500 );
		}
	}

	public function ajax_restore_abort(): void {
		$bid = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		if ( '' === $bid ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup id.', 'wp-powersuite' ) ] );
		}
		if ( ! $this->backup_restore_restore_tick_authorized( $bid ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$service = new Includes\BackupService();
		$service->abort_restore( $bid );
		wp_send_json_success();
	}

	public function ajax_delete_backup(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$id = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		if ( ! $id ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup id.', 'wp-powersuite' ) ] );
		}
		try {
			$service = new Includes\BackupService();
			if ( $service->delete_backup( $id ) ) {
				wp_send_json_success();
			}
			wp_send_json_error( [ 'message' => __( 'Could not delete backup.', 'wp-powersuite' ) ] );
		} catch ( \Throwable $e ) {
			wp_send_json_error( [ 'message' => $e->getMessage() ], 500 );
		}
	}

	public function ajax_clear_stuck_lock(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$service = new Includes\BackupService();
		$service->force_clear_backup_lock();
		wp_send_json_success();
	}

	public function ajax_clear_activity_log(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$service = new Includes\BackupService();
		$service->prune_old_log_files( 0 );
		$root = $service->get_backup_root();
		$dirs = glob( trailingslashit( $root ) . '*', GLOB_ONLYDIR );
		if ( is_array( $dirs ) ) {
			foreach ( $dirs as $dir ) {
				foreach ( [ 'backup.log', 'restore.log' ] as $f ) {
					$p = trailingslashit( $dir ) . $f;
					if ( is_file( $p ) ) {
						// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
						@unlink( $p );
					}
				}
			}
		}
		$up_log = $service->get_upload_activity_log_abs_path();
		if ( is_file( $up_log ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $up_log );
		}
		wp_send_json_success();
	}

	public function ajax_delete_log_file(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid  = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		$file = isset( $_POST['log_file'] ) ? sanitize_file_name( wp_unslash( $_POST['log_file'] ) ) : '';
		$service = new Includes\BackupService();
		if (
			Includes\BackupService::UPLOAD_ACTIVITY_LOG_BACKUP_ID === $bid
			&& Includes\BackupService::UPLOAD_ACTIVITY_LOG_FILENAME === $file
		) {
			$path = $service->get_upload_activity_log_abs_path();
		} elseif ( '' === $bid || ! in_array( $file, [ 'backup.log', 'restore.log' ], true ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'wp-powersuite' ) ] );
			return;
		} else {
			$dir = $service->locate_backup_dir( $bid );
			$path = $dir !== '' ? $dir . $file : '';
		}
		if ( $path !== '' && is_file( $path ) && $service->is_path_under_managed_storage( $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $path );
		}
		wp_send_json_success();
	}

	/**
	 * Return lock-state JSON for the browser console (manage_options + nonce).
	 */
	public function ajax_lock_debug(): void {
		if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
			wp_send_json_error( [ 'message' => __( 'Lock debug is only available when WP_DEBUG is enabled.', 'wp-powersuite' ) ], 403 );
		}
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid     = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		$service = new Includes\BackupService();
		$snap    = $service->get_lock_debug_snapshot( self::VERSION, '' !== $bid ? $bid : null );
		wp_send_json_success( $snap );
	}

	public function ajax_rescan(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$service = new Includes\BackupService();
		$roots   = [];
		$primary = $service->get_backup_root();
		if ( $primary !== '' && is_dir( $primary ) ) {
			$roots[] = $primary;
		}
		foreach ( $service->get_legacy_backup_roots() as $legacy ) {
			if ( is_dir( $legacy ) ) {
				$roots[] = $legacy;
			}
		}
		$roots = array_values( array_unique( $roots ) );
		if ( $roots === [] ) {
			wp_send_json_success( [ 'count' => count( Includes\BackupService::get_history() ) ] );
		}

		$dirs = [];
		foreach ( $roots as $root ) {
			$found = glob( trailingslashit( $root ) . 'backup-*', GLOB_ONLYDIR );
			if ( is_array( $found ) ) {
				foreach ( $found as $dir ) {
					$dirs[] = $dir;
				}
			}
		}

		foreach ( $dirs as $dir ) {
			$bid = basename( $dir );
			if ( ! $service->is_allowed_backup_folder_id( $bid ) ) {
				continue;
			}
			$service->ensure_backup_in_history_from_disk( $bid );
		}

		foreach ( $dirs as $dir ) {
			$bid = basename( $dir );
			if ( $service->is_allowed_backup_folder_id( $bid ) ) {
				$service->sync_backup_metadata_from_disk( $bid );
			}
		}

		$hist = Includes\BackupService::get_history();
		wp_send_json_success( [ 'count' => count( $hist ) ] );
	}

	/**
	 * Part labels for backup components (aligned with admin table).
	 *
	 * @return array<string, string>
	 */
	private function backup_part_labels(): array {
		return [
			'database' => __( 'Database', 'wp-powersuite' ),
			'plugins'  => __( 'Plugins', 'wp-powersuite' ),
			'themes'   => __( 'Themes', 'wp-powersuite' ),
			'uploads'  => __( 'Uploads', 'wp-powersuite' ),
			'others'   => __( 'Others', 'wp-powersuite' ),
		];
	}

	/**
	 * Human-readable archive label + date for browse UI (Updraft-style).
	 *
	 * @return array{archive_label: string, plabel: string, date_fmt: string}
	 */
	private function browse_archive_context( string $bid, string $part ): array {
		$labels = $this->backup_part_labels();
		$plabel = $labels[ $part ] ?? $part;
		$hist   = Includes\BackupService::get_history();
		$ts     = isset( $hist[ $bid ]['created'] ) ? (int) $hist[ $bid ]['created'] : 0;
		if ( ! $ts ) {
			$srv = new Includes\BackupService();
			$dir = $srv->locate_backup_dir( $bid );
			$ts  = $dir !== '' && is_dir( $dir ) ? (int) filemtime( $dir ) : time();
		}
		$date_fmt = date_i18n( 'M d, Y H:i', $ts );
		/* translators: 1: part name (e.g. Plugins), 2: formatted backup date */
		$archive_label = sprintf( __( '%1$s archive %2$s', 'wp-powersuite' ), $plabel, $date_fmt );
		return [
			'archive_label' => $archive_label,
			'plabel'        => $plabel,
			'date_fmt'      => $date_fmt,
		];
	}

	public function ajax_part_panel(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid  = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		$part = isset( $_POST['part'] ) ? sanitize_key( wp_unslash( $_POST['part'] ) ) : '';
		if ( '' === $bid || '' === $part ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup or part.', 'wp-powersuite' ) ] );
		}
		$service = new Includes\BackupService();
		$file    = $service->get_component_download( $bid, $part );
		if ( ! is_array( $file ) ) {
			wp_send_json_error( [ 'message' => __( 'File not found.', 'wp-powersuite' ) ] );
		}
		$labels  = $this->backup_part_labels();
		$plabel  = $labels[ $part ] ?? $part;
		$hist    = Includes\BackupService::get_history();
		$ts      = isset( $hist[ $bid ]['created'] ) ? (int) $hist[ $bid ]['created'] : 0;
		if ( ! $ts ) {
			$dir = $service->locate_backup_dir( $bid );
			$ts  = $dir !== '' && is_dir( $dir ) ? (int) filemtime( $dir ) : time();
		}
		$date_fmt = date_i18n( 'M d, Y H:i', $ts );
		$fsize    = (int) @filesize( $file['path'] );
		$size_fmt = $fsize > 0 ? size_format( $fsize, 2 ) : '—';

		// Raw URL for JSON/JS: wp_nonce_url(..., true) runs esc_url() and encodes & as &amp;,
		// which breaks query parsing when the value is assigned via JavaScript (not decoded).
		$download_url = wp_nonce_url(
			add_query_arg(
				[
					'action' => 'wpps_br_download',
					'backup' => $bid,
					'part'   => $part,
				],
				admin_url( 'admin-post.php' )
			),
			'wpps_br_dl_' . $bid . '_' . $part,
			'_wpnonce',
			false
		);
		/* translators: 1: component name (e.g. Plugins), 2: backup date, 3: component key, 4: formatted file size */
		$status = sprintf(
			__( 'Download %1$s (%2$s): %3$s: File ready. (size: %4$s).', 'wp-powersuite' ),
			$plabel,
			$date_fmt,
			$part,
			$size_fmt
		);
		wp_send_json_success(
			[
				'backup_id'           => $bid,
				'part'                => $part,
				'part_label'          => $plabel,
				'backup_date_formatted' => $date_fmt,
				'status_line'         => $status,
				'size_formatted'      => $size_fmt,
				'download_url'        => $download_url,
				'can_browse'          => true,
			]
		);
	}

	public function ajax_delete_part(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid  = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		$part = isset( $_POST['part'] ) ? sanitize_key( wp_unslash( $_POST['part'] ) ) : '';
		if ( '' === $bid || '' === $part ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup or part.', 'wp-powersuite' ) ] );
		}
		$service = new Includes\BackupService();
		if ( ! $service->delete_backup_component( $bid, $part ) ) {
			wp_send_json_error( [ 'message' => __( 'Could not delete this file.', 'wp-powersuite' ) ] );
		}
		wp_send_json_success();
	}

	public function ajax_browse_part(): void {
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid  = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		$part = isset( $_POST['part'] ) ? sanitize_key( wp_unslash( $_POST['part'] ) ) : '';
		if ( '' === $bid || '' === $part ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup or part.', 'wp-powersuite' ) ] );
		}
		$service = new Includes\BackupService();
		$file    = $service->get_component_download( $bid, $part );
		if ( ! is_array( $file ) ) {
			wp_send_json_error( [ 'message' => __( 'File not found.', 'wp-powersuite' ) ] );
		}
		$ctx = $this->browse_archive_context( $bid, $part );

		if ( 'database' === $part ) {
			$sql_bytes = (int) @filesize( $file['path'] );
			$download_url = wp_nonce_url(
				add_query_arg(
					[
						'action' => 'wpps_br_download',
						'backup' => $bid,
						'part'   => $part,
					],
					admin_url( 'admin-post.php' )
				),
				'wpps_br_dl_' . $bid . '_' . $part,
				'_wpnonce',
				false
			);
			wp_send_json_success(
				[
					'kind'           => 'sql',
					'file'           => $file['filename'],
					'size_formatted' => $sql_bytes > 0 ? size_format( $sql_bytes, 2 ) : '—',
					'browse_title'   => $ctx['archive_label'],
					'download_url'   => $download_url,
				]
			);
		}
		$sub_raw = isset( $_POST['browse_subpath'] ) ? wp_unslash( (string) $_POST['browse_subpath'] ) : '';
		$sub_raw = str_replace( '\\', '/', $sub_raw );
		$sub_raw = trim( $sub_raw, '/' );
		if ( str_contains( $sub_raw, '..' ) ) {
			$sub_raw = '';
		}
		$children = $service->list_zip_children_at_path( $file['path'], $sub_raw );
		if ( ! is_array( $children ) ) {
			wp_send_json_error( [ 'message' => __( 'Could not read this archive.', 'wp-powersuite' ) ] );
		}
		$rows = [];
		foreach ( $children as $c ) {
			$rows[] = [
				'name'    => $c['name'],
				'isDir'   => ! empty( $c['is_dir'] ),
				'relPath' => $c['rel'],
				'size'    => ! empty( $c['is_dir'] ) ? null : (int) ( $c['size'] ?? 0 ),
			];
		}
		$browse_title = $ctx['archive_label'];
		wp_send_json_success(
			[
				'kind'             => 'zip',
				'file'             => $file['filename'],
				'browse_lazy'      => true,
				'children'         => $rows,
				'browse_subpath'   => $sub_raw,
				'browse_title'     => $browse_title,
				'archive_label'    => $ctx['archive_label'],
				'zip_entry_nonce'  => wp_create_nonce( 'wpps_br_zipentry_' . $bid . '_' . $part ),
			]
		);
	}

	public function handle_browse_zip_entry(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'Forbidden.', 'wp-powersuite' ) );
		}
		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
		$bid   = isset( $_GET['backup'] ) ? sanitize_file_name( wp_unslash( $_GET['backup'] ) ) : '';
		$part  = isset( $_GET['part'] ) ? sanitize_key( wp_unslash( $_GET['part'] ) ) : '';
		$entry = isset( $_GET['entry'] ) ? wp_unslash( $_GET['entry'] ) : '';
		$entry = is_string( $entry ) ? $entry : '';
		if ( ! $bid || ! $part || $entry === '' || ! wp_verify_nonce( $nonce, 'wpps_br_zipentry_' . $bid . '_' . $part ) ) {
			wp_die( esc_html__( 'Invalid download link.', 'wp-powersuite' ) );
		}
		$service = new Includes\BackupService();
		if ( ! $service->stream_zip_entry( $bid, $part, $entry ) ) {
			wp_die( esc_html__( 'File not found in archive.', 'wp-powersuite' ) );
		}
		exit;
	}

	/**
	 * Serve backup.log as download when dl_log query is present.
	 */
	public function maybe_serve_log_download(): void {
		if ( ! isset( $_GET['page'] ) || 'powersuite-backup-restore' !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) {
			return;
		}

		if ( ! empty( $_GET['dl_restore_log'] ) ) {
			$bid = sanitize_file_name( wp_unslash( $_GET['dl_restore_log'] ) );
			if ( '' === $bid || strpos( $bid, '..' ) !== false ) {
				return;
			}
			$log_token = isset( $_GET['restore_log_token'] ) ? sanitize_text_field( wp_unslash( $_GET['restore_log_token'] ) ) : '';
			$nonce     = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
			$service   = new Includes\BackupService();
			$auth_ok   = false;
			if ( '' !== $log_token && $service->verify_restore_log_token( $bid, $log_token ) ) {
				$auth_ok = true;
			} elseif ( $this->current_user_can_manage_backups() && '' !== $nonce && wp_verify_nonce( $nonce, 'wpps_br_dl_restore_log_' . $bid ) ) {
				$auth_ok = true;
			}
			if ( ! $auth_ok ) {
				return;
			}
			$dir  = $service->locate_backup_dir( $bid );
			$path = $dir !== '' ? $dir . 'restore.log' : '';
			if ( $path === '' || ! is_readable( $path ) || ! $service->is_path_under_managed_storage( $path ) ) {
				wp_die( esc_html__( 'Restore log not found.', 'wp-powersuite' ) );
			}
			$inline = ! empty( $_GET['display'] ) && 'inline' === sanitize_key( wp_unslash( $_GET['display'] ) );
			nocache_headers();
			header( 'Content-Type: text/plain; charset=utf-8' );
			header( $inline
				? 'Content-Disposition: inline'
				: 'Content-Disposition: attachment; filename="' . rawurlencode( $bid . '-restore.log' ) . '"'
			);
			header( 'Content-Length: ' . (string) filesize( $path ) );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
			readfile( $path );
			exit;
		}

		if ( ! $this->current_user_can_manage_backups() ) {
			return;
		}

		if ( empty( $_GET['dl_log'] ) ) {
			return;
		}
		$bid = sanitize_file_name( wp_unslash( $_GET['dl_log'] ) );
		if ( '' === $bid || strpos( $bid, '..' ) !== false ) {
			return;
		}
		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'wpps_br_dl_log_' . $bid ) ) {
			return;
		}
		$service = new Includes\BackupService();
		if ( Includes\BackupService::UPLOAD_ACTIVITY_LOG_BACKUP_ID === $bid ) {
			$path = $service->get_upload_activity_log_abs_path();
		} else {
			$dir  = $service->locate_backup_dir( $bid );
			$path = $dir !== '' ? $dir . 'backup.log' : '';
		}
		if ( $path === '' || ! is_readable( $path ) || ! $service->is_path_under_managed_storage( $path ) ) {
			wp_die( esc_html__( 'Log not found.', 'wp-powersuite' ) );
		}
		$inline = ! empty( $_GET['display'] ) && 'inline' === sanitize_key( wp_unslash( $_GET['display'] ) );
		nocache_headers();
		header( 'Content-Type: text/plain; charset=utf-8' );
		$dl_name = Includes\BackupService::UPLOAD_ACTIVITY_LOG_BACKUP_ID === $bid
			? Includes\BackupService::UPLOAD_ACTIVITY_LOG_FILENAME
			: ( $bid . '-backup.log' );
		header( $inline
			? 'Content-Disposition: inline'
			: 'Content-Disposition: attachment; filename="' . rawurlencode( $dl_name ) . '"'
		);
		header( 'Content-Length: ' . (string) filesize( $path ) );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		readfile( $path );
		exit;
	}

	/**
	 * Tick-based full ZIP prepare (one part per request) so hosts with short FastCGI limits do not 503.
	 * When packing finishes, returns a tokenized URL that serves the ready cache in place.
	 */
	public function ajax_bundle_prepare(): void {
		$this->prepare_large_ajax_json_response();
		if ( ! $this->current_user_can_manage_backups() || ! check_ajax_referer( 'wpps_br', 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 403 );
		}
		$bid = isset( $_POST['backup_id'] ) ? sanitize_file_name( wp_unslash( $_POST['backup_id'] ) ) : '';
		if ( '' === $bid ) {
			wp_send_json_error( [ 'message' => __( 'Missing backup id.', 'wp-powersuite' ) ] );
		}
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		}
		$service = new Includes\BackupService();
		$service->cleanup_stale_staged_downloads();
		if ( ! $service->has_safe_backup_storage() && $service->locate_backup_dir( $bid ) === '' ) {
			wp_send_json_error(
				[
					'message' => $service->get_storage_error_message() !== ''
						? $service->get_storage_error_message()
						: __( 'Could not build a bundle for this backup (no files on disk, or ZipArchive missing).', 'wp-powersuite' ),
				]
			);
		}

		$tick = $service->tick_bundle_prepare( $bid );
		if ( empty( $tick['success'] ) ) {
			wp_send_json_error(
				[
					'message'  => (string) ( $tick['message'] ?? __( 'Could not build a bundle for this backup (no files on disk, or ZipArchive missing).', 'wp-powersuite' ) ),
					'progress' => $tick['progress'] ?? null,
				]
			);
		}
		if ( empty( $tick['done'] ) ) {
			wp_send_json_success(
				[
					'done'     => false,
					'progress' => $tick['progress'] ?? null,
				]
			);
		}

		// The tick returns the reusable ready-cache path inside the backup folder; serve it in place
		// (no multi-hundred-MB staging copy — that copy was itself a 503 source).
		$path = isset( $tick['path'] ) ? (string) $tick['path'] : '';
		$name = isset( $tick['filename'] ) && is_string( $tick['filename'] ) ? $tick['filename'] : 'wpps-backup-bundle.zip';
		$path_real = '' !== $path ? realpath( $path ) : false;
		if ( is_string( $path_real ) && '' !== $path_real ) {
			$path = $path_real;
		}
		if ( '' === $path || ! is_readable( $path ) || ! $this->is_path_inside_allowed_dir( $path ) ) {
			wp_send_json_error( [ 'message' => __( 'Could not prepare bundle path.', 'wp-powersuite' ) ] );
		}
		$filesize = (int) filesize( $path );
		if ( $filesize < 1 ) {
			wp_send_json_error( [ 'message' => __( 'Could not build a bundle for this backup (no files on disk, or ZipArchive missing).', 'wp-powersuite' ) ] );
		}

		$token   = strtolower( wp_generate_password( 32, false, false ) );
		$payload = [
			'backup_id' => $bid,
			'user_id'   => get_current_user_id(),
			'path'      => $path,
			'filename'  => $name,
			'filesize'  => $filesize,
		];
		set_transient( 'wpps_br_bundlert_' . $token, $payload, HOUR_IN_SECONDS );
		$download_url = wp_nonce_url(
			add_query_arg(
				[
					'action' => 'wpps_br_download_bundle_ready',
					'backup' => $bid,
					'token'  => $token,
				],
				admin_url( 'admin-post.php' )
			),
			'wpps_br_dl_bundle_ready_' . $bid . '_' . $token,
			'_wpnonce',
			false
		);

		wp_send_json_success(
			[
				'done'         => true,
				'download_url' => $download_url,
				'file_size'    => $filesize,
				'filename'     => $name,
				'progress'     => isset( $tick['progress'] ) && is_array( $tick['progress'] ) ? $tick['progress'] : null,
			]
		);
	}

	/**
	 * Serve a bundle file created in ajax_bundle_prepare (token valid for HOUR_IN_SECONDS; reusable for Range chunk downloads).
	 */
	public function handle_download_bundle_ready(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'Forbidden.', 'wp-powersuite' ) );
		}
		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
		$bid   = isset( $_GET['backup'] ) ? sanitize_file_name( wp_unslash( $_GET['backup'] ) ) : '';
		$token = isset( $_GET['token'] ) ? sanitize_key( wp_unslash( $_GET['token'] ) ) : '';
		if ( ! $bid || ! $token || ! wp_verify_nonce( $nonce, 'wpps_br_dl_bundle_ready_' . $bid . '_' . $token ) ) {
			wp_die( esc_html__( 'Invalid download link.', 'wp-powersuite' ) );
		}
		$row = get_transient( 'wpps_br_bundlert_' . $token );
		if ( ! is_array( $row ) ) {
			wp_die( esc_html__( 'This download link has expired. Prepare the bundle again.', 'wp-powersuite' ) );
		}
		$uid = get_current_user_id();
		if ( $uid <= 0 || (int) ( $row['user_id'] ?? 0 ) !== $uid ) {
			wp_die( esc_html__( 'Forbidden.', 'wp-powersuite' ) );
		}
		if ( (string) ( $row['backup_id'] ?? '' ) !== $bid ) {
			wp_die( esc_html__( 'Invalid download link.', 'wp-powersuite' ) );
		}
		$path = (string) ( $row['path'] ?? '' );
		$name = (string) ( $row['filename'] ?? 'wpps-backup-bundle.zip' );
		if ( '' === $path || ! is_readable( $path ) || ! $this->is_path_inside_allowed_dir( $path ) ) {
			delete_transient( 'wpps_br_bundlert_' . $token );
			wp_die( esc_html__( 'Bundle file is no longer available.', 'wp-powersuite' ) );
		}
		$full = (int) filesize( $path );
		if ( $full < 1 ) {
			delete_transient( 'wpps_br_bundlert_' . $token );
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $path );
			wp_die( esc_html__( 'Bundle file is no longer available.', 'wp-powersuite' ) );
		}

		$range = $this->resolve_http_byte_range( $full );
		if ( '416' === $range ) {
			status_header( 416 );
			header( 'Content-Range: bytes */' . (string) $full );
			exit;
		}

		$this->prepare_large_file_download();
		nocache_headers();
		header( 'Content-Encoding: identity' );

		if ( is_array( $range ) ) {
			status_header( 206 );
			header( 'Accept-Ranges: bytes' );
			header( 'Content-Type: application/zip' );
			header( 'Content-Disposition: attachment; filename="' . rawurlencode( $name ) . '"' );
			header(
				sprintf(
					'Content-Range: bytes %d-%d/%d',
					(int) $range['start'],
					(int) $range['end'],
					$full
				)
			);
			header( 'Content-Length: ' . (string) (int) $range['length'] );
			$this->stream_file_path_range( $path, (int) $range['start'], (int) $range['length'] );
			exit;
		}

		/*
		 * Always PHP-stream bundle bodies. X-Sendfile/Accel against private storage often yields 0-byte downloads.
		 *
		 * Do NOT delete the file or token here. Proxies often strip Range so the browser/JS first gets a
		 * full 200, then retries (Range or second GET). Immediate unlink caused "save dialog then empty/never
		 * finishes" on those hosts. The file is the reusable ready cache; it lives with the backup folder.
		 */
		header( 'Accept-Ranges: bytes' );
		header( 'Content-Type: application/zip' );
		header( 'Content-Disposition: attachment; filename="' . rawurlencode( $name ) . '"' );
		header( 'Content-Length: ' . (string) $full );
		// Keep token alive for retries / Range continuation after a full GET.
		set_transient( 'wpps_br_bundlert_' . $token, $row, HOUR_IN_SECONDS );
		$this->stream_readfile_path( $path );
		exit;
	}

	public function handle_download(): void {
		if ( ! $this->current_user_can_manage_backups() ) {
			wp_die( esc_html__( 'Forbidden.', 'wp-powersuite' ) );
		}
		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
		$bid   = isset( $_GET['backup'] ) ? sanitize_file_name( wp_unslash( $_GET['backup'] ) ) : '';
		$part  = isset( $_GET['part'] ) ? sanitize_key( wp_unslash( $_GET['part'] ) ) : '';
		if ( ! $bid || ! $part || ! wp_verify_nonce( $nonce, 'wpps_br_dl_' . $bid . '_' . $part ) ) {
			wp_die( esc_html__( 'Invalid download link.', 'wp-powersuite' ) );
		}
		$service = new Includes\BackupService();
		$file    = $service->get_component_download( $bid, $part );
		if ( ! is_array( $file ) ) {
			wp_die( esc_html__( 'File not found.', 'wp-powersuite' ) );
		}
		$path = $file['path'];
		$name = $file['filename'];
		if ( ! $this->is_path_inside_allowed_dir( $path ) ) {
			wp_die( esc_html__( 'File not found.', 'wp-powersuite' ) );
		}
		$full = (int) filesize( $path );
		if ( $full < 1 ) {
			wp_die( esc_html__( 'File not found.', 'wp-powersuite' ) );
		}
		$range = $this->resolve_http_byte_range( $full );
		if ( '416' === $range ) {
			status_header( 416 );
			header( 'Content-Range: bytes */' . (string) $full );
			exit;
		}

		$this->prepare_large_file_download();
		nocache_headers();
		header( 'Content-Encoding: identity' );

		if ( is_array( $range ) ) {
			status_header( 206 );
			header( 'Content-Type: application/octet-stream' );
			header( 'Content-Disposition: attachment; filename="' . rawurlencode( $name ) . '"' );
			header(
				sprintf(
					'Content-Range: bytes %d-%d/%d',
					(int) $range['start'],
					(int) $range['end'],
					$full
				)
			);
			header( 'Content-Length: ' . (string) (int) $range['length'] );
			$this->stream_file_path_range( $path, (int) $range['start'], (int) $range['length'] );
			exit;
		}

		header( 'Accept-Ranges: bytes' );
		if ( $this->try_offload_file_download( $path, $name, 'application/octet-stream' ) ) {
			exit;
		}
		header( 'Content-Type: application/octet-stream' );
		header( 'Content-Disposition: attachment; filename="' . rawurlencode( $name ) . '"' );
		header( 'Content-Length: ' . (string) $full );
		$this->stream_readfile_path( $path );
		exit;
	}

}
