
// ---------------------------------------------------------------------------
// Safety & helpers
// ---------------------------------------------------------------------------

// Hard fail on missing token.
$token = isset( $_GET['token'] ) ? (string) $_GET['token'] : '';
if ( '' === $token || ! hash_equals( $security_token, $token ) ) {
	http_response_code( 403 );
	exit( 'Forbidden' );
}

$action       = isset( $_GET['asenha_action'] ) ? (string) $_GET['asenha_action'] : '';
$migration_id = isset( $_GET['migration_id'] ) ? (string) $_GET['migration_id'] : '';

// Allow a dedicated cleanup action that runs only after final completion.
$allowed_actions = array( 'migration_db', 'migration_cleanup' );
if ( ! in_array( $action, $allowed_actions, true ) ) {
	http_response_code( 400 );
	exit( 'Bad Request' );
}

if ( '' === $migration_id || 1 !== preg_match( '/^[0-9a-fA-F-]{36}$/', $migration_id ) ) {
	http_response_code( 400 );
	exit( 'Invalid migration ID' );
}

// Prevent concurrent runner workers for the same migration ID.
$lock_prefix = ( 'migration_cleanup' === $action ) ? 'migration_cleanup_lock_' : 'migration_db_runner_lock_';
$lock_file   = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . $lock_prefix . preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $migration_id ) . '.lock';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
$lock_handle = @fopen( $lock_file, 'c' );
if ( false === $lock_handle ) {
	http_response_code( 500 );
	exit( 'Lock unavailable' );
}
if ( ! @flock( $lock_handle, LOCK_EX | LOCK_NB ) ) {
	// Another worker is already running; exit quietly.
	exit( 'Locked' );
}
register_shutdown_function(
	static function() use ( $lock_handle, $lock_file, $action ) {
		if ( is_resource( $lock_handle ) ) {
			@flock( $lock_handle, LOCK_UN );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			@fclose( $lock_handle );
		}

		// Best-effort: cleanup-action lock file should not linger.
		if ( 'migration_cleanup' === (string) $action && '' !== (string) $lock_file && file_exists( $lock_file ) ) {
			@unlink( $lock_file );
		}
	}
);

// Basic max execution time awareness.
$max_exec_time = (int) ini_get( 'max_execution_time' );
if ( $max_exec_time <= 0 ) {
	$max_exec_time = 30;
}
$start_time = microtime( true );
$time_budget = max( 5, $max_exec_time - 5 ); // Keep a safety margin.
// Safety cap: prefer short runner iterations to avoid proxy/gateway timeouts.
$time_budget = min( $time_budget, 20 );

// Try to increase limits (may be ignored).
if ( function_exists( 'set_time_limit' ) ) {
	@set_time_limit( 300 );
}
@ini_set( 'memory_limit', '512M' );

/**
 * Build state file path for the migration.
 *
 * @param string $backup_dir Backup directory.
 * @param string $migration_id Migration ID.
 * @return string
 */
function asenha_get_state_file_path( $backup_dir, $migration_id ) {
	return rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'restore_state_' . preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $migration_id ) . '.json';
}

/**
 * Read a JSON file safely.
 *
 * @param string $path Path.
 * @return array|null
 */
function asenha_read_json_file( $path ) {
	if ( '' === (string) $path || ! file_exists( $path ) ) {
		return null;
	}
	$content = @file_get_contents( $path );
	if ( false === $content || '' === $content ) {
		return null;
	}
	$data = json_decode( $content, true );
	return is_array( $data ) ? $data : null;
}

/**
 * Atomic write JSON file.
 *
 * @param string $path Path.
 * @param array  $data Data.
 * @return bool
 */
function asenha_atomic_write_json( $path, $data ) {
	$path = (string) $path;
	if ( '' === $path ) {
		return false;
	}

	$dir = dirname( $path );
	if ( ! is_dir( $dir ) ) {
		@mkdir( $dir, 0755, true );
	}

	$tmp = $path . '.tmp.' . mt_rand();
	$json = json_encode( $data, JSON_PRETTY_PRINT );
	if ( false === $json ) {
		return false;
	}
	$ok = ( false !== @file_put_contents( $tmp, $json ) );
	if ( ! $ok ) {
		@unlink( $tmp );
		return false;
	}
	$renamed = @rename( $tmp, $path );
	if ( ! $renamed ) {
		@unlink( $tmp );
		return false;
	}
	clearstatcache( true, $path );
	return true;
}

/**
 * POST application/x-www-form-urlencoded using curl (preferred) or streams (fallback).
 *
 * @param string $url URL.
 * @param array  $data Form fields.
 * @param int    $timeout Timeout seconds.
 * @return array{code:int,body:string,error:string}
 */
function asenha_http_post_form( $url, $data, $timeout = 20 ) {
	$url     = (string) $url;
	$data    = is_array( $data ) ? $data : array();
	$timeout = (int) $timeout;
	if ( $timeout <= 0 ) {
		$timeout = 20;
	}

	$body  = http_build_query( $data, '', '&' );
	$error = '';
	$code  = 0;

	// Prefer curl when available.
	if ( function_exists( 'curl_init' ) ) {
		$ch = curl_init();
		if ( false !== $ch ) {
			curl_setopt( $ch, CURLOPT_URL, $url );
			curl_setopt( $ch, CURLOPT_POST, true );
			curl_setopt( $ch, CURLOPT_POSTFIELDS, $body );
			curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
			curl_setopt( $ch, CURLOPT_HEADER, false );
			curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, min( 10, $timeout ) );
			curl_setopt( $ch, CURLOPT_TIMEOUT, $timeout );
			curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/x-www-form-urlencoded' ) );
			curl_setopt( $ch, CURLOPT_USERAGENT, 'ASE Migration DB Runner' );
			// Verify SSL by default (source transfers require HTTPS unless localhost).
			curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
			curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 );

			$response = curl_exec( $ch );
			if ( false === $response ) {
				$error = (string) curl_error( $ch );
			}
			$code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
			curl_close( $ch );

			return array(
				'code'  => $code,
				'body'  => ( false === $response ) ? '' : (string) $response,
				'error' => $error,
			);
		}
	}

	// Fallback to streams.
	$opts = array(
		'http' => array(
			'method'  => 'POST',
			'timeout' => $timeout,
			'header'  => "Content-Type: application/x-www-form-urlencoded\r\n"
				. 'User-Agent: ASE Migration DB Runner' . "\r\n",
			'content' => $body,
		),
	);
	$context  = stream_context_create( $opts );
	$response = @file_get_contents( $url, false, $context );

	// Try to infer HTTP status from response headers.
	if ( isset( $http_response_header ) && is_array( $http_response_header ) && ! empty( $http_response_header[0] ) ) {
		if ( 1 === preg_match( '/\s(\d{3})\s/', (string) $http_response_header[0], $m ) ) {
			$code = (int) $m[1];
		}
	}

	return array(
		'code'  => $code,
		'body'  => ( false === $response ) ? '' : (string) $response,
		'error' => $error,
	);
}

/**
 * Write public migration progress JSON (static polling fallback).
 *
 * @param array  $state Migration state array.
 * @param string $wp_content_dir WP_CONTENT_DIR.
 * @return void
 */
function asenha_write_public_progress( $state, $wp_content_dir ) {
	if ( ! is_array( $state ) ) {
		return;
	}
	if ( empty( $state['public_progress_relpath'] ) || ! is_string( $state['public_progress_relpath'] ) ) {
		return;
	}

	$rel = ltrim( $state['public_progress_relpath'], '/\\' );
	$abs = rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . str_replace( array( '/', '\\' ), DIRECTORY_SEPARATOR, $rel );

	$payload = array(
		'id'           => isset( $state['id'] ) ? (string) $state['id'] : '',
		'status'       => isset( $state['status'] ) ? (string) $state['status'] : 'running',
		'progress'     => isset( $state['progress'] ) ? (int) $state['progress'] : 0,
		'current_step' => isset( $state['current_step'] ) ? (string) $state['current_step'] : '',
		'last_update'  => isset( $state['last_update'] ) ? (int) $state['last_update'] : time(),
	);
	if ( array_key_exists( 'finalized', $state ) ) {
		$payload['finalized'] = ! empty( $state['finalized'] );
	}
	$payload['db_runner'] = 1;
	$payload['db_runner_heartbeat'] = (int) $payload['last_update'];

	// Safe message/error fields for UI. Do NOT include secrets (tokens/paths).
	if ( ! empty( $state['message'] ) && is_string( $state['message'] ) ) {
		$msg = trim( preg_replace( '/\\s+/', ' ', $state['message'] ) );
		$payload['message'] = substr( $msg, 0, 200 );
	}
	if ( isset( $state['error'] ) && is_string( $state['error'] ) ) {
		$err = trim( preg_replace( '/\\s+/', ' ', $state['error'] ) );
		$payload['error'] = substr( $err, 0, 800 );
	}
	if ( ! empty( $state['db_last_warning'] ) && is_string( $state['db_last_warning'] ) ) {
		$warn = trim( preg_replace( '/\\s+/', ' ', $state['db_last_warning'] ) );
		$payload['warning'] = substr( $warn, 0, 300 );
	}
	if ( isset( $state['database_stats'] ) && is_array( $state['database_stats'] ) ) {
		$public_db_stats = array();
		if ( isset( $state['database_stats']['total_tables'] ) ) {
			$public_db_stats['total_tables'] = (int) $state['database_stats']['total_tables'];
		}
		if ( isset( $state['database_stats']['total_rows'] ) ) {
			$public_db_stats['total_rows'] = (int) $state['database_stats']['total_rows'];
		}
		if ( ! empty( $public_db_stats ) ) {
			$payload['database_stats'] = $public_db_stats;
		}
	}
	if ( isset( $state['search_replace_stats'] ) && is_array( $state['search_replace_stats'] ) ) {
		$payload['search_replace_stats'] = array(
			'url_replacements'  => isset( $state['search_replace_stats']['url_replacements'] ) ? (int) $state['search_replace_stats']['url_replacements'] : 0,
			'path_replacements' => isset( $state['search_replace_stats']['path_replacements'] ) ? (int) $state['search_replace_stats']['path_replacements'] : 0,
			'serialized_fixes'  => isset( $state['search_replace_stats']['serialized_fixes'] ) ? (int) $state['search_replace_stats']['serialized_fixes'] : 0,
		);
	}
	if ( isset( $state['sub_progress'] ) && is_array( $state['sub_progress'] ) ) {
		$sp = $state['sub_progress'];
		$public_sub = array();
		if ( isset( $sp['label'] ) ) {
			$public_sub['label'] = (string) $sp['label'];
		}
		if ( isset( $sp['current'] ) ) {
			$public_sub['current'] = (int) $sp['current'];
		}
		if ( isset( $sp['total'] ) ) {
			$public_sub['total'] = (int) $sp['total'];
		}
		if ( isset( $sp['current_table_name'] ) ) {
			$public_sub['current_table_name'] = (string) $sp['current_table_name'];
		}
		if ( isset( $sp['current_table_rows_expected'] ) ) {
			$public_sub['current_table_rows_expected'] = (int) $sp['current_table_rows_expected'];
		}
		if ( isset( $sp['rows_imported'] ) ) {
			$public_sub['rows_imported'] = (int) $sp['rows_imported'];
		}
		if ( isset( $sp['files_imported'] ) ) {
			$public_sub['files_imported'] = (int) $sp['files_imported'];
		}
		if ( isset( $sp['total_files'] ) ) {
			$public_sub['total_files'] = (int) $sp['total_files'];
		}
		if ( isset( $sp['tables_imported'] ) ) {
			$public_sub['tables_imported'] = (int) $sp['tables_imported'];
		}
		if ( isset( $sp['collation_remap_count'] ) ) {
			$public_sub['collation_remap_count'] = (int) $sp['collation_remap_count'];
		}
		if ( isset( $sp['collation_remap_target'] ) && is_string( $sp['collation_remap_target'] ) ) {
			$public_sub['collation_remap_target'] = (string) $sp['collation_remap_target'];
		}
		if ( isset( $sp['url_replacements'] ) ) {
			$public_sub['url_replacements'] = (int) $sp['url_replacements'];
		}
		if ( isset( $sp['path_replacements'] ) ) {
			$public_sub['path_replacements'] = (int) $sp['path_replacements'];
		}
		if ( isset( $sp['serialized_fixes'] ) ) {
			$public_sub['serialized_fixes'] = (int) $sp['serialized_fixes'];
		}
		if ( ! empty( $public_sub ) ) {
			$payload['sub_progress'] = $public_sub;
		}
	}

	asenha_atomic_write_json( $abs, $payload );
}

/**
 * Upsert a WordPress option row.
 *
 * @param mysqli $mysqli Mysqli connection.
 * @param string $safe_options_table Escaped options table name.
 * @param string $option_name Option name.
 * @param mixed  $option_value Option value.
 * @param string $autoload Autoload value.
 * @return array{
 *   success:bool,
 *   method:string,
 *   bytes:int,
 *   value_sha256:string,
 *   predicted_oversized:bool,
 *   packet_limit:int,
 *   safe_budget:int,
 *   errno:int,
 *   error:string
 * }
 */
