<?php
/**
 * Backup Module
 *
 * Create and restore local backups of your site files and database.
 *
 * @package BlaminhorEssentials
 */

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Backup module class
 */
class Blaminhor_Essentials_Module_Backup extends Blaminhor_Essentials_Module {

	/**
	 * Module ID
	 *
	 * @var string
	 */
	protected $id = 'backup';

	/**
	 * Module name
	 *
	 * @var string
	 */
	protected $name = 'Backup & Restore';

	/**
	 * Backup directory path
	 *
	 * @var string
	 */
	private $backup_dir;

	/**
	 * Current log file path
	 *
	 * @var string
	 */
	private $log_file = '';

	/**
	 * Initialize module
	 */
	protected function init() {
		$this->name        = __( 'Backup & Restore', 'blaminhor-essentials' );
		$this->description = __( 'Create and restore local backups of your site files and database.', 'blaminhor-essentials' );
		$this->backup_dir  = is_multisite()
			? WP_CONTENT_DIR . '/blaminhor-essentials/backups/' . get_current_blog_id()
			: WP_CONTENT_DIR . '/blaminhor-essentials/backups';
	}

	/**
	 * Get default settings
	 *
	 * @return array
	 */
	protected function get_default_settings() {
		return array(
			'backup_database'           => true,
			'backup_plugins'            => true,
			'backup_themes'             => true,
			'backup_uploads'            => true,
			'backup_wp_content'         => false,
			'backup_wp_core'            => false,
			'retention_count'              => 5,
			'max_archive_size'             => 200,
			'backup_log_retention'         => 3,
			// Scheduled backup settings.
			'schedule_enabled'             => false,
			'schedule_db_frequency'        => 'daily',
			'schedule_db_time'             => '03:00',
			'schedule_files_frequency'     => 'weekly',
			'schedule_files_time'          => '03:00',
			'schedule_db_retention'        => 7,
			'schedule_files_retention'     => 7,
			// Pre-update backup settings.
			'auto_backup_on_update'     => false,
			'auto_backup_core_update'   => true,
			'auto_backup_plugin_update' => true,
		);
	}

	/**
	 * Register hooks
	 */
	protected function register_hooks() {
		add_action( 'admin_init', array( $this, 'handle_settings_submission' ) );
		add_action( 'admin_init', array( $this, 'handle_backup_actions' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );

		// AJAX handlers.
		add_action( 'wp_ajax_ap_backup_create', array( $this, 'ajax_create_backup' ) );
		add_action( 'wp_ajax_ap_backup_delete', array( $this, 'ajax_delete_backup' ) );
		add_action( 'wp_ajax_ap_backup_download', array( $this, 'ajax_download_backup' ) );
		add_action( 'wp_ajax_ap_backup_download_bundle', array( $this, 'ajax_download_bundle' ) );
		add_action( 'wp_ajax_ap_backup_restore', array( $this, 'ajax_restore_backup' ) );
		add_action( 'wp_ajax_ap_backup_undo_restore', array( $this, 'ajax_undo_restore' ) );
		add_action( 'wp_ajax_ap_backup_clear_lock', array( $this, 'ajax_clear_lock' ) );
		add_action( 'wp_ajax_ap_backup_dismiss_pre_restore', array( $this, 'ajax_dismiss_pre_restore' ) );
		add_action( 'wp_ajax_ap_backup_upload', array( $this, 'ajax_upload_backup' ) );
		add_action( 'wp_ajax_ap_backup_get_domain_info', array( $this, 'ajax_get_domain_info' ) );
		add_action( 'wp_ajax_ap_backup_create_step', array( $this, 'ajax_create_backup_step' ) );
		add_action( 'wp_ajax_ap_backup_get_log', array( $this, 'ajax_get_backup_log' ) );
		add_action( 'wp_ajax_ap_backup_get_list', array( $this, 'ajax_get_backup_list' ) );
		add_action( 'wp_ajax_ap_backup_bulk_delete', array( $this, 'ajax_bulk_delete_backups' ) );

		// Scheduled backup cron (separate hooks for DB and files).
		add_action( 'blaminhor_essentials_scheduled_backup_db', array( $this, 'run_scheduled_backup_db' ) );
		add_action( 'blaminhor_essentials_scheduled_backup_files', array( $this, 'run_scheduled_backup_files' ) );
		add_filter( 'cron_schedules', array( $this, 'add_cron_schedules' ) );
		$this->maybe_schedule_backup();

		// Pre-update backup hook.
		if ( $this->get_setting( 'auto_backup_on_update', false ) ) {
			add_filter( 'upgrader_pre_install', array( $this, 'maybe_backup_before_update' ), 10, 2 );
		}

		// Create backup directory on init.
		$this->ensure_backup_directory();
	}

	/**
	 * Enqueue module assets
	 *
	 * @param string $hook Admin page hook.
	 */
	public function enqueue_assets( $hook ) {
		if ( strpos( $hook, 'blaminhor-essentials-backup' ) === false ) {
			return;
		}

		$config = array(
			'nonce'          => wp_create_nonce( 'blaminhor_essentials_admin' ),
			'ajaxurl'        => admin_url( 'admin-ajax.php' ),
			'maxUploadSize'  => wp_max_upload_size(),
			'maxUploadLabel' => size_format( wp_max_upload_size() ),
			'strings'        => array(
				'error'              => __( 'An error occurred. Please try again.', 'blaminhor-essentials' ),
				'confirmDelete'      => __( 'Are you sure you want to delete this backup?', 'blaminhor-essentials' ),
				'confirmBulkDelete'  => __( 'Are you sure you want to delete the selected backups?', 'blaminhor-essentials' ),
				/* translators: %d: number of selected backups */
				'selectedCount'      => __( '%d selected', 'blaminhor-essentials' ),
				'deleting'           => __( 'Deleting...', 'blaminhor-essentials' ),
				'confirmUndo'   => __( 'Are you sure you want to undo the last restore? This will revert your site to its previous state.', 'blaminhor-essentials' ),
				'undoing'       => __( 'Undoing...', 'blaminhor-essentials' ),
				'onlyZip'       => __( 'Only ZIP files are allowed.', 'blaminhor-essentials' ),
				'processing'    => __( 'Processing...', 'blaminhor-essentials' ),
				'backupFrom'    => __( 'This backup is from', 'blaminhor-essentials' ),
				'currentDomain' => __( 'Current domain is', 'blaminhor-essentials' ),
				'initializing'  => __( 'Initializing...', 'blaminhor-essentials' ),
				'creating'      => __( 'Backing up', 'blaminhor-essentials' ),
				'complete'      => __( 'Complete!', 'blaminhor-essentials' ),
				'backupSuccess'    => __( 'Backup created successfully!', 'blaminhor-essentials' ),
				'uploadSuccess'    => __( 'Backup uploaded successfully!', 'blaminhor-essentials' ),
				'uploading'        => __( 'Uploading', 'blaminhor-essentials' ),
			'preparing'        => __( 'Preparing...', 'blaminhor-essentials' ),
				'backupsUploaded'  => __( 'backups uploaded successfully!', 'blaminhor-essentials' ),
				'uploadsFailed'    => __( 'file(s) failed to upload.', 'blaminhor-essentials' ),
				'fileTooLarge'     => __( 'The following file(s) exceed the server upload limit', 'blaminhor-essentials' ),
			),
		);

		wp_add_inline_script(
			'blaminhor-essentials-modules',
			'jQuery(document).ready(function($) { BlaminhorEssentialsBackup.init(' . wp_json_encode( $config ) . '); });'
		);
	}

	/**
	 * Ensure backup directory exists and is protected
	 */
	private function ensure_backup_directory() {
		$dir = $this->backup_dir;

		if ( ! file_exists( $dir ) ) {
			wp_mkdir_p( $dir );
		}

		// Create .htaccess to protect backups.
		$htaccess = $dir . '/.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			file_put_contents( $htaccess, "Order deny,allow\nDeny from all" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		}

		// Create index.php for additional protection.
		$index = $dir . '/index.php';
		if ( ! file_exists( $index ) ) {
			file_put_contents( $index, '<?php // Silence is golden.' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		}
	}

	/**
	 * Add custom cron schedules
	 *
	 * @param array $schedules Existing schedules.
	 * @return array Modified schedules.
	 */
	public function add_cron_schedules( $schedules ) {
		$schedules['weekly'] = array(
			'interval' => 604800,
			'display'  => __( 'Once Weekly', 'blaminhor-essentials' ),
		);
		$schedules['biweekly'] = array(
			'interval' => 1209600,
			'display'  => __( 'Every Two Weeks', 'blaminhor-essentials' ),
		);
		$schedules['monthly'] = array(
			'interval' => 2592000,
			'display'  => __( 'Once Monthly', 'blaminhor-essentials' ),
		);
		return $schedules;
	}

	/**
	 * Schedule or unschedule backup based on settings.
	 *
	 * Manages two separate cron events: one for database backups and one for file backups.
	 * Also handles migration from the legacy single-hook system.
	 */
	private function maybe_schedule_backup() {
		// Migrate legacy settings if needed.
		$this->migrate_legacy_schedule_settings();

		// Unschedule legacy hook if still present.
		$legacy_hook = 'blaminhor_essentials_scheduled_backup';
		$legacy_ts   = wp_next_scheduled( $legacy_hook );
		if ( $legacy_ts ) {
			wp_unschedule_event( $legacy_ts, $legacy_hook );
		}

		$db_hook    = 'blaminhor_essentials_scheduled_backup_db';
		$files_hook = 'blaminhor_essentials_scheduled_backup_files';
		$enabled    = $this->get_setting( 'schedule_enabled', false );

		if ( ! $enabled ) {
			$this->unschedule_hook( $db_hook );
			$this->unschedule_hook( $files_hook );
			return;
		}

		// Schedule DB backup.
		$this->schedule_cron_event(
			$db_hook,
			$this->get_setting( 'schedule_db_frequency', 'daily' ),
			$this->get_setting( 'schedule_db_time', '03:00' )
		);

		// Schedule files backup.
		$this->schedule_cron_event(
			$files_hook,
			$this->get_setting( 'schedule_files_frequency', 'weekly' ),
			$this->get_setting( 'schedule_files_time', '03:00' )
		);
	}

	/**
	 * Schedule a single cron event with the given frequency and time.
	 *
	 * @param string $hook      The cron hook name.
	 * @param string $frequency Recurrence: daily, weekly, monthly.
	 * @param string $time      Time in HH:MM format.
	 */
	private function schedule_cron_event( $hook, $frequency, $time ) {
		$scheduled = wp_next_scheduled( $hook );

		// Calculate next run time.
		$time_parts = explode( ':', $time );
		$hour       = isset( $time_parts[0] ) ? intval( $time_parts[0] ) : 3;
		$minute     = isset( $time_parts[1] ) ? intval( $time_parts[1] ) : 0;

		$next_run = strtotime( 'today ' . $hour . ':' . str_pad( $minute, 2, '0', STR_PAD_LEFT ) );
		if ( $next_run < time() ) {
			$next_run = strtotime( 'tomorrow ' . $hour . ':' . str_pad( $minute, 2, '0', STR_PAD_LEFT ) );
		}

		// Check if we need to reschedule (frequency changed).
		if ( $scheduled ) {
			$current_schedule = wp_get_schedule( $hook );
			if ( $current_schedule !== $frequency ) {
				wp_unschedule_event( $scheduled, $hook );
				$scheduled = false;
			}
		}

		if ( ! $scheduled ) {
			wp_schedule_event( $next_run, $frequency, $hook );
		}
	}

	/**
	 * Unschedule a cron hook if it is currently scheduled.
	 *
	 * @param string $hook The cron hook name.
	 */
	private function unschedule_hook( $hook ) {
		$timestamp = wp_next_scheduled( $hook );
		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, $hook );
		}
	}

	/**
	 * Migrate legacy single-schedule settings to the new DB/files split.
	 *
	 * If the old schedule_frequency setting exists and the new schedule_db_frequency
	 * has not been explicitly set, copy the old values to both new pairs.
	 */
	private function migrate_legacy_schedule_settings() {
		$settings = $this->load_settings();

		// Only migrate if old key exists and new key does not.
		if ( isset( $settings['schedule_frequency'] ) && ! isset( $settings['schedule_db_frequency'] ) ) {
			$settings['schedule_db_frequency']    = $settings['schedule_frequency'];
			$settings['schedule_db_time']         = isset( $settings['schedule_time'] ) ? $settings['schedule_time'] : '03:00';
			$settings['schedule_files_frequency'] = $settings['schedule_frequency'];
			$settings['schedule_files_time']      = isset( $settings['schedule_time'] ) ? $settings['schedule_time'] : '03:00';

			// Remove legacy keys.
			unset( $settings['schedule_frequency'], $settings['schedule_time'] );

			$this->save_settings( $settings );
		}
	}

	/**
	 * Run scheduled database backup.
	 */
	public function run_scheduled_backup_db() {
		$options = array(
			'database'   => true,
			'plugins'    => false,
			'themes'     => false,
			'uploads'    => false,
			'wp_content' => false,
			'wp_core'    => false,
		);

		$result = $this->create_backup( $options, '-scheduled' );

		if ( $result && ! is_wp_error( $result ) ) {
			$this->cleanup_scheduled_backups( 'database' );
		}
	}

	/**
	 * Run scheduled files backup.
	 */
	public function run_scheduled_backup_files() {
		$options = array(
			'database'   => false,
			'plugins'    => $this->get_setting( 'backup_plugins', true ),
			'themes'     => $this->get_setting( 'backup_themes', true ),
			'uploads'    => $this->get_setting( 'backup_uploads', true ),
			'wp_content' => $this->get_setting( 'backup_wp_content', false ),
			'wp_core'    => $this->get_setting( 'backup_wp_core', false ),
		);

		// Skip if no file components are enabled.
		$has_files = $options['plugins'] || $options['themes'] || $options['uploads'] || $options['wp_content'] || $options['wp_core'];
		if ( ! $has_files ) {
			return;
		}

		$result = $this->create_backup( $options, '-scheduled' );

		if ( $result && ! is_wp_error( $result ) ) {
			$this->cleanup_scheduled_backups( 'files' );
		}
	}

	/**
	 * Clean up old scheduled backups based on retention setting.
	 *
	 * Identifies scheduled backups by the '-scheduled' suffix in the prefix.
	 *
	 * @param string $backup_type Type of backup to clean up: 'database' or 'files'.
	 */
	private function cleanup_scheduled_backups( $backup_type ) {
		$retention_key = 'database' === $backup_type ? 'schedule_db_retention' : 'schedule_files_retention';
		$retention     = intval( $this->get_setting( $retention_key, 7 ) );

		// Backward compatibility: fall back to old single retention setting.
		if ( ! $retention ) {
			$retention = intval( $this->get_setting( 'schedule_retention', 7 ) );
		}

		if ( $retention < 1 ) {
			return;
		}

		$backups = $this->get_backups();

		// Filter only scheduled backups of the given type.
		$scheduled_backups = array_filter( $backups, function( $backup ) use ( $backup_type ) {
			return isset( $backup['prefix'] ) && strpos( $backup['prefix'], '-scheduled' ) !== false
				&& isset( $backup['type'] ) && $backup['type'] === $backup_type;
		} );

		// Sort by date descending.
		usort( $scheduled_backups, function( $a, $b ) {
			return strtotime( $b['date'] ) - strtotime( $a['date'] );
		} );

		// Delete backups beyond retention count.
		$to_delete = array_slice( $scheduled_backups, $retention );

		foreach ( $to_delete as $backup ) {
			$this->delete_backup( $backup['id'] );
		}
	}


	/**
	 * Maybe create backup before update
	 *
	 * @param bool|WP_Error $return Return value (pass-through).
	 * @param array         $hook_extra Extra arguments passed to the hook.
	 * @return bool|WP_Error
	 */
	public function maybe_backup_before_update( $return, $hook_extra ) {
		// Don't interfere if there's already an error.
		if ( is_wp_error( $return ) ) {
			return $return;
		}

		// Check if this is a core update.
		if ( isset( $hook_extra['type'] ) && 'core' === $hook_extra['type'] ) {
			if ( $this->get_setting( 'auto_backup_core_update', true ) ) {
				$this->create_pre_update_backup( 'core' );
			}
			return $return;
		}

		// Check if this is a plugin update.
		if ( isset( $hook_extra['plugin'] ) ) {
			if ( $this->get_setting( 'auto_backup_plugin_update', true ) ) {
				$this->create_pre_update_backup( 'plugin', $hook_extra['plugin'] );
			}
			return $return;
		}

		// Check if this is a theme update.
		if ( isset( $hook_extra['theme'] ) ) {
			if ( $this->get_setting( 'auto_backup_plugin_update', true ) ) {
				$this->create_pre_update_backup( 'theme', $hook_extra['theme'] );
			}
		}

		return $return;
	}

	/**
	 * Create backup before update
	 *
	 * @param string $type   Type of update: 'core', 'plugin', or 'theme'.
	 * @param string $target Target plugin file or theme slug (optional).
	 */
	private function create_pre_update_backup( $type, $target = null ) {
		$this->ensure_backup_directory();

		// Increase limits for backup.
		if ( function_exists( 'set_time_limit' ) ) {
			// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required to prevent timeout during backup operations.
			set_time_limit( 300 );
		}
		wp_raise_memory_limit( 'admin' );

		$prefix = sprintf(
			'%s-%s-%s-pre-update',
			$this->get_site_name_slug(),
			current_time( 'Y-m-d' ),
			current_time( 'H-i-s' )
		);

		$components = array( 'database' );

		if ( 'core' === $type ) {
			// For core updates: backup database + wp-core files.
			$components[] = 'wp-core';
			$this->create_backup( $components, 'pre-update-core' );
		} elseif ( 'plugin' === $type && $target ) {
			// For plugin updates: backup database + specific plugin.
			$this->create_single_plugin_backup( $prefix, $target );
		} elseif ( 'theme' === $type && $target ) {
			// For theme updates: backup database + specific theme.
			$this->create_single_theme_backup( $prefix, $target );
		}
	}

