<?php
/**
 * Database import, prefix rewrite, URL migration, and post-import repair helpers for BackupService.
 *
 * @package BackupRestore
 */

namespace BackupRestore\Includes;

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

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

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

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

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

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

		$argv[] = $name;

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

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

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

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

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

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

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

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

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

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

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

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

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

		return $tmp;
	}

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

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

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

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

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

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

		global $wpdb;

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

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

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

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

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

		return true;
	}

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

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

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

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

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

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

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

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

			if ( $mysql_cli_usable && $this->sql_dump_contains_wpdb_placeholder_tokens( $sql_path, $state ) ) {
				// The CLI would import the corruption verbatim; only the PHP path repairs it.
				$this->log_line( __( 'Dump contains wpdb placeholder tokens from an older export — importing via PHP so every token is repaired back to a "%" character.', 'wppowersuite-lite' ) );
				$mysql_cli_usable = false;
			}

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

		global $wpdb;

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

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

		mysqli_report( MYSQLI_REPORT_OFF );

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

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

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

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

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

				$trimmed_l = ltrim( $line );
				if ( ! $in_string && ( $trimmed_l === '' || str_starts_with( $trimmed_l, '--' ) || str_starts_with( $trimmed_l, '#' ) ) ) {
					if ( preg_match( '/^-- wpps:rowcount ([A-Za-z0-9_]+) (\d+)\s*$/', $trimmed_l, $rcm ) ) {
						if ( ! isset( $state['db_import_rowcounts'] ) || ! is_array( $state['db_import_rowcounts'] ) ) {
							$state['db_import_rowcounts'] = [];
						}
						$state['db_import_rowcounts'][ $rcm[1] ] = (int) $rcm[2];
					}
					continue;
				}

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

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

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

				/*
				 * Mid-tick checkpoint. Statements are applied continuously, but without
				 * this the parser offset is only persisted when the tick ends normally.
				 * A hard process kill (LVE/cgroup limits, OOM, max_execution_time) then
				 * makes the next tick replay every statement since the last persisted
				 * offset — the first replayed INSERT dies with MySQL 1062 duplicate-key.
				 * COMMIT before recording progress: state must never claim rows that are
				 * not on disk yet (crash → rollback → resume replays them cleanly; any
				 * duplicate committed INSERTs from a replay are skipped below).
				 */
				if (
					$stmts_this_tick > $checkpoint_stmts
					&& ( $stmts_this_tick - $checkpoint_stmts >= 2000 || microtime( true ) - $checkpoint_at >= 5 )
				) {
					$dbh->query( 'COMMIT' );
					$state['db_php_import_offset']       = (int) ftell( $h );
					$state['db_php_import_buffer']       = $buffer;
					$state['db_php_import_delimiter']    = $delimiter;
					$state['db_php_import_stmt_count']   = $stmt_count;
					$state['db_php_import_in_string']    = $in_string;
					$state['db_php_import_string_quote'] = $quote;
					$state['db_php_import_escape']       = $escape;
					$bid_ckpt                            = isset( $state['restore_backup_id'] ) ? (string) $state['restore_backup_id'] : '';
					if ( '' !== $bid_ckpt ) {
						$this->save_restore_state( $bid_ckpt, $state );
					}
					$checkpoint_stmts = $stmts_this_tick;
					$checkpoint_at    = microtime( true );
				}
			}

			if ( $eof && ! $in_string && trim( $buffer ) !== '' ) {
				$dbh = $this->import_db_execute_with_reconnect( $dbh, trim( $buffer ), $stmt_count, 0 );
				++$stmt_count;
				$buffer = '';
			}
		} catch ( \RuntimeException $e ) {
			if ( 'wpps_import_connection_lost' !== $e->getMessage() ) {
				throw $e;
			}
			$conn_lost = true;
		} finally {
			// Commit applied work and hand the shared connection back in autocommit mode
			// (the rest of WordPress writes through this same handle after the tick).
			$end_dbh = ( $wpdb->dbh instanceof \mysqli ) ? $wpdb->dbh : null;
			if ( $end_dbh instanceof \mysqli ) {
				$end_dbh->query( 'COMMIT' );
				$end_dbh->query( 'SET autocommit = 1' );
			}
			$new_offset = (int) ftell( $h );
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		if ( $conn_lost ) {
			/*
			 * The dropped connection rolled back everything since the last COMMIT, so
			 * $state still holds the matching checkpoint — do NOT overwrite it with this
			 * tick's parser position. The next tick resumes from the committed point.
			 */
			$this->log_line( __( 'MySQL connection was lost mid-import; resuming from the last committed checkpoint on the next tick.', 'wppowersuite-lite' ) );
			return false;
		}

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

		if ( $eof && ! $in_string && trim( $buffer ) === '' ) {
			$dbh->query( 'SET SESSION foreign_key_checks = 1' );
			$this->log_line( sprintf( __( 'Database import complete: %d statements executed.', 'wppowersuite-lite' ), $stmt_count ) );
			if ( $this->db_import_duplicate_rows_skipped > 0 ) {
				$this->log_line(
					sprintf(
						/* translators: %d: row count */
						__( 'Duplicate rows skipped during import: %d (replay after interrupted ticks; the rows were already applied).', 'wppowersuite-lite' ),
						$this->db_import_duplicate_rows_skipped
					)
				);
				$this->db_import_duplicate_rows_skipped = 0;
			}
			if ( $this->db_import_placeholder_tokens_fixed > 0 ) {
				$this->log_line(
					sprintf(
						/* translators: %s: repaired character count */
						__( 'Repaired %s "%%" characters that an earlier export had corrupted into wpdb placeholder tokens (serialized page-builder data restored).', 'wppowersuite-lite' ),
						number_format_i18n( $this->db_import_placeholder_tokens_fixed )
					)
				);
				$this->db_import_placeholder_tokens_fixed = 0;
			}
			$this->import_verify_row_counts( $state );
			unset( $state['db_php_import_started'] );
			return true;
		}

		$this->log_line(
			sprintf(
				/* translators: 1: statements, 2: imported size, 3: total size, 4: percent */
				__( 'Database import progress: %1$s statements — %2$s of %3$s (%4$d%%).', 'wppowersuite-lite' ),
				number_format_i18n( $stmt_count ),
				size_format( $new_offset, 1 ),
				size_format( max( 1, $sql_size ), 1 ),
				min( 99, (int) floor( $new_offset / max( 1, $sql_size ) * 100 ) )
			)
		);

		return false;
	}

	private function import_db_session_init( \mysqli $dbh ): void {
		$dbh->query( "SET SESSION sql_mode = ''" );
		$dbh->query( 'SET SESSION foreign_key_checks = 0' );
		$dbh->query( 'SET SESSION wait_timeout = 28800' );
		$dbh->query( 'SET SESSION net_read_timeout = 600' );
		$dbh->query( 'SET SESSION net_write_timeout = 600' );
		/*
		 * Batch commits: with autocommit every single-row INSERT pays an InnoDB flush
		 * (~100–500 stmts/s on shared hosting). The import COMMITs at each checkpoint
		 * and the tick end restores autocommit for the rest of WordPress.
		 */
		$dbh->query( 'SET autocommit = 0' );
		$packet = (int) ini_get( 'memory_limit' );
		if ( $packet > 0 ) {
			$dbh->query( 'SET SESSION max_allowed_packet = ' . min( $packet, 512 * 1024 * 1024 ) );
		}
	}

	/**
	 * Duplicate rows skipped by the current import (interrupted-import replay).
	 */
	private int $db_import_duplicate_rows_skipped = 0;

	/**
	 * "%" characters repaired from leaked wpdb placeholder tokens during import.
	 */
	private int $db_import_placeholder_tokens_fixed = 0;

	/**
	 * Compare live row counts against the totals the exporter recorded in the
	 * dump ("-- wpps:rowcount" comments). Live tables may legitimately hold
	 * MORE rows than the dump (WordPress writes options/transients while the
	 * import runs), so only FEWER rows — real data loss — raises a warning.
	 *
	 * @param array<string, mixed> $state
	 */
	private function import_verify_row_counts( array &$state ): void {
		global $wpdb;

		$counts = isset( $state['db_import_rowcounts'] ) && is_array( $state['db_import_rowcounts'] ) ? $state['db_import_rowcounts'] : [];
		unset( $state['db_import_rowcounts'] );
		if ( $counts === [] ) {
			return;
		}

		$old_prefix = isset( $state['db_import_dump_prefix'] ) ? (string) $state['db_import_dump_prefix'] : '';
		$new_prefix = (string) $wpdb->prefix;
		$checked    = 0;
		$mismatched = 0;

		foreach ( $counts as $table => $expected ) {
			$live = (string) $table;
			if ( $old_prefix !== '' && $old_prefix !== $new_prefix && str_starts_with( $live, $old_prefix ) ) {
				$live = $new_prefix . substr( $live, strlen( $old_prefix ) );
			}
			if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $live ) ) {
				continue;
			}
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifier validated above
			$actual = $wpdb->get_var( "SELECT COUNT(*) FROM `{$live}`" );
			if ( null === $actual ) {
				++$mismatched;
				$this->log_line(
					sprintf(
						/* translators: %s: table name */
						__( 'WARNING: table "%s" from the backup is missing after import.', 'wppowersuite-lite' ),
						$live
					)
				);
				continue;
			}
			++$checked;
			if ( (int) $actual < (int) $expected ) {
				++$mismatched;
				$this->log_line(
					sprintf(
						/* translators: 1: table name, 2: expected rows, 3: actual rows */
						__( 'WARNING: table "%1$s" has %3$s rows but the backup contains %2$s — some rows were not imported. (Harmless for cache/transient tables; investigate for content tables.)', 'wppowersuite-lite' ),
						$live,
						number_format_i18n( (int) $expected ),
						number_format_i18n( (int) $actual )
					)
				);
			}
		}

		if ( 0 === $mismatched && $checked > 0 ) {
			$this->log_line(
				sprintf(
					/* translators: %s: table count */
					__( 'Row-count verification passed: all %s tables match the backup.', 'wppowersuite-lite' ),
					number_format_i18n( $checked )
				)
			);
		}
	}

	/**
	 * Collect "-- wpps:rowcount" comments from a dump (CLI import path — the
	 * PHP parser collects them inline while parsing).
	 *
	 * @param array<string, mixed> $state
	 */
	private function import_collect_row_counts_from_dump( string $sql_path, array &$state ): void {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$h = fopen( $sql_path, 'rb' );
		if ( false === $h ) {
			return;
		}
		while ( ( $line = fgets( $h ) ) !== false ) {
			if ( '' === $line || '-' !== $line[0] ) {
				continue;
			}
			if ( preg_match( '/^-- wpps:rowcount ([A-Za-z0-9_]+) (\d+)\s*$/', $line, $m ) ) {
				if ( ! isset( $state['db_import_rowcounts'] ) || ! is_array( $state['db_import_rowcounts'] ) ) {
					$state['db_import_rowcounts'] = [];
				}
				$state['db_import_rowcounts'][ $m[1] ] = (int) $m[2];
			}
		}
		fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
	}

	/**
	 * Streaming scan: does the dump contain leaked wpdb placeholder tokens?
	 * Result is cached in restore state so multi-tick imports scan only once.
	 *
	 * @param array<string, mixed> $state
	 */
	private function sql_dump_contains_wpdb_placeholder_tokens( string $sql_path, array &$state ): bool {
		if ( isset( $state['db_import_has_placeholder_tokens'] ) ) {
			return (bool) $state['db_import_has_placeholder_tokens'];
		}
		$found = false;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$h = fopen( $sql_path, 'rb' );
		if ( false !== $h ) {
			$tail = '';
			while ( ! feof( $h ) ) {
				$chunk = fread( $h, 1048576 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
				if ( ! is_string( $chunk ) || '' === $chunk ) {
					break;
				}
				if ( preg_match( '/\{[0-9a-f]{64}\}/', $tail . $chunk ) ) {
					$found = true;
					break;
				}
				// Overlap so a token split across chunk boundaries is still seen.
				$tail = substr( $chunk, -70 );
			}
			fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}
		$state['db_import_has_placeholder_tokens'] = $found;

		return $found;
	}

	/**
	 * Repair dumps written by exporters that leaked wpdb placeholder tokens.
	 *
	 * Older engine builds ran data values through esc_sql(), which since
	 * WP 4.8.3 replaces every literal "%" with a per-request {sha256-hex}
	 * token. Reverting the token to "%" is byte-exact: it restores the
	 * original value length, so serialized strings unserialize again.
	 * A legitimate value that is exactly 64 lowercase hex chars in braces
	 * would be a false positive — astronomically unlikely, and the repair
	 * count is logged so it never happens silently.
	 */
	private function import_repair_wpdb_placeholder_tokens( string $stmt ): string {
		if ( ! str_contains( $stmt, '{' ) ) {
			return $stmt;
		}
		$count = 0;
		$fixed = preg_replace( '/\{[0-9a-f]{64}\}/', '%', $stmt, -1, $count );
		if ( is_string( $fixed ) && $count > 0 ) {
			$this->db_import_placeholder_tokens_fixed += $count;
			return $fixed;
		}

		return $stmt;
	}

	/**
	 * Preserve dump INSERT semantics by default.
	 *
	 * REPLACE INTO is useful for a narrow disaster-recovery mode, but it can delete
	 * and reinsert rows under the hood, firing cascades/triggers and changing row
	 * side effects. Production restores should replay the dump exactly unless a
	 * site explicitly opts into the legacy rewrite.
	 */
	private function import_rewrite_insert_to_replace( string $stmt ): string {
		if ( ! (bool) apply_filters( 'wpps_br_import_rewrite_insert_to_replace', false ) ) {
			return $stmt;
		}
		if ( preg_match( '/^\s*INSERT\s+INTO\s/i', $stmt ) ) {
			$rewritten = preg_replace( '/^\s*INSERT\s+INTO\s/i', 'REPLACE INTO ', $stmt, 1 );
			if ( is_string( $rewritten ) ) {
				return $rewritten;
			}
		}
		return $stmt;
	}

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

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

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

			/*
			 * Do NOT retry-and-continue: the import runs with autocommit off, so the
			 * dropped connection rolled back every statement since the last COMMIT.
			 * Continuing here would silently lose those rows. The tick aborts and
			 * resumes from the last committed checkpoint instead.
			 */
			throw new \RuntimeException( 'wpps_import_connection_lost' );
		}

		/*
		 * MySQL 1062 (duplicate key) on an INSERT: the row is already in the table.
		 * That happens when an interrupted tick's statements are replayed on resume
		 * (the dump itself never inserts the same key twice into a freshly created
		 * table). Skipping keeps the import resumable instead of failing at 90%.
		 */
		if ( false === $result && 1062 === (int) $dbh->errno && preg_match( '/^\s*INSERT\s/i', $stmt ) ) {
			++$this->db_import_duplicate_rows_skipped;
			if ( $this->db_import_duplicate_rows_skipped <= 5 ) {
				$this->log_line(
					sprintf(
						/* translators: 1: statement number, 2: MySQL error text */
						__( 'Skipped already-imported row at statement #%1$d (%2$s) — expected after a resumed/interrupted import tick.', 'wppowersuite-lite' ),
						$stmt_index + 1,
						$dbh->error
					)
				);
			}
			return $dbh;
		}

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

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

		return $dbh;
	}

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

		$wpdb->check_connection( false );

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

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

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

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

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

		return $out;
	}

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

			return $next;
		}

		return $data;
	}

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

		return $value;
	}

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

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

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

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

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

	/**
	 * Keep the backup-restore module enabled in the imported plugin settings.
	 *
	 * A cross-site restore imports the source site's wp_powersuite_settings. If that
	 * site had this module disabled, the module (and its AJAX handlers and admin page)
	 * would vanish on the next request, orphaning the running restore. Mirrors
	 * restore_ensure_powersuite_in_active_plugins() at the module-flag level.
	 * Reads/writes the option row directly: OptionStore's request cache still holds
	 * pre-import data at this point and must not be written back.
	 */
	private function restore_ensure_backup_module_enabled_in_settings(): void {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->options
		$raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = %s LIMIT 1", 'wp_powersuite_settings' ) );

		$settings = is_string( $raw ) && $raw !== '' ? maybe_unserialize( $raw ) : [];
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}
		$enabled = isset( $settings['enabled_modules'] ) && is_array( $settings['enabled_modules'] ) ? $settings['enabled_modules'] : [];
		if ( in_array( 'backup-restore', $enabled, true ) ) {
			return;
		}
		$enabled[]                   = 'backup-restore';
		$settings['enabled_modules'] = array_values( $enabled );
		$blob                        = maybe_serialize( $settings );

		if ( is_string( $raw ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update( $wpdb->options, [ 'option_value' => $blob ], [ 'option_name' => 'wp_powersuite_settings' ], [ '%s' ], [ '%s' ] );
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->insert(
				$wpdb->options,
				[
					'option_name'  => 'wp_powersuite_settings',
					'option_value' => $blob,
					'autoload'     => 'no',
				],
				[ '%s', '%s', '%s' ]
			);
		}
		wp_cache_delete( 'wp_powersuite_settings', 'options' );
		wp_cache_delete( 'alloptions', 'options' );

		$this->log_line( __( 'Imported settings had the Backup & Restore module disabled — re-enabled it so the running restore and this admin page keep working.', 'wppowersuite-lite' ) );
	}

	/**
	 * Purge the module-registry caches the imported dump just replaced.
	 *
	 * Module metadata is cached in transients (wp_options rows), so a database
	 * restore swaps in the SOURCE site's cache. When the source runs the Lite
	 * edition, its manifest cache has no backup-restore entry — the registry on
	 * this install would then "lose" the module (menu, page, AJAX handlers) on
	 * the very next request, orphaning this restore. Deleting the caches forces
	 * a rescan of the local module files.
	 */
	private function restore_clear_module_registry_caches(): void {
		if ( class_exists( '\WP_PowerSuite\Core\DataRegistry', true ) ) {
			\WP_PowerSuite\Core\DataRegistry::clearCaches();
			$this->log_line( __( 'Cleared imported module-registry caches so this install rescans its own module files.', 'wppowersuite-lite' ) );
		}
	}

	/**
	 * After SQL import + optional URL migration, warn if the plugin activation list is unusable.
	 */
	private function migrate_cell_value( array $pairs, string $value ): string {
		if ( $value === '' || $pairs === [] ) {
			return $value;
		}
		if ( function_exists( 'is_serialized' ) && is_serialized( $value ) ) {
			if ( $value === serialize( false ) ) {
				return $value;
			}
			$decoded = @unserialize( $value, [ 'allowed_classes' => false ] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize_unserialize
			if ( false === $decoded && $value !== serialize( false ) ) {
				// Plain str_replace on a blob that looks serialized would break length prefixes — leave unchanged.
				return $value;
			}
			$replaced = $this->migrate_replace_deep( $pairs, $decoded );

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

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

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

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

		return $cols;
	}

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

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

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

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

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

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

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

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

		return $updated;
	}

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

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

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

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

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

		$items = 0;

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

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

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

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

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

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

}