function asenha_upsert_option_row( $mysqli, $safe_options_table, $option_name, $option_value, $autoload = 'yes' ) {
	$result = array(
		'success'            => false,
		'method'             => 'primary',
		'bytes'              => 0,
		'value_sha256'       => '',
		'predicted_oversized' => false,
		'packet_limit'       => 0,
		'safe_budget'        => 0,
		'errno'              => 0,
		'error'              => '',
	);

	$option_name = (string) $option_name;
	if ( '' === $option_name ) {
		$result['error'] = 'Invalid option name.';
		return $result;
	}

	$value_to_store          = is_array( $option_value ) ? serialize( $option_value ) : (string) $option_value;
	$result['bytes']         = strlen( $value_to_store );
	$result['value_sha256']  = (string) hash( 'sha256', $value_to_store );
	$packet_budget           = asenha_get_packet_budget( $mysqli );
	$result['packet_limit']  = isset( $packet_budget['max_allowed_packet'] ) ? (int) $packet_budget['max_allowed_packet'] : 0;
	$result['safe_budget']   = isset( $packet_budget['safe_query_budget'] ) ? (int) $packet_budget['safe_query_budget'] : 0;
	$result['predicted_oversized'] = ( $result['safe_budget'] > 0 && $result['bytes'] > $result['safe_budget'] );
	if ( $result['predicted_oversized'] ) {
		$result['error'] = 'Option payload exceeds safe single-query write budget.';
		return $result;
	}

	$name_esc       = $mysqli->real_escape_string( $option_name );
	$value_esc      = $mysqli->real_escape_string( $value_to_store );
	$autoload_esc   = $mysqli->real_escape_string( (string) $autoload );

	$query_ok = $mysqli->query( "REPLACE INTO `{$safe_options_table}` (`option_name`,`option_value`,`autoload`) VALUES ('{$name_esc}','{$value_esc}','{$autoload_esc}')" );
	if ( false === $query_ok ) {
		$result['errno'] = (int) $mysqli->errno;
		$result['error'] = (string) $mysqli->error;
		return $result;
	}

	$result['success'] = true;
	return $result;
}

/**
 * Get packet budget for option writes.
 *
 * @param mysqli $mysqli Mysqli connection.
 * @return array{max_allowed_packet:int,safe_query_budget:int}
 */
function asenha_get_packet_budget( $mysqli ) {
	static $packet_budget = null;
	if ( null !== $packet_budget ) {
		return $packet_budget;
	}

	$max_allowed_packet = 16 * 1024 * 1024;
	$result             = $mysqli->query( "SHOW VARIABLES LIKE 'max_allowed_packet'" );
	if ( false !== $result ) {
		$row = $result->fetch_assoc();
		if ( is_array( $row ) && isset( $row['Value'] ) ) {
			$detected = (int) $row['Value'];
			if ( $detected > 0 ) {
				$max_allowed_packet = $detected;
			}
		}
		$result->free();
	}

	// Keep a conservative margin for SQL syntax + escaping expansion.
	$safe_query_budget = (int) floor( $max_allowed_packet * 0.60 );
	if ( $safe_query_budget < ( 256 * 1024 ) ) {
		$safe_query_budget = 256 * 1024;
	}

	$packet_budget = array(
		'max_allowed_packet' => (int) $max_allowed_packet,
		'safe_query_budget'  => (int) $safe_query_budget,
	);
	return $packet_budget;
}

/**
 * Option name suffixes scoped to the table prefix.
 *
 * @return string[]
 */
function asenha_prefix_scoped_option_suffixes() {
	return array( 'user_roles' );
}

/**
 * Usermeta key suffixes scoped to the table prefix.
 *
 * @return string[]
 */
function asenha_prefix_scoped_usermeta_suffixes() {
	return array(
		'capabilities',
		'user_level',
		'user-settings',
		'user-settings-time',
		'dashboard_quick_press_last_post_id',
		'media_library_mode',
	);
}

/**
 * Remap SQL table prefix without rewriting serialized value bodies.
 *
 * @param string $sql        SQL fragment.
 * @param string $old_prefix Source prefix.
 * @param string $new_prefix Destination prefix.
 * @return string
 */
function asenha_remap_sql_table_prefix( $sql, $old_prefix, $new_prefix ) {
	$sql        = (string) $sql;
	$old_prefix = (string) $old_prefix;
	$new_prefix = (string) $new_prefix;

	if ( '' === $old_prefix || '' === $new_prefix || $old_prefix === $new_prefix ) {
		return $sql;
	}

	$sql = str_replace( '`' . $old_prefix, '`' . $new_prefix, $sql );

	foreach ( asenha_prefix_scoped_option_suffixes() as $suffix ) {
		$suffix = (string) $suffix;
		if ( '' === $suffix ) {
			continue;
		}
		$sql = str_replace( "'" . $old_prefix . $suffix . "'", "'" . $new_prefix . $suffix . "'", $sql );
	}

	foreach ( asenha_prefix_scoped_usermeta_suffixes() as $suffix ) {
		$suffix = (string) $suffix;
		if ( '' === $suffix ) {
			continue;
		}
		$sql = str_replace( "'" . $old_prefix . $suffix . "'", "'" . $new_prefix . $suffix . "'", $sql );
		$pattern     = "/'" . preg_quote( $old_prefix, '/' ) . "(\\d+)_" . preg_quote( $suffix, '/' ) . "'/";
		$replacement = "'" . $new_prefix . '$1_' . $suffix . "'";
		$rewritten   = preg_replace( $pattern, $replacement, $sql );
		if ( is_string( $rewritten ) ) {
			$sql = $rewritten;
		}
	}

	return $sql;
}

/**
 * Extract the table/view name from a DROP/CREATE/INSERT SQL statement.
 *
 * @param string $statement SQL statement.
 * @return string
 */
function asenha_extract_sql_statement_table_name( $statement ) {
	$statement = trim( (string) $statement );
	if ( '' === $statement ) {
		return '';
	}
	if ( preg_match( '/^DROP\\s+(?:TABLE|VIEW)\\s+IF\\s+EXISTS\\s+`?([^`;\\s]+)`?/i', $statement, $matches ) ) {
		return (string) $matches[1];
	}
	if ( preg_match( '/^CREATE\\s+TABLE\\s+`?([^`\\s(]+)`?/i', $statement, $matches ) ) {
		return (string) $matches[1];
	}
	if ( preg_match( '/^(INSERT|REPLACE)\\s+(IGNORE\\s+)?INTO\\s+`?([^`\\s(]+)`?/i', $statement, $matches ) ) {
		return (string) $matches[3];
	}
	if ( preg_match( '/^CREATE\\s+(?:ALGORITHM\\s*=\\s*\\w+\\s+)?(?:DEFINER\\s*=\\s*`?[^`]+`?@`?[^`]+`?\\s+)?(?:SQL\\s+SECURITY\\s+\\w+\\s+)?(?:OR\\s+REPLACE\\s+)?VIEW\\s+`?([^\\s`(]+)`?/i', $statement, $matches ) ) {
		return (string) $matches[1];
	}
	return '';
}

/**
 * Collect dumped table/view names from DROP/CREATE lines in a SQL file.
 *
 * @param string $sql_file Path to SQL dump.
 * @return string[]
 */
function asenha_collect_dumped_table_names_from_sql_file( $sql_file ) {
	$names = array();
	if ( ! is_string( $sql_file ) || '' === $sql_file || ! file_exists( $sql_file ) ) {
		return $names;
	}
	$handle = fopen( $sql_file, 'r' );
	if ( false === $handle ) {
		return $names;
	}
	while ( ( $line = fgets( $handle ) ) !== false ) {
		$trim = ltrim( (string) $line );
		if ( '' === $trim || 0 === strpos( $trim, '--' ) || 0 === strpos( $trim, '/*' ) ) {
			continue;
		}
		$name = asenha_extract_sql_statement_table_name( $trim );
		if ( '' === $name && preg_match( '/^(?:DROP\\s+(?:TABLE|VIEW)\\s+IF\\s+EXISTS|CREATE\\s+TABLE)\\s+`([^`]+)`/i', $trim, $matches ) ) {
			$name = (string) $matches[1];
		}
		if ( '' !== $name ) {
			$names[ $name ] = true;
		}
	}
	fclose( $handle );
	return array_keys( $names );
}

/**
 * Destination table names occupied by remapped source-prefix tables.
 *
 * @param string[] $table_names   Original dump table names.
 * @param string   $source_prefix Source prefix.
 * @param string   $dest_prefix   Destination prefix.
 * @return array<string,bool>
 */
function asenha_build_prefix_collision_table_names( $table_names, $source_prefix, $dest_prefix ) {
	$source_prefix = (string) $source_prefix;
	$dest_prefix   = (string) $dest_prefix;
	$collision     = array();
	if ( '' === $source_prefix || '' === $dest_prefix || $source_prefix === $dest_prefix ) {
		return $collision;
	}
	foreach ( (array) $table_names as $name ) {
		$name = (string) $name;
		if ( '' === $name || 0 !== strpos( $name, $source_prefix ) ) {
			continue;
		}
		$collision[ $dest_prefix . substr( $name, strlen( $source_prefix ) ) ] = true;
	}
	return $collision;
}

/**
 * Whether an original dump table would overwrite remapped live tables.
 *
 * @param string             $original_table_name Original table name.
 * @param string             $source_prefix       Source prefix.
 * @param string             $dest_prefix         Destination prefix.
 * @param array<string,bool> $collision_names     Collision set.
 * @return bool
 */
function asenha_should_skip_prefix_collision_table( $original_table_name, $source_prefix, $dest_prefix, $collision_names ) {
	$original_table_name = (string) $original_table_name;
	$source_prefix       = (string) $source_prefix;
	$dest_prefix         = (string) $dest_prefix;
	if ( '' === $original_table_name || '' === $source_prefix || '' === $dest_prefix || $source_prefix === $dest_prefix ) {
		return false;
	}
	if ( 0 === strpos( $original_table_name, $source_prefix ) ) {
		return false;
	}
	return isset( $collision_names[ $original_table_name ] );
}

/**
 * Post-import safety net for prefix-scoped option/meta keys (mysqli).
 *
 * @param mysqli $mysqli     Database connection.
 * @param string $old_prefix Source prefix.
 * @param string $new_prefix Destination prefix (also used for table names).
 * @return array{options:int,usermeta:int}
 */
function asenha_remap_prefix_scoped_option_and_meta_keys_mysqli( $mysqli, $old_prefix, $new_prefix ) {
	$stats      = array(
		'options'  => 0,
		'usermeta' => 0,
	);
	$old_prefix = (string) $old_prefix;
	$new_prefix = (string) $new_prefix;

	if ( ! ( $mysqli instanceof mysqli ) || '' === $old_prefix || '' === $new_prefix || $old_prefix === $new_prefix ) {
		return $stats;
	}
	if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $new_prefix ) ) {
		return $stats;
	}

	$options_table  = str_replace( '`', '``', $new_prefix . 'options' );
	$usermeta_table = str_replace( '`', '``', $new_prefix . 'usermeta' );

	foreach ( asenha_prefix_scoped_option_suffixes() as $suffix ) {
		$suffix = (string) $suffix;
		if ( '' === $suffix ) {
			continue;
		}
		$old_name = $mysqli->real_escape_string( $old_prefix . $suffix );
		$new_name = $mysqli->real_escape_string( $new_prefix . $suffix );
		if ( $mysqli->query( "UPDATE `{$options_table}` SET option_name = '{$new_name}' WHERE option_name = '{$old_name}'" ) ) {
			$stats['options'] += (int) $mysqli->affected_rows;
		}
	}

	foreach ( asenha_prefix_scoped_usermeta_suffixes() as $suffix ) {
		$suffix = (string) $suffix;
		if ( '' === $suffix ) {
			continue;
		}
		$old_key = $mysqli->real_escape_string( $old_prefix . $suffix );
		$new_key = $mysqli->real_escape_string( $new_prefix . $suffix );
		if ( $mysqli->query( "UPDATE `{$usermeta_table}` SET meta_key = '{$new_key}' WHERE meta_key = '{$old_key}'" ) ) {
			$stats['usermeta'] += (int) $mysqli->affected_rows;
		}

		$like   = $mysqli->real_escape_string( addcslashes( $old_prefix, '\\%_' ) . '%_' . addcslashes( $suffix, '\\%_' ) );
		$result = $mysqli->query( "SELECT umeta_id, meta_key FROM `{$usermeta_table}` WHERE meta_key LIKE '{$like}'" );
		if ( false === $result ) {
			continue;
		}
		$pattern = '/^' . preg_quote( $old_prefix, '/' ) . '(\\d+)_' . preg_quote( $suffix, '/' ) . '$/';
		while ( $row = $result->fetch_assoc() ) {
			$umeta_id = isset( $row['umeta_id'] ) ? (int) $row['umeta_id'] : 0;
			$meta_key = isset( $row['meta_key'] ) ? (string) $row['meta_key'] : '';
			if ( $umeta_id <= 0 || '' === $meta_key || ! preg_match( $pattern, $meta_key, $matches ) ) {
				continue;
			}
			$remapped = $new_prefix . $matches[1] . '_' . $suffix;
			if ( $remapped === $meta_key ) {
				continue;
			}
			$remapped_esc = $mysqli->real_escape_string( $remapped );
			if ( $mysqli->query( "UPDATE `{$usermeta_table}` SET meta_key = '{$remapped_esc}' WHERE umeta_id = {$umeta_id}" ) ) {
				$stats['usermeta'] += (int) $mysqli->affected_rows;
			}
		}
		$result->free();
	}

	return $stats;
}

/**
 * Determine whether a MySQL error is packet-size related.
 *
 * @param int    $errno MySQL errno.
 * @param string $error MySQL error text.
 * @return bool
 */
function asenha_is_packet_error( $errno, $error ) {
	if ( in_array( (int) $errno, array( 1153, 2006, 2020 ), true ) ) {
		return true;
	}

	$error = (string) $error;
	if ( '' === $error ) {
		return false;
	}

	return ( 1 === preg_match( '/max_allowed_packet|packet\\s+.*too\\s+large|got\\s+a\\s+packet\\s+bigger\\s+than|packet\\s+for\\s+query\\s+is\\s+too\\s+large/i', $error ) );
}

/**
 * Write an option value using chunked CONCAT updates.
 *
 * @param mysqli $mysqli Mysqli connection.
 * @param string $safe_options_table Escaped options table name.
 * @param string $option_name Option name.
 * @param mixed  $option_value Option value.
 * @param string $autoload Autoload value.
 * @return array{
 *   success:bool,
 *   method:string,
 *   bytes:int,
 *   value_sha256:string,
 *   packet_limit:int,
 *   safe_budget:int,
 *   chunk_size:int,
 *   chunks_written:int,
 *   errno:int,
 *   error:string
 * }
 */