	/**
	 * Create backup of a single plugin
	 *
	 * @param string $prefix      Backup prefix.
	 * @param string $plugin_file Plugin file (e.g., "akismet/akismet.php").
	 * @return bool
	 */
	private function create_single_plugin_backup( $prefix, $plugin_file ) {
		if ( ! class_exists( 'ZipArchive' ) ) {
			return false;
		}

		// Extract plugin directory from plugin file.
		$plugin_dir = dirname( $plugin_file );
		if ( '.' === $plugin_dir ) {
			// Single-file plugin.
			$plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file;
		} else {
			$plugin_path = WP_PLUGIN_DIR . '/' . $plugin_dir;
		}

		if ( ! file_exists( $plugin_path ) ) {
			return false;
		}

		// Create database backup first.
		$this->create_component_archive( $prefix, 'database' );

		// Create plugin backup.
		$plugin_archive = $this->backup_dir . '/' . $prefix . '-plugin-' . sanitize_file_name( $plugin_dir ?: basename( $plugin_file, '.php' ) ) . '.zip';

		$zip = new ZipArchive();
		if ( true !== $zip->open( $plugin_archive, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
			return false;
		}

		// Add manifest.
		$manifest = array(
			'created'     => current_time( 'Y-m-d H:i:s' ),
			'site_name'   => get_bloginfo( 'name' ),
			'site_url'    => home_url(),
			'wp_version'  => get_bloginfo( 'version' ),
			'php_version' => phpversion(),
			'component'   => 'plugin',
			'plugin'      => $plugin_file,
			'prefix'      => $prefix,
			'type'        => 'pre-update-plugin',
		);
		$zip->addFromString( 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) );

		// Add plugin files.
		if ( is_dir( $plugin_path ) ) {
			$this->add_directory_to_zip( $zip, $plugin_path, $plugin_dir );
		} else {
			$zip->addFile( $plugin_path, basename( $plugin_file ) );
		}

		$zip->close();

		return true;
	}

	/**
	 * Create backup of a single theme
	 *
	 * @param string $prefix     Backup prefix.
	 * @param string $theme_slug Theme directory name.
	 * @return bool
	 */
	private function create_single_theme_backup( $prefix, $theme_slug ) {
		if ( ! class_exists( 'ZipArchive' ) ) {
			return false;
		}

		$theme_path = get_theme_root() . '/' . $theme_slug;

		if ( ! is_dir( $theme_path ) ) {
			return false;
		}

		// Create database backup first.
		$this->create_component_archive( $prefix, 'database' );

		// Create theme backup.
		$theme_archive = $this->backup_dir . '/' . $prefix . '-theme-' . sanitize_file_name( $theme_slug ) . '.zip';

		$zip = new ZipArchive();
		if ( true !== $zip->open( $theme_archive, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
			return false;
		}

		// Add manifest.
		$manifest = array(
			'created'     => current_time( 'Y-m-d H:i:s' ),
			'site_name'   => get_bloginfo( 'name' ),
			'site_url'    => home_url(),
			'wp_version'  => get_bloginfo( 'version' ),
			'php_version' => phpversion(),
			'component'   => 'theme',
			'theme'       => $theme_slug,
			'prefix'      => $prefix,
			'type'        => 'pre-update-theme',
		);
		$zip->addFromString( 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) );

		// Add theme files.
		$this->add_directory_to_zip( $zip, $theme_path, $theme_slug );

		$zip->close();

		return true;
	}

	/**
	 * Get sanitized site name for filename
	 *
	 * @return string
	 */
	private function get_site_name_slug() {
		$site_name = get_bloginfo( 'name' );
		$site_name = sanitize_title( $site_name );
		$site_name = substr( $site_name, 0, 30 ); // Limit length.
		return $site_name ?: 'wordpress';
	}

	/**
	 * Handle backup actions
	 */
	public function handle_backup_actions() {
		if ( ! isset( $_GET['ap_backup_action'] ) || ! isset( $_GET['_wpnonce'] ) ) {
			return;
		}

		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ap_backup_action' ) ) {
			return;
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$action = sanitize_key( $_GET['ap_backup_action'] );

		if ( 'download' === $action && isset( $_GET['file'] ) ) {
			$file = sanitize_file_name( wp_unslash( $_GET['file'] ) );
			$path = $this->backup_dir . '/' . $file;

			if ( file_exists( $path ) && strpos( realpath( $path ), realpath( $this->backup_dir ) ) === 0 ) {
				header( 'Content-Type: application/zip' );
				header( 'Content-Disposition: attachment; filename="' . $file . '"' );
				header( 'Content-Length: ' . filesize( $path ) );
				readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
				exit;
			}
		}
	}

	/**
	 * Get list of existing backups grouped by prefix
	 *
	 * @return array
	 */
	private function get_backups() {
		$backups = array();

		if ( ! is_dir( $this->backup_dir ) ) {
			return $backups;
		}

		$files = glob( $this->backup_dir . '/*.zip' );
		if ( ! $files ) {
			return $backups;
		}

		// Group files by prefix (sitename-YYYY-MM-DD-HHMMSS).
		$grouped = array();
		foreach ( $files as $file ) {
			$filename = basename( $file );
			$info     = $this->parse_backup_filename( $filename );

			$prefix = $info['prefix'];
			if ( ! isset( $grouped[ $prefix ] ) ) {
				$grouped[ $prefix ] = array(
					'prefix'      => $prefix,
					'archives'    => array(),
					'total_size'  => 0,
					'date'        => filemtime( $file ),
					'date_human'  => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), filemtime( $file ) ),
					'components'  => array(),
					'targets'     => array(),
				);
			}

			$size = filesize( $file );
			$grouped[ $prefix ]['archives'][] = array(
				'filename'   => $filename,
				'path'       => $file,
				'size'       => $size,
				'size_human' => size_format( $size, 2 ),
				'component'  => $info['component'],
			);
			$grouped[ $prefix ]['total_size'] += $size;
			$grouped[ $prefix ]['components'][] = $info['component'];
			$grouped[ $prefix ]['targets'][]    = $info['target'] ?? '';