function asenha_upsert_option_row_chunked_fallback( $mysqli, $safe_options_table, $option_name, $option_value, $autoload = 'yes' ) {
	$result = array(
		'success'        => false,
		'method'         => 'fallback_chunked',
		'bytes'          => 0,
		'value_sha256'   => '',
		'packet_limit'   => 0,
		'safe_budget'    => 0,
		'chunk_size'     => 0,
		'chunks_written' => 0,
		'errno'          => 0,
		'error'          => '',
	);

	$option_name = (string) $option_name;
	if ( '' === $option_name ) {
		$result['error'] = 'Invalid option name.';
		return $result;
	}

	$value_to_store         = is_array( $option_value ) ? serialize( $option_value ) : (string) $option_value;
	$result['bytes']        = strlen( $value_to_store );
	$result['value_sha256'] = (string) hash( 'sha256', $value_to_store );
	$packet_budget          = asenha_get_packet_budget( $mysqli );
	$result['packet_limit'] = isset( $packet_budget['max_allowed_packet'] ) ? (int) $packet_budget['max_allowed_packet'] : 0;
	$result['safe_budget']  = isset( $packet_budget['safe_query_budget'] ) ? (int) $packet_budget['safe_query_budget'] : 0;

	$chunk_size = (int) floor( $result['safe_budget'] * 0.45 );
	if ( $chunk_size < ( 64 * 1024 ) ) {
		$chunk_size = 64 * 1024;
	}
	if ( $chunk_size > ( 512 * 1024 ) ) {
		$chunk_size = 512 * 1024;
	}
	$result['chunk_size'] = $chunk_size;

	$name_esc     = $mysqli->real_escape_string( $option_name );
	$autoload_esc = $mysqli->real_escape_string( (string) $autoload );

	// Seed row to empty value before chunk appends.
	$seed_ok = $mysqli->query( "REPLACE INTO `{$safe_options_table}` (`option_name`,`option_value`,`autoload`) VALUES ('{$name_esc}','', '{$autoload_esc}')" );
	if ( false === $seed_ok ) {
		$result['errno'] = (int) $mysqli->errno;
		$result['error'] = (string) $mysqli->error;
		return $result;
	}

	$bytes_written = 0;
	$value_length  = strlen( $value_to_store );
	while ( $bytes_written < $value_length ) {
		$chunk     = substr( $value_to_store, $bytes_written, $chunk_size );
		$chunk_esc = $mysqli->real_escape_string( (string) $chunk );
		$append_ok = $mysqli->query( "UPDATE `{$safe_options_table}` SET `option_value` = CONCAT(`option_value`, '{$chunk_esc}'), `autoload` = '{$autoload_esc}' WHERE `option_name` = '{$name_esc}' LIMIT 1" );
		if ( false === $append_ok ) {
			$result['errno'] = (int) $mysqli->errno;
			$result['error'] = (string) $mysqli->error;
			return $result;
		}

		$bytes_written += strlen( (string) $chunk );
		$result['chunks_written']++;
	}

	$result['success'] = true;
	return $result;
}

/**
 * Verify stored option value integrity.
 *
 * @param mysqli $mysqli Mysqli connection.
 * @param string $safe_options_table Escaped options table name.
 * @param string $option_name Option name.
 * @param int    $expected_bytes Expected stored bytes.
 * @param string $expected_sha256 Expected SHA256.
 * @return array{success:bool,actual_bytes:int,actual_sha256:string,error:string}
 */
function asenha_verify_option_row_integrity( $mysqli, $safe_options_table, $option_name, $expected_bytes, $expected_sha256 ) {
	$result = array(
		'success'       => false,
		'actual_bytes'  => 0,
		'actual_sha256' => '',
		'error'         => '',
	);

	$option_name = (string) $option_name;
	if ( '' === $option_name ) {
		$result['error'] = 'Invalid option name for verification.';
		return $result;
	}

	$name_esc = $mysqli->real_escape_string( $option_name );
	$query    = $mysqli->query( "SELECT `option_value` FROM `{$safe_options_table}` WHERE `option_name` = '{$name_esc}' LIMIT 1" );
	if ( false === $query ) {
		$result['error'] = (string) $mysqli->error;
		return $result;
	}

	$row = $query->fetch_assoc();
	$query->free();
	if ( ! is_array( $row ) || ! array_key_exists( 'option_value', $row ) ) {
		$result['error'] = 'Option row not found after write.';
		return $result;
	}

	$stored_value            = (string) $row['option_value'];
	$result['actual_bytes']  = strlen( $stored_value );
	$result['actual_sha256'] = (string) hash( 'sha256', $stored_value );

	if ( (int) $expected_bytes !== (int) $result['actual_bytes'] ) {
		$result['error'] = 'Stored option byte length mismatch.';
		return $result;
	}
	if ( '' !== (string) $expected_sha256 && ! hash_equals( (string) $expected_sha256, (string) $result['actual_sha256'] ) ) {
		$result['error'] = 'Stored option checksum mismatch.';
		return $result;
	}

	$result['success'] = true;
	return $result;
}

/**
 * Spawn a non-blocking request to continue this runner.
 *
 * @param string $url URL to call.
 * @return bool
 */
function asenha_spawn_self_async( $url ) {
	$parts = @parse_url( (string) $url );
	if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
		return false;
	}

	$scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : 'http';
	$host   = (string) $parts['host'];
	$port   = isset( $parts['port'] ) ? (int) $parts['port'] : ( ( 'https' === $scheme ) ? 443 : 80 );
	$path   = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
	$query  = isset( $parts['query'] ) ? (string) $parts['query'] : '';
	$target = $path . ( '' !== $query ? '?' . $query : '' );

	$transport = ( 'https' === $scheme ) ? 'ssl://' : '';
	$fp = @fsockopen( $transport . $host, $port, $errno, $errstr, 1 );
	if ( ! $fp ) {
		// Fallback: some hosts disable/flake on fsockopen; try curl with a tiny timeout.
		// This is best-effort fire-and-forget; we ignore the response.
		if ( function_exists( 'curl_init' ) ) {
			$ch = curl_init();
			if ( false !== $ch ) {
				curl_setopt( $ch, CURLOPT_URL, (string) $url );
				curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
				curl_setopt( $ch, CURLOPT_HEADER, false );
				curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 1 );
				curl_setopt( $ch, CURLOPT_TIMEOUT, 2 );
				curl_setopt( $ch, CURLOPT_NOSIGNAL, true );

				// Loopback continuation: allow self-signed/local certs (best-effort).
				if ( 'https' === $scheme ) {
					curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
					curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 );
				}

				@curl_exec( $ch );
				curl_close( $ch );
				return true;
			}
		}

		return false;
	}

	@stream_set_blocking( $fp, false );

	$req  = "GET {$target} HTTP/1.1\r\n";
	$req .= "Host: {$host}\r\n";
	$req .= "Connection: Close\r\n\r\n";

	@fwrite( $fp, $req );
	@fclose( $fp );
	return true;
}

/**
 * Build the current absolute URL for this script with query args preserved.
 *
 * @param array $query Query args.
 * @return string
 */
function asenha_current_url( $query ) {
	$scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';
	$host   = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : ( isset( $_SERVER['SERVER_NAME'] ) ? (string) $_SERVER['SERVER_NAME'] : '' );
	$path   = isset( $_SERVER['SCRIPT_NAME'] ) ? (string) $_SERVER['SCRIPT_NAME'] : '';
	if ( '' === $host || '' === $path ) {
		return '';
	}
	$qs = http_build_query( $query );
	return $scheme . '://' . $host . $path . ( '' !== $qs ? '?' . $qs : '' );
}

/**
 * Recursively delete a directory (best effort).
 *
 * @param string $dir Directory.
 * @param string $must_be_under Safety prefix.
 * @return bool
 */
function asenha_delete_dir_recursive( $dir, $must_be_under ) {
	$dir = (string) $dir;
	$must_be_under = (string) $must_be_under;
	if ( '' === $dir || '' === $must_be_under ) {
		return false;
	}

	$dir_real  = realpath( $dir );
	$base_real = realpath( $must_be_under );
	if ( false === $dir_real || false === $base_real ) {
		return false;
	}

	if ( 0 !== strpos( $dir_real, $base_real ) ) {
		return false;
	}

	if ( ! is_dir( $dir_real ) ) {
		return true;
	}

	$items = @scandir( $dir_real );
	if ( ! is_array( $items ) ) {
		return false;
	}

	foreach ( $items as $item ) {
		if ( '.' === $item || '..' === $item ) {
			continue;
		}
		$path = $dir_real . DIRECTORY_SEPARATOR . $item;
		if ( is_dir( $path ) ) {
			asenha_delete_dir_recursive( $path, $must_be_under );
		} else {
			@unlink( $path );
		}
	}

	@rmdir( $dir_real );
	return true;
}

// ---------------------------------------------------------------------------
// Delayed cleanup pass (token-gated, best-effort, non-blocking)
// ---------------------------------------------------------------------------

if ( 'migration_cleanup' === $action ) {
	$state_file = asenha_get_state_file_path( $backup_dir, $migration_id );
	$state      = asenha_read_json_file( $state_file );

	// If state is missing, still attempt to delete stale mapping tmp and empty progress dirs.
	if ( null === $state ) {
		$mapping_tmp = rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-restore-dirs.json.tmp';
		if ( file_exists( $mapping_tmp ) ) {
			@unlink( $mapping_tmp );
		}

		$maybe_dirs = array(
			rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-migration-progress',
			rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-restore-progress',
		);
		foreach ( $maybe_dirs as $dir ) {
			if ( ! is_dir( $dir ) ) {
				continue;
			}
			$items = @scandir( $dir );
			if ( ! is_array( $items ) ) {
				continue;
			}
			$has_files = false;
			foreach ( $items as $item ) {
				if ( '.' === $item || '..' === $item ) {
					continue;
				}
				if ( 'index.php' === $item || '.index.html' === $item ) {
					continue;
				}
				$has_files = true;
				break;
			}
			if ( ! $has_files ) {
				@rmdir( $dir );
			}
		}

		// Self-delete even if state is gone (best-effort).
		@unlink( __FILE__ );
		echo 'State missing';
		exit;
	}

	// Only allow cleanup when the migration is in a final status.
	$status   = isset( $state['status'] ) ? (string) $state['status'] : '';
	$is_final = in_array( $status, array( 'completed', 'failed', 'cancelled' ), true );
	if ( ! $is_final ) {
		echo 'Not final';
		exit;
	}

	// Enforce grace window so the UI can read the final state before we delete it.
	$grace_seconds    = 60;
	$not_before       = isset( $state['cleanup_not_before'] ) ? (int) $state['cleanup_not_before'] : 0;
	$now              = time();

	if ( $not_before <= 0 ) {
		$state['cleanup_not_before'] = $now + $grace_seconds;
		$state['last_update']        = $now;
		asenha_atomic_write_json( $state_file, $state );
		asenha_write_public_progress( $state, $wp_content_dir );
	}

	$not_before = isset( $state['cleanup_not_before'] ) ? (int) $state['cleanup_not_before'] : 0;
	if ( $not_before > $now ) {
		$self_url = asenha_current_url( array(
			'asenha_action' => 'migration_cleanup',
			'migration_id'  => $migration_id,
			'token'         => $token,
			't'             => $now,
		) );
		if ( '' !== $self_url ) {
			asenha_spawn_self_async( $self_url );
		}
		echo 'Waiting';
		exit;
	}

	// Capture non-sensitive hints before deleting state.
	$public_relpath = isset( $state['public_progress_relpath'] ) ? (string) $state['public_progress_relpath'] : '';
	$transfer_id    = isset( $state['transfer_id'] ) ? (string) $state['transfer_id'] : '';

	// 1) Delete migration public progress JSON (best-effort).
	if ( '' !== $public_relpath ) {
		$rel = ltrim( $public_relpath, "/\\ \t\n\r\0\x0B" );
		if ( 0 === strpos( $rel, 'asenha-migration-progress/' ) && '.json' === substr( $rel, -5 ) && false === strpos( $rel, '..' ) ) {
			$abs = rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . str_replace( array( '/', '\\' ), DIRECTORY_SEPARATOR, $rel );
			if ( file_exists( $abs ) ) {
				@unlink( $abs );
			}
		}
	}

	// 2) Remove mapping entry (wp-content/asenha-restore-dirs.json) and stale tmp file.
	$mapping_file = rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-restore-dirs.json';
	if ( file_exists( $mapping_file ) ) {
		$mapping = asenha_read_json_file( $mapping_file );
		if ( is_array( $mapping ) ) {
			if ( isset( $mapping[ $migration_id ] ) ) {
				unset( $mapping[ $migration_id ] );
			}
			if ( empty( $mapping ) ) {
				@unlink( $mapping_file );
			} else {
				asenha_atomic_write_json( $mapping_file, $mapping );
			}
		}
	}
	$mapping_tmp = rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-restore-dirs.json.tmp';
	if ( file_exists( $mapping_tmp ) ) {
		@unlink( $mapping_tmp );
	}

	// 3) Transfer artifacts on target (best-effort).
	if ( '' !== $transfer_id && 1 === preg_match( '/^[0-9a-fA-F-]{36}$/', $transfer_id ) ) {
		$expected_zip = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'transfer_receive_' . $transfer_id . '.zip';
		if ( file_exists( $expected_zip ) ) {
			@unlink( $expected_zip );
		}

		$transfer_session_file = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'transfer_session_' . $transfer_id . '.json';
		$transfer_session      = asenha_read_json_file( $transfer_session_file );

		// Delete transfer public progress JSON when available.
		if ( is_array( $transfer_session ) && ! empty( $transfer_session['public_progress_relpath'] ) && is_string( $transfer_session['public_progress_relpath'] ) ) {
			$t_rel = ltrim( (string) $transfer_session['public_progress_relpath'], "/\\ \t\n\r\0\x0B" );
			if ( 0 === strpos( $t_rel, 'asenha-restore-progress/' ) && '.json' === substr( $t_rel, -5 ) && false === strpos( $t_rel, '..' ) ) {
				$t_abs = rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . str_replace( array( '/', '\\' ), DIRECTORY_SEPARATOR, $t_rel );
				if ( file_exists( $t_abs ) ) {
					@unlink( $t_abs );
				}
			}
		}

		if ( file_exists( $transfer_session_file ) ) {
			@unlink( $transfer_session_file );
		}
	}

	// 4) Delete migration locks and state.
	$migration_lock = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'migration_lock_' . preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $migration_id ) . '.lock';
	if ( file_exists( $migration_lock ) ) {
		@unlink( $migration_lock );
	}
	$db_runner_lock = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'migration_db_runner_lock_' . preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $migration_id ) . '.lock';
	if ( file_exists( $db_runner_lock ) ) {
		@unlink( $db_runner_lock );
	}

	if ( file_exists( $state_file ) ) {
		@unlink( $state_file );
	}

	// 5) Remove empty public progress directories (ignore protection files).
	$dirs = array(
		rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-migration-progress',
		rtrim( (string) $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'asenha-restore-progress',
	);
	foreach ( $dirs as $dir ) {
		if ( ! is_dir( $dir ) ) {
			continue;
		}
		$items = @scandir( $dir );
		if ( ! is_array( $items ) ) {
			continue;
		}
		$has_files = false;
		foreach ( $items as $item ) {
			if ( '.' === $item || '..' === $item ) {
				continue;
			}
			if ( 'index.php' === $item || '.index.html' === $item ) {
				continue;
			}
			$has_files = true;
			break;
		}
		if ( ! $has_files ) {
			@rmdir( $dir );
		}
	}

	// Self-delete once cleanup completes (best-effort).
	@unlink( __FILE__ );
	echo 'Cleaned';
	exit;
}