			// Keep the most recent date.
			$file_date = filemtime( $file );
			if ( $file_date > $grouped[ $prefix ]['date'] ) {
				$grouped[ $prefix ]['date']       = $file_date;
				$grouped[ $prefix ]['date_human'] = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $file_date );
			}
		}

		// Deduplicate components and targets (multi-part archives repeat the same component).
		foreach ( $grouped as $prefix => $data ) {
			$grouped[ $prefix ]['components'] = array_values( array_unique( $data['components'] ) );
			$grouped[ $prefix ]['targets']    = array_values( array_filter( array_unique( $data['targets'] ) ) );
		}

		// Convert to array and add computed fields.
		foreach ( $grouped as $prefix => $data ) {
			$data['size_human'] = size_format( $data['total_size'], 2 );
			$data['type']       = $this->get_backup_type_from_components( $data['components'] );
			$data['type_label'] = $this->get_backup_type_label( $data['type'] );

			// Check for log file and determine backup status.
			$log_path = $this->backup_dir . '/' . $prefix . '.log';
			if ( file_exists( $log_path ) ) {
				$data['has_log'] = true;
				// Read last line to determine if backup completed.
				$log_content = file_get_contents( $log_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
				$lines       = array_filter( explode( "\n", trim( $log_content ) ) );
				$last_line   = end( $lines );
				$data['status'] = ( false !== strpos( $last_line, 'Backup completed' ) ) ? 'complete' : 'interrupted';
			} else {
				$data['has_log'] = false;
				$data['status']  = 'complete'; // Legacy backups without log.
			}

			$backups[] = $data;
		}

		// Sort by date descending.
		usort( $backups, function( $a, $b ) {
			return $b['date'] - $a['date'];
		} );

		return $backups;
	}

	/**
	 * Parse backup filename to extract prefix and component
	 *
	 * Filename format: sitename-YYYY-MM-DD-HHMMSS-component.zip
	 *
	 * @param string $filename Filename.
	 * @return array
	 */
	private function parse_backup_filename( $filename ) {
		// Remove .zip extension.
		$base = str_replace( '.zip', '', $filename );

		// Detect multi-part pattern: component-partN at the end.
		$part = 0;
		if ( preg_match( '/-part(\d+)$/', $base, $part_match ) ) {
			$part = (int) $part_match[1];
			$base = substr( $base, 0, -strlen( $part_match[0] ) );
		}

		// Known components.
		$components = array( 'database', 'plugins', 'themes', 'uploads', 'wp-content', 'wp-core', 'full', 'files', 'custom', 'pre-restore', 'uploaded', 'scheduled', 'bundle', 'db-optimizer', 'domain-change' );

		$component = 'unknown';
		$prefix    = $base;
		$target    = '';

		foreach ( $components as $comp ) {
			if ( substr( $base, -strlen( $comp ) - 1 ) === '-' . $comp ) {
				$component = $comp;
				$prefix    = substr( $base, 0, -strlen( $comp ) - 1 );
				break;
			}
		}

		// Detect plugin-{name} and theme-{name} patterns for pre-update backups.
		if ( 'unknown' === $component ) {
			if ( preg_match( '/^(.+)-(plugin|theme)-(.+)$/', $base, $matches ) ) {
				$prefix    = $matches[1];
				$component = $matches[2];
				$target    = $matches[3];
			}
		}

		return array(
			'prefix'    => $prefix,
			'component' => $component,
			'part'      => $part,
			'target'    => $target,
		);
	}

	/**
	 * Determine backup type from components
	 *
	 * @param array $components List of components.
	 * @return string
	 */
	private function get_backup_type_from_components( $components ) {
		// Check for pre-update backups (single plugin or theme component).
		if ( in_array( 'plugin', $components, true ) || in_array( 'theme', $components, true ) ) {
			return 'pre-update';
		}

		// Check for pre-restore backups.
		if ( in_array( 'pre-restore', $components, true ) ) {
			return 'pre-restore';
		}

		// Check for uploaded backups.
		if ( in_array( 'uploaded', $components, true ) ) {
			return 'uploaded';
		}

		// Check for DB tool backups (DB Optimizer, Domain Changer).
		if ( in_array( 'db-optimizer', $components, true ) ) {
			return 'db-optimizer';
		}
		if ( in_array( 'domain-change', $components, true ) ) {
			return 'domain-change';
		}

		$has_db    = in_array( 'database', $components, true );
		$has_files = array_intersect( array( 'plugins', 'themes', 'uploads', 'wp-content', 'wp-core' ), $components );

		if ( $has_db && $has_files ) {
			return 'full';
		} elseif ( $has_db ) {
			return 'database';
		} elseif ( $has_files ) {
			return 'files';
		}
		return 'custom';
	}

	/**
	 * Get backup type label
	 *
	 * @param string $type Backup type.
	 * @return string
	 */
	private function get_backup_type_label( $type ) {
		$labels = array(
			'full'          => __( 'Full Backup', 'blaminhor-essentials' ),
			'database'      => __( 'Database', 'blaminhor-essentials' ),
			'files'         => __( 'Files', 'blaminhor-essentials' ),
			'custom'        => __( 'Custom', 'blaminhor-essentials' ),
			'pre-restore'   => __( 'Pre-restore', 'blaminhor-essentials' ),
			'pre-update'    => __( 'Pre-update', 'blaminhor-essentials' ),
			'uploaded'      => __( 'Uploaded', 'blaminhor-essentials' ),
			'scheduled'     => __( 'Scheduled', 'blaminhor-essentials' ),
			'db-optimizer'  => __( 'Database Optimizer', 'blaminhor-essentials' ),
			'domain-change' => __( 'Domain Changer', 'blaminhor-essentials' ),
		);
		return $labels[ $type ] ?? $labels['custom'];
	}

	/**
	 * Create backup
	 *
	 * Creates separate archives for each component (database, plugins, themes, etc.)
	 * instead of one big archive for better flexibility and smaller file sizes.
	 *
	 * @param array  $options        Backup options.
	 * @param string $prefix_suffix  Optional suffix appended to the prefix (e.g. '-scheduled').
	 * @return array|WP_Error
	 */
	private function create_backup( $options, $prefix_suffix = '' ) {
		// Check requirements.
		if ( ! class_exists( 'ZipArchive' ) ) {
			return new WP_Error( 'no_zip', __( 'ZipArchive extension is not available.', 'blaminhor-essentials' ) );
		}

		// Generate common prefix for this backup set: sitename-YYYY-MM-DD-HHMMSS[-suffix]
		$prefix = sprintf(
			'%s-%s-%s%s',
			$this->get_site_name_slug(),
			gmdate( 'Y-m-d' ),
			gmdate( 'H-i-s' ),
			$prefix_suffix
		);

		$created_archives = array();
		$total_size       = 0;

		// Create database archive.
		if ( $options['database'] ) {
			$result = $this->create_component_archive( $prefix, 'database' );
			if ( is_wp_error( $result ) ) {
				$this->cleanup_partial_backup( $created_archives );
				return $result;
			}
			$created_archives[] = $result;
			$total_size        += $result['size'];
		}

		// Create plugins archive.
		if ( $options['plugins'] ) {
			$result = $this->create_component_archive( $prefix, 'plugins' );
			if ( is_wp_error( $result ) ) {
				$this->cleanup_partial_backup( $created_archives );
				return $result;
			}
			$created_archives[] = $result;
			$total_size        += $result['size'];
		}

		// Create themes archive.
		if ( $options['themes'] ) {
			$result = $this->create_component_archive( $prefix, 'themes' );
			if ( is_wp_error( $result ) ) {
				$this->cleanup_partial_backup( $created_archives );
				return $result;
			}
			$created_archives[] = $result;
			$total_size        += $result['size'];
		}

		// Create uploads archive.
		if ( $options['uploads'] ) {
			$result = $this->create_component_archive( $prefix, 'uploads' );
			if ( is_wp_error( $result ) ) {
				$this->cleanup_partial_backup( $created_archives );
				return $result;
			}
			$created_archives[] = $result;
			$total_size        += $result['size'];
		}

		// Create other wp-content archive.
		if ( $options['wp_content'] ) {
			$result = $this->create_component_archive( $prefix, 'wp-content' );
			if ( is_wp_error( $result ) ) {
				$this->cleanup_partial_backup( $created_archives );
				return $result;
			}
			$created_archives[] = $result;
			$total_size        += $result['size'];
		}

		// Create wp-core archive.
		if ( $options['wp_core'] ) {
			$result = $this->create_component_archive( $prefix, 'wp-core' );
			if ( is_wp_error( $result ) ) {
				$this->cleanup_partial_backup( $created_archives );
				return $result;
			}
			$created_archives[] = $result;
			$total_size        += $result['size'];
		}

		// Apply retention policy.
		$this->apply_retention_policy();

		return array(
			'prefix'   => $prefix,
			'archives' => $created_archives,
			'count'    => count( $created_archives ),
			'size'     => $total_size,
		);
	}

	/**
	 * Create a single component archive
	 *
	 * @param string     $prefix      Common prefix for this backup set.
	 * @param string     $component   Component type (database, plugins, themes, uploads, wp-content, wp-core).
	 * @param array|null $file_list   Optional list of files for multi-part archives (from scan_directory_files).
	 * @param int        $part        Part number (0 = single archive, 1+ = multi-part).
	 * @param int        $total_parts Total number of parts for this component.
	 * @return array|WP_Error Archive info or error.
	 */
	private function create_component_archive( $prefix, $component, $file_list = null, $part = 0, $total_parts = 1 ) {
		if ( $part > 0 ) {
			$filename = $prefix . '-' . $component . '-part' . $part . '.zip';
		} else {
			$filename = $prefix . '-' . $component . '.zip';
		}
		$filepath = $this->backup_dir . '/' . $filename;

		$zip = new ZipArchive();
		if ( $zip->open( $filepath, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) {
			return new WP_Error( 'zip_create_failed', __( 'Failed to create backup archive.', 'blaminhor-essentials' ) );
		}

		// Add manifest for this component.
		$manifest = array(
			'created'     => gmdate( 'Y-m-d H:i:s' ),
			'site_name'   => get_bloginfo( 'name' ),
			'site_url'    => home_url(),
			'wp_version'  => get_bloginfo( 'version' ),
			'php_version' => phpversion(),
			'component'   => $component,
			'prefix'      => $prefix,
			'part'        => $part,
			'total_parts' => $total_parts,
		);
		$zip->addFromString( 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) );

		// If a file_list is provided, use it directly (multi-part mode).
		if ( is_array( $file_list ) ) {
			foreach ( $file_list as $file_info ) {
				if ( file_exists( $file_info['path'] ) ) {
					$zip->addFile( $file_info['path'], $file_info['relative'] );
				}
			}
			$zip->close();

			return array(
				'filename'    => $filename,
				'path'        => $filepath,
				'size'        => file_exists( $filepath ) ? filesize( $filepath ) : 0,
				'component'   => $component,
				'part'        => $part,
				'total_parts' => $total_parts,
			);
		}

		$sql_file = null;

		switch ( $component ) {
			case 'database':
				$sql_file = $this->export_database();
				if ( is_wp_error( $sql_file ) ) {
					$zip->close();
					unlink( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
					return $sql_file;
				}
				$zip->addFile( $sql_file, 'database.sql' );
				break;

			case 'plugins':
				$this->add_directory_to_zip( $zip, WP_PLUGIN_DIR, 'plugins' );
				break;

			case 'themes':
				$this->add_directory_to_zip( $zip, get_theme_root(), 'themes' );
				break;

			case 'uploads':
				$upload_dir = wp_upload_dir();
				$this->add_directory_to_zip( $zip, $upload_dir['basedir'], 'uploads' );
				break;

			case 'wp-content':
				$this->add_directory_to_zip( $zip, WP_CONTENT_DIR, 'wp-content', array(
					WP_PLUGIN_DIR,
					get_theme_root(),
					wp_upload_dir()['basedir'],
					$this->backup_dir,
				) );
				break;

			case 'wp-core':
				$this->add_directory_to_zip( $zip, ABSPATH, 'wp-core', array(
					WP_CONTENT_DIR,
				) );
				break;
		}

		$zip->close();

		// Clean up temp SQL file.
		if ( $sql_file && file_exists( $sql_file ) ) {
			unlink( $sql_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		}

		return array(
			'filename'    => $filename,
			'path'        => $filepath,
			'size'        => file_exists( $filepath ) ? filesize( $filepath ) : 0,
			'component'   => $component,
			'part'        => $part,
			'total_parts' => $total_parts,
		);
	}

	/**
	 * Clean up partial backup if creation fails midway
	 *
	 * @param array $archives Array of created archives to delete.
	 */
	private function cleanup_partial_backup( $archives ) {
		foreach ( $archives as $archive ) {
			if ( isset( $archive['path'] ) && file_exists( $archive['path'] ) ) {
				unlink( $archive['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
			}
		}
	}

	/**
	 * Export database to SQL file
	 *
	 * @return string|WP_Error Path to SQL file or error.
	 */
	private function export_database() {
		global $wpdb;

		$sql_file = $this->backup_dir . '/temp-db-' . wp_generate_password( 8, false ) . '.sql';

		$tables = $wpdb->get_results( 'SHOW TABLES', ARRAY_N ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery

		if ( empty( $tables ) ) {
			return new WP_Error( 'no_tables', __( 'No database tables found.', 'blaminhor-essentials' ) );
		}

		// Start with SET NAMES to ensure proper encoding
		$sql = "SET NAMES utf8mb4;\n";
		$sql .= "SET CHARACTER SET utf8mb4;\n\n";
		$sql .= "-- Alfred-Proteus Database Backup\n";
		$sql .= "-- Site: " . html_entity_decode( get_bloginfo( 'name' ), ENT_QUOTES, 'UTF-8' ) . "\n";
		$sql .= "-- URL: " . home_url() . "\n";
		$sql .= "-- Generated: " . gmdate( 'Y-m-d H:i:s' ) . "\n\n";
		$sql .= "SET FOREIGN_KEY_CHECKS = 0;\n\n";

		foreach ( $tables as $table ) {
			$table_name = $table[0];

			// Only backup tables with our prefix.
			if ( strpos( $table_name, $wpdb->prefix ) !== 0 ) {
				continue;
			}

			// Skip multisite sub-site tables that share the same prefix start.
			// WordPress multisite uses {prefix}{blog_id}_{table} where blog_id is numeric.
			// e.g. with prefix "wp_", skip "wp_2_options", "wp_1304475_options", etc.
			$base_name = substr( $table_name, strlen( $wpdb->prefix ) );
			if ( preg_match( '/^\d+_/', $base_name ) ) {
				continue;
			}

			// Validate table name contains only safe characters.
			if ( preg_match( '/[^a-zA-Z0-9_]/', $table_name ) ) {
				continue;
			}

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
			$create = $wpdb->get_row(
				$wpdb->prepare( 'SHOW CREATE TABLE %i', $table_name ),
				ARRAY_N
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
			if ( $create ) {
				$sql .= $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table_name ) . ";\n";
				$sql .= $create[1] . ";\n\n";
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for backup functionality
			$rows = $wpdb->get_results(
				$wpdb->prepare( 'SELECT * FROM %i', $table_name ),
				ARRAY_A
			);

			if ( ! empty( $rows ) ) {
				$columns = array_keys( $rows[0] );
				$columns_escaped = array_map( function( $col ) {
					return '`' . esc_sql( $col ) . '`';
				}, $columns );

				// Prepare table identifier once for this table.
				$table_identifier = $wpdb->prepare( '%i', $table_name );

				foreach ( array_chunk( $rows, 100 ) as $chunk ) {
					$sql .= 'INSERT INTO ' . $table_identifier . ' (' . implode( ', ', $columns_escaped ) . ") VALUES\n";

					$values = array();
					foreach ( $chunk as $row ) {
						$row_values = array_map( function( $value ) use ( $wpdb ) {
							if ( is_null( $value ) ) {
								return 'NULL';
							}
							return "'" . $wpdb->remove_placeholder_escape( esc_sql( $value ) ) . "'";
						}, $row );
						$values[] = '(' . implode( ', ', $row_values ) . ')';
					}
					$sql .= implode( ",\n", $values ) . ";\n\n";
				}
			}
		}

		$sql .= "SET FOREIGN_KEY_CHECKS = 1;\n";

		if ( file_put_contents( $sql_file, $sql ) === false ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			return new WP_Error( 'write_failed', __( 'Failed to write database export.', 'blaminhor-essentials' ) );
		}

		return $sql_file;
	}

	/**
	 * Add directory to ZIP archive
	 *
	 * @param ZipArchive $zip ZIP archive.
	 * @param string     $dir Directory path.
	 * @param string     $zip_path Path inside ZIP.
	 * @param array      $exclude Directories to exclude.
	 */
	private function add_directory_to_zip( $zip, $dir, $zip_path, $exclude = array() ) {
		if ( ! is_dir( $dir ) ) {
			return;
		}

		$dir = rtrim( $dir, '/' );
		$exclude = array_map( function( $path ) {
			return rtrim( $path, '/' );
		}, $exclude );

		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
			RecursiveIteratorIterator::SELF_FIRST
		);

		foreach ( $iterator as $file ) {
			$file_path = $file->getPathname();
			$relative_path = substr( $file_path, strlen( $dir ) + 1 );

			$skip = false;
			foreach ( $exclude as $excl ) {
				if ( strpos( $file_path, $excl ) === 0 ) {
					$skip = true;
					break;
				}
			}
			if ( $skip ) {
				continue;
			}

			$zip_file_path = $zip_path ? $zip_path . '/' . $relative_path : $relative_path;

			if ( $file->isDir() ) {
				$zip->addEmptyDir( $zip_file_path );
			} else {
				// Skip large files (> 100MB).
				if ( $file->getSize() > 100 * 1024 * 1024 ) {
					continue;
				}
				$zip->addFile( $file_path, $zip_file_path );
			}
		}
	}

	/**
	 * Scan a directory and return a flat list of files with metadata
	 *
	 * Same traversal logic as add_directory_to_zip() but returns file info
	 * instead of adding to a ZIP archive.
	 *
	 * @param string $dir      Directory path.
	 * @param string $zip_path Path prefix inside the future ZIP.
	 * @param array  $exclude  Directories to exclude.
	 * @return array Array of ['path' => abs_path, 'relative' => zip_path, 'size' => bytes].
	 */
	private function scan_directory_files( $dir, $zip_path, $exclude = array() ) {
		$files = array();

		if ( ! is_dir( $dir ) ) {
			return $files;
		}

		$dir     = rtrim( $dir, '/' );
		$exclude = array_map( function ( $path ) {
			return rtrim( $path, '/' );
		}, $exclude );

		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
			RecursiveIteratorIterator::SELF_FIRST
		);

		foreach ( $iterator as $file ) {
			$file_path     = $file->getPathname();
			$relative_path = substr( $file_path, strlen( $dir ) + 1 );

			$skip = false;
			foreach ( $exclude as $excl ) {
				if ( strpos( $file_path, $excl ) === 0 ) {
					$skip = true;
					break;
				}
			}
			if ( $skip ) {
				continue;
			}

			$zip_file_path = $zip_path ? $zip_path . '/' . $relative_path : $relative_path;

			if ( $file->isDir() ) {
				// Directories are handled implicitly when files are added.
				continue;
			}

			// Skip large files (> 100MB).
			$size = $file->getSize();
			if ( $size > 100 * 1024 * 1024 ) {
				continue;
			}

			$files[] = array(
				'path'     => $file_path,
				'relative' => $zip_file_path,
				'size'     => $size,
			);
		}

		return $files;
	}

	/**
	 * Split a list of files into chunks based on a maximum size
	 *
	 * @param array $files     Array of file info from scan_directory_files().
	 * @param int   $max_bytes Maximum size per chunk in bytes.
	 * @return array Array of chunks, each chunk being an array of file info.
	 */
	private function split_files_into_chunks( $files, $max_bytes ) {
		if ( $max_bytes <= 0 || empty( $files ) ) {
			return array( $files );
		}

		$chunks       = array();
		$current      = array();
		$current_size = 0;

		foreach ( $files as $file ) {
			// If a single file exceeds max_bytes, it gets its own chunk.
			if ( $current_size + $file['size'] > $max_bytes && ! empty( $current ) ) {
				$chunks[]     = $current;
				$current      = array();
				$current_size = 0;
			}

			$current[]     = $file;
			$current_size += $file['size'];
		}

		if ( ! empty( $current ) ) {
			$chunks[] = $current;
		}

		return $chunks;
	}

	/**
	 * Get the directory and exclusions for a given component
	 *
	 * @param string $component Component type.
	 * @return array ['dir' => string, 'zip_path' => string, 'exclude' => array] or empty if not a file component.
	 */
	private function get_component_dir_info( $component ) {
		switch ( $component ) {
			case 'plugins':
				return array(
					'dir'      => WP_PLUGIN_DIR,
					'zip_path' => 'plugins',
					'exclude'  => array(),
				);
			case 'themes':
				return array(
					'dir'      => get_theme_root(),
					'zip_path' => 'themes',
					'exclude'  => array(),
				);
			case 'uploads':
				$upload_dir = wp_upload_dir();
				return array(
					'dir'      => $upload_dir['basedir'],
					'zip_path' => 'uploads',
					'exclude'  => array(),
				);
			case 'wp-content':
				return array(
					'dir'      => WP_CONTENT_DIR,
					'zip_path' => 'wp-content',
					'exclude'  => array(
						WP_PLUGIN_DIR,
						get_theme_root(),
						wp_upload_dir()['basedir'],
						$this->backup_dir,
					),
				);
			case 'wp-core':
				return array(
					'dir'      => ABSPATH,
					'zip_path' => 'wp-core',
					'exclude'  => array( WP_CONTENT_DIR ),
				);
			default:
				return array();
		}
	}

	/**
	 * Create a pre-restore backup before restoring
	 *
	 * This creates a full backup of the current state before restoration,
	 * so the user can recover if the restore goes wrong.
	 *
	 * @return array|WP_Error Backup info or error.
	 */
	private function create_pre_restore_backup() {
		// Ensure directory exists.
		$this->ensure_backup_directory();

		// Check requirements.
		if ( ! class_exists( 'ZipArchive' ) ) {
			return new WP_Error( 'no_zip', __( 'ZipArchive extension is not available.', 'blaminhor-essentials' ) );
		}

		// Generate filename - store in main backup directory.
		$filename = sprintf(
			'%s-%s-%s-pre-restore.zip',
			$this->get_site_name_slug(),
			gmdate( 'Y-m-d' ),
			gmdate( 'H-i-s' )
		);

		$filepath = $this->backup_dir . '/' . $filename;

		// Create ZIP archive.
		$zip = new ZipArchive();
		if ( $zip->open( $filepath, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) {
			return new WP_Error( 'zip_create_failed', __( 'Failed to create pre-restore backup.', 'blaminhor-essentials' ) );
		}

		// Add manifest.
		$manifest = array(
			'created'     => gmdate( 'Y-m-d H:i:s' ),
			'site_name'   => get_bloginfo( 'name' ),
			'site_url'    => home_url(),
			'wp_version'  => get_bloginfo( 'version' ),
			'php_version' => phpversion(),
			'type'        => 'pre-restore',
			'purpose'     => 'Automatic backup created before restore operation',
		);
		$zip->addFromString( 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) );

		// Add database dump.
		$sql_file = $this->export_database();
		if ( ! is_wp_error( $sql_file ) ) {
			$zip->addFile( $sql_file, 'database.sql' );
		}

		// Add plugins.
		$this->add_directory_to_zip( $zip, WP_PLUGIN_DIR, 'wp-content/plugins' );

		// Add themes.
		$this->add_directory_to_zip( $zip, get_theme_root(), 'wp-content/themes' );

		// Add uploads.
		$upload_dir = wp_upload_dir();
		$this->add_directory_to_zip( $zip, $upload_dir['basedir'], 'wp-content/uploads' );

		$zip->close();

		// Clean up temp SQL file.
		if ( ! is_wp_error( $sql_file ) && file_exists( $sql_file ) ) {
			unlink( $sql_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		}

		return array(
			'filename' => $filename,
			'path'     => $filepath,
			'size'     => filesize( $filepath ),
		);
	}

	/**
	 * Get pre-restore backup if exists
	 *
	 * Returns the most recent pre-restore backup from the main backup directory.
	 *
	 * @return array|false Backup info or false if not found.
	 */
	private function get_pre_restore_backup() {
		if ( ! is_dir( $this->backup_dir ) ) {
			return false;
		}

		$files = glob( $this->backup_dir . '/*-pre-restore.zip' );
		if ( empty( $files ) ) {
			return false;
		}

		// Sort by modification time descending to get the most recent.
		usort( $files, function( $a, $b ) {
			return filemtime( $b ) - filemtime( $a );
		} );

		$file     = $files[0];
		$filename = basename( $file );

		// Check if this backup notice has been dismissed.
		$dismissed = get_option( 'blaminhor_essentials_dismissed_pre_restore', '' );
		if ( $dismissed === $filename ) {
			return false;
		}

		return array(
			'filename'   => $filename,
			'path'       => $file,
			'size'       => filesize( $file ),
			'size_human' => size_format( filesize( $file ), 2 ),
			'date'       => filemtime( $file ),
			'date_human' => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), filemtime( $file ) ),
		);
	}

	/**
	 * AJAX: Undo last restore (restore from pre-restore backup)
	 */
	public function ajax_undo_restore() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$pre_restore = $this->get_pre_restore_backup();
		if ( ! $pre_restore ) {
			wp_send_json_error( __( 'No pre-restore backup found.', 'blaminhor-essentials' ) );
		}

		// Restore operations may take a long time.
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for large restore operations
		@set_time_limit( 600 );
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for large restore operations
		@ini_set( 'memory_limit', '512M' );

		// Restore from the pre-restore backup (restore both database and files).
		$result = $this->restore_from_pre_restore( $pre_restore['path'] );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		// Note: Pre-restore backup is NOT deleted automatically.
		// It appears in the backup list and user can delete it manually.

		wp_send_json_success( __( 'Restore undone successfully! Your site has been reverted to its previous state.', 'blaminhor-essentials' ) );
	}

	/**
	 * AJAX: Dismiss pre-restore backup notice
	 */
	public function ajax_dismiss_pre_restore() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$filename = isset( $_POST['filename'] ) ? sanitize_file_name( wp_unslash( $_POST['filename'] ) ) : '';
		if ( empty( $filename ) ) {
			wp_send_json_error( __( 'No filename provided.', 'blaminhor-essentials' ) );
		}

		update_option( 'blaminhor_essentials_dismissed_pre_restore', $filename );
		wp_send_json_success();
	}

	/**
	 * AJAX: Upload backup file
	 */
	public function ajax_upload_backup() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		if ( empty( $_FILES['backup_file'] ) ) {
			wp_send_json_error( __( 'No file uploaded.', 'blaminhor-essentials' ) );
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- File upload data cannot be sanitized, validation is done below.
		$file = $_FILES['backup_file'];

		// Check for upload errors.
		if ( $file['error'] !== UPLOAD_ERR_OK ) {
			$error_messages = array(
				UPLOAD_ERR_INI_SIZE   => __( 'The uploaded file exceeds the upload_max_filesize directive.', 'blaminhor-essentials' ),
				UPLOAD_ERR_FORM_SIZE  => __( 'The uploaded file exceeds the MAX_FILE_SIZE directive.', 'blaminhor-essentials' ),
				UPLOAD_ERR_PARTIAL    => __( 'The uploaded file was only partially uploaded.', 'blaminhor-essentials' ),
				UPLOAD_ERR_NO_FILE    => __( 'No file was uploaded.', 'blaminhor-essentials' ),
				UPLOAD_ERR_NO_TMP_DIR => __( 'Missing a temporary folder.', 'blaminhor-essentials' ),
				UPLOAD_ERR_CANT_WRITE => __( 'Failed to write file to disk.', 'blaminhor-essentials' ),
				UPLOAD_ERR_EXTENSION  => __( 'A PHP extension stopped the file upload.', 'blaminhor-essentials' ),
			);
			$error_message = isset( $error_messages[ $file['error'] ] ) ? $error_messages[ $file['error'] ] : __( 'Unknown upload error.', 'blaminhor-essentials' );
			wp_send_json_error( $error_message );
		}

		// Validate file extension.
		$extension = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
		if ( 'zip' !== $extension ) {
			wp_send_json_error( __( 'Only ZIP files are allowed.', 'blaminhor-essentials' ) );
		}

		// Validate file is a real ZIP.
		if ( ! class_exists( 'ZipArchive' ) ) {
			wp_send_json_error( __( 'ZipArchive extension is not available.', 'blaminhor-essentials' ) );
		}

		$zip = new ZipArchive();
		if ( $zip->open( $file['tmp_name'] ) !== true ) {
			wp_send_json_error( __( 'Invalid ZIP file.', 'blaminhor-essentials' ) );
		}

		// Detect bundle: a ZIP containing multiple .zip files.
		$is_bundle   = false;
		$zip_entries  = array();
		$num_entries  = $zip->numFiles;

		for ( $i = 0; $i < $num_entries; $i++ ) {
			$entry_name = $zip->getNameIndex( $i );
			if ( false === $entry_name ) {
				continue;
			}
			// Skip directory entries.
			if ( substr( $entry_name, -1 ) === '/' ) {
				continue;
			}
			if ( strtolower( pathinfo( $entry_name, PATHINFO_EXTENSION ) ) === 'zip' ) {
				$zip_entries[] = $entry_name;
			}
		}

		if ( count( $zip_entries ) > 1 ) {
			$is_bundle = true;
		}

		// Check for manifest or database file to validate it's a backup.
		$has_manifest = ( $zip->locateName( 'manifest.json' ) !== false );
		$has_database = ( $zip->locateName( 'database.sql' ) !== false );

		// Also check for component-based backups.
		$is_component_backup = false;
		$original_name       = sanitize_file_name( pathinfo( $file['name'], PATHINFO_FILENAME ) );
		if ( preg_match( '/-(?:database|plugins|themes|uploads|wp-content|wp-core)$/', $original_name ) ) {
			$is_component_backup = true;
		}

		if ( ! $has_manifest && ! $has_database && ! $is_component_backup && ! $is_bundle ) {
			$zip->close();
			wp_send_json_error( __( 'This does not appear to be a valid backup file. It must contain a manifest.json or database.sql file.', 'blaminhor-essentials' ) );
		}

		// Extract domain info from manifest if available.
		$backup_domain = '';
		$backup_url    = '';
		if ( $has_manifest ) {
			$manifest_content = $zip->getFromName( 'manifest.json' );
			if ( $manifest_content ) {
				$manifest = json_decode( $manifest_content, true );
				if ( isset( $manifest['site_url'] ) ) {
					$backup_url    = $manifest['site_url'];
					$parsed        = wp_parse_url( $backup_url );
					$backup_domain = isset( $parsed['host'] ) ? $parsed['host'] : '';
				}
			}
		}

		$zip->close();

		// Prepare site name and timestamp for destination filenames.
		$site_name = sanitize_title( get_bloginfo( 'name' ) );
		if ( empty( $site_name ) ) {
			$site_name = 'backup';
		}
		$timestamp = current_time( 'Y-m-d-H-i-s' );

		global $wp_filesystem;
		if ( empty( $wp_filesystem ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}

		if ( $is_bundle ) {
			// Bundle: extract sub-archives to disk (avoids loading large files into memory).
			$extracted_files = array();
			$extract_zip     = new ZipArchive();

			if ( $extract_zip->open( $file['tmp_name'] ) !== true ) {
				wp_send_json_error( __( 'Failed to read the bundle archive.', 'blaminhor-essentials' ) );
			}

			// Create a temporary directory for extraction.
			$temp_dir = $this->backup_dir . '/tmp-upload-' . wp_generate_password( 8, false );
			wp_mkdir_p( $temp_dir );

			// Collect entries to extract.
			$entries_to_process = array();
			for ( $i = 0; $i < $extract_zip->numFiles; $i++ ) {
				$entry_name = $extract_zip->getNameIndex( $i );
				if ( false === $entry_name || substr( $entry_name, -1 ) === '/' ) {
					continue;
				}
				if ( strtolower( pathinfo( $entry_name, PATHINFO_EXTENSION ) ) !== 'zip' ) {
					continue;
				}
				$entries_to_process[] = $entry_name;
			}

			// Extract all sub-archives to temp directory (streams to disk, no memory issue).
			if ( ! $extract_zip->extractTo( $temp_dir, $entries_to_process ) ) {
				$extract_zip->close();
				$wp_filesystem->rmdir( $temp_dir, true );
				wp_send_json_error( __( 'Failed to save the uploaded file.', 'blaminhor-essentials' ) );
			}

			$extract_zip->close();

			// Rename each extracted file to the proper destination.
			foreach ( $entries_to_process as $entry_name ) {
				$extracted_path = $temp_dir . '/' . basename( $entry_name );
				if ( ! file_exists( $extracted_path ) ) {
					continue;
				}

				// Determine the component from the sub-archive filename.
				$entry_base = sanitize_file_name( pathinfo( $entry_name, PATHINFO_FILENAME ) );
				$entry_info = $this->parse_backup_filename( $entry_base . '.zip' );
				$component  = $entry_info['component'];

				// If parse_backup_filename didn't recognize the component,
				// check if the filename itself IS a known component name (e.g. "database.zip").
				if ( 'unknown' === $component || 'bundle' === $component ) {
					$known_components = array( 'database', 'plugins', 'themes', 'uploads', 'wp-content', 'wp-core' );
					if ( in_array( $entry_base, $known_components, true ) ) {
						$component = $entry_base;
					} else {
						$component = 'uploaded';
					}
				}

				$new_filename = $site_name . '-' . $timestamp . '-' . $component . '.zip';
				$destination  = $this->backup_dir . '/' . $new_filename;

				// Avoid collisions.
				$counter = 2;
				while ( file_exists( $destination ) ) {
					$new_filename = $site_name . '-' . $timestamp . '-' . $component . '-' . $counter . '.zip';
					$destination  = $this->backup_dir . '/' . $new_filename;
					$counter++;
				}

				if ( ! $wp_filesystem->move( $extracted_path, $destination ) ) {
					// Fallback: try copy + delete if rename fails (cross-device move).
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- File operations may fail silently.
					$content = @file_get_contents( $extracted_path );
					if ( false === $content || ! $wp_filesystem->put_contents( $destination, $content, FS_CHMOD_FILE ) ) {
						// Clean up on failure.
						foreach ( $extracted_files as $extracted ) {
							$wp_filesystem->delete( $this->backup_dir . '/' . $extracted );
						}
						$wp_filesystem->rmdir( $temp_dir, true );
						wp_send_json_error( __( 'Failed to save the uploaded file.', 'blaminhor-essentials' ) );
					}
				}

				$extracted_files[] = $new_filename;
			}

			// Clean up temp directory.
			$wp_filesystem->rmdir( $temp_dir, true );

			// Clean up temp file.
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Cleaning up PHP temp file.
			@unlink( $file['tmp_name'] );

			if ( empty( $extracted_files ) ) {
				wp_send_json_error( __( 'No valid backup archives found in the bundle.', 'blaminhor-essentials' ) );
			}

			// Get current domain for comparison.
			$current_url    = home_url();
			$parsed_current = wp_parse_url( $current_url );
			$current_domain = isset( $parsed_current['host'] ) ? $parsed_current['host'] : '';

			$domain_differs = ! empty( $backup_domain ) && $backup_domain !== $current_domain;

			wp_send_json_success(
				array(
					'is_bundle'      => true,
					'files'          => $extracted_files,
					'file_count'     => count( $extracted_files ),
					/* translators: %d: number of backup archives extracted from the bundle */
					'filename'       => sprintf( __( '%d archives extracted', 'blaminhor-essentials' ), count( $extracted_files ) ),
					'domain_differs' => $domain_differs,
					'backup_domain'  => $backup_domain,
					'backup_url'     => $backup_url,
					'current_domain' => $current_domain,
					'current_url'    => $current_url,
				)
			);
		}

		// Single file upload: preserve component suffix from original filename.
		$original_info = $this->parse_backup_filename( $original_name . '.zip' );
		$component     = $original_info['component'];

		// Use original component if recognized, otherwise default to 'uploaded'.
		$valid_components = array( 'database', 'plugins', 'themes', 'uploads', 'wp-content', 'wp-core', 'full', 'files' );
		if ( ! in_array( $component, $valid_components, true ) ) {
			$component = 'uploaded';
		}

		$new_filename = $site_name . '-' . $timestamp . '-' . $component . '.zip';
		$destination  = $this->backup_dir . '/' . $new_filename;

		// Avoid collisions.
		$counter = 2;
		while ( file_exists( $destination ) ) {
			$new_filename = $site_name . '-' . $timestamp . '-' . $component . '-' . $counter . '.zip';
			$destination  = $this->backup_dir . '/' . $new_filename;
			$counter++;
		}

		// Copy uploaded file to destination.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- File operations may fail silently.
		$tmp_content = @file_get_contents( $file['tmp_name'] );
		if ( false === $tmp_content || ! $wp_filesystem->put_contents( $destination, $tmp_content, FS_CHMOD_FILE ) ) {
			wp_send_json_error( __( 'Failed to save the uploaded file.', 'blaminhor-essentials' ) );
		}

		// Clean up temp file.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Cleaning up PHP temp file.
		@unlink( $file['tmp_name'] );

		// Get current domain for comparison.
		$current_url    = home_url();
		$parsed_current = wp_parse_url( $current_url );
		$current_domain = isset( $parsed_current['host'] ) ? $parsed_current['host'] : '';

		$domain_differs = ! empty( $backup_domain ) && $backup_domain !== $current_domain;

		wp_send_json_success(
			array(
				'filename'       => $new_filename,
				'size'           => size_format( filesize( $destination ), 2 ),
				'domain_differs' => $domain_differs,
				'backup_domain'  => $backup_domain,
				'backup_url'     => $backup_url,
				'current_domain' => $current_domain,
				'current_url'    => $current_url,
			)
		);
	}

	/**
	 * AJAX: Get domain info from a backup file
	 */
	public function ajax_get_domain_info() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$filename = isset( $_POST['filename'] ) ? sanitize_file_name( wp_unslash( $_POST['filename'] ) ) : '';
		$prefix   = isset( $_POST['prefix'] ) ? sanitize_file_name( wp_unslash( $_POST['prefix'] ) ) : '';

		// If prefix is provided, find the first matching archive.
		if ( ! empty( $prefix ) && empty( $filename ) ) {
			$archives = glob( $this->backup_dir . '/' . $prefix . '-*.zip' );
			if ( ! empty( $archives ) ) {
				$filename = basename( $archives[0] );
			}
		}

		if ( empty( $filename ) ) {
			wp_send_json_error( __( 'No filename provided.', 'blaminhor-essentials' ) );
		}

		$filepath = $this->backup_dir . '/' . $filename;
		if ( ! file_exists( $filepath ) ) {
			wp_send_json_error( __( 'Backup file not found.', 'blaminhor-essentials' ) );
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			wp_send_json_error( __( 'ZipArchive extension is not available.', 'blaminhor-essentials' ) );
		}

		$zip = new ZipArchive();
		if ( $zip->open( $filepath ) !== true ) {
			wp_send_json_error( __( 'Failed to open backup archive.', 'blaminhor-essentials' ) );
		}

		$backup_domain = '';
		$backup_url    = '';

		// Try to get domain from manifest.
		$manifest_content = $zip->getFromName( 'manifest.json' );
		if ( $manifest_content ) {
			$manifest = json_decode( $manifest_content, true );
			if ( isset( $manifest['site_url'] ) ) {
				$backup_url    = $manifest['site_url'];
				$parsed        = wp_parse_url( $backup_url );
				$backup_domain = isset( $parsed['host'] ) ? $parsed['host'] : '';
			}
		}

		$zip->close();

		// Get current domain.
		$current_url    = home_url();
		$parsed_current = wp_parse_url( $current_url );
		$current_domain = isset( $parsed_current['host'] ) ? $parsed_current['host'] : '';

		wp_send_json_success(
			array(
				'backup_domain'  => $backup_domain,
				'backup_url'     => $backup_url,
				'current_domain' => $current_domain,
				'current_url'    => $current_url,
				'domain_differs' => ! empty( $backup_domain ) && $backup_domain !== $current_domain,
			)
		);
	}

	/**
	 * Restore from pre-restore backup
	 *
	 * Similar to restore_backup but doesn't create another pre-restore backup.
	 *
	 * @param string $path Full path to the backup file.
	 * @return bool|WP_Error
	 */
	private function restore_from_pre_restore( $path ) {
		if ( ! file_exists( $path ) ) {
			return new WP_Error( 'not_found', __( 'Pre-restore backup file not found.', 'blaminhor-essentials' ) );
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			return new WP_Error( 'no_zip', __( 'ZipArchive extension is not available.', 'blaminhor-essentials' ) );
		}

		$zip = new ZipArchive();
		if ( $zip->open( $path ) !== true ) {
			return new WP_Error( 'zip_open_failed', __( 'Failed to open backup archive.', 'blaminhor-essentials' ) );
		}

		// Restore database.
		$sql_content = $zip->getFromName( 'database.sql' );
		if ( $sql_content ) {
			$result = $this->import_database( $sql_content );
			if ( is_wp_error( $result ) ) {
				$zip->close();
				return $result;
			}
		}

		// Restore files.
		$temp_dir = $this->backup_dir . '/temp-undo-' . wp_generate_password( 8, false );
		wp_mkdir_p( $temp_dir );

		// Extract to temp directory.
		$zip->extractTo( $temp_dir );

		// Restore plugins.
		if ( is_dir( $temp_dir . '/wp-content/plugins' ) ) {
			$this->copy_directory( $temp_dir . '/wp-content/plugins', WP_PLUGIN_DIR );
		}

		// Restore themes.
		if ( is_dir( $temp_dir . '/wp-content/themes' ) ) {
			$this->copy_directory( $temp_dir . '/wp-content/themes', get_theme_root() );
		}

		// Restore uploads.
		if ( is_dir( $temp_dir . '/wp-content/uploads' ) ) {
			$upload_dir = wp_upload_dir();
			$this->copy_directory( $temp_dir . '/wp-content/uploads', $upload_dir['basedir'] );
		}

		// Clean up temp directory.
		$this->delete_directory( $temp_dir );

		$zip->close();

		return true;
	}

	/**
	 * Restore backup
	 *
	 * @param string $filename Backup filename.
	 * @param array  $options Restore options.
	 * @return bool|WP_Error
	 */
	private function restore_backup( $prefix, $options ) {
		$prefix = sanitize_file_name( $prefix );

		// Initialize restore log.
		$this->init_log( $prefix . '-restore' );
		$restore_parts = array();
		if ( $options['database'] ) {
			$restore_parts[] = 'database';
		}
		if ( $options['files'] ) {
			$restore_parts[] = 'files';
		}
		$this->write_log( 'info', 'Restore started — ' . implode( ' + ', $restore_parts ) );

		// Find all archives with this prefix.
		$archives = glob( $this->backup_dir . '/' . $prefix . '-*.zip' );

		if ( empty( $archives ) ) {
			$this->write_log( 'error', 'No archive files found for prefix: ' . $prefix );
			return new WP_Error( 'not_found', __( 'Backup files not found.', 'blaminhor-essentials' ) );
		}

		$this->write_log( 'info', count( $archives ) . ' archive(s) found' );

		if ( ! class_exists( 'ZipArchive' ) ) {
			$this->write_log( 'error', 'ZipArchive PHP extension not available' );
			return new WP_Error( 'no_zip', __( 'ZipArchive extension is not available.', 'blaminhor-essentials' ) );
		}

		// Create a pre-restore backup first (for safety).
		$this->write_log( 'info', 'Creating pre-restore safety backup...' );
		$pre_restore = $this->create_pre_restore_backup();
		if ( is_wp_error( $pre_restore ) ) {
			$this->write_log( 'warning', 'Pre-restore backup failed: ' . $pre_restore->get_error_message() . ' — continuing anyway' );
		} else {
			$this->write_log( 'success', 'Pre-restore backup created' );
		}

		$restored_components = array();

		// Group archives by component for multi-part handling.
		$archives_by_component = array();
		foreach ( $archives as $archive_path ) {
			// Security check.
			if ( strpos( realpath( $archive_path ), realpath( $this->backup_dir ) ) !== 0 ) {
				continue;
			}

			$filename  = basename( $archive_path );
			$info      = $this->parse_backup_filename( $filename );
			$component = $info['component'];
			$part      = isset( $info['part'] ) ? $info['part'] : 0;

			// Skip if component doesn't match restore options.
			if ( 'database' === $component && ! $options['database'] ) {
				continue;
			}
			if ( in_array( $component, array( 'plugins', 'themes', 'uploads', 'wp-content', 'wp-core', 'plugin', 'theme' ), true ) && ! $options['files'] ) {
				continue;
			}

			if ( ! isset( $archives_by_component[ $component ] ) ) {
				$archives_by_component[ $component ] = array();
			}
			$archives_by_component[ $component ][] = array(
				'path' => $archive_path,
				'part' => $part,
			);
		}

		// Sort parts within each component and restore.
		foreach ( $archives_by_component as $component => $component_archives ) {
			// Sort by part number.
			usort( $component_archives, function ( $a, $b ) {
				return $a['part'] - $b['part'];
			} );

			$part_count = count( $component_archives );
			$log_label  = ucfirst( $component );
			if ( $part_count > 1 ) {
				$log_label .= ' (' . $part_count . ' parts)';
			}
			$this->write_log( 'info', 'Restoring ' . $log_label . '...' );

			$is_first_part = true;
			foreach ( $component_archives as $archive_info ) {
				if ( $part_count > 1 ) {
					$this->write_log( 'info', ucfirst( $component ) . ' part ' . $archive_info['part'] . '/' . $part_count );
				}

				$result = $this->restore_single_archive( $archive_info['path'], $component, $is_first_part );
				if ( is_wp_error( $result ) ) {
					$this->write_log( 'error', 'Failed: ' . $result->get_error_message() );
					$this->write_log( 'error', 'Restore aborted' );
					return $result;
				}

				$is_first_part = false;

				// Collect database stats if available.
				if ( is_array( $result ) && isset( $result['executed'] ) ) {
					$db_stats = $result;
					$this->write_log( 'success', 'Database imported — ' . $result['executed'] . ' statements executed' . ( $result['failed'] > 0 ? ', ' . $result['failed'] . ' failed' : '' ) );
				}
			}

			$this->write_log( 'success', $log_label . ' restored' );
			$restored_components[] = $component;
		}

		// Verify that the database was actually restored when requested.
		$db_components = array( 'database', 'db-optimizer', 'domain-change' );
		if ( $options['database'] && ! array_intersect( $db_components, $restored_components ) ) {
			$this->write_log( 'error', 'Database archive not found in backup — restore aborted' );
			return new WP_Error(
				'database_not_restored',
				sprintf(
					/* translators: %s: comma-separated list of component names found in the backup */
					__( 'Database restoration was requested but no database archive was found. Components found: %s', 'blaminhor-essentials' ),
					implode( ', ', $restored_components )
				)
			);
		}

		$this->write_log( 'success', 'Restore completed — ' . implode( ', ', $restored_components ) );

		return array(
			'components' => $restored_components,
			'db_stats'   => isset( $db_stats ) ? $db_stats : null,
		);
	}

	/**
	 * Restore a single archive
	 *
	 * @param string $archive_path  Path to the archive.
	 * @param string $component     Component type.
	 * @param bool   $is_first_part Whether this is the first part of a multi-part restore (controls directory clearing).
	 * @return bool|array|WP_Error
	 */
	private function restore_single_archive( $archive_path, $component, $is_first_part = true ) {
		$zip = new ZipArchive();
		if ( $zip->open( $archive_path ) !== true ) {
			return new WP_Error( 'zip_open_failed', __( 'Failed to open backup archive.', 'blaminhor-essentials' ) );
		}

		if ( in_array( $component, array( 'database', 'db-optimizer', 'domain-change' ), true ) ) {
			// Restore database.
			$sql_content = $zip->getFromName( 'database.sql' );
			if ( false === $sql_content ) {
				$zip->close();
				return new WP_Error( 'no_sql', __( 'No database.sql found in the database archive.', 'blaminhor-essentials' ) );
			}
			$db_result = $this->import_database( $sql_content );
			if ( is_wp_error( $db_result ) ) {
				$zip->close();
				return $db_result;
			}
			$zip->close();
			return $db_result; // Return array with executed/failed stats.
		} else {
			// Restore files.
			$temp_dir = $this->backup_dir . '/temp-restore-' . wp_generate_password( 8, false );
			wp_mkdir_p( $temp_dir );

			// Extract to temp directory.
			$zip->extractTo( $temp_dir );

			// Map component to destination.
			$destinations = array(
				'plugins'    => array( $temp_dir . '/plugins', WP_PLUGIN_DIR ),
				'themes'     => array( $temp_dir . '/themes', get_theme_root() ),
				'uploads'    => array( $temp_dir . '/uploads', wp_upload_dir()['basedir'] ),
				'wp-content' => array( $temp_dir . '/wp-content', WP_CONTENT_DIR ),
				'wp-core'    => array( $temp_dir . '/wp-core', ABSPATH ),
			);

			if ( isset( $destinations[ $component ] ) ) {
				list( $source, $dest ) = $destinations[ $component ];
				if ( is_dir( $source ) ) {
					// Clear destination only once (first part) to replace contents, not merge.
					if ( $is_first_part && in_array( $component, array( 'plugins', 'themes', 'uploads' ), true ) ) {
						$this->clear_directory_contents( $dest );
					}
					$this->copy_directory( $source, $dest );
				}
			} elseif ( 'plugin' === $component || 'theme' === $component ) {
				// Pre-update archives contain the plugin/theme directory at root level.
				$dest = 'plugin' === $component ? WP_PLUGIN_DIR : get_theme_root();
				$dirs = glob( $temp_dir . '/*', GLOB_ONLYDIR );
				foreach ( $dirs as $dir ) {
					$target = $dest . '/' . basename( $dir );
					// Clear the specific plugin/theme folder before restoring (first part only).
					if ( $is_first_part && is_dir( $target ) ) {
						$this->delete_directory( $target );
					}
					$this->copy_directory( $dir, $target );
				}
			}

			// Clean up temp directory.
			$this->delete_directory( $temp_dir );
		}

		$zip->close();
		return true;
	}

	/**
	 * Import database from SQL content
	 *
	 * @param string $sql_content SQL content.
	 * @return bool|WP_Error
	 */
	private function import_database( $sql_content ) {
		global $wpdb;

		// Clean up wpdb placeholder escape hashes left by _real_escape() in older backups.
		// Pattern: {64-char hex SHA-256 hash} that replaced % characters during export.
		$sql_content = preg_replace( '/\{[a-f0-9]{64}\}/', '%', $sql_content );

		// Detect source table prefix and replace with current prefix if different.
		$source_prefix = '';
		if ( preg_match( '/DROP TABLE IF EXISTS `([a-zA-Z0-9_]+?)options`/', $sql_content, $matches ) ) {
			$source_prefix = $matches[1];
		}

		if ( ! empty( $source_prefix ) && $source_prefix !== $wpdb->prefix ) {
			$escaped_prefix = preg_quote( $source_prefix, '/' );
			// Only replace prefix in SQL identifiers (backtick-quoted table/column names).
			$sql_content = preg_replace(
				'/`' . $escaped_prefix . '([a-zA-Z0-9_]+)`/',
				'`' . $wpdb->prefix . '$1`',
				$sql_content
			);
		}

		// Split into statements.
		$statements = array();
		$current = '';
		$delimiter = ';';

		$lines = explode( "\n", $sql_content );
		foreach ( $lines as $line ) {
			// Skip comments.
			if ( strpos( trim( $line ), '--' ) === 0 || empty( trim( $line ) ) ) {
				continue;
			}

			$current .= $line . "\n";

			if ( substr( rtrim( $line ), -1 ) === $delimiter ) {
				$statements[] = trim( $current );
				$current = '';
			}
		}

		if ( empty( $statements ) ) {
			$this->write_log( 'error', 'SQL file is empty or contains no valid statements' );
			return new WP_Error( 'empty_sql', __( 'The database backup file is empty or contains no valid SQL statements.', 'blaminhor-essentials' ) );
		}

		$this->write_log( 'info', count( $statements ) . ' SQL statements to execute' );

		if ( ! empty( $source_prefix ) && $source_prefix !== $wpdb->prefix ) {
			$this->write_log( 'info', 'Prefix change detected: ' . $source_prefix . ' → ' . $wpdb->prefix );
		}

		// Execute statements.
		$executed   = 0;
		$failed     = 0;
		$last_error = '';

		foreach ( $statements as $statement ) {
			if ( empty( trim( $statement ) ) ) {
				continue;
			}

			// Log DROP/CREATE for tracking which tables are processed.
			if ( preg_match( '/^DROP TABLE IF EXISTS `([^`]+)`/', $statement, $m ) ) {
				$this->write_log( 'info', 'Dropping table ' . $m[1] );
			} elseif ( preg_match( '/^CREATE TABLE `([^`]+)`/', $statement, $m ) ) {
				$this->write_log( 'info', 'Creating table ' . $m[1] );
			}

			// Note: The SQL statements come from our own backup file and are not user-provided.
			// We cannot use prepare() here because these are complete SQL statements from the backup.
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Restoring from trusted backup file
			$result = $wpdb->query( $statement );

			if ( false === $result ) {
				$failed++;
				$last_error = $wpdb->last_error;
				// Log first 120 chars of the failed statement + the MySQL error.
				$stmt_preview = substr( preg_replace( '/\s+/', ' ', $statement ), 0, 120 );
				$this->write_log( 'error', 'SQL failed: ' . $stmt_preview . '...' );
				$this->write_log( 'error', 'MySQL error: ' . $last_error );
			} else {
				$executed++;
			}
		}

		// Fix prefix-dependent meta keys and options after prefix replacement.
		if ( ! empty( $source_prefix ) && $source_prefix !== $wpdb->prefix ) {
			$old_prefix_like = $wpdb->esc_like( $source_prefix ) . '%';
			$old_prefix_len  = strlen( $source_prefix );

			// Update all usermeta keys that start with the old prefix.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixing prefix-dependent meta keys after restore
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE `{$wpdb->usermeta}` SET `meta_key` = CONCAT(%s, SUBSTRING(`meta_key`, %d)) WHERE `meta_key` LIKE %s",
					$wpdb->prefix,
					$old_prefix_len + 1,
					$old_prefix_like
				)
			);

			// Fix user_roles option: delete any existing one first to avoid duplicate key error.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixing prefix-dependent option after restore
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM `{$wpdb->options}` WHERE `option_name` = %s",
					$wpdb->prefix . 'user_roles'
				)
			);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixing prefix-dependent option after restore
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE `{$wpdb->options}` SET `option_name` = %s WHERE `option_name` = %s",
					$wpdb->prefix . 'user_roles',
					$source_prefix . 'user_roles'
				)
			);
		}

		$this->write_log( 'info', 'SQL execution: ' . $executed . ' succeeded, ' . $failed . ' failed' );

		if ( 0 === $executed ) {
			$this->write_log( 'error', 'All SQL statements failed — last error: ' . $last_error );
			return new WP_Error(
				'sql_all_failed',
				sprintf(
					/* translators: 1: number of failed statements, 2: last SQL error message */
					__( 'Database import failed: %1$d statements failed. Last error: %2$s', 'blaminhor-essentials' ),
					$failed,
					$last_error
				)
			);
		}

		return array(
			'executed' => $executed,
			'failed'   => $failed,
		);
	}

	/**
	 * Clear directory contents without removing the directory itself
	 *
	 * @param string $dir Directory path.
	 */
	private function clear_directory_contents( $dir ) {
		if ( ! is_dir( $dir ) ) {
			return;
		}

		$files = array_diff( scandir( $dir ), array( '.', '..' ) );
		foreach ( $files as $file ) {
			$path = $dir . '/' . $file;
			if ( is_dir( $path ) ) {
				$this->delete_directory( $path );
			} else {
				unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
			}
		}
	}

	/**
	 * Copy directory recursively
	 *
	 * @param string $source Source directory.
	 * @param string $dest Destination directory.
	 */
	private function copy_directory( $source, $dest ) {
		if ( ! is_dir( $source ) ) {
			return;
		}

		if ( ! is_dir( $dest ) ) {
			wp_mkdir_p( $dest );
		}

		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $source, RecursiveDirectoryIterator::SKIP_DOTS ),
			RecursiveIteratorIterator::SELF_FIRST
		);

		foreach ( $iterator as $item ) {
			$target = $dest . '/' . $iterator->getSubPathName();

			if ( $item->isDir() ) {
				if ( ! is_dir( $target ) ) {
					wp_mkdir_p( $target );
				}
			} else {
				copy( $item, $target ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy
			}
		}
	}

	/**
	 * Delete directory recursively
	 *
	 * @param string $dir Directory path.
	 */
	private function delete_directory( $dir ) {
		if ( ! is_dir( $dir ) ) {
			return;
		}

		$files = array_diff( scandir( $dir ), array( '.', '..' ) );
		foreach ( $files as $file ) {
			$path = $dir . '/' . $file;
			if ( is_dir( $path ) ) {
				$this->delete_directory( $path );
			} else {
				unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
			}
		}

		rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
	}

	/**
	 * Apply retention policy (works with grouped backups)
	 */
	private function apply_retention_policy() {
		$retention = $this->get_setting( 'retention_count', 5 );
		$backups   = $this->get_backups();

		if ( count( $backups ) > $retention ) {
			$to_delete = array_slice( $backups, $retention );
			foreach ( $to_delete as $backup ) {
				// Delete all archives in this backup set.
				$this->delete_backup_by_prefix( $backup['prefix'] );
			}
		}
	}

	/**
	 * Delete all backup archives with a given prefix
	 *
	 * @param string $prefix Backup prefix.
	 * @return bool
	 */
	private function delete_backup_by_prefix( $prefix ) {
		$prefix = sanitize_file_name( $prefix );
		$files  = glob( $this->backup_dir . '/' . $prefix . '-*.zip' );

		// Also match exact {prefix}.zip (e.g. pre-restore backups).
		$exact = $this->backup_dir . '/' . $prefix . '.zip';
		if ( file_exists( $exact ) ) {
			$files[] = $exact;
		}

		// Also delete associated log files.
		$log_patterns = array(
			$this->backup_dir . '/' . $prefix . '.log',
			$this->backup_dir . '/' . $prefix . '-restore.log',
		);
		foreach ( $log_patterns as $log_file ) {
			if ( file_exists( $log_file ) ) {
				$files[] = $log_file;
			}
		}

		if ( empty( $files ) ) {
			return false;
		}

		$success = true;
		foreach ( $files as $file ) {
			if ( strpos( realpath( $file ), realpath( $this->backup_dir ) ) !== 0 ) {
				continue;
			}
			if ( file_exists( $file ) ) {
				if ( ! unlink( $file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
					$success = false;
				}
			}
		}

		return $success;
	}

	/**
	 * Delete single backup file
	 *
	 * @param string $filename Backup filename.
	 * @return bool
	 */
	private function delete_backup( $filename ) {
		$path = $this->backup_dir . '/' . sanitize_file_name( $filename );

		if ( strpos( realpath( $path ), realpath( $this->backup_dir ) ) !== 0 ) {
			return false;
		}

		if ( file_exists( $path ) ) {
			return unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		}

		return false;
	}

	/**
	 * AJAX: Create backup
	 */
	public function ajax_create_backup() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		// Prevent double submission with a transient lock
		$lock_key = 'blaminhor_backup_in_progress';
		if ( get_transient( $lock_key ) ) {
			wp_send_json_error( array(
				'message' => __( 'A backup is already in progress. Please wait.', 'blaminhor-essentials' ),
				'locked'  => true,
			) );
			return;
		}
		// Set lock for 3 minutes (reduced from 10 to avoid long stale locks)
		set_transient( $lock_key, true, 180 );

		$options = array(
			'database'   => isset( $_POST['database'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['database'] ) ),
			'plugins'    => isset( $_POST['plugins'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['plugins'] ) ),
			'themes'     => isset( $_POST['themes'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['themes'] ) ),
			'uploads'    => isset( $_POST['uploads'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['uploads'] ) ),
			'wp_content' => isset( $_POST['wp_content'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['wp_content'] ) ),
			'wp_core'    => isset( $_POST['wp_core'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['wp_core'] ) ),
		);

		// Backup operations may take a long time and require more memory.
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for large backup operations
		@set_time_limit( 600 );
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for large backup operations
		@ini_set( 'memory_limit', '512M' );

		$result = $this->create_backup( $options );

		// Release the lock
		delete_transient( $lock_key );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		// Build list of created archives for display.
		$archive_names = array_map( function( $a ) {
			return $a['component'];
		}, $result['archives'] );

		wp_send_json_success( array(
			'prefix'   => $result['prefix'],
			'count'    => $result['count'],
			'archives' => implode( ', ', $archive_names ),
			'size'     => size_format( $result['size'], 2 ),
			'message'  => sprintf(
				/* translators: %d: number of archives created */
				_n(
					'Backup created successfully! %d archive.',
					'Backup created successfully! %d archives.',
					$result['count'],
					'blaminhor-essentials'
				),
				$result['count']
			),
		) );
	}

	/**
	 * Initialize backup log file
	 *
	 * @param string $prefix Backup prefix.
	 */
	private function init_log( $prefix ) {
		$this->log_file = $this->backup_dir . '/' . $prefix . '.log';
		file_put_contents( $this->log_file, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		$this->cleanup_old_logs();
	}

	/**
	 * Write a line to the backup log
	 *
	 * @param string $level   Log level (info, success, warning, error).
	 * @param string $message Log message.
	 */
	private function write_log( $level, $message ) {
		if ( empty( $this->log_file ) ) {
			return;
		}
		$line = sprintf( "[%s] [%s] %s\n", gmdate( 'H:i:s' ), strtoupper( $level ), $message );
		file_put_contents( $this->log_file, $line, FILE_APPEND ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
	}

	/**
	 * Remove old log files beyond retention limit
	 */
	private function cleanup_old_logs() {
		$retention = absint( $this->get_setting( 'backup_log_retention', 3 ) );
		if ( $retention < 1 ) {
			$retention = 3;
		}

		$logs = glob( $this->backup_dir . '/*.log' );
		if ( empty( $logs ) || count( $logs ) <= $retention ) {
			return;
		}

		// Sort by modification time, oldest first.
		usort( $logs, function ( $a, $b ) {
			return filemtime( $a ) - filemtime( $b );
		} );

		$to_delete = count( $logs ) - $retention;
		for ( $i = 0; $i < $to_delete; $i++ ) {
			unlink( $logs[ $i ] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		}
	}

	/**
	 * AJAX: Get backup log content for real-time display
	 */
	public function ajax_get_backup_log() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$prefix = isset( $_POST['prefix'] ) ? sanitize_file_name( wp_unslash( $_POST['prefix'] ) ) : '';
		$offset = isset( $_POST['offset'] ) ? intval( $_POST['offset'] ) : 0;

		if ( empty( $prefix ) ) {
			wp_send_json_error( 'Invalid prefix' );
		}

		$log_path = $this->backup_dir . '/' . $prefix . '.log';

		if ( ! file_exists( $log_path ) ) {
			wp_send_json_success( array(
				'lines'      => array(),
				'new_offset' => 0,
			) );
		}

		// Security: ensure path is within backup dir.
		if ( strpos( realpath( $log_path ), realpath( $this->backup_dir ) ) !== 0 ) {
			wp_send_json_error( 'Invalid path' );
		}

		$content = file_get_contents( $log_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$new_content = substr( $content, $offset );
		$new_offset  = strlen( $content );

		$lines = array();
		if ( ! empty( $new_content ) ) {
			$raw_lines = explode( "\n", rtrim( $new_content, "\n" ) );
			foreach ( $raw_lines as $line ) {
				if ( empty( $line ) ) {
					continue;
				}
				// Parse level from [TIMESTAMP] [LEVEL] message.
				$level = 'info';
				if ( preg_match( '/\[(\w+)\]\s/', $line, $m, 0, 10 ) ) {
					$level = strtolower( $m[1] );
				}
				$lines[] = array(
					'text'  => $line,
					'level' => $level,
				);
			}
		}

		wp_send_json_success( array(
			'lines'      => $lines,
			'new_offset' => $new_offset,
		) );
	}

	/**
	 * AJAX: Create backup step by step
	 *
	 * Creates one archive at a time for progress tracking.
	 */
	public function ajax_create_backup_step() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$step      = isset( $_POST['step'] ) ? intval( $_POST['step'] ) : 0;
		$prefix    = isset( $_POST['prefix'] ) ? sanitize_file_name( wp_unslash( $_POST['prefix'] ) ) : '';
		$component = isset( $_POST['component'] ) ? sanitize_text_field( wp_unslash( $_POST['component'] ) ) : '';
		$type      = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'manual';

		// Backup operations may take a long time.
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for backup operations
		@set_time_limit( 300 );
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for backup operations
		@ini_set( 'memory_limit', '512M' );

		// Step 0: Initialize backup (create manifest, generate prefix, build split plan).
		if ( 0 === $step ) {
			$components = array();
			if ( isset( $_POST['database'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['database'] ) ) ) {
				$components[] = 'database';
			}
			if ( isset( $_POST['plugins'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['plugins'] ) ) ) {
				$components[] = 'plugins';
			}
			if ( isset( $_POST['themes'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['themes'] ) ) ) {
				$components[] = 'themes';
			}
			if ( isset( $_POST['uploads'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['uploads'] ) ) ) {
				$components[] = 'uploads';
			}
			if ( isset( $_POST['wp_content'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['wp_content'] ) ) ) {
				$components[] = 'wp-content';
			}
			if ( isset( $_POST['wp_core'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['wp_core'] ) ) ) {
				$components[] = 'wp-core';
			}

			if ( empty( $components ) ) {
				wp_send_json_error( __( 'No components selected for backup.', 'blaminhor-essentials' ) );
			}

			// Generate prefix.
			$prefix = sprintf(
				'%s-%s-%s',
				$this->get_site_name_slug(),
				current_time( 'Y-m-d' ),
				current_time( 'H-i-s' )
			);

			// Ensure backup directory exists.
			$this->ensure_backup_directory();

			// Initialize log.
			$this->init_log( $prefix );
			$this->write_log( 'info', 'Backup started — components: ' . implode( ', ', $components ) );

			// Build split plan.
			$max_size_mb = absint( $this->get_setting( 'max_archive_size', 200 ) );
			$max_bytes   = $max_size_mb * 1024 * 1024;
			$steps       = array();
			$plan_data   = array();

			foreach ( $components as $comp ) {
				if ( 'database' === $comp || 0 === $max_bytes ) {
					// Database is never split; also no splitting if disabled.
					$steps[] = array(
						'component'   => $comp,
						'part'        => 0,
						'total_parts' => 1,
					);
				} else {
					$dir_info = $this->get_component_dir_info( $comp );
					if ( empty( $dir_info ) ) {
						$steps[] = array(
							'component'   => $comp,
							'part'        => 0,
							'total_parts' => 1,
						);
						continue;
					}

					$files  = $this->scan_directory_files( $dir_info['dir'], $dir_info['zip_path'], $dir_info['exclude'] );
					$chunks = $this->split_files_into_chunks( $files, $max_bytes );

					if ( count( $chunks ) <= 1 ) {
						// No split needed.
						$steps[] = array(
							'component'   => $comp,
							'part'        => 0,
							'total_parts' => 1,
						);
					} else {
						// Store chunks in transient for later steps.
						$plan_data[ $comp ] = $chunks;
						foreach ( $chunks as $i => $chunk ) {
							$steps[] = array(
								'component'   => $comp,
								'part'        => $i + 1,
								'total_parts' => count( $chunks ),
							);
						}
					}
				}
			}

			// Store plan if there are multi-part components.
			if ( ! empty( $plan_data ) ) {
				set_transient( 'blaminhor_backup_plan_' . $prefix, $plan_data, HOUR_IN_SECONDS );
				foreach ( $plan_data as $comp_name => $chunks ) {
					$this->write_log( 'info', ucfirst( $comp_name ) . ' will be split into ' . count( $chunks ) . ' parts' );
				}
			}

			$this->write_log( 'info', 'Backup plan ready — ' . count( $steps ) . ' step(s)' );

			// Set lock.
			set_transient( 'blaminhor_backup_in_progress', true, 600 );

			wp_send_json_success( array(
				'step'       => 1,
				'prefix'     => $prefix,
				'components' => $components,
				'steps'      => $steps,
				'total'      => count( $steps ),
				'completed'  => 0,
				'current'    => $components[0],
				'message'    => __( 'Initializing backup...', 'blaminhor-essentials' ),
			) );
		}

		// Step 1+: Create individual archives.
		if ( empty( $prefix ) || empty( $component ) ) {
			wp_send_json_error( __( 'Invalid backup parameters.', 'blaminhor-essentials' ) );
		}

		$part        = isset( $_POST['part'] ) ? intval( $_POST['part'] ) : 0;
		$total_parts = isset( $_POST['total_parts'] ) ? intval( $_POST['total_parts'] ) : 1;

		// Restore log file path for this prefix.
		$this->log_file = $this->backup_dir . '/' . $prefix . '.log';

		$log_label = ucfirst( $component );
		if ( $total_parts > 1 ) {
			$log_label .= ' (part ' . $part . '/' . $total_parts . ')';
		}
		$this->write_log( 'info', 'Creating ' . $log_label . '...' );

		if ( $part > 0 ) {
			// Multi-part: retrieve chunk from transient.
			$plan_data = get_transient( 'blaminhor_backup_plan_' . $prefix );
			$file_list = null;
			if ( is_array( $plan_data ) && isset( $plan_data[ $component ][ $part - 1 ] ) ) {
				$file_list = $plan_data[ $component ][ $part - 1 ];
				$this->write_log( 'info', count( $file_list ) . ' files in this chunk' );
			}

			$result = $this->create_component_archive( $prefix, $component, $file_list, $part, $total_parts );
		} else {
			// Single part: use standard method.
			$result = $this->create_component_archive( $prefix, $component );
		}

		if ( is_wp_error( $result ) ) {
			$this->write_log( 'error', 'Failed: ' . $result->get_error_message() );
			delete_transient( 'blaminhor_backup_in_progress' );
			delete_transient( 'blaminhor_backup_plan_' . $prefix );
			wp_send_json_error( $result->get_error_message() );
		}

		if ( ! $result ) {
			$this->write_log( 'error', 'Failed to create ' . $component . ' archive' );
			delete_transient( 'blaminhor_backup_in_progress' );
			delete_transient( 'blaminhor_backup_plan_' . $prefix );
			wp_send_json_error( sprintf(
				/* translators: %s: component name */
				__( 'Failed to create %s archive.', 'blaminhor-essentials' ),
				$component
			) );
		}

		$this->write_log( 'success', $log_label . ' done — ' . size_format( $result['size'], 2 ) );

		// Write final log entry when this is the last step.
		$total_steps = isset( $_POST['total'] ) ? intval( $_POST['total'] ) : 0;
		if ( $total_steps > 0 && $step === $total_steps ) {
			$this->write_log( 'success', 'Backup completed' );
			delete_transient( 'blaminhor_backup_plan_' . $prefix );
		}

		$message = ( $total_parts > 1 )
			? sprintf(
				/* translators: 1: component name, 2: part number, 3: total parts */
				__( '%1$s archive created (part %2$d/%3$d).', 'blaminhor-essentials' ),
				ucfirst( $component ),
				$part,
				$total_parts
			)
			: sprintf(
				/* translators: %s: component name */
				__( '%s archive created.', 'blaminhor-essentials' ),
				ucfirst( $component )
			);

		wp_send_json_success( array(
			'step'      => $step,
			'component' => $component,
			'part'      => $part,
			'size'      => size_format( $result['size'], 2 ),
			'completed' => true,
			'message'   => $message,
		) );
	}

	/**
	 * Render the backups table HTML (bulk-actions bar + table with checkboxes,
	 * or the empty-state notice). Single source of truth, used both by the
	 * static render and by the AJAX list refresh, so they cannot diverge.
	 *
	 * @param array $backups List of backups returned by get_backups().
	 * @return string Rendered HTML.
	 */
	private function render_backups_table( $backups ) {
		ob_start();
		if ( empty( $backups ) ) :
			?>
			<div class="blaminhor-essentials-notice warning">
				<?php esc_html_e( 'No backups found. Create your first backup now!', 'blaminhor-essentials' ); ?>
			</div>
			<?php
		else :
			$component_labels = array(
				'database'   => __( 'DB', 'blaminhor-essentials' ),
				'plugins'    => __( 'Plugins', 'blaminhor-essentials' ),
				'themes'     => __( 'Themes', 'blaminhor-essentials' ),
				'uploads'    => __( 'Uploads', 'blaminhor-essentials' ),
				'wp-content' => __( 'WP-Content', 'blaminhor-essentials' ),
				'wp-core'    => __( 'WP Core', 'blaminhor-essentials' ),
				'plugin'     => __( 'Plugin', 'blaminhor-essentials' ),
				'theme'      => __( 'Theme', 'blaminhor-essentials' ),
			);
			?>
			<div class="ap-bulk-actions" id="ap-backup-bulk-actions">
				<span class="ap-bulk-count"></span>
				<button type="button" class="button button-small ap-bulk-delete-btn">
					<span class="dashicons dashicons-trash ap-icon-16" style="margin-top: 2px; margin-right: 2px;"></span>
					<?php esc_html_e( 'Delete Selected', 'blaminhor-essentials' ); ?>
				</button>
			</div>
			<table class="widefat striped ap-sortable-table" id="ap-backups-table">
				<thead>
					<tr>
						<th class="ap-col-checkbox"><input type="checkbox" id="ap-backup-select-all" title="<?php esc_attr_e( 'Select All', 'blaminhor-essentials' ); ?>"></th>
						<th class="ap-sortable" data-sort="string"><?php esc_html_e( 'Backup', 'blaminhor-essentials' ); ?> <span class="ap-sort-icon"></span></th>
						<th class="ap-sortable ap-col-actions-sm" data-sort="string"><?php esc_html_e( 'Type', 'blaminhor-essentials' ); ?> <span class="ap-sort-icon"></span></th>
						<th class="ap-sortable ap-col-actions-sm" data-sort="number"><?php esc_html_e( 'Size', 'blaminhor-essentials' ); ?> <span class="ap-sort-icon"></span></th>
						<th class="ap-sortable desc ap-col-actions-md" data-sort="number"><?php esc_html_e( 'Date', 'blaminhor-essentials' ); ?> <span class="ap-sort-icon"></span></th>
						<th class="ap-col-actions-xl"><?php esc_html_e( 'Actions', 'blaminhor-essentials' ); ?></th>
					</tr>
				</thead>
				<tbody>
					<?php foreach ( $backups as $backup ) : ?>
						<tr data-prefix="<?php echo esc_attr( $backup['prefix'] ); ?>" data-type="<?php echo esc_attr( $backup['type'] ); ?>" data-size="<?php echo esc_attr( $backup['total_size'] ); ?>" data-date="<?php echo esc_attr( $backup['date'] ); ?>">
							<td class="ap-col-checkbox"><input type="checkbox" class="ap-backup-checkbox" value="<?php echo esc_attr( $backup['prefix'] ); ?>"></td>
							<td>
								<code class="ap-backup-file-prefix"><?php echo esc_html( $backup['prefix'] ); ?></code>
								<div class="ap-backup-file-meta">
									<?php
									$labels = array();
									foreach ( $backup['components'] as $comp ) {
										if ( isset( $component_labels[ $comp ] ) ) {
											$labels[] = $component_labels[ $comp ];
										}
									}
									echo esc_html( implode( ', ', $labels ) );
									?>
									(<?php echo esc_html( count( $backup['archives'] ) ); ?> <?php echo esc_html( _n( 'archive', 'archives', count( $backup['archives'] ), 'blaminhor-essentials' ) ); ?>)
								</div>
							</td>
							<td>
								<?php if ( 'interrupted' === $backup['status'] ) : ?>
									<span class="ap-backup-type ap-backup-type-interrupted"><?php esc_html_e( 'Interrupted', 'blaminhor-essentials' ); ?></span>
								<?php else : ?>
									<span class="ap-backup-type ap-backup-type-<?php echo esc_attr( $backup['type'] ); ?>"><?php echo esc_html( $backup['type_label'] ); ?></span>
								<?php endif; ?>
								<?php if ( 'pre-update' === $backup['type'] && ! empty( $backup['targets'] ) ) : ?>
									<br><small class="ap-backup-target"><?php echo esc_html( implode( ', ', $backup['targets'] ) ); ?></small>
								<?php endif; ?>
							</td>
							<td><?php echo esc_html( $backup['size_human'] ); ?></td>
							<td><?php echo esc_html( $backup['date_human'] ); ?></td>
							<td class="ap-nowrap">
								<button type="button" class="button button-small ap-backup-restore" data-prefix="<?php echo esc_attr( $backup['prefix'] ); ?>" data-type="<?php echo esc_attr( $backup['type'] ); ?>" data-components="<?php echo esc_attr( wp_json_encode( $backup['components'] ) ); ?>">
									<?php esc_html_e( 'Restore', 'blaminhor-essentials' ); ?>
								</button>
								<button type="button" class="button button-small ap-backup-download" data-prefix="<?php echo esc_attr( $backup['prefix'] ); ?>">
									<?php esc_html_e( 'Download', 'blaminhor-essentials' ); ?>
								</button>
								<?php if ( $backup['has_log'] ) : ?>
									<button type="button" class="button button-small ap-backup-view-log" data-prefix="<?php echo esc_attr( $backup['prefix'] ); ?>">
										<span class="dashicons dashicons-text-page ap-icon-14" style="vertical-align: middle; margin-top: -2px;"></span> <?php esc_html_e( 'Log', 'blaminhor-essentials' ); ?>
									</button>
								<?php endif; ?>
								<button type="button" class="button button-small ap-btn-text-danger ap-backup-delete" data-prefix="<?php echo esc_attr( $backup['prefix'] ); ?>">
									<?php esc_html_e( 'Delete', 'blaminhor-essentials' ); ?>
								</button>
							</td>
						</tr>
					<?php endforeach; ?>
				</tbody>
			</table>
			<?php
		endif;
		return ob_get_clean();
	}

	/**
	 * AJAX: Get backup list HTML and count
	 */
	public function ajax_get_backup_list() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$backups = $this->get_backups();
		$html    = $this->render_backups_table( $backups );

		// Clear the backup in progress lock.
		delete_transient( 'blaminhor_backup_in_progress' );

		wp_send_json_success( array(
			'count' => count( $backups ),
			'html'  => $html,
		) );
	}

	/**
	 * AJAX: Delete backup (all archives with same prefix)
	 */
	public function ajax_delete_backup() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
			return;
		}

		$prefix = isset( $_POST['prefix'] ) ? sanitize_file_name( wp_unslash( $_POST['prefix'] ) ) : '';

		if ( empty( $prefix ) ) {
			wp_send_json_error( __( 'Invalid backup identifier', 'blaminhor-essentials' ) );
			return;
		}

		if ( $this->delete_backup_by_prefix( $prefix ) ) {
			// Return the new backup count for updating the tab.
			$backups = $this->get_backups();
			wp_send_json_success( array(
				'message' => __( 'Backup deleted successfully.', 'blaminhor-essentials' ),
				'count'   => count( $backups ),
			) );
			return;
		}

		wp_send_json_error( __( 'Failed to delete backup.', 'blaminhor-essentials' ) );
	}

	/**
	 * AJAX: Bulk delete backups
	 */
	public function ajax_bulk_delete_backups() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
			return;
		}

		$prefixes = isset( $_POST['prefixes'] ) ? array_map( 'sanitize_file_name', wp_unslash( $_POST['prefixes'] ) ) : array();

		if ( empty( $prefixes ) ) {
			wp_send_json_error( __( 'No backups selected.', 'blaminhor-essentials' ) );
			return;
		}

		$deleted = 0;
		foreach ( $prefixes as $prefix ) {
			if ( ! empty( $prefix ) && $this->delete_backup_by_prefix( $prefix ) ) {
				++$deleted;
			}
		}

		$backups = $this->get_backups();
		wp_send_json_success( array(
			'deleted' => $deleted,
			'count'   => count( $backups ),
		) );
	}

	/**
	 * AJAX: Download backup
	 */
	public function ajax_download_backup() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$filename = isset( $_POST['filename'] ) ? sanitize_file_name( wp_unslash( $_POST['filename'] ) ) : '';
		$path     = $this->backup_dir . '/' . $filename;

		if ( file_exists( $path ) && strpos( realpath( $path ), realpath( $this->backup_dir ) ) === 0 ) {
			wp_send_json_success( array(
				'url' => admin_url( 'admin.php?page=blaminhor-essentials-backup&ap_backup_action=download&file=' . rawurlencode( $filename ) . '&_wpnonce=' . wp_create_nonce( 'ap_backup_action' ) ),
			) );
		}

		wp_send_json_error( __( 'Backup file not found.', 'blaminhor-essentials' ) );
	}

	/**
	 * AJAX: Download backup as a single bundle ZIP
	 *
	 * Creates a ZIP containing all archives for a given prefix,
	 * or returns a direct download URL if only one archive exists.
	 */
	public function ajax_download_bundle() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		$prefix = isset( $_POST['prefix'] ) ? sanitize_file_name( wp_unslash( $_POST['prefix'] ) ) : '';
		if ( empty( $prefix ) ) {
			wp_send_json_error( __( 'Invalid backup prefix.', 'blaminhor-essentials' ) );
		}

		$files = glob( $this->backup_dir . '/' . $prefix . '-*.zip' );
		if ( empty( $files ) ) {
			wp_send_json_error( __( 'Backup file not found.', 'blaminhor-essentials' ) );
		}

		// Filter out any existing bundle files.
		$archives = array();
		foreach ( $files as $file ) {
			if ( strpos( basename( $file ), '-bundle.zip' ) === false ) {
				$archives[] = $file;
			}
		}

		if ( empty( $archives ) ) {
			wp_send_json_error( __( 'Backup file not found.', 'blaminhor-essentials' ) );
		}

		// Single archive: download directly without creating a bundle.
		if ( count( $archives ) === 1 ) {
			$filename = basename( $archives[0] );
			wp_send_json_success( array(
				'url' => admin_url( 'admin.php?page=blaminhor-essentials-backup&ap_backup_action=download&file=' . rawurlencode( $filename ) . '&_wpnonce=' . wp_create_nonce( 'ap_backup_action' ) ),
			) );
		}

		// Multiple archives: create a bundle ZIP.
		$bundle_name = $prefix . '-bundle.zip';
		$bundle_path = $this->backup_dir . '/' . $bundle_name;

		if ( file_exists( $bundle_path ) ) {
			wp_delete_file( $bundle_path );
		}

		$zip = new ZipArchive();
		if ( true !== $zip->open( $bundle_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
			wp_send_json_error( __( 'Failed to create bundle archive.', 'blaminhor-essentials' ) );
		}

		foreach ( $archives as $archive ) {
			$zip->addFile( $archive, basename( $archive ) );
		}
		$zip->close();

		wp_send_json_success( array(
			'url' => admin_url( 'admin.php?page=blaminhor-essentials-backup&ap_backup_action=download&file=' . rawurlencode( $bundle_name ) . '&_wpnonce=' . wp_create_nonce( 'ap_backup_action' ) ),
		) );
	}

	/**
	 * AJAX: Restore backup
	 */
	public function ajax_restore_backup() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'blaminhor-essentials' ) );
		}

		// Accept either prefix (new format) or filename (legacy format).
		$prefix = isset( $_POST['prefix'] ) ? sanitize_file_name( wp_unslash( $_POST['prefix'] ) ) : '';
		if ( empty( $prefix ) && isset( $_POST['filename'] ) ) {
			// Legacy support: extract prefix from filename.
			$prefix = sanitize_file_name( wp_unslash( $_POST['filename'] ) );
		}

		$options = array(
			'database' => isset( $_POST['restore_database'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['restore_database'] ) ),
			'files'    => isset( $_POST['restore_files'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['restore_files'] ) ),
		);

		// Domain change options.
		$change_domain = isset( $_POST['change_domain'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['change_domain'] ) );
		$old_domain    = isset( $_POST['old_domain'] ) ? esc_url_raw( wp_unslash( $_POST['old_domain'] ) ) : '';
		$new_domain    = isset( $_POST['new_domain'] ) ? esc_url_raw( wp_unslash( $_POST['new_domain'] ) ) : '';

		if ( empty( $prefix ) ) {
			wp_send_json_error( __( 'Invalid backup identifier', 'blaminhor-essentials' ) );
		}

		if ( ! $options['database'] && ! $options['files'] ) {
			wp_send_json_error( __( 'Please select what to restore.', 'blaminhor-essentials' ) );
		}

		// Restore operations may take a long time and require more memory.
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for large restore operations
		@set_time_limit( 600 );
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for large restore operations
		@ini_set( 'memory_limit', '512M' );

		$result = $this->restore_backup( $prefix, $options );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		// Handle domain change if requested.
		$redirect_url = null;
		if ( $change_domain && ! empty( $old_domain ) && ! empty( $new_domain ) && $old_domain !== $new_domain ) {
			$domain_result = $this->change_domain_after_restore( $old_domain, $new_domain );
			if ( ! is_wp_error( $domain_result ) ) {
				$redirect_url = admin_url( 'admin.php?page=blaminhor-essentials-backup' );
			}
		}

		$message = __( 'Backup restored successfully!', 'blaminhor-essentials' );

		wp_send_json_success(
			array(
				'message'      => $message,
				'redirect_url' => $redirect_url,
			)
		);
	}

	/**
	 * Change domain after restore
	 *
	 * @param string $old_url Old site URL.
	 * @param string $new_url New site URL.
	 * @return bool|WP_Error
	 */
	private function change_domain_after_restore( $old_url, $new_url ) {
		global $wpdb;

		// Normalize URLs.
		$old_url = untrailingslashit( $old_url );
		$new_url = untrailingslashit( $new_url );

		if ( $old_url === $new_url ) {
			return true;
		}

		// Extract domains for protocol-agnostic replacement.
		$old_domain = preg_replace( '#^https?://#', '', $old_url );
		$new_domain = preg_replace( '#^https?://#', '', $new_url );

		// Flush object cache: after a raw SQL database import, the cache is stale
		// and update_option() would compare against cached (pre-import) values,
		// causing it to skip the update if cached value matches new value.
		wp_cache_flush();

		// Update siteurl and home using direct SQL to bypass cache.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$wpdb->options,
			array( 'option_value' => $new_url ),
			array( 'option_name' => 'siteurl' )
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$wpdb->options,
			array( 'option_value' => $new_url ),
			array( 'option_name' => 'home' )
		);

		// Update other options with serialized data handling.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$options = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT option_id, option_value FROM {$wpdb->options} WHERE option_value LIKE %s AND option_name NOT IN ('siteurl', 'home')",
				'%' . $wpdb->esc_like( $old_domain ) . '%'
			)
		);

		foreach ( $options as $option ) {
			$new_value = $this->replace_domain_in_data( $option->option_value, $old_domain, $new_domain );
			if ( $new_value !== $option->option_value ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->update(
					$wpdb->options,
					array( 'option_value' => $new_value ),
					array( 'option_id' => $option->option_id )
				);
			}
		}

		// Replace in posts (plain text, no serialized data).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)",
				'https://' . $old_domain,
				'https://' . $new_domain
			)
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)",
				'http://' . $old_domain,
				'http://' . $new_domain
			)
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->posts} SET guid = REPLACE(guid, %s, %s)",
				$old_domain,
				$new_domain
			)
		);

		// Update postmeta with serialized data handling.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$postmeta = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_value LIKE %s",
				'%' . $wpdb->esc_like( $old_domain ) . '%'
			)
		);

		foreach ( $postmeta as $meta ) {
			$new_value = $this->replace_domain_in_data( $meta->meta_value, $old_domain, $new_domain );
			if ( $new_value !== $meta->meta_value ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				$wpdb->update(
					$wpdb->postmeta,
					array( 'meta_value' => $new_value ),
					array( 'meta_id' => $meta->meta_id )
				);
			}
		}

		// Replace in comments (plain text).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->comments} SET comment_content = REPLACE(comment_content, %s, %s)",
				$old_domain,
				$new_domain
			)
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->comments} SET comment_author_url = REPLACE(comment_author_url, %s, %s)",
				$old_domain,
				$new_domain
			)
		);

		// Update commentmeta with serialized data handling.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$commentmeta = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT meta_id, meta_value FROM {$wpdb->commentmeta} WHERE meta_value LIKE %s",
				'%' . $wpdb->esc_like( $old_domain ) . '%'
			)
		);

		foreach ( $commentmeta as $meta ) {
			$new_value = $this->replace_domain_in_data( $meta->meta_value, $old_domain, $new_domain );
			if ( $new_value !== $meta->meta_value ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				$wpdb->update(
					$wpdb->commentmeta,
					array( 'meta_value' => $new_value ),
					array( 'meta_id' => $meta->meta_id )
				);
			}
		}

		// Update termmeta with serialized data handling.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$termmeta = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT meta_id, meta_value FROM {$wpdb->termmeta} WHERE meta_value LIKE %s",
				'%' . $wpdb->esc_like( $old_domain ) . '%'
			)
		);

		foreach ( $termmeta as $meta ) {
			$new_value = $this->replace_domain_in_data( $meta->meta_value, $old_domain, $new_domain );
			if ( $new_value !== $meta->meta_value ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				$wpdb->update(
					$wpdb->termmeta,
					array( 'meta_value' => $new_value ),
					array( 'meta_id' => $meta->meta_id )
				);
			}
		}

		// Update usermeta with serialized data handling.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$usermeta = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT umeta_id, meta_value FROM {$wpdb->usermeta} WHERE meta_value LIKE %s",
				'%' . $wpdb->esc_like( $old_domain ) . '%'
			)
		);

		foreach ( $usermeta as $meta ) {
			$new_value = $this->replace_domain_in_data( $meta->meta_value, $old_domain, $new_domain );
			if ( $new_value !== $meta->meta_value ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				$wpdb->update(
					$wpdb->usermeta,
					array( 'meta_value' => $new_value ),
					array( 'umeta_id' => $meta->umeta_id )
				);
			}
		}

		// Replace in links (plain text).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->links} SET link_url = REPLACE(link_url, %s, %s)",
				$old_domain,
				$new_domain
			)
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->links} SET link_image = REPLACE(link_image, %s, %s)",
				$old_domain,
				$new_domain
			)
		);

		// Flush cache after all replacements.
		wp_cache_flush();

		// If restoring from HTTPS to HTTP, deactivate the HTTPS Redirect module.
		if ( strpos( $old_url, 'https://' ) === 0 && strpos( $new_url, 'https://' ) !== 0 ) {
			$active_modules = get_option( 'blaminhor_essentials_active_modules', array() );
			$key = array_search( 'https-redirect', $active_modules, true );
			if ( false !== $key ) {
				unset( $active_modules[ $key ] );
				update_option( 'blaminhor_essentials_active_modules', array_values( $active_modules ) );
			}
		}

		// Final cache flush.
		wp_cache_flush();

		return true;
	}

	/**
	 * Replace domain in data, handling serialized PHP and JSON safely.
	 *
	 * Unserializes data before replacing, then re-serializes so that
	 * string length prefixes remain correct (prevents data corruption).
	 *
	 * @param mixed  $data       Data to process.
	 * @param string $old_domain Old domain (without protocol).
	 * @param string $new_domain New domain (without protocol).
	 * @return mixed Processed data.
	 */
	private function replace_domain_in_data( $data, $old_domain, $new_domain ) {
		if ( is_string( $data ) ) {
			// Check if it's serialized data.
			$unserialized = @unserialize( $data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged
			if ( false !== $unserialized && is_array( $unserialized ) ) {
				$processed = $this->replace_domain_in_data( $unserialized, $old_domain, $new_domain );
				return serialize( $processed ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
			}

			// Check if it's JSON.
			$json = json_decode( $data, true );
			if ( json_last_error() === JSON_ERROR_NONE && is_array( $json ) ) {
				$processed = $this->replace_domain_in_data( $json, $old_domain, $new_domain );
				return wp_json_encode( $processed );
			}

			// Plain string: replace across all protocols.
			$data = str_replace( 'https://' . $old_domain, 'https://' . $new_domain, $data );
			$data = str_replace( 'http://' . $old_domain, 'http://' . $new_domain, $data );
			$data = str_replace( '//' . $old_domain, '//' . $new_domain, $data );

			return $data;
		}

		if ( is_array( $data ) ) {
			foreach ( $data as $key => $value ) {
				$data[ $key ] = $this->replace_domain_in_data( $value, $old_domain, $new_domain );
			}
			return $data;
		}

		return $data;
	}

	/**
	 * Process settings
	 *
	 * @param array $data Form data.
	 */
	protected function process_settings( $data ) {
		// WordPress slashes $_POST automatically. Unslash once recursively so
		// sanitize_* calls below receive clean input and apostrophes do not
		// accumulate backslashes across successive saves.
		$data = wp_unslash( $data );

		$settings = array(
			'backup_database'           => isset( $data['backup_database'] ),
			'backup_plugins'            => isset( $data['backup_plugins'] ),
			'backup_themes'             => isset( $data['backup_themes'] ),
			'backup_uploads'            => isset( $data['backup_uploads'] ),
			'backup_wp_content'         => isset( $data['backup_wp_content'] ),
			'backup_wp_core'            => isset( $data['backup_wp_core'] ),
			'retention_count'           => isset( $data['retention_count'] ) ? absint( $data['retention_count'] ) : 5,
			'max_archive_size'          => isset( $data['max_archive_size'] ) ? absint( $data['max_archive_size'] ) : 200,
			'backup_log_retention'      => isset( $data['backup_log_retention'] ) ? absint( $data['backup_log_retention'] ) : 3,
			// Scheduled backup settings.
			'schedule_enabled'             => isset( $data['schedule_enabled'] ),
			'schedule_db_frequency'        => isset( $data['schedule_db_frequency'] ) ? sanitize_text_field( $data['schedule_db_frequency'] ) : 'daily',
			'schedule_db_time'             => isset( $data['schedule_db_time'] ) ? sanitize_text_field( $data['schedule_db_time'] ) : '03:00',
			'schedule_files_frequency'     => isset( $data['schedule_files_frequency'] ) ? sanitize_text_field( $data['schedule_files_frequency'] ) : 'weekly',
			'schedule_files_time'          => isset( $data['schedule_files_time'] ) ? sanitize_text_field( $data['schedule_files_time'] ) : '03:00',
			'schedule_db_retention'        => isset( $data['schedule_db_retention'] ) ? absint( $data['schedule_db_retention'] ) : 7,
			'schedule_files_retention'     => isset( $data['schedule_files_retention'] ) ? absint( $data['schedule_files_retention'] ) : 7,
			// Pre-update backup settings.
			'auto_backup_on_update'     => isset( $data['auto_backup_on_update'] ),
			'auto_backup_core_update'   => isset( $data['auto_backup_core_update'] ),
			'auto_backup_plugin_update' => isset( $data['auto_backup_plugin_update'] ),
		);

		$this->save_settings( $settings );

		// Reschedule backup if settings changed.
		$this->maybe_schedule_backup();

		// Redirect to settings tab with success message.
		wp_safe_redirect( add_query_arg(
			array(
				'page'    => 'blaminhor-essentials-backup',
				'tab'     => 'settings',
				'updated' => '1',
			),
			admin_url( 'admin.php' )
		) );
		exit;
	}

	/**
	 * Render settings page
	 */
	public function render_settings_page() {
		$backups      = $this->get_backups();
		$disk_free    = @disk_free_space( $this->backup_dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$pre_restore  = $this->get_pre_restore_backup();
		?>
		<div class="wrap blaminhor-essentials-wrap">
			<div class="blaminhor-essentials-header">
				<h1><?php esc_html_e( 'Backup & Restore', 'blaminhor-essentials' ); ?></h1>
				<p class="blaminhor-essentials-tagline"><?php esc_html_e( 'Create and restore local backups of your site files and database.', 'blaminhor-essentials' ); ?></p>
			</div>

			<?php if ( $pre_restore ) : ?>
				<div class="blaminhor-essentials-notice warning ap-flex-between ap-flex-wrap ap-gap-lg ap-mb-lg">
					<div>
						<strong><span class="dashicons dashicons-backup ap-icon-mr"></span><?php esc_html_e( 'Restore point available', 'blaminhor-essentials' ); ?></strong>
						<p style="margin: 5px 0 0;">
							<?php
							printf(
								/* translators: 1: backup date, 2: backup size */
								esc_html__( 'A backup was created before your last restore operation on %1$s (%2$s). You can undo the restore to revert to this state.', 'blaminhor-essentials' ),
								'<strong>' . esc_html( $pre_restore['date_human'] ) . '</strong>',
								esc_html( $pre_restore['size_human'] )
							);
							?>
						</p>
					</div>
					<div class="ap-notice-actions" style="flex-wrap: wrap;">
						<button type="button" class="button button-secondary ap-nowrap" id="ap-undo-restore">
							<span class="dashicons dashicons-undo ap-mt-xs"></span>
							<?php esc_html_e( 'Undo Last Restore', 'blaminhor-essentials' ); ?>
						</button>
						<button type="button" class="button ap-nowrap ap-btn-text-danger" id="ap-delete-pre-restore" data-filename="<?php echo esc_attr( $pre_restore['filename'] ); ?>">
							<span class="dashicons dashicons-trash ap-mt-xs"></span>
							<?php esc_html_e( 'Delete this backup', 'blaminhor-essentials' ); ?>
						</button>
						<button type="button" class="button ap-nowrap" id="ap-dismiss-pre-restore" data-filename="<?php echo esc_attr( $pre_restore['filename'] ); ?>">
							<span class="dashicons dashicons-hidden ap-mt-xs"></span>
							<?php esc_html_e( 'Don\'t show again', 'blaminhor-essentials' ); ?>
						</button>
					</div>
				</div>
			<?php endif; ?>

			<?php
			// Handle tab persistence and success message.
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab parameter is not sensitive, used for UI navigation only.
			$active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'create';
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Updated parameter is not sensitive, used for displaying a success message only.
			if ( isset( $_GET['updated'] ) && '1' === $_GET['updated'] ) :
			?>
				<div class="blaminhor-essentials-notice success ap-mb-lg">
					<p><?php esc_html_e( 'Settings saved.', 'blaminhor-essentials' ); ?></p>
				</div>
			<?php endif; ?>

			<div class="blaminhor-essentials-tabs-wrapper">
				<div class="blaminhor-essentials-tabs">
					<button class="blaminhor-essentials-tab <?php echo 'create' === $active_tab ? 'active' : ''; ?>" data-tab="create"><?php esc_html_e( 'Create Backup', 'blaminhor-essentials' ); ?></button>
					<button class="blaminhor-essentials-tab <?php echo 'backups' === $active_tab ? 'active' : ''; ?>" data-tab="backups"><?php esc_html_e( 'Backups', 'blaminhor-essentials' ); ?> (<?php echo count( $backups ); ?>)</button>
					<button class="blaminhor-essentials-tab <?php echo 'upload' === $active_tab ? 'active' : ''; ?>" data-tab="upload"><?php esc_html_e( 'Upload', 'blaminhor-essentials' ); ?></button>
					<button class="blaminhor-essentials-tab <?php echo 'settings' === $active_tab ? 'active' : ''; ?>" data-tab="settings"><?php esc_html_e( 'Settings', 'blaminhor-essentials' ); ?></button>
				</div>

				<!-- Create Backup Tab -->
				<div class="blaminhor-essentials-tab-content <?php echo 'create' === $active_tab ? 'active' : ''; ?>" data-tab-content="create">
					<h3><?php esc_html_e( 'Create New Backup', 'blaminhor-essentials' ); ?></h3>

					<?php if ( $disk_free !== false ) : ?>
						<p class="description ap-mb-md">
							<?php
							printf(
								/* translators: %s: Available disk space */
								esc_html__( 'Available disk space: %s', 'blaminhor-essentials' ),
								'<strong>' . esc_html( size_format( $disk_free, 2 ) ) . '</strong>'
							);
							?>
						</p>
					<?php endif; ?>

					<div class="ap-backup-options">
						<h4 style="margin: 0 0 16px 0; font-size: 14px; font-weight: 600;"><?php esc_html_e( 'What to backup?', 'blaminhor-essentials' ); ?></h4>

						<div class="ap-backup-grid">
							<label class="ap-backup-card">
								<input type="checkbox" id="ap-backup-database" <?php checked( $this->get_setting( 'backup_database', true ) ); ?>>
								<span class="ap-backup-card-inner">
									<span class="ap-backup-card-icon ap-backup-card-icon-db">
										<span class="dashicons dashicons-database"></span>
									</span>
									<span class="ap-backup-card-text">
										<strong><?php esc_html_e( 'Database', 'blaminhor-essentials' ); ?></strong>
										<small><?php esc_html_e( 'All tables', 'blaminhor-essentials' ); ?></small>
									</span>
								</span>
							</label>

							<label class="ap-backup-card">
								<input type="checkbox" id="ap-backup-plugins" <?php checked( $this->get_setting( 'backup_plugins', true ) ); ?>>
								<span class="ap-backup-card-inner">
									<span class="ap-backup-card-icon ap-backup-card-icon-plugins">
										<span class="dashicons dashicons-admin-plugins"></span>
									</span>
									<span class="ap-backup-card-text">
										<strong><?php esc_html_e( 'Plugins', 'blaminhor-essentials' ); ?></strong>
										<small><?php echo esc_html( count( get_plugins() ) ); ?> <?php esc_html_e( 'plugins', 'blaminhor-essentials' ); ?></small>
									</span>
								</span>
							</label>

							<label class="ap-backup-card">
								<input type="checkbox" id="ap-backup-themes" <?php checked( $this->get_setting( 'backup_themes', true ) ); ?>>
								<span class="ap-backup-card-inner">
									<span class="ap-backup-card-icon ap-backup-card-icon-themes">
										<span class="dashicons dashicons-admin-appearance"></span>
									</span>
									<span class="ap-backup-card-text">
										<strong><?php esc_html_e( 'Themes', 'blaminhor-essentials' ); ?></strong>
										<small><?php echo esc_html( count( wp_get_themes() ) ); ?> <?php esc_html_e( 'themes', 'blaminhor-essentials' ); ?></small>
									</span>
								</span>
							</label>

							<label class="ap-backup-card">
								<input type="checkbox" id="ap-backup-uploads" <?php checked( $this->get_setting( 'backup_uploads', true ) ); ?>>
								<span class="ap-backup-card-inner">
									<span class="ap-backup-card-icon ap-backup-card-icon-uploads">
										<span class="dashicons dashicons-admin-media"></span>
									</span>
									<span class="ap-backup-card-text">
										<strong><?php esc_html_e( 'Uploads', 'blaminhor-essentials' ); ?></strong>
										<small><?php esc_html_e( 'Media files', 'blaminhor-essentials' ); ?></small>
									</span>
								</span>
							</label>

							<label class="ap-backup-card">
								<input type="checkbox" id="ap-backup-wp-content" <?php checked( $this->get_setting( 'backup_wp_content', false ) ); ?>>
								<span class="ap-backup-card-inner">
									<span class="ap-backup-card-icon ap-backup-card-icon-content">
										<span class="dashicons dashicons-portfolio"></span>
									</span>
									<span class="ap-backup-card-text">
										<strong><?php esc_html_e( 'Other wp-content', 'blaminhor-essentials' ); ?></strong>
										<small><?php esc_html_e( 'Cache, languages...', 'blaminhor-essentials' ); ?></small>
									</span>
								</span>
							</label>

							<label class="ap-backup-card">
								<input type="checkbox" id="ap-backup-wp-core" <?php checked( $this->get_setting( 'backup_wp_core', false ) ); ?>>
								<span class="ap-backup-card-inner">
									<span class="ap-backup-card-icon ap-backup-card-icon-core">
										<span class="dashicons dashicons-wordpress"></span>
									</span>
									<span class="ap-backup-card-text">
										<strong><?php esc_html_e( 'WordPress Core', 'blaminhor-essentials' ); ?></strong>
										<small><?php esc_html_e( 'Full installation', 'blaminhor-essentials' ); ?></small>
									</span>
								</span>
							</label>
						</div>
					</div>

					<div id="ap-backup-progress" class="ap-hidden ap-mb-lg">
						<div class="ap-backup-progress-box">
							<div class="ap-backup-progress-inner">
								<span class="spinner is-active" style="margin: 0;"></span>
								<span id="ap-backup-progress-status" style="line-height: 20px;"><?php esc_html_e( 'Initializing...', 'blaminhor-essentials' ); ?></span>
							</div>
							<div class="ap-mini-progress-bar">
								<div id="ap-backup-progress-bar" class="ap-mini-progress-fill" style="width: 0%;"></div>
							</div>
						</div>
					</div>

					<div id="ap-backup-log-panel" class="ap-hidden ap-mb-lg">
						<button type="button" class="button button-small ap-mb-sm" id="ap-backup-log-toggle">
							<span class="dashicons dashicons-arrow-down-alt2 ap-icon-16"></span>
							<?php esc_html_e( 'Show Logs', 'blaminhor-essentials' ); ?>
						</button>
						<div id="ap-backup-log-content" class="ap-backup-log-terminal ap-hidden"></div>
					</div>

					<div id="ap-backup-result" class="ap-hidden ap-mb-lg"></div>

					<button type="button" class="button button-primary button-hero ap-btn-icon ap-inline-flex-center ap-gap-sm" id="ap-create-backup">
						<span class="dashicons dashicons-cloud-upload"></span>
						<?php esc_html_e( 'Create Backup Now', 'blaminhor-essentials' ); ?>
					</button>

					<p class="description" style="margin-top: 24px;">
						<?php
						printf(
							/* translators: %s: Backup directory path */
							esc_html__( 'Backups are stored in: %s', 'blaminhor-essentials' ),
							'<code>' . esc_html( str_replace( ABSPATH, '', $this->backup_dir ) ) . '</code>'
						);
						?>
					</p>
				</div>

				<!-- Existing Backups Tab -->
				<div class="blaminhor-essentials-tab-content <?php echo 'backups' === $active_tab ? 'active' : ''; ?>" data-tab-content="backups">
					<h3><?php esc_html_e( 'Existing Backups', 'blaminhor-essentials' ); ?></h3>

					<?php echo $this->render_backups_table( $backups ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output already escaped inside the method. ?>

					<!-- Restore Modal -->
					<div id="ap-restore-modal" class="ap-db-modal ap-hidden">
						<div class="ap-db-modal-overlay"></div>
						<div class="ap-db-modal-content">
							<h3 class="ap-text-danger">
								<span class="dashicons dashicons-warning ap-icon-mr"></span>
								<?php esc_html_e( 'Restore Backup', 'blaminhor-essentials' ); ?>
							</h3>

							<div class="blaminhor-essentials-notice warning">
								<?php esc_html_e( 'Warning: Restoring a backup will overwrite your current data. This action cannot be undone.', 'blaminhor-essentials' ); ?>
							</div>

							<div class="blaminhor-essentials-notice info">
								<span class="dashicons dashicons-shield ap-icon-mr ap-text-success"></span>
								<?php esc_html_e( 'A full backup of your current site will be created automatically before restoring.', 'blaminhor-essentials' ); ?>
							</div>

							<input type="hidden" id="ap-restore-filename">

							<div class="ap-db-modal-body">
								<label class="ap-label-block">
									<input type="checkbox" id="ap-restore-database" checked>
									<?php esc_html_e( 'Restore database', 'blaminhor-essentials' ); ?>
								</label>
								<label class="ap-label-block">
									<input type="checkbox" id="ap-restore-files" checked>
									<?php esc_html_e( 'Restore files (plugins, themes, uploads)', 'blaminhor-essentials' ); ?>
								</label>
							</div>

							<div id="ap-restore-domain-option" class="ap-restore-domain-option ap-hidden">
								<p style="margin: 0 0 10px; font-weight: 600;">
									<span class="dashicons dashicons-info" class="ap-text-warning"></span>
									<?php esc_html_e( 'Different domain detected', 'blaminhor-essentials' ); ?>
								</p>
								<p id="ap-restore-domain-info" style="margin: 0 0 10px; font-size: 13px;"></p>
								<label class="ap-label-flex">
									<input type="checkbox" id="ap-restore-change-domain" checked>
									<?php esc_html_e( 'Change domain automatically after restore', 'blaminhor-essentials' ); ?>
								</label>
								<input type="hidden" id="ap-restore-old-domain">
								<input type="hidden" id="ap-restore-new-domain">
							</div>

							<div id="ap-restore-progress" class="ap-hidden">
								<div class="ap-restore-progress-box">
									<span class="spinner is-active" style="float: left; margin: 0 10px 0 0;"></span>
									<span style="line-height: 20px;"><?php esc_html_e( 'Restoring backup...', 'blaminhor-essentials' ); ?></span>
								</div>
							</div>

							<div class="ap-db-modal-footer">
								<button type="button" class="button" id="ap-restore-cancel"><?php esc_html_e( 'Cancel', 'blaminhor-essentials' ); ?></button>
								<button type="button" class="button button-primary ap-restore-confirm-btn" id="ap-restore-confirm">
									<?php esc_html_e( 'Restore Now', 'blaminhor-essentials' ); ?>
								</button>
							</div>
						</div>
					</div>

					<!-- Backup Log Modal -->
					<div id="ap-backup-log-modal" class="ap-db-modal ap-hidden">
						<div class="ap-db-modal-overlay"></div>
						<div class="ap-db-modal-content">
							<div class="ap-db-modal-header">
								<h3>
									<span class="dashicons dashicons-text-page ap-icon-mr"></span>
									<?php esc_html_e( 'Backup Log', 'blaminhor-essentials' ); ?>
								</h3>
								<button type="button" class="button button-small ap-db-modal-close" id="ap-backup-log-modal-close">
									<?php esc_html_e( 'Close', 'blaminhor-essentials' ); ?>
								</button>
							</div>
							<div id="ap-backup-log-modal-content" class="ap-backup-log-terminal"></div>
						</div>
					</div>
				</div>

				<!-- Upload Tab -->
				<div class="blaminhor-essentials-tab-content <?php echo 'upload' === $active_tab ? 'active' : ''; ?>" data-tab-content="upload">
					<h3><?php esc_html_e( 'Upload Backup', 'blaminhor-essentials' ); ?></h3>
					<p class="description"><?php esc_html_e( 'Upload a backup file from another site or a previous backup. Supported formats: ZIP archives created by this plugin or compatible backup tools.', 'blaminhor-essentials' ); ?></p>

					<div class="ap-upload-area" id="ap-backup-upload-area">
						<div class="ap-upload-dropzone" id="ap-backup-dropzone">
							<span class="dashicons dashicons-upload"></span>
							<p><?php esc_html_e( 'Drag and drop backup files here', 'blaminhor-essentials' ); ?></p>
							<p class="ap-upload-or"><?php esc_html_e( 'or', 'blaminhor-essentials' ); ?></p>
							<label class="button button-secondary">
								<?php esc_html_e( 'Browse Files', 'blaminhor-essentials' ); ?>
								<input type="file" id="ap-backup-file-input" accept=".zip" multiple class="ap-hidden">
							</label>
							<p class="description ap-mt-md">
								<?php
								$max_upload = size_format( wp_max_upload_size() );
								printf(
									/* translators: %s: maximum upload size */
									esc_html__( 'Maximum upload size: %s per file', 'blaminhor-essentials' ),
									esc_html( $max_upload )
								);
								?>
							</p>
						</div>

						<!-- File Queue -->
						<div class="ap-upload-queue ap-hidden" id="ap-upload-queue">
							<h4><?php esc_html_e( 'Selected Files', 'blaminhor-essentials' ); ?></h4>
							<ul class="ap-file-list" id="ap-file-list"></ul>
							<div class="ap-flex ap-gap-md ap-mt-md">
								<button type="button" class="button button-primary" id="ap-start-upload">
									<span class="dashicons dashicons-upload ap-icon-mr"></span>
									<?php esc_html_e( 'Upload All Files', 'blaminhor-essentials' ); ?>
								</button>
								<button type="button" class="button" id="ap-clear-queue">
									<?php esc_html_e( 'Clear', 'blaminhor-essentials' ); ?>
								</button>
							</div>
						</div>

						<div class="ap-upload-progress ap-hidden" id="ap-upload-progress">
							<p id="ap-upload-current-file" style="margin-bottom: 10px; font-weight: 500;"></p>
							<div class="ap-upload-progress-bar">
								<div class="ap-upload-progress-fill" id="ap-upload-progress-fill"></div>
							</div>
							<p id="ap-upload-status"><?php esc_html_e( 'Uploading...', 'blaminhor-essentials' ); ?></p>
						</div>

						<div class="ap-upload-result ap-hidden" id="ap-upload-result">
							<div class="blaminhor-essentials-notice success ap-hidden" id="ap-upload-success">
								<p><strong><?php esc_html_e( 'Backup uploaded successfully!', 'blaminhor-essentials' ); ?></strong></p>
								<p id="ap-upload-filename"></p>
							</div>

							<div class="blaminhor-essentials-notice warning ap-hidden" id="ap-upload-domain-warning">
								<p>
									<strong><span class="dashicons dashicons-info ap-icon-mr"></span><?php esc_html_e( 'Different domain detected', 'blaminhor-essentials' ); ?></strong>
								</p>
								<p id="ap-upload-domain-info"></p>
								<p>
									<label class="ap-label-flex">
										<input type="checkbox" id="ap-change-domain-after-restore" checked>
										<?php esc_html_e( 'Change domain automatically after restore', 'blaminhor-essentials' ); ?>
									</label>
								</p>
							</div>

							<div class="blaminhor-essentials-notice error ap-hidden" id="ap-upload-error">
								<p id="ap-upload-error-message"></p>
							</div>
						</div>
					</div>
				</div>

				<!-- Settings Tab -->
				<div class="blaminhor-essentials-tab-content <?php echo 'settings' === $active_tab ? 'active' : ''; ?>" data-tab-content="settings">
					<form method="post">
						<?php $this->render_nonce_field(); ?>

						<h3><?php esc_html_e( 'Default Backup Options', 'blaminhor-essentials' ); ?></h3>

						<table class="form-table">
							<tr>
								<th scope="row"><?php esc_html_e( 'Include by Default', 'blaminhor-essentials' ); ?></th>
								<td>
									<label class="ap-label-block">
										<input type="checkbox" name="backup_database" <?php checked( $this->get_setting( 'backup_database', true ) ); ?>>
										<?php esc_html_e( 'Database', 'blaminhor-essentials' ); ?>
									</label>
									<label class="ap-label-block">
										<input type="checkbox" name="backup_plugins" <?php checked( $this->get_setting( 'backup_plugins', true ) ); ?>>
										<?php esc_html_e( 'Plugins', 'blaminhor-essentials' ); ?>
									</label>
									<label class="ap-label-block">
										<input type="checkbox" name="backup_themes" <?php checked( $this->get_setting( 'backup_themes', true ) ); ?>>
										<?php esc_html_e( 'Themes', 'blaminhor-essentials' ); ?>
									</label>
									<label class="ap-label-block">
										<input type="checkbox" name="backup_uploads" <?php checked( $this->get_setting( 'backup_uploads', true ) ); ?>>
										<?php esc_html_e( 'Uploads', 'blaminhor-essentials' ); ?>
									</label>
									<label class="ap-label-block">
										<input type="checkbox" name="backup_wp_content" <?php checked( $this->get_setting( 'backup_wp_content', false ) ); ?>>
										<?php esc_html_e( 'Other wp-content files', 'blaminhor-essentials' ); ?>
									</label>
									<label class="ap-label-block">
										<input type="checkbox" name="backup_wp_core" <?php checked( $this->get_setting( 'backup_wp_core', false ) ); ?>>
										<?php esc_html_e( 'WordPress Core', 'blaminhor-essentials' ); ?>
									</label>
								</td>
							</tr>
						</table>

						<hr>

						<h3><?php esc_html_e( 'Retention', 'blaminhor-essentials' ); ?></h3>

						<table class="form-table">
							<tr>
								<th scope="row">
									<label for="retention_count"><?php esc_html_e( 'Keep Last', 'blaminhor-essentials' ); ?></label>
								</th>
								<td>
									<input type="number" name="retention_count" id="retention_count"
										value="<?php echo esc_attr( $this->get_setting( 'retention_count', 5 ) ); ?>"
										min="1" max="50" class="small-text">
									<?php esc_html_e( 'backups', 'blaminhor-essentials' ); ?>
									<p class="description"><?php esc_html_e( 'Older backups will be automatically deleted.', 'blaminhor-essentials' ); ?></p>
								</td>
							</tr>
							<tr>
								<th scope="row">
									<label for="max_archive_size"><?php esc_html_e( 'Max Archive Size', 'blaminhor-essentials' ); ?></label>
								</th>
								<td>
									<input type="number" name="max_archive_size" id="max_archive_size"
										value="<?php echo esc_attr( $this->get_setting( 'max_archive_size', 200 ) ); ?>"
										min="0" max="2000" class="small-text">
									<?php esc_html_e( 'MB', 'blaminhor-essentials' ); ?>
									<p class="description"><?php esc_html_e( 'Split large components into multiple archives of this size. Set to 0 to disable splitting.', 'blaminhor-essentials' ); ?></p>
								</td>
							</tr>
						</table>

						<hr>

						<h3><?php esc_html_e( 'Scheduled Backups', 'blaminhor-essentials' ); ?></h3>

						<table class="form-table">
							<tr>
								<th scope="row"><?php esc_html_e( 'Enable', 'blaminhor-essentials' ); ?></th>
								<td>
									<label>
										<input type="checkbox" name="schedule_enabled" id="schedule_enabled" <?php checked( $this->get_setting( 'schedule_enabled', false ) ); ?>>
										<?php esc_html_e( 'Enable automatic scheduled backups', 'blaminhor-essentials' ); ?>
									</label>
								</td>
							</tr>
						</table>

						<div class="schedule-option">
							<div class="ap-schedule-grid">
								<!-- Database -->
								<div class="ap-schedule-card">
									<h4><span class="dashicons dashicons-database ap-icon-16" style="vertical-align: text-bottom; margin-right: 4px; color: #2271b1;"></span><?php esc_html_e( 'Database', 'blaminhor-essentials' ); ?></h4>
									<div class="ap-schedule-field">
										<label for="schedule_db_frequency"><?php esc_html_e( 'Frequency', 'blaminhor-essentials' ); ?></label>
										<select name="schedule_db_frequency" id="schedule_db_frequency">
											<option value="daily" <?php selected( $this->get_setting( 'schedule_db_frequency', 'daily' ), 'daily' ); ?>><?php esc_html_e( 'Daily', 'blaminhor-essentials' ); ?></option>
											<option value="weekly" <?php selected( $this->get_setting( 'schedule_db_frequency', 'daily' ), 'weekly' ); ?>><?php esc_html_e( 'Weekly', 'blaminhor-essentials' ); ?></option>
											<option value="biweekly" <?php selected( $this->get_setting( 'schedule_db_frequency', 'daily' ), 'biweekly' ); ?>><?php esc_html_e( 'Every Two Weeks', 'blaminhor-essentials' ); ?></option>
											<option value="monthly" <?php selected( $this->get_setting( 'schedule_db_frequency', 'daily' ), 'monthly' ); ?>><?php esc_html_e( 'Monthly', 'blaminhor-essentials' ); ?></option>
										</select>
									</div>
									<div class="ap-schedule-field">
										<label for="schedule_db_time"><?php esc_html_e( 'Time', 'blaminhor-essentials' ); ?></label>
										<input type="time" name="schedule_db_time" id="schedule_db_time"
											value="<?php echo esc_attr( $this->get_setting( 'schedule_db_time', '03:00' ) ); ?>">
									</div>
									<div class="ap-schedule-field">
										<label for="schedule_db_retention"><?php esc_html_e( 'Keep', 'blaminhor-essentials' ); ?></label>
										<input type="number" name="schedule_db_retention" id="schedule_db_retention"
											value="<?php echo esc_attr( $this->get_setting( 'schedule_db_retention', 7 ) ); ?>"
											min="1" max="100" class="small-text ap-input-60">
									</div>
									<p class="description"><?php esc_html_e( 'Server time. Best to run during low-traffic hours.', 'blaminhor-essentials' ); ?></p>
								</div>

								<!-- Files -->
								<div class="ap-schedule-card">
									<h4><span class="dashicons dashicons-media-archive ap-icon-16" style="vertical-align: text-bottom; margin-right: 4px; color: #2271b1;"></span><?php esc_html_e( 'Files', 'blaminhor-essentials' ); ?></h4>
									<div class="ap-schedule-field">
										<label for="schedule_files_frequency"><?php esc_html_e( 'Frequency', 'blaminhor-essentials' ); ?></label>
										<select name="schedule_files_frequency" id="schedule_files_frequency">
											<option value="daily" <?php selected( $this->get_setting( 'schedule_files_frequency', 'weekly' ), 'daily' ); ?>><?php esc_html_e( 'Daily', 'blaminhor-essentials' ); ?></option>
											<option value="weekly" <?php selected( $this->get_setting( 'schedule_files_frequency', 'weekly' ), 'weekly' ); ?>><?php esc_html_e( 'Weekly', 'blaminhor-essentials' ); ?></option>
											<option value="biweekly" <?php selected( $this->get_setting( 'schedule_files_frequency', 'weekly' ), 'biweekly' ); ?>><?php esc_html_e( 'Every Two Weeks', 'blaminhor-essentials' ); ?></option>
											<option value="monthly" <?php selected( $this->get_setting( 'schedule_files_frequency', 'weekly' ), 'monthly' ); ?>><?php esc_html_e( 'Monthly', 'blaminhor-essentials' ); ?></option>
										</select>
									</div>
									<div class="ap-schedule-field">
										<label for="schedule_files_time"><?php esc_html_e( 'Time', 'blaminhor-essentials' ); ?></label>
										<input type="time" name="schedule_files_time" id="schedule_files_time"
											value="<?php echo esc_attr( $this->get_setting( 'schedule_files_time', '03:00' ) ); ?>">
									</div>
									<div class="ap-schedule-field">
										<label for="schedule_files_retention"><?php esc_html_e( 'Keep', 'blaminhor-essentials' ); ?></label>
										<input type="number" name="schedule_files_retention" id="schedule_files_retention"
											value="<?php echo esc_attr( $this->get_setting( 'schedule_files_retention', 7 ) ); ?>"
											min="1" max="100" class="small-text ap-input-60">
									</div>
									<p class="description"><?php esc_html_e( 'Server time. Best to run during low-traffic hours.', 'blaminhor-essentials' ); ?></p>
								</div>
							</div>

							<?php
							$next_db    = wp_next_scheduled( 'blaminhor_essentials_scheduled_backup_db' );
							$next_files = wp_next_scheduled( 'blaminhor_essentials_scheduled_backup_files' );
							$date_fmt   = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );

							if ( $next_db ) :
							?>
							<div class="blaminhor-essentials-notice info ap-mt-md">
								<?php
								printf(
									/* translators: %s: date and time of next scheduled database backup */
									esc_html__( 'Next scheduled database backup: %s', 'blaminhor-essentials' ),
									esc_html( wp_date( $date_fmt, $next_db ) )
								);
								?>
							</div>
							<?php endif; ?>

							<?php if ( $next_files ) : ?>
							<div class="blaminhor-essentials-notice info ap-mt-sm">
								<?php
								printf(
									/* translators: %s: date and time of next scheduled files backup */
									esc_html__( 'Next scheduled files backup: %s', 'blaminhor-essentials' ),
									esc_html( wp_date( $date_fmt, $next_files ) )
								);
								?>
							</div>
							<?php endif; ?>
						</div>

						<hr>

						<h3><?php esc_html_e( 'Pre-update Backup', 'blaminhor-essentials' ); ?></h3>

						<table class="form-table">
							<tr>
								<th scope="row"><?php esc_html_e( 'Enable', 'blaminhor-essentials' ); ?></th>
								<td>
									<label>
										<input type="checkbox" name="auto_backup_on_update" id="auto_backup_on_update" <?php checked( $this->get_setting( 'auto_backup_on_update', false ) ); ?>>
										<?php esc_html_e( 'Automatically backup before updates', 'blaminhor-essentials' ); ?>
									</label>
									<p class="description"><?php esc_html_e( 'Create a backup before WordPress core, plugin, or theme updates.', 'blaminhor-essentials' ); ?></p>
								</td>
							</tr>
							<tr class="ap-pre-update-options" style="<?php echo esc_attr( $this->get_setting( 'auto_backup_on_update', false ) ? '' : 'display:none;' ); ?>">
								<th scope="row"><?php esc_html_e( 'Backup Types', 'blaminhor-essentials' ); ?></th>
								<td>
									<fieldset>
										<label>
											<input type="checkbox" name="auto_backup_core_update" <?php checked( $this->get_setting( 'auto_backup_core_update', true ) ); ?>>
											<?php esc_html_e( 'WordPress core updates', 'blaminhor-essentials' ); ?>
										</label>
										<p class="description"><?php esc_html_e( 'Creates a full backup (database + WordPress files) before core updates.', 'blaminhor-essentials' ); ?></p>
										<br>
										<label>
											<input type="checkbox" name="auto_backup_plugin_update" <?php checked( $this->get_setting( 'auto_backup_plugin_update', true ) ); ?>>
											<?php esc_html_e( 'Plugin and theme updates', 'blaminhor-essentials' ); ?>
										</label>
										<p class="description"><?php esc_html_e( 'Creates a backup of the specific plugin/theme + database before updates.', 'blaminhor-essentials' ); ?></p>
									</fieldset>
								</td>
							</tr>
						</table>

						<script>
						jQuery(function($) {
							$('#auto_backup_on_update').on('change', function() {
								$('.ap-pre-update-options').toggle(this.checked);
							});
						});
						</script>

						<p class="submit">
							<button type="submit" class="button button-primary"><?php esc_html_e( 'Save Settings', 'blaminhor-essentials' ); ?></button>
						</p>
					</form>
				</div>
			</div>
		</div>
		<?php
	}
}