// ---------------------------------------------------------------------------
// Load migration state + derive paths
// ---------------------------------------------------------------------------

$state_file = asenha_get_state_file_path( $backup_dir, $migration_id );
$state = asenha_read_json_file( $state_file );
if ( null === $state ) {
	http_response_code( 404 );
	exit( 'State not found' );
}

// Idempotency guard: once a migration has reached a final state (or the DB runner has been
// explicitly marked inactive), late/stray async requests must not overwrite the state.
// This can happen when a previously-dispatched continuation hits the runner after the temp
// directory has already been deleted during completion.
if ( 'migration_db' === $action ) {
	$status = isset( $state['status'] ) ? (string) $state['status'] : '';
	$is_final = in_array( $status, array( 'completed', 'failed', 'cancelled' ), true );

	$runner_inactive = false;
	if ( array_key_exists( 'db_runner_active', $state ) ) {
		// State is JSON; db_runner_active may be boolean or 0/1.
		$runner_inactive = empty( $state['db_runner_active'] );
	}

	if ( $is_final || $runner_inactive ) {
		// Best-effort: keep the public progress JSON consistent without mutating state.
		asenha_write_public_progress( $state, $wp_content_dir );
		echo 'Done';
		exit;
	}
}

// Ensure minimal state fields.
$state['id'] = isset( $state['id'] ) ? (string) $state['id'] : $migration_id;
$state['last_update'] = time();
$state['db_runner_active'] = true;
$state['db_runner_heartbeat'] = time();

// Resolve temp dir and SQL/manifest paths.
$temp_dir = isset( $state['temp_dir'] ) ? (string) $state['temp_dir'] : '';
if ( '' === $temp_dir ) {
	$state['status'] = 'failed';
	$state['error']  = 'Missing temp_dir in state';
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Missing temp_dir' );
}

// Safety: temp dir must be under backup_dir.
if ( false === realpath( $backup_dir ) || false === realpath( $temp_dir ) || 0 !== strpos( realpath( $temp_dir ), realpath( $backup_dir ) ) ) {
	$state['status'] = 'failed';
	$state['error']  = 'Invalid temp_dir location';
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Invalid temp_dir' );
}

$sql_file      = rtrim( $temp_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'database.sql';
$manifest_file = rtrim( $temp_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'manifest.json';

if ( ! file_exists( $sql_file ) || ! file_exists( $manifest_file ) ) {
	$state['status'] = 'failed';
	$state['error']  = 'Missing SQL or manifest file';
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Missing SQL/manifest' );
}

$manifest = asenha_read_json_file( $manifest_file );
if ( null === $manifest ) {
	$state['status'] = 'failed';
	$state['error']  = 'Invalid manifest';
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Invalid manifest' );
}

$manifest_prefix = isset( $manifest['db_prefix'] ) ? (string) $manifest['db_prefix'] : '';
$expected_counts = isset( $manifest['db_table_rows'] ) && is_array( $manifest['db_table_rows'] ) ? $manifest['db_table_rows'] : array();
$total_tables    = isset( $manifest['db_table_count'] ) ? (int) $manifest['db_table_count'] : 0;
$total_rows      = isset( $manifest['db_row_count'] ) ? (int) $manifest['db_row_count'] : 0;

// DB import checkpoint fields (reuse migration state keys for compatibility).
$byte_offset      = isset( $state['db_byte_offset'] ) ? (int) $state['db_byte_offset'] : 0;
$tables_completed = isset( $state['db_tables_completed'] ) ? (int) $state['db_tables_completed'] : 0;
$rows_imported    = isset( $state['db_rows_imported'] ) ? (int) $state['db_rows_imported'] : 0;
$sql_objects      = isset( $state['db_sql_objects'] ) && is_array( $state['db_sql_objects'] ) ? $state['db_sql_objects'] : array();
$collation_remap_count = isset( $state['collation_remap_count'] ) ? (int) $state['collation_remap_count'] : 0;
if ( empty( $db_collate ) || 0 !== stripos( (string) $db_collate, 'utf8mb4_' ) ) {
	$db_collate = 'utf8mb4_unicode_ci';
}
$collation_remap_target = isset( $state['collation_remap_target'] ) && is_string( $state['collation_remap_target'] ) && '' !== $state['collation_remap_target']
	? (string) $state['collation_remap_target']
	: (string) $db_collate;

// Per-table restart metadata. Used to avoid slow REPLACE storms when the runner is interrupted and resumes mid-table.
// IMPORTANT: ASE exports use many INSERT statements per table, so we must NOT delete/truncate per statement.
// Instead, on duplicate replay signals we truncate and rewind to the first INSERT offset for the table.
$table_first_insert_offset = isset( $state['db_table_first_insert_offset'] ) && is_array( $state['db_table_first_insert_offset'] ) ? $state['db_table_first_insert_offset'] : array();
$table_rows_base           = isset( $state['db_table_rows_base'] ) && is_array( $state['db_table_rows_base'] ) ? $state['db_table_rows_base'] : array();
$table_restart_count       = isset( $state['db_table_restart_count'] ) && is_array( $state['db_table_restart_count'] ) ? $state['db_table_restart_count'] : array();

// Always operate in DB step.
$state['status']       = 'running';
$state['current_step'] = 'database';
$state['message']      = 'Importing database';
$state['database_stats'] = array(
	'total_tables' => $total_tables,
	'total_rows'   => $total_rows,
);

// Compute progress within migration's database step (65-87 for importing).
$progress_step = 65;
if ( $total_tables > 0 ) {
	$progress_step = 65 + ( ( $tables_completed / max( $total_tables, 1 ) ) * 22 );
}
$state['progress'] = isset( $state['progress'] ) ? max( (int) $state['progress'], (int) $progress_step ) : (int) $progress_step;

$state['sub_progress'] = array(
	'label'   => 'importing',
	'current' => $tables_completed,
	'total'   => $total_tables,
	'rows_imported' => $rows_imported,
);

/**
 * Open a mysqli connection, retrying 127.0.0.1 when localhost Unix socket connect fails.
 *
 * @param string      $host       Hostname.
 * @param string      $db_user    Database user.
 * @param string      $db_password Database password.
 * @param string      $db_name    Database name.
 * @param int|null    $port       Optional TCP port.
 * @param string|null $socket     Optional Unix socket path.
 * @return mysqli
 */
function asenha_mysqli_connect( $host, $db_user, $db_password, $db_name, $port = null, $socket = null ) {
	mysqli_report( MYSQLI_REPORT_OFF );
	$port_arg   = ( is_numeric( $port ) && (int) $port > 0 ) ? (int) $port : 3306;
	$socket_arg = ( is_string( $socket ) && '' !== $socket ) ? $socket : null;
	$host       = (string) $host;
	$mysqli     = new mysqli( $host, $db_user, $db_password, $db_name, $port_arg, $socket_arg );
	if ( $mysqli->connect_error && 'localhost' === strtolower( $host ) ) {
		$errno = (int) $mysqli->connect_errno;
		$error = (string) $mysqli->connect_error;
		if ( 2002 === $errno || false !== stripos( $error, 'No such file or directory' ) || false !== stripos( $error, "Can't connect to local MySQL server" ) ) {
			$mysqli = new mysqli( '127.0.0.1', $db_user, $db_password, $db_name, $port_arg );
		}
	}
	return $mysqli;
}

// ---------------------------------------------------------------------------
// Streaming SQL import (resumable)
// ---------------------------------------------------------------------------

// Disable mysqli exception mode for consistent error handling (PHP 8.1+).
mysqli_report( MYSQLI_REPORT_OFF );

$host   = $db_host;
$port   = isset( $db_port ) ? $db_port : null;
$socket = isset( $db_socket ) ? $db_socket : null;

$mysqli = asenha_mysqli_connect( $host, $db_user, $db_password, $db_name, $port, $socket );
if ( $mysqli->connect_error ) {
	$state['status'] = 'failed';
	$state['error']  = 'Database connection failed: ' . $mysqli->connect_error;
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'DB connect failed' );
}

// Always use utf8mb4 for maximum compatibility.
$mysqli->set_charset( 'utf8mb4' );
$mysqli->query( 'SET FOREIGN_KEY_CHECKS = 0' );
$mysqli->query( 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"' );

$handle = @fopen( $sql_file, 'r' );
if ( false === $handle ) {
	$mysqli->close();
	$state['status'] = 'failed';
	$state['error']  = 'Failed to open SQL file';
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Failed to open SQL file' );
}

if ( $byte_offset > 0 ) {
	@fseek( $handle, $byte_offset );
}

$current_statement = '';
$in_string         = false;
$string_char       = '';
$line_start_pos    = ftell( $handle );
$time_up           = false;
$eof_reached       = false;
$file_pos          = 0;
$statement_start_offset = 0;

// Mid-statement resume (for chunked multi-row INSERT/REPLACE statements).
$resume_stmt_offset = isset( $state['db_stmt_offset'] ) ? (int) $state['db_stmt_offset'] : 0;
$resume_chunk_index = isset( $state['db_stmt_chunk_index'] ) ? (int) $state['db_stmt_chunk_index'] : 0;
$resume_chunk_size  = isset( $state['db_stmt_chunk_size'] ) ? (int) $state['db_stmt_chunk_size'] : 200;
if ( $resume_chunk_size <= 0 ) {
	$resume_chunk_size = 200;
}

if ( ! isset( $state['prefix_collision_names'] ) || ! is_array( $state['prefix_collision_names'] ) ) {
	$state['prefix_collision_names'] = ( '' !== $manifest_prefix && $manifest_prefix !== $db_prefix )
		? asenha_build_prefix_collision_table_names(
			asenha_collect_dumped_table_names_from_sql_file( $sql_file ),
			$manifest_prefix,
			$db_prefix
		)
		: array();
}
$collision_names = $state['prefix_collision_names'];
if ( ! isset( $state['skipped_prefix_collision_tables'] ) || ! is_array( $state['skipped_prefix_collision_tables'] ) ) {
	$state['skipped_prefix_collision_tables'] = array();
}

/**
 * Check if a character in a string is escaped by backslashes.
 *
 * @param string $str String.
 * @param int    $pos Position.
 * @return bool
 */
function asenha_is_escaped_at( $str, $pos ) {
	$bs = 0;
	$i  = (int) $pos - 1;
	while ( $i >= 0 && '\\' === $str[ $i ] ) {
		$bs++;
		$i--;
	}
	return ( $bs % 2 ) === 1;
}

/**
 * Split a multi-row INSERT/REPLACE statement into smaller statements with N rows each.
 *
 * @param string $statement Full statement (no trailing ';' required).
 * @param int    $rows_per_chunk Rows per chunk.
 * @return array
 */
function asenha_split_multi_row_insert_into_chunks( $statement, $rows_per_chunk ) {
	$rows_per_chunk = max( 1, (int) $rows_per_chunk );
	$statement      = rtrim( (string) $statement );
	$statement      = rtrim( $statement, ';' );

	$pos = stripos( $statement, ' VALUES' );
	if ( false === $pos ) {
		return array();
	}

	$head   = substr( $statement, 0, $pos + 7 ); // includes " VALUES"
	$values = trim( substr( $statement, $pos + 7 ) );
	if ( '' === $values ) {
		return array();
	}

	$rows        = array();
	$buf         = '';
	$depth       = 0;
	$in_str      = false;
	$str_char    = '';
	$len         = strlen( $values );

	for ( $i = 0; $i < $len; $i++ ) {
		$ch = $values[ $i ];

		if ( ( "'" === $ch || '"' === $ch ) && ! asenha_is_escaped_at( $values, $i ) ) {
			if ( ! $in_str ) {
				$in_str   = true;
				$str_char = $ch;
			} elseif ( $ch === $str_char ) {
				$in_str = false;
			}
		}

		if ( ! $in_str ) {
			if ( '(' === $ch ) {
				$depth++;
			} elseif ( ')' === $ch && $depth > 0 ) {
				$depth--;
			}

			if ( ',' === $ch && 0 === $depth ) {
				$row = trim( $buf );
				if ( '' !== $row ) {
					$rows[] = $row;
				}
				$buf = '';
				continue;
			}
		}

		$buf .= $ch;
	}

	$last = trim( $buf );
	if ( '' !== $last ) {
		$rows[] = $last;
	}

	if ( empty( $rows ) ) {
		return array();
	}

	if ( count( $rows ) <= $rows_per_chunk ) {
		return array( $head . ' ' . implode( ',', $rows ) . ';' );
	}

	$out = array();
	for ( $off = 0; $off < count( $rows ); $off += $rows_per_chunk ) {
		$slice  = array_slice( $rows, $off, $rows_per_chunk );
		$out[]  = $head . ' ' . implode( ',', $slice ) . ';';
	}

	return $out;
}

// Track current table for UI.
$current_table_name    = isset( $state['sub_progress']['current_table_name'] ) ? (string) $state['sub_progress']['current_table_name'] : '';
$current_expected_rows = isset( $state['sub_progress']['current_table_rows_expected'] ) ? (int) $state['sub_progress']['current_table_rows_expected'] : 0;

// Ensure sql objects map.
if ( ! is_array( $sql_objects ) ) {
	$sql_objects = array();
}
if ( ! is_array( $table_first_insert_offset ) ) {
	$table_first_insert_offset = array();
}
if ( ! is_array( $table_rows_base ) ) {
	$table_rows_base = array();
}
if ( ! is_array( $table_restart_count ) ) {
	$table_restart_count = array();
}

while ( ( $line = fgets( $handle ) ) !== false ) {
	// Stop when time is nearly up.
	if ( ( microtime( true ) - $start_time ) >= $time_budget ) {
		$time_up = true;
		break;
	}

	$trimmed_line = trim( $line );

	// Skip blank lines.
	if ( '' === $trimmed_line ) {
		$line_start_pos = ftell( $handle );
		continue;
	}

	// Skip -- comments when not in a statement.
	if ( '' === $current_statement && ! $in_string ) {
		if ( 0 === strpos( $trimmed_line, '--' ) ) {
			$line_start_pos = ftell( $handle );
			continue;
		}
		if ( 0 === strpos( $trimmed_line, '/*' ) && false !== strpos( $trimmed_line, '*/' ) ) {
			$line_start_pos = ftell( $handle );
			continue;
		}
	}

	$len = strlen( $line );
	for ( $i = 0; $i < $len; $i++ ) {
		$char = $line[ $i ];

		// Quote tracking (escape aware).
		if ( ( "'" === $char || '"' === $char ) ) {
			$bs = 0;
			$j = $i - 1;
			while ( $j >= 0 && '\\' === $line[ $j ] ) {
				$bs++;
				$j--;
			}
			$is_escaped = ( $bs % 2 ) === 1;
			if ( ! $is_escaped ) {
				if ( ! $in_string ) {
					$in_string   = true;
					$string_char = $char;
				} elseif ( $char === $string_char ) {
					$in_string = false;
				}
			}
		}

		// Inline comment start.
		if ( ! $in_string && '-' === $char && $i + 1 < $len && '-' === $line[ $i + 1 ] ) {
			break;
		}

		// Statement delimiter.
		if ( ';' === $char && ! $in_string ) {
			$statement = trim( $current_statement );
			$current_statement = '';
			$statement_start = (int) $statement_start_offset;
			$statement_start_offset = 0;

			if ( '' === $statement ) {
				continue;
			}

			// Skip transactional/SET statements.
			if ( preg_match( '/^(SET|START|COMMIT|\\/\\*!)/i', $statement ) ) {
				continue;
			}

			$original_table_name = asenha_extract_sql_statement_table_name( $statement );
			if ( '' !== $original_table_name && asenha_should_skip_prefix_collision_table( $original_table_name, $manifest_prefix, $db_prefix, $collision_names ) ) {
				if ( empty( $state['skipped_prefix_collision_tables'][ $original_table_name ] ) ) {
					$state['skipped_prefix_collision_tables'][ $original_table_name ] = true;
					if ( ! isset( $state['messages'] ) || ! is_array( $state['messages'] ) ) {
						$state['messages'] = array();
					}
					$state['messages'][] = 'Skipped leftover table that would overwrite remapped data: ' . $original_table_name;
				}
				continue;
			}

			// Prefix replacement at statement-level to keep file offsets stable for checkpoints.
			// Identifier + allowlisted keys only — never rewrite serialized value bodies.
			if ( '' !== $manifest_prefix && $manifest_prefix !== $db_prefix ) {
				$statement = asenha_remap_sql_table_prefix( $statement, $manifest_prefix, $db_prefix );
			}

			// Normalize utf8 -> utf8mb4 for CREATE TABLE.
			if ( preg_match( '/^CREATE\\s+TABLE/i', $statement ) ) {
				$statement = preg_replace( '/CHARSET\\s*=\\s*utf8([^m]|$)/i', 'CHARSET=utf8mb4$1', $statement );
				$statement = preg_replace( '/CHARACTER SET utf8([^m]|$)/i', 'CHARACTER SET utf8mb4$1', $statement );
				$statement = preg_replace( '/COLLATE(\\s*=?\\s*)utf8_(\\w+)/i', 'COLLATE$1utf8mb4_$2', $statement );
				// Remap MySQL 8 utf8mb4_*0900* collations unsupported on MariaDB / MySQL 5.7.
				$statement = preg_replace_callback(
					'/utf8mb4_[a-z0-9_]*0900_[a-z0-9_]+/i',
					static function( $matches ) use ( $db_collate, &$collation_remap_count, &$collation_remap_target ) {
						$found = (string) $matches[0];
						$replacement = preg_match( '/_bin$/i', $found ) ? 'utf8mb4_bin' : (string) $db_collate;
						if ( 0 === strcasecmp( $found, $replacement ) ) {
							return $found;
						}
						$collation_remap_count++;
						$collation_remap_target = $replacement;
						return $replacement;
					},
					$statement
				);
			}

			// Track SQL objects for cleanup.
			$insert_table_for_statement = '';
			if ( preg_match( '/^DROP\\s+VIEW\\s+IF\\s+EXISTS\\s+`?([^`;\\s]+)`?/i', $statement, $m ) ) {
				if ( 0 === strpos( (string) $m[1], $db_prefix ) ) {
					$sql_objects[ (string) $m[1] ] = true;
				}
			}
			if ( preg_match( '/^DROP\\s+TABLE\\s+IF\\s+EXISTS\\s+`?([^`;\\s]+)`?/i', $statement, $m ) ) {
				if ( 0 === strpos( (string) $m[1], $db_prefix ) ) {
					$sql_objects[ (string) $m[1] ] = true;
				}
				// Table progress semantics:
				// - Track the active table by DROP/CREATE statements for UI display.
				// - Only count a table as "completed" when we START the NEXT table (or at EOF).
				$new_table_name = (string) $m[1];
				if ( '' !== $current_table_name && $new_table_name !== $current_table_name ) {
					$tables_completed++;
				}

				$current_table_name = $new_table_name;
				$table_short = ( '' !== $db_prefix && 0 === strpos( $current_table_name, $db_prefix ) ) ? substr( $current_table_name, strlen( $db_prefix ) ) : $current_table_name;
				$current_expected_rows = isset( $expected_counts[ $table_short ] ) ? (int) $expected_counts[ $table_short ] : 0;
			}
			if ( preg_match( '/^CREATE\\s+TABLE\\s+`?([^`\\s(]+)`?/i', $statement, $m ) ) {
				if ( 0 === strpos( (string) $m[1], $db_prefix ) ) {
					$sql_objects[ (string) $m[1] ] = true;
				}
				// Ensure current table display is stable even when SQL doesn't include DROP TABLE.
				// Also treat CREATE TABLE as a boundary for "table completed" tracking when needed.
				$new_table_name = (string) $m[1];
				if ( '' !== $current_table_name && $new_table_name !== $current_table_name ) {
					$tables_completed++;
				}

				$current_table_name = $new_table_name;
				$table_short = ( '' !== $db_prefix && 0 === strpos( $current_table_name, $db_prefix ) ) ? substr( $current_table_name, strlen( $db_prefix ) ) : $current_table_name;
				$current_expected_rows = isset( $expected_counts[ $table_short ] ) ? (int) $expected_counts[ $table_short ] : 0;
			}
			if ( preg_match( '/^(INSERT|REPLACE)\\s+(IGNORE\\s+)?INTO\\s+`?([^`\\s(]+)`?/i', $statement, $m ) ) {
				$current_table_name = (string) $m[3];
				$insert_table_for_statement = $current_table_name;
				$table_short = ( '' !== $db_prefix && 0 === strpos( $current_table_name, $db_prefix ) ) ? substr( $current_table_name, strlen( $db_prefix ) ) : $current_table_name;
				$current_expected_rows = isset( $expected_counts[ $table_short ] ) ? (int) $expected_counts[ $table_short ] : 0;

				// Track the first INSERT offset for this table so we can safely rewind to the start of table data
				// if a resume causes duplicate key replays (avoid very slow REPLACE storms).
				if ( '' !== $insert_table_for_statement && ! array_key_exists( $insert_table_for_statement, $table_first_insert_offset ) ) {
					$table_first_insert_offset[ $insert_table_for_statement ] = (int) $statement_start;
					// Capture the rows_imported counter at the start of this table's data so we can rewind UI counters on restart.
					$table_rows_base[ $insert_table_for_statement ] = (int) $rows_imported;
				}
			}

			// Detect CREATE VIEW statements for special handling.
			// Views can reference tables that may not exist in the target environment (e.g. plugin removed).
			$is_create_view     = false;
			$create_view_name   = '';
			$statement_for_exec = $statement;
			if ( preg_match( '/^CREATE\\s+(?:ALGORITHM\\s*=\\s*\\w+\\s+)?(?:DEFINER\\s*=\\s*`?[^`]+`?@`?[^`]+`?\\s+)?(?:SQL\\s+SECURITY\\s+\\w+\\s+)?(?:OR\\s+REPLACE\\s+)?VIEW\\s+`?([^\\s`(]+)`?/i', $statement, $vm ) ) {
				$is_create_view   = true;
				$create_view_name = (string) $vm[1];
				if ( '' !== $create_view_name && 0 === strpos( $create_view_name, $db_prefix ) ) {
					$sql_objects[ $create_view_name ] = true;
				}

				// Strip DEFINER for portability. Creating a view with a different DEFINER often requires SUPER/SET USER.
				$statement_for_exec = preg_replace( '/\\s+DEFINER\\s*=\\s*`?[^`]+`?@`?[^`]+`?\\s+/i', ' ', (string) $statement_for_exec );
				$statement_for_exec = trim( preg_replace( '/\\s+/', ' ', (string) $statement_for_exec ) );
			}

			// Build a short statement context for error reporting (no SQL contents).
			$statement_context = '';
			if ( $is_create_view && '' !== $create_view_name ) {
				$statement_context = 'CREATE VIEW ' . $create_view_name;
			} elseif ( '' !== $insert_table_for_statement ) {
				$statement_context = 'INSERT/REPLACE INTO ' . $insert_table_for_statement;
			} elseif ( '' !== $current_table_name ) {
				$statement_context = 'TABLE ' . $current_table_name;
			}
			$statement_context_suffix = ( '' !== $statement_context ) ? ( ' | Context: ' . $statement_context ) : '';

			// Before executing CREATE VIEW, drop any existing object with the same name (table or view).
			// This mirrors restore behavior and handles incomplete prior runs or missing DROP VIEW statements.
			if ( $is_create_view && '' !== $create_view_name ) {
				$safe_view = str_replace( '`', '``', $create_view_name );
				$mysqli->query( "DROP VIEW IF EXISTS `{$safe_view}`" );
				$mysqli->query( "DROP TABLE IF EXISTS `{$safe_view}`" );
			}

			// Execute statement (chunk multi-row INSERT/REPLACE to avoid packet/timeout issues).
			$res = true;
			$did_chunk = false;
			$chunk_resume_from = 0;
			$chunk_size = 200;
			if ( $resume_chunk_size > 0 ) {
				$chunk_size = (int) $resume_chunk_size;
			}

			if (
				preg_match( '/^(INSERT|REPLACE)\\s+(IGNORE\\s+)?INTO\\b/i', $statement )
				&& false === stripos( $statement, ' ON DUPLICATE KEY' )
				&& false !== stripos( $statement, ' VALUES' )
			) {
				$chunks = asenha_split_multi_row_insert_into_chunks( $statement, $chunk_size );
				if ( ! empty( $chunks ) && count( $chunks ) > 1 ) {
					$did_chunk = true;
					$chunk_resume_from = ( $statement_start > 0 && $resume_stmt_offset === $statement_start && $resume_chunk_index > 0 ) ? $resume_chunk_index : 0;

					$chunk_index_done = 0;
					$last_chunk_checkpoint_at = microtime( true );
					for ( $ci = 0; $ci < count( $chunks ); $ci++ ) {
						$chunk_index_done = $ci;
						if ( $ci < $chunk_resume_from ) {
							continue;
						}

						$chunk_sql = $chunks[ $ci ];
						$chunk_affected = 0;
						$q = $mysqli->query( $chunk_sql );
						if ( false === $q ) {
							$err = $mysqli->error;
							$restart_cap = 2;

							// Duplicate option_name in options table: prefer safe table restart over REPLACE storms when resuming.
							// Accept legacy "for key 'option_name'" and MySQL 8/MariaDB "for key 'wp_options.option_name'"
							// (plus errno 1062 as a message-format fallback).
							if (
								(
									preg_match( '/Duplicate entry .+ for key [\'"`]?(?:\\S+\\.)?option_name/i', $err )
									|| 1062 === (int) $mysqli->errno
								)
								&& preg_match( '/^INSERT\\s+(IGNORE\\s+)?INTO\\s+`?' . preg_quote( $db_prefix, '/' ) . 'options`?/i', $chunk_sql )
							) {
								if (
									'' !== $insert_table_for_statement
									&& isset( $table_first_insert_offset[ $insert_table_for_statement ] )
									&& ( ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? (int) $table_restart_count[ $insert_table_for_statement ] : 0 ) < $restart_cap )
								) {
									$safe_table = str_replace( '`', '``', $insert_table_for_statement );
									$mysqli->query( "TRUNCATE TABLE `{$safe_table}`" );

									$table_restart_count[ $insert_table_for_statement ] = ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? ( (int) $table_restart_count[ $insert_table_for_statement ] + 1 ) : 1 );
									if ( isset( $table_rows_base[ $insert_table_for_statement ] ) ) {
										$rows_imported = (int) $table_rows_base[ $insert_table_for_statement ];
									}

									if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
										$state['db_warnings'] = array();
									}
									$warn = 'Restarted table import for ' . $insert_table_for_statement . ' due to duplicate key replay; rewinding to first INSERT.';
									$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
									$warn = substr( $warn, 0, 500 );
									$state['db_warnings'][]   = $warn;
									$state['db_last_warning'] = $warn;
									if ( count( $state['db_warnings'] ) > 10 ) {
										$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
									}

									// Clear any mid-statement resume fields and rewind to the first INSERT for this table.
									unset( $state['db_stmt_offset'], $state['db_stmt_chunk_index'], $state['db_stmt_chunk_size'] );
									$resume_stmt_offset = 0;
									$resume_chunk_index = 0;
									$resume_chunk_size  = 200;

									$byte_offset = (int) $table_first_insert_offset[ $insert_table_for_statement ];
									$time_up     = true;
									break;
								}

								$retry = preg_replace( '/^INSERT(\\s+IGNORE)?\\s+INTO/i', 'REPLACE INTO', $chunk_sql );
								$q2 = $mysqli->query( $retry );
								if ( false === $q2 ) {
									$state['status'] = 'failed';
									$state['error']  = 'SQL error (chunk retry failed, errno ' . (int) $mysqli->errno . '): ' . $mysqli->error . $statement_context_suffix;
									$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
									$res = false;
									break;
								}
								$chunk_affected += (int) $mysqli->affected_rows;
							} elseif ( preg_match( '/Duplicate entry .* for key .*PRIMARY/i', $err ) && preg_match( '/^INSERT\\s+/i', $chunk_sql ) ) {
								// Duplicate PRIMARY key usually indicates a partial replay after resume.
								// Prefer a safe table restart (truncate + rewind to first INSERT) to avoid very slow REPLACE storms.
								if (
									'' !== $insert_table_for_statement
									&& isset( $table_first_insert_offset[ $insert_table_for_statement ] )
									&& ( ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? (int) $table_restart_count[ $insert_table_for_statement ] : 0 ) < $restart_cap )
								) {
									$safe_table = str_replace( '`', '``', $insert_table_for_statement );
									$mysqli->query( "TRUNCATE TABLE `{$safe_table}`" );

									$table_restart_count[ $insert_table_for_statement ] = ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? ( (int) $table_restart_count[ $insert_table_for_statement ] + 1 ) : 1 );
									if ( isset( $table_rows_base[ $insert_table_for_statement ] ) ) {
										$rows_imported = (int) $table_rows_base[ $insert_table_for_statement ];
									}

									if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
										$state['db_warnings'] = array();
									}
									$warn = 'Restarted table import for ' . $insert_table_for_statement . ' due to duplicate PRIMARY replay; rewinding to first INSERT.';
									$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
									$warn = substr( $warn, 0, 500 );
									$state['db_warnings'][]   = $warn;
									$state['db_last_warning'] = $warn;
									if ( count( $state['db_warnings'] ) > 10 ) {
										$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
									}

									unset( $state['db_stmt_offset'], $state['db_stmt_chunk_index'], $state['db_stmt_chunk_size'] );
									$resume_stmt_offset = 0;
									$resume_chunk_index = 0;
									$resume_chunk_size  = 200;

									$byte_offset = (int) $table_first_insert_offset[ $insert_table_for_statement ];
									$time_up     = true;
									break;
								}

								// Fallback: retry this chunk as REPLACE to make the import idempotent.
								$retry = preg_replace( '/^INSERT(\\s+IGNORE)?\\s+INTO/i', 'REPLACE INTO', $chunk_sql );
								$qpk = $mysqli->query( $retry );
								if ( false === $qpk ) {
									$state['status'] = 'failed';
									$state['error']  = 'SQL error (PRIMARY retry failed, errno ' . (int) $mysqli->errno . '): ' . $mysqli->error . $statement_context_suffix;
									$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
									$res = false;
									break;
								}
								$chunk_affected += (int) $mysqli->affected_rows;
							} elseif ( preg_match( '/server has gone away|lost connection/i', $err ) ) {
								// Reconnect once and retry the same chunk.
								$mysqli->close();
								$mysqli = asenha_mysqli_connect( $host, $db_user, $db_password, $db_name, $port, $socket );
								if ( $mysqli->connect_error ) {
									$state['status'] = 'failed';
									$state['error']  = 'Database reconnect failed: ' . $mysqli->connect_error;
									$res = false;
									break;
								}
								$mysqli->set_charset( 'utf8mb4' );
								$mysqli->query( 'SET FOREIGN_KEY_CHECKS = 0' );
								$mysqli->query( 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"' );

								$q3 = $mysqli->query( $chunk_sql );
								if ( false === $q3 ) {
									// If this is a duplicate primary issue after reconnect, retry as REPLACE.
									$err2 = $mysqli->error;
									if ( preg_match( '/Duplicate entry .* for key .*PRIMARY/i', $err2 ) && preg_match( '/^INSERT\\s+/i', $chunk_sql ) ) {
										if (
											'' !== $insert_table_for_statement
											&& isset( $table_first_insert_offset[ $insert_table_for_statement ] )
											&& ( ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? (int) $table_restart_count[ $insert_table_for_statement ] : 0 ) < $restart_cap )
										) {
											$safe_table = str_replace( '`', '``', $insert_table_for_statement );
											$mysqli->query( "TRUNCATE TABLE `{$safe_table}`" );

											$table_restart_count[ $insert_table_for_statement ] = ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? ( (int) $table_restart_count[ $insert_table_for_statement ] + 1 ) : 1 );
											if ( isset( $table_rows_base[ $insert_table_for_statement ] ) ) {
												$rows_imported = (int) $table_rows_base[ $insert_table_for_statement ];
											}

											if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
												$state['db_warnings'] = array();
											}
											$warn = 'Restarted table import for ' . $insert_table_for_statement . ' due to duplicate PRIMARY replay after reconnect; rewinding to first INSERT.';
											$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
											$warn = substr( $warn, 0, 500 );
											$state['db_warnings'][]   = $warn;
											$state['db_last_warning'] = $warn;
											if ( count( $state['db_warnings'] ) > 10 ) {
												$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
											}

											unset( $state['db_stmt_offset'], $state['db_stmt_chunk_index'], $state['db_stmt_chunk_size'] );
											$resume_stmt_offset = 0;
											$resume_chunk_index = 0;
											$resume_chunk_size  = 200;

											$byte_offset = (int) $table_first_insert_offset[ $insert_table_for_statement ];
											$time_up     = true;
											break;
										}

										$retry = preg_replace( '/^INSERT(\\s+IGNORE)?\\s+INTO/i', 'REPLACE INTO', $chunk_sql );
										$q4 = $mysqli->query( $retry );
										if ( false === $q4 ) {
											$state['status'] = 'failed';
											$state['error']  = 'SQL error (PRIMARY retry failed after reconnect, errno ' . (int) $mysqli->errno . '): ' . $mysqli->error . $statement_context_suffix;
											$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
											$res = false;
											break;
										}
										$chunk_affected += (int) $mysqli->affected_rows;
									} else {
										$state['status'] = 'failed';
										$state['error']  = 'SQL error (after reconnect, errno ' . (int) $mysqli->errno . '): ' . $mysqli->error . $statement_context_suffix;
										$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
										$res = false;
										break;
									}
								}
								$chunk_affected += (int) $mysqli->affected_rows;
							} elseif ( preg_match( '/max_allowed_packet|packet\\s+.*too\\s+large|allowed\\s+packet/i', $err ) ) {
								// Packet too large: retry by splitting this chunk further.
								$retry_sizes = array( 50, 10, 1 );
								$chunk_ok    = false;
								foreach ( $retry_sizes as $rs ) {
									$sub_chunks = asenha_split_multi_row_insert_into_chunks( $chunk_sql, $rs );
									if ( empty( $sub_chunks ) ) {
										continue;
									}
									$sub_ok = true;
									foreach ( $sub_chunks as $sub_sql ) {
										$qsub = $mysqli->query( $sub_sql );
										if ( false === $qsub ) {
											$sub_ok = false;
											break;
										}
										$chunk_affected += (int) $mysqli->affected_rows;
									}
									if ( $sub_ok ) {
										$chunk_ok = true;
										break;
									}
								}
								if ( ! $chunk_ok ) {
									$state['status'] = 'failed';
									$state['error']  = 'SQL error (packet too large, errno ' . (int) $mysqli->errno . '): ' . $err . $statement_context_suffix;
									$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
									$res = false;
									break;
								}
							} else {
								$state['status'] = 'failed';
								$state['error']  = 'SQL error (errno ' . (int) $mysqli->errno . '): ' . $err . $statement_context_suffix;
								$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
								$res = false;
								break;
							}
						} else {
							$chunk_affected += (int) $mysqli->affected_rows;
						}

						// If we triggered a table restart (truncate + rewind), yield immediately so the next worker
						// restarts the table from its first INSERT offset.
						if ( $time_up ) {
							break;
						}

						// Track affected rows for INSERT/REPLACE.
						$rows_imported += (int) $chunk_affected;

						// Periodic checkpoint during long chunked statements. This improves resiliency if a runner
						// is terminated externally (gateway timeout) without reaching our time_budget exit.
						if ( ( $ci > 0 && 0 === ( $ci % 25 ) ) || ( microtime( true ) - $last_chunk_checkpoint_at ) > 2 ) {
							$last_chunk_checkpoint_at = microtime( true );

							$state['last_update']          = time();
							$state['db_byte_offset']       = (int) $statement_start;
							$state['db_tables_completed']  = (int) $tables_completed;
							$state['db_rows_imported']     = (int) $rows_imported;
							$state['db_sql_objects']       = $sql_objects;
							$state['collation_remap_count']  = (int) $collation_remap_count;
							$state['collation_remap_target'] = (string) $collation_remap_target;
							$state['db_table_first_insert_offset'] = $table_first_insert_offset;
							$state['db_table_rows_base']           = $table_rows_base;
							$state['db_table_restart_count']       = $table_restart_count;
							$state['db_stmt_offset']       = (int) $statement_start;
							$state['db_stmt_chunk_index']  = (int) ( $ci + 1 );
							$state['db_stmt_chunk_size']   = (int) $chunk_size;
							$state['sub_progress'] = array(
								'label'                       => 'importing',
								'current'                     => $tables_completed,
								'total'                       => $total_tables,
								'rows_imported'               => $rows_imported,
								'current_table_name'          => $current_table_name,
								'current_table_rows_expected' => $current_expected_rows,
							);

							asenha_atomic_write_json( $state_file, $state );
							asenha_write_public_progress( $state, $wp_content_dir );
						}

						// Stop if time is nearly up (mid-statement yield).
						if ( ( microtime( true ) - $start_time ) >= $time_budget ) {
							$time_up = true;
							// Resume at this statement next run, skipping completed chunks.
							$state['db_stmt_offset']      = $statement_start;
							$state['db_stmt_chunk_index'] = $ci + 1; // next chunk to execute
							$state['db_stmt_chunk_size']  = $chunk_size;
							$byte_offset = $statement_start;
							break;
						}
					}

					// If we yielded mid-statement, persist and break out.
					if ( $time_up ) {
						break 2;
					}

					// Clear resume fields if we completed the statement.
					unset( $state['db_stmt_offset'], $state['db_stmt_chunk_index'], $state['db_stmt_chunk_size'] );
					$res = true;
				}
			}

			if ( ! $did_chunk ) {
				$res = $mysqli->query( $statement_for_exec );
			}
			if ( false === $res ) {
				$err = $mysqli->error;
				$restart_cap = 2;

				// Duplicate option_name in options table -> retry as REPLACE.
				// Accept legacy and MySQL 8/MariaDB qualified option_name key names (plus errno 1062).
				if (
					(
						preg_match( '/Duplicate entry .+ for key [\'"`]?(?:\\S+\\.)?option_name/i', $err )
						|| 1062 === (int) $mysqli->errno
					)
					&& preg_match( '/^INSERT\\s+(IGNORE\\s+)?INTO\\s+`?' . preg_quote( $db_prefix, '/' ) . 'options`?/i', $statement )
				) {
					if (
						'' !== $insert_table_for_statement
						&& isset( $table_first_insert_offset[ $insert_table_for_statement ] )
						&& ( ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? (int) $table_restart_count[ $insert_table_for_statement ] : 0 ) < $restart_cap )
					) {
						$safe_table = str_replace( '`', '``', $insert_table_for_statement );
						$mysqli->query( "TRUNCATE TABLE `{$safe_table}`" );

						$table_restart_count[ $insert_table_for_statement ] = ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? ( (int) $table_restart_count[ $insert_table_for_statement ] + 1 ) : 1 );
						if ( isset( $table_rows_base[ $insert_table_for_statement ] ) ) {
							$rows_imported = (int) $table_rows_base[ $insert_table_for_statement ];
						}

						if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
							$state['db_warnings'] = array();
						}
						$warn = 'Restarted table import for ' . $insert_table_for_statement . ' due to duplicate key replay; rewinding to first INSERT.';
						$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
						$warn = substr( $warn, 0, 500 );
						$state['db_warnings'][]   = $warn;
						$state['db_last_warning'] = $warn;
						if ( count( $state['db_warnings'] ) > 10 ) {
							$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
						}

						unset( $state['db_stmt_offset'], $state['db_stmt_chunk_index'], $state['db_stmt_chunk_size'] );
						$resume_stmt_offset = 0;
						$resume_chunk_index = 0;
						$resume_chunk_size  = 200;

						$byte_offset = (int) $table_first_insert_offset[ $insert_table_for_statement ];
						$time_up     = true;
						break 2;
					}

					$retry = preg_replace( '/^INSERT(\\s+IGNORE)?\\s+INTO/i', 'REPLACE INTO', $statement );
					$res2 = $mysqli->query( $retry );
					if ( false === $res2 ) {
						$state['status'] = 'failed';
						$state['error']  = 'SQL error (retry failed, errno ' . (int) $mysqli->errno . '): ' . $mysqli->error . $statement_context_suffix;
						$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
						break 2;
					}
					// REPLACE succeeded — clear the failed flag so the generic failure branch does not fire.
					$res = true;
					$rows_imported += (int) $mysqli->affected_rows;
				} elseif ( preg_match( '/Duplicate entry .* for key .*PRIMARY/i', $err ) && preg_match( '/^INSERT\\s+/i', $statement ) ) {
					// Duplicate PRIMARY key -> prefer safe table restart, fallback to REPLACE.
					if (
						'' !== $insert_table_for_statement
						&& isset( $table_first_insert_offset[ $insert_table_for_statement ] )
						&& ( ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? (int) $table_restart_count[ $insert_table_for_statement ] : 0 ) < $restart_cap )
					) {
						$safe_table = str_replace( '`', '``', $insert_table_for_statement );
						$mysqli->query( "TRUNCATE TABLE `{$safe_table}`" );

						$table_restart_count[ $insert_table_for_statement ] = ( isset( $table_restart_count[ $insert_table_for_statement ] ) ? ( (int) $table_restart_count[ $insert_table_for_statement ] + 1 ) : 1 );
						if ( isset( $table_rows_base[ $insert_table_for_statement ] ) ) {
							$rows_imported = (int) $table_rows_base[ $insert_table_for_statement ];
						}

						if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
							$state['db_warnings'] = array();
						}
						$warn = 'Restarted table import for ' . $insert_table_for_statement . ' due to duplicate PRIMARY replay; rewinding to first INSERT.';
						$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
						$warn = substr( $warn, 0, 500 );
						$state['db_warnings'][]   = $warn;
						$state['db_last_warning'] = $warn;
						if ( count( $state['db_warnings'] ) > 10 ) {
							$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
						}

						unset( $state['db_stmt_offset'], $state['db_stmt_chunk_index'], $state['db_stmt_chunk_size'] );
						$resume_stmt_offset = 0;
						$resume_chunk_index = 0;
						$resume_chunk_size  = 200;

						$byte_offset = (int) $table_first_insert_offset[ $insert_table_for_statement ];
						$time_up     = true;
						break 2;
					}

					$retry = preg_replace( '/^INSERT(\\s+IGNORE)?\\s+INTO/i', 'REPLACE INTO', $statement );
					$res2 = $mysqli->query( $retry );
					if ( false === $res2 ) {
						$state['status'] = 'failed';
						$state['error']  = 'SQL error (PRIMARY retry failed, errno ' . (int) $mysqli->errno . '): ' . $mysqli->error . $statement_context_suffix;
						$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
						break 2;
					}
					// REPLACE succeeded — clear the failed flag so the generic failure branch does not fire.
					$res = true;
					$rows_imported += (int) $mysqli->affected_rows;
				} elseif ( $is_create_view && '' !== $create_view_name && preg_match( '/Table .* already exists/i', $err ) ) {
					// View already exists: drop and retry (fallback if pre-drop didn't succeed).
					$safe_view = str_replace( '`', '``', $create_view_name );
					$mysqli->query( "DROP VIEW IF EXISTS `{$safe_view}`" );
					$mysqli->query( "DROP TABLE IF EXISTS `{$safe_view}`" );
					// Retry using the sanitized CREATE VIEW statement (DEFINER stripped) to avoid privilege issues.
					$res2 = $mysqli->query( $statement_for_exec );
					if ( false !== $res2 ) {
						$res = true;
					} else {
						$err = $mysqli->error;
					}
				}

				// CREATE VIEW can fail if the view references a table that doesn't exist in the target DB.
				// For migration, treat this as non-fatal: skip the view, record a warning, and continue.
				if ( false === $res && $is_create_view && '' !== $create_view_name && ( 1146 === (int) $mysqli->errno || preg_match( '/Table .* doesn\\x27t exist/i', $err ) || preg_match( "/Table .* doesn't exist/i", $err ) ) ) {
					if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
						$state['db_warnings'] = array();
					}
					$warn = 'Skipped view ' . $create_view_name . ': ' . $err;
					$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
					$warn = substr( $warn, 0, 500 );
					$state['db_warnings'][]    = $warn;
					$state['db_last_warning']  = $warn;
					if ( count( $state['db_warnings'] ) > 10 ) {
						$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
					}
					$res = true;
				}

				// CREATE VIEW can also fail due to privilege issues (SUPER / SET USER) when the SQL includes DEFINER.
				// For migration, treat this as non-fatal: record a warning and continue. Views can usually be recreated.
				if (
					false === $res
					&& $is_create_view
					&& '' !== $create_view_name
					&& ( 1227 === (int) $mysqli->errno || preg_match( '/\\bSUPER\\b|\\bSET USER\\b/i', $err ) )
				) {
					// If we didn't already sanitize/execute a DEFINER-less statement, retry once.
					if ( $statement_for_exec !== $statement ) {
						$res2 = $mysqli->query( $statement_for_exec );
						if ( false !== $res2 ) {
							$res = true;
						} else {
							$err = $mysqli->error;
						}
					}

					if ( false === $res ) {
						if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
							$state['db_warnings'] = array();
						}
						$warn = 'Skipped view ' . $create_view_name . ': ' . $err;
						$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
						$warn = substr( $warn, 0, 500 );
						$state['db_warnings'][]   = $warn;
						$state['db_last_warning'] = $warn;
						if ( count( $state['db_warnings'] ) > 10 ) {
							$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
						}
						$res = true;
					}
				}

				// INSERT into non-insertable view - skip gracefully (views don't store data).
				if ( false === $res && preg_match( '/not insertable-into/i', $err ) && preg_match( '/^INSERT\\s+/i', $statement ) ) {
					if ( ! isset( $state['db_warnings'] ) || ! is_array( $state['db_warnings'] ) ) {
						$state['db_warnings'] = array();
					}
					$warn = 'Skipped INSERT into non-insertable view: ' . $err;
					$warn = trim( preg_replace( '/\\s+/', ' ', (string) $warn ) );
					$warn = substr( $warn, 0, 500 );
					$state['db_warnings'][]   = $warn;
					$state['db_last_warning'] = $warn;
					if ( count( $state['db_warnings'] ) > 10 ) {
						$state['db_warnings'] = array_slice( $state['db_warnings'], -10 );
					}
					$res = true;
				} else {
					if ( false === $res ) {
						$state['status'] = 'failed';
						$state['error']  = 'SQL error (errno ' . (int) $mysqli->errno . '): ' . $err . $statement_context_suffix;
						$state['error']  = substr( trim( preg_replace( '/\\s+/', ' ', (string) $state['error'] ) ), 0, 800 );
						break 2;
					}
				}
			} else {
				// Track affected rows for INSERT/REPLACE (non-chunked).
				if ( ! $did_chunk && preg_match( '/^(INSERT|REPLACE)\\s+(IGNORE\\s+)?INTO/i', $statement ) ) {
					$rows_imported += (int) $mysqli->affected_rows;
				}
			}

			// Save checkpoint after each statement (position after ';').
			$byte_offset = (int) ( $line_start_pos + $i + 1 );

			// Update state and public JSON periodically (or on table boundaries).
			if ( ( $tables_completed % 1 ) === 0 ) {
				$state['last_update'] = time();
				$state['db_byte_offset']      = $byte_offset;
				$state['db_tables_completed'] = $tables_completed;
				$state['db_rows_imported']    = $rows_imported;
				$state['db_sql_objects']      = $sql_objects;
				$state['collation_remap_count']  = (int) $collation_remap_count;
				$state['collation_remap_target'] = (string) $collation_remap_target;
				$state['db_table_first_insert_offset'] = $table_first_insert_offset;
				$state['db_table_rows_base']           = $table_rows_base;
				$state['db_table_restart_count']       = $table_restart_count;

				// Update progress in the same shape migration UI expects.
				$progress_step = 65;
				if ( $total_tables > 0 ) {
					$progress_step = 65 + ( ( $tables_completed / max( $total_tables, 1 ) ) * 22 );
				}
				$state['progress'] = isset( $state['progress'] ) ? max( (int) $state['progress'], (int) $progress_step ) : (int) $progress_step;

				$state['sub_progress'] = array(
					'label'                     => 'importing',
					'current'                   => $tables_completed,
					'total'                     => $total_tables,
					'rows_imported'             => $rows_imported,
					'current_table_name'        => $current_table_name,
					'current_table_rows_expected' => $current_expected_rows,
				);

				asenha_atomic_write_json( $state_file, $state );
				asenha_write_public_progress( $state, $wp_content_dir );
			}

			// Stop if time is nearly up.
			if ( ( microtime( true ) - $start_time ) >= $time_budget ) {
				$time_up = true;
				break 2;
			}
		} else {
			// Track statement start offset (for mid-statement resume).
			if ( '' === $current_statement && 0 === $statement_start_offset ) {
				$statement_start_offset = (int) ( $line_start_pos + $i );
			}
			$current_statement .= $char;
		}
	}

	$line_start_pos = ftell( $handle );
}

// Capture EOF / file pointer state before closing the handle.
$eof_reached = @feof( $handle );
$file_pos    = (int) @ftell( $handle );

@fclose( $handle );
$mysqli->close();

// If failed, persist and stop.
if ( 'failed' === $state['status'] ) {
	$state['last_update'] = time();
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Failed' );
}

// Determine completion.
// IMPORTANT: byte_offset only advances on statement delimiters (';'). A SQL file can legally
// end with trailing whitespace/comments after the last ';', which would otherwise cause an
// infinite "Continuing" loop (byte_offset < filesize forever). Treat "EOF reached with no
// pending statement" as complete.
$file_size = (int) @filesize( $sql_file );
$done      = false;

// If we reached EOF (not because we yielded for time), consider this complete as long as we
// don't have a partial statement buffered.
if ( $eof_reached && ! $time_up ) {
	$pending = trim( (string) $current_statement );
	if ( '' !== $pending || $in_string ) {
		$state['status'] = 'failed';
		$state['error']  = 'SQL file ended unexpectedly (possible missing semicolon).';
		$state['last_update'] = time();
		asenha_atomic_write_json( $state_file, $state );
		asenha_write_public_progress( $state, $wp_content_dir );
		http_response_code( 500 );
		exit( 'Failed' );
	}
	$done = true;
} elseif ( $file_size > 0 && $byte_offset >= $file_size ) {
	$done = true;
} elseif ( $file_size > 0 && $file_pos >= $file_size && ! $time_up ) {
	// Fallback: if file pointer is already at EOF and we didn't yield for time, we're done.
	$done = true;
}

// If we finished importing and we have an active table name, count the last table as completed.
// (We only increment completion when the next table begins, so EOF needs to close the final table.)
if ( $done && '' !== $current_table_name && $total_tables > 0 && $tables_completed < $total_tables ) {
	$tables_completed = min( $total_tables, $tables_completed + 1 );
}

// Persist checkpoint.
$state['last_update'] = time();
$state['db_byte_offset']      = $done ? 0 : $byte_offset;
$state['db_tables_completed'] = $tables_completed;
$state['db_rows_imported']    = $rows_imported;
$state['db_sql_objects']      = $sql_objects;
$state['collation_remap_count']  = (int) $collation_remap_count;
$state['collation_remap_target'] = (string) $collation_remap_target;
$state['db_table_first_insert_offset'] = $table_first_insert_offset;
$state['db_table_rows_base']           = $table_rows_base;
$state['db_table_restart_count']       = $table_restart_count;

$progress_step = 65;
if ( $total_tables > 0 ) {
	$progress_step = 65 + ( ( $tables_completed / max( $total_tables, 1 ) ) * 22 );
}
$state['progress'] = isset( $state['progress'] ) ? max( (int) $state['progress'], (int) $progress_step ) : (int) $progress_step;
$state['sub_progress'] = array(
	'label'                       => 'importing',
	'current'                     => $tables_completed,
	'total'                       => $total_tables,
	'rows_imported'               => $rows_imported,
	'current_table_name'          => $current_table_name,
	'current_table_rows_expected' => $current_expected_rows,
);

// If not done, persist checkpoint and spawn continuation, then exit quickly.
if ( ! $done ) {
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );

	$self_query = array(
		'asenha_action' => 'migration_db',
		'migration_id'  => $migration_id,
		'token'         => $token,
		't'             => time(),
	);
	$self_url = '';

	// Prefer persisted runner URL so continuation does not depend on SCRIPT_NAME/HOST.
	if ( ! empty( $state['db_runner_url'] ) && is_string( $state['db_runner_url'] ) ) {
		$runner_base = (string) $state['db_runner_url'];
		$parts       = @parse_url( $runner_base );
		if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
			$sep      = ( false === strpos( $runner_base, '?' ) ) ? '?' : '&';
			$self_url = $runner_base . $sep . http_build_query( $self_query );
		}
	}
	if ( '' === $self_url ) {
		$self_url = asenha_current_url( $self_query );
	}
	if ( '' !== $self_url ) {
		asenha_spawn_self_async( $self_url );
	} else {
		$state['db_last_warning'] = 'Runner continuation URL unavailable. Waiting for keepalive kick.';
		$state['last_update']     = time();
		asenha_atomic_write_json( $state_file, $state );
		asenha_write_public_progress( $state, $wp_content_dir );
	}

	echo 'Continuing';
	exit;
}

// ---------------------------------------------------------------------------
// Completion: finalize DB import + cleanup + redirect (without WordPress)
// ---------------------------------------------------------------------------

// Reconnect to DB for post-import steps.
mysqli_report( MYSQLI_REPORT_OFF );
$mysqli = asenha_mysqli_connect( $host, $db_user, $db_password, $db_name, $port, $socket );
if ( $mysqli->connect_error ) {
	$state['status'] = 'failed';
	$state['error']  = 'Database connection failed during finalization: ' . $mysqli->connect_error;
	asenha_atomic_write_json( $state_file, $state );
	asenha_write_public_progress( $state, $wp_content_dir );
	http_response_code( 500 );
	exit( 'Finalize failed' );
}

$mysqli->set_charset( 'utf8mb4' );
$mysqli->query( 'SET FOREIGN_KEY_CHECKS = 0' );
$mysqli->query( 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"' );

// Safety net: rename any leftover source-prefixed option/meta keys after identifier-safe SQL remap.
if ( '' !== $manifest_prefix && $manifest_prefix !== $db_prefix ) {
	$prefix_key_remap = asenha_remap_prefix_scoped_option_and_meta_keys_mysqli( $mysqli, $manifest_prefix, $db_prefix );
	if ( ! empty( $prefix_key_remap['options'] ) || ! empty( $prefix_key_remap['usermeta'] ) ) {
		$state['db_prefix_key_remap'] = $prefix_key_remap;
	}
}

$options_table = $db_prefix . 'options';
$safe_options_table = str_replace( '`', '``', $options_table );

// Cleanup leftover prefixed DB objects not present in the SQL file.
if ( is_array( $sql_objects ) && ! empty( $sql_objects ) && 1 === preg_match( '/^[A-Za-z0-9_]+$/', $db_prefix ) ) {
	$like = addcslashes( $db_prefix, '\\%_' ) . '%';
	$like_sql = $mysqli->real_escape_string( $like );
	$res = $mysqli->query( "SHOW FULL TABLES LIKE '{$like_sql}'" );

	if ( false !== $res ) {
		while ( $row = $res->fetch_array( MYSQLI_NUM ) ) {
			$object_name = isset( $row[0] ) ? (string) $row[0] : '';
			$object_type = isset( $row[1] ) ? (string) $row[1] : 'BASE TABLE';
			if ( '' === $object_name || 0 !== strpos( $object_name, $db_prefix ) ) {
				continue;
			}

			if ( empty( $sql_objects[ $object_name ] ) ) {
				$safe_name = str_replace( '`', '``', $object_name );
				if ( 'VIEW' === strtoupper( $object_type ) ) {
					$mysqli->query( "DROP VIEW IF EXISTS `{$safe_name}`" );
					$mysqli->query( "DROP TABLE IF EXISTS `{$safe_name}`" );
				} else {
					$mysqli->query( "DROP TABLE IF EXISTS `{$safe_name}`" );
				}
			}
		}
		$res->free();
	}
}

// Restore preserved WP core options (db_version, initial_db_version) to avoid DB upgrade prompt.
$preserved_path = rtrim( $temp_dir, '/\\' ) . DIRECTORY_SEPARATOR . '.preserved_wp_options.json';
$preserved = asenha_read_json_file( $preserved_path );
if ( is_array( $preserved ) ) {
	foreach ( $preserved as $opt_name => $opt_value ) {
		if ( false === $opt_value ) {
			continue;
		}
		$opt_name_esc  = $mysqli->real_escape_string( (string) $opt_name );
		$opt_value_esc = $mysqli->real_escape_string( (string) $opt_value );
		$mysqli->query( "REPLACE INTO `{$safe_options_table}` (`option_name`,`option_value`,`autoload`) VALUES ('{$opt_name_esc}','{$opt_value_esc}','yes')" );
	}
	@unlink( $preserved_path );
}

// Restore Freemius options to preserve target license.
$freemius_path = rtrim( $temp_dir, '/\\' ) . DIRECTORY_SEPARATOR . '.freemius_preserve.json';
$freemius = asenha_read_json_file( $freemius_path );
if ( is_array( $freemius ) ) {
	// Clear all Freemius options/transients from imported DB (source site data).
	$mysqli->query(
		"DELETE FROM `{$safe_options_table}`
		 WHERE option_name LIKE 'fs|_%' ESCAPE '|'
		    OR option_name LIKE '|_transient|_fs%' ESCAPE '|'
		    OR option_name LIKE '|_transient|_timeout|_fs%' ESCAPE '|'
		    OR option_name LIKE '|_site|_transient|_fs%' ESCAPE '|'
		    OR option_name LIKE '|_site|_transient|_timeout|_fs%' ESCAPE '|'"
	);

	foreach ( $freemius as $row ) {
		if ( ! is_array( $row ) || empty( $row['option_name'] ) ) {
			continue;
		}
		$name = $mysqli->real_escape_string( (string) $row['option_name'] );
		$val  = isset( $row['option_value'] ) ? $mysqli->real_escape_string( (string) $row['option_value'] ) : '';
		$autoload = isset( $row['autoload'] ) ? $mysqli->real_escape_string( (string) $row['autoload'] ) : 'yes';
		$mysqli->query( "REPLACE INTO `{$safe_options_table}` (`option_name`,`option_value`,`autoload`) VALUES ('{$name}','{$val}','{$autoload}')" );
	}
	@unlink( $freemius_path );
}

// Reset rewrite rules so WordPress regenerates them on next request.
$mysqli->query( "UPDATE `{$safe_options_table}` SET option_value = '' WHERE option_name = 'rewrite_rules'" );

// Verify ASE settings integrity after import. If the option is empty, missing,
// or has invalid serialization, record a diagnostic warning in the state file.
// This helps distinguish serialization corruption from cache race conditions.
$ase_row = $mysqli->query(
	"SELECT option_value FROM `{$safe_options_table}` WHERE option_name = 'admin_site_enhancements' LIMIT 1"
);
if ( $ase_row ) {
	$ase_data = $ase_row->fetch_assoc();
	$ase_row->free();
	if ( ! is_array( $ase_data ) || empty( $ase_data['option_value'] ) ) {
		$state['db_last_warning'] = 'admin_site_enhancements option is empty/missing after import.';
	} else {
		$ase_test = @unserialize( $ase_data['option_value'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
		if ( ! is_array( $ase_test ) || empty( $ase_test ) ) {
			$state['db_last_warning'] = 'admin_site_enhancements has invalid serialization after import.';
		} elseif ( ! empty( $ase_test['smtp_password'] )
			&& is_string( $ase_test['smtp_password'] )
			&& 0 === strpos( $ase_test['smtp_password'], 'asenha_encrypted::smtp_password::v1::' )
		) {
			$state['smtp_password_restore_warning'] = 'Imported SMTP password uses legacy v1 encryption and may need to be re-entered after a cross-environment restore.';
		}
	}
} else {
	$state['db_last_warning'] = 'Failed to query admin_site_enhancements after import.';
}

$mysqli->query( 'SET FOREIGN_KEY_CHECKS = 1' );
$mysqli->close();

// Clean up the extracted temp dir (best-effort; constrained to backup_dir).
asenha_delete_dir_recursive( $temp_dir, $backup_dir );

// Flush persistent object cache on next WordPress bootstrap.
// The DB runner writes options directly via mysqli, bypassing WordPress.
// If a persistent object cache (Redis, Memcached, etc.) is active,
// the alloptions cache will be stale. Drop a self-deleting mu-plugin
// that flushes the cache on the first post-migration request.
$mu_plugins_dir = rtrim( $wp_content_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'mu-plugins';
if ( ! is_dir( $mu_plugins_dir ) ) {
	@mkdir( $mu_plugins_dir, 0755, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
}
if ( is_dir( $mu_plugins_dir ) && is_writable( $mu_plugins_dir ) ) {
	$flush_file = $mu_plugins_dir . DIRECTORY_SEPARATOR . '000-asenha-cache-flush.php';
	$flush_script = 
%%ASENHA_CACHE_FLUSH_EXPORT%%;
	@file_put_contents( $flush_file, $flush_script ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
}

// Final state: completed (finalizing).
$new_url = '';
if ( isset( $state['search_replace_config']['new_url'] ) ) {
	$new_url = (string) $state['search_replace_config']['new_url'];
}
if ( '' === $new_url ) {
	// Best-effort guess from current request.
	$scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';
	$host   = isset( $_SERVER['HTTP_HOST'] ) ? (string) $_SERVER['HTTP_HOST'] : '';
	if ( '' !== $host ) {
		$new_url = $scheme . '://' . $host;
	}
}

// Transfer finalization (site-to-site): update transfer session, delete target zip,
// and notify the source site to clean up its transfer package.
//
// Important: do not include secrets (token/signature) in any state/public messages.
$transfer_id = isset( $state['transfer_id'] ) ? (string) $state['transfer_id'] : '';
if ( '' !== $transfer_id && 1 === preg_match( '/^[0-9a-fA-F-]{36}$/', $transfer_id ) ) {
	$transfer_session_file = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'transfer_session_' . preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $transfer_id ) . '.json';
	$transfer_session      = asenha_read_json_file( $transfer_session_file );

	// Mark transfer session as completed (best-effort).
	if ( is_array( $transfer_session ) ) {
		$transfer_session['status']       = 'completed';
		$transfer_session['current_step'] = 'cleanup';
		$transfer_session['progress']     = 100;
		$transfer_session['last_update']  = time();
		$transfer_session['completed_at'] = time();
		asenha_atomic_write_json( $transfer_session_file, $transfer_session );
	}

	// Delete downloaded archive on the target site (restrict to the expected filename).
	$zip_basename = isset( $state['filename'] ) ? basename( (string) $state['filename'] ) : '';
	$expected_zip = 'transfer_receive_' . $transfer_id . '.zip';
	if ( '' !== $zip_basename && $zip_basename === $expected_zip ) {
		$zip_path = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . $zip_basename;
		if ( file_exists( $zip_path ) ) {
			if ( ! @unlink( $zip_path ) ) {
				$state['db_last_warning'] = 'Transfer finalization: failed to delete downloaded archive on target site.';
			} else {
				$state['transfer_target_zip_deleted'] = true;
			}
		}
	}

	// Notify source site (best-effort) to clean up its transfer package.
	if (
		is_array( $transfer_session )
		&& ! empty( $transfer_session['source_url'] )
		&& ! empty( $transfer_session['package_id'] )
		&& ! empty( $transfer_session['token'] )
		&& ! empty( $transfer_session['signature'] )
	) {
		$source_url   = rtrim( (string) $transfer_session['source_url'], '/' );
		$complete_url = $source_url . '/wp-json/asenha/v1/transfer/complete';

		$resp = asenha_http_post_form(
			$complete_url,
			array(
				'package_id' => (string) $transfer_session['package_id'],
				'token'      => (string) $transfer_session['token'],
				'signature'  => (string) $transfer_session['signature'],
				'target_url' => (string) $new_url,
			),
			20
		);

		$code = isset( $resp['code'] ) ? (int) $resp['code'] : 0;
		if ( $code >= 200 && $code < 300 ) {
			$state['transfer_origin_notified'] = true;

			// Persist non-sensitive outcome on the transfer session file.
			if ( is_array( $transfer_session ) ) {
				$transfer_session['origin_notified']            = true;
				$transfer_session['origin_notified_at']         = time();
				$transfer_session['origin_notify_http_code']    = $code;
				$transfer_session['origin_notify_last_error']   = '';
				asenha_atomic_write_json( $transfer_session_file, $transfer_session );
			}
		} else {
			$state['transfer_origin_notified'] = false;
			$state['db_last_warning']          = 'Transfer finalization: failed to notify source site of completion.';

			if ( is_array( $transfer_session ) ) {
				$transfer_session['origin_notified']          = false;
				$transfer_session['origin_notify_failed_at']  = time();
				$transfer_session['origin_notify_http_code']  = $code;
				$transfer_session['origin_notify_last_error'] = '';
				asenha_atomic_write_json( $transfer_session_file, $transfer_session );
			}
		}
	}
}

$state['db_runner_active']     = false;
$state['db_runner_completed_at'] = time();
$state['cleanup_pending']      = true;
$state['cleanup_completed']    = false;
$state['cleanup_scheduled_at'] = time();
$state['status']               = 'completed';
$state['finalized']            = true;
$state['current_step']         = 'cleanup';
$state['progress']             = 100;
$state['message'] = 'Migration completed successfully! Redirecting to login';
$state['redirect_countdown']   = 60;
$state['redirect_url']         = rtrim( $new_url, '/' ) . '/wp-login.php';

// Stable completion summary for DB step.
$state['collation_remap_count']  = (int) $collation_remap_count;
$state['collation_remap_target'] = (string) $collation_remap_target;
$state['sub_progress'] = array(
	'label'           => 'done',
	'tables_imported' => $total_tables > 0 ? $total_tables : $tables_completed,
	'rows_imported'   => $total_rows > 0 ? $total_rows : $rows_imported,
);
if ( $collation_remap_count > 0 ) {
	$state['sub_progress']['collation_remap_count']  = (int) $collation_remap_count;
	$state['sub_progress']['collation_remap_target'] = (string) $collation_remap_target;
}

// Delay artifact cleanup so the UI can reliably read the final state.
// Cleanup runs out-of-band in a separate request (migration runner only).
if ( defined( 'ASENHA_MIGRATION_DB_RUNNER' ) && true === ASENHA_MIGRATION_DB_RUNNER ) {
	$state['cleanup_not_before'] = time() + 120;
}

asenha_atomic_write_json( $state_file, $state );
asenha_write_public_progress( $state, $wp_content_dir );

// Spawn delayed cleanup (best-effort, migration runner only).
if ( defined( 'ASENHA_MIGRATION_DB_RUNNER' ) && true === ASENHA_MIGRATION_DB_RUNNER ) {
	$cleanup_url = asenha_current_url( array(
		'asenha_action' => 'migration_cleanup',
		'migration_id'  => $migration_id,
		'token'         => $token,
		't'             => time(),
	) );
	if ( '' !== $cleanup_url ) {
		asenha_spawn_self_async( $cleanup_url );
	}
}

// Best-effort: release lock and remove lock artifacts now that we are complete.
// This avoids leaving migration_db_runner_lock_*.lock and migration_lock_*.lock behind.
if ( is_resource( $lock_handle ) ) {
	@flock( $lock_handle, LOCK_UN );
	@fclose( $lock_handle );
}
if ( '' !== $lock_file && file_exists( $lock_file ) ) {
	@unlink( $lock_file );
}
$migration_lock_file = rtrim( (string) $backup_dir, '/\\' ) . DIRECTORY_SEPARATOR . 'migration_lock_' . preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $migration_id ) . '.lock';
if ( '' !== $migration_lock_file && file_exists( $migration_lock_file ) ) {
	@unlink( $migration_lock_file );
}

// Completion: attempt self-delete (best-effort).
// For the Migration DB runner we keep the runner file until the delayed cleanup finishes
// (it is token-gated and will self-delete once artifacts are cleaned).
if ( defined( 'ASENHA_MIGRATION_DB_RUNNER' ) && true === ASENHA_MIGRATION_DB_RUNNER ) {
	if ( 'migration_cleanup' === $action ) {
		@unlink( __FILE__ );
	}
} else {
	// Restore runner (and other variants) should keep current behavior.
	@unlink( __FILE__ );
}
echo 'Done';
exit;

