<?php
/**
 * aisv-installer.php — standalone recovery installer for AI-SiteArk backups.
 *
 * Rebuilds a WordPress site from a `.aisv` backup on a server that has NO WordPress
 * and NO AI-SiteArk — the disaster-recovery path when the original site is gone.
 * It imports the database, extracts the files, rewrites the site URL/path for the new
 * location (serialization-safe), and writes a fresh wp-config.php.
 *
 * HOW TO USE
 *   1. Create an empty database on the new server (note the name, user, password, host).
 *   2. Upload this file AND your `.aisv` backup into the target web directory
 *      (e.g. public_html). For a split backup, upload every volume (name.aisv,
 *      name.aisv.2, …).
 *   3. Visit this file in a browser — e.g. https://your-new-site.com/aisv-installer.php
 *   4. Follow the two-step wizard.
 *   5. When it finishes, DELETE this installer and the `.aisv` file(s).
 *
 * Requirements: PHP 7.4+, mysqli, zlib (always present). openssl only for encrypted
 * backups. WordPress core files are NOT in the backup (they are identical to a fresh
 * download from wordpress.org) — the installer fetches the matching core automatically.
 *
 * @package AI_SiteArk
 * Copyright (c) 2026 AtoZ INFOWAY INC.
 * Licensed under the GNU General Public License, version 2 or later.
 */

error_reporting( E_ALL & ~E_DEPRECATED & ~E_NOTICE );
@set_time_limit( 0 );
@ini_set( 'memory_limit', '512M' );

const AISV_MAGIC = "AISV1\n";
const AISV_ITER  = 100000;
const AISV_CHUNK = 1048576;

/* =========================================================================
 * Archive reader (mirrors Archive_Writer / Helpers\Crypto)
 * ====================================================================== */

/**
 * Class AISV_Archive — streams entries out of a (possibly split / encrypted) archive.
 */
class AISV_Archive {

	/** @var string[] */
	private $volumes = array();
	/** @var resource|null */
	private $fh = null;
	private $vi = 0;
	/** @var string|null */
	private $key = null;
	/** @var array */
	public $manifest = array();

	/**
	 * @param string $base First-volume path.
	 * @throws RuntimeException On a bad archive.
	 */
	public function __construct( $base ) {
		$this->volumes = self::volumes( $base );
		$this->open_manifest();
	}

	/**
	 * Every volume for a base path, in order.
	 *
	 * @param string $base Base path.
	 * @return string[]
	 */
	public static function volumes( $base ) {
		$vols = array( $base );
		for ( $i = 2; ; $i++ ) {
			$p = $base . '.' . $i;
			if ( ! is_file( $p ) ) {
				break;
			}
			$vols[] = $p;
		}
		return $vols;
	}

	/**
	 * Read + validate the manifest (always the first, unencrypted, entry).
	 *
	 * @return void
	 * @throws RuntimeException On failure.
	 */
	private function open_manifest() {
		$this->rewind();
		$h = $this->next_header();
		if ( ! $h || ( $h['header']['type'] ?? '' ) !== 'manifest' ) {
			throw new RuntimeException( 'This file is not an AI-SiteArk backup (no manifest).' );
		}
		$this->manifest = json_decode( $this->read( $h['len'] ), true );
		if ( ! is_array( $this->manifest ) ) {
			throw new RuntimeException( 'The backup manifest could not be read.' );
		}
	}

	/** @return bool */
	public function is_encrypted() {
		return ! empty( $this->manifest['encrypted'] );
	}

	/**
	 * Verify a password and arm decryption. Returns true on success.
	 *
	 * @param string $password Password.
	 * @return bool
	 */
	public function unlock( $password ) {
		if ( ! $this->is_encrypted() ) {
			return true;
		}
		if ( ! function_exists( 'openssl_decrypt' ) || '' === (string) $password ) {
			return false;
		}
		$key = hash_pbkdf2( 'sha256', (string) $password, base64_decode( $this->manifest['salt'] ?? '' ), AISV_ITER, 32, true );
		$ok  = openssl_decrypt(
			base64_decode( $this->manifest['verify'] ?? '' ),
			'aes-256-ctr',
			$key,
			OPENSSL_RAW_DATA,
			base64_decode( $this->manifest['verify_iv'] ?? '' )
		);
		if ( 'AISV-OK' !== $ok ) {
			return false;
		}
		$this->key = $key;
		return true;
	}

	/**
	 * Iterate every non-manifest entry, calling $cb( $header, $reader ) for each.
	 * $reader is $this; use read_payload()/copy_payload_to() inside the callback.
	 *
	 * @param callable $cb Callback.
	 * @return void
	 */
	public function each( callable $cb ) {
		$this->rewind();
		while ( null !== ( $h = $this->next_header() ) ) {
			$start = $this->payload_start;
			$len   = $this->cur_len;
			if ( ( $h['header']['type'] ?? '' ) !== 'manifest' ) {
				$cb( $h['header'], $this );
			}
			// Always resynchronise to the exact next entry, no matter how much (or how
			// little) the callback consumed. Without this, a callback that ignores an
			// entry's payload leaves the pointer mid-payload and the next header read
			// interprets random bytes as a gigantic length.
			fseek( $this->fh, $start + $len, SEEK_SET );
		}
	}

	/* ---- payload helpers (valid only inside each()'s callback) ---------- */

	private $cur_len       = 0;
	private $payload_start = 0;

	/**
	 * Read + decrypt + gunzip a small payload (manifest/db entries).
	 *
	 * @param array $header Entry header.
	 * @return string
	 */
	public function payload( array $header ) {
		$data = $this->read( $this->cur_len );
		if ( null !== $this->key && isset( $header['iv'] ) ) {
			$data = openssl_decrypt( $data, 'aes-256-ctr', $this->key, OPENSSL_RAW_DATA, base64_decode( $header['iv'] ) );
		}
		if ( 'gzip' === ( $header['enc'] ?? '' ) ) {
			$plain = @gzdecode( $data );
			if ( false === $plain ) {
				throw new RuntimeException( 'A database entry could not be decompressed (wrong password, or a damaged backup).' );
			}
			$data = $plain;
		}
		return $data;
	}

	/**
	 * Stream a file payload to disk (decrypting as it goes).
	 *
	 * @param array  $header Entry header.
	 * @param string $dest   Local path.
	 * @return void
	 */
	public function copy_to( array $header, $dest ) {
		$out = fopen( $dest, 'wb' );
		if ( ! $out ) {
			throw new RuntimeException( 'Cannot write ' . $dest );
		}
		$iv        = ( null !== $this->key && isset( $header['iv'] ) ) ? base64_decode( $header['iv'] ) : null;
		$blocks    = 0;
		$remaining = $this->cur_len;
		while ( $remaining > 0 ) {
			$buf = $this->read( (int) min( AISV_CHUNK, $remaining ) );
			if ( '' === $buf ) {
				break;
			}
			$read       = strlen( $buf );
			$remaining -= $read;
			if ( null !== $iv ) {
				$buf     = openssl_decrypt( $buf, 'aes-256-ctr', $this->key, OPENSSL_RAW_DATA, self::iv_add( $iv, $blocks ) );
				$blocks += intdiv( $read, 16 ) + ( ( $read % 16 ) ? 1 : 0 );
			}
			fwrite( $out, $buf );
		}
		fclose( $out );
	}

	/* ---- low-level volume spanning ------------------------------------- */

	private function rewind() {
		$this->close();
		$this->vi = 0;
		$this->open_volume( 0 );
	}

	private function open_volume( $i ) {
		$this->close();
		$this->fh = fopen( $this->volumes[ $i ], 'rb' );
		if ( ! $this->fh || fread( $this->fh, strlen( AISV_MAGIC ) ) !== AISV_MAGIC ) {
			throw new RuntimeException( 'Bad or missing volume: ' . basename( $this->volumes[ $i ] ) );
		}
		$this->vi = $i;
	}

	private function close() {
		if ( $this->fh ) {
			fclose( $this->fh );
			$this->fh = null;
		}
	}

	/**
	 * Next entry header, rolling to the next volume at EOF. Sets $cur_len.
	 *
	 * @return array{header:array,len:int}|null
	 */
	private function next_header() {
		$lb = $this->raw( 4 );
		if ( strlen( $lb ) < 4 ) {
			// Try the next volume.
			if ( $this->vi + 1 < count( $this->volumes ) ) {
				$this->open_volume( $this->vi + 1 );
				$lb = $this->raw( 4 );
				if ( strlen( $lb ) < 4 ) {
					return null;
				}
			} else {
				return null;
			}
		}
		$hlen  = unpack( 'N', $lb )[1];
		$hjson = $this->raw( $hlen );
		$plb   = $this->raw( 8 );
		if ( strlen( $plb ) < 8 ) {
			return null;
		}
		$plen                = unpack( 'J', $plb )[1];
		$this->cur_len       = (int) $plen;
		$this->payload_start = ftell( $this->fh );
		$header              = json_decode( $hjson, true );
		return array( 'header' => is_array( $header ) ? $header : array(), 'len' => (int) $plen );
	}

	/**
	 * Read exactly $n payload bytes (never spans a volume — entries don't).
	 *
	 * @param int $n Bytes.
	 * @return string
	 */
	private function read( $n ) {
		return $this->raw( $n );
	}

	private function raw( $n ) {
		$out = '';
		while ( $n > 0 && $this->fh ) {
			$buf = fread( $this->fh, $n );
			if ( false === $buf || '' === $buf ) {
				break;
			}
			$out .= $buf;
			$n   -= strlen( $buf );
		}
		return $out;
	}

	private static function iv_add( $iv, $blocks ) {
		$b = array_values( unpack( 'C16', $iv ) );
		$c = (int) $blocks;
		for ( $i = 15; $i >= 0 && $c > 0; $i-- ) {
			$sum     = $b[ $i ] + ( $c & 0xFF );
			$b[ $i ] = $sum & 0xFF;
			$c       = ( $c >> 8 ) + ( $sum >> 8 );
		}
		return pack( 'C16', ...$b );
	}
}

/* =========================================================================
 * Serialization-safe search-replace (ports Restore\Search_Replace)
 * ====================================================================== */

/**
 * Replace inside a single value, re-serializing serialized data so string lengths
 * stay valid.
 *
 * @param string $val   Value.
 * @param array  $pairs old => new.
 * @return string
 */
function aisv_replace_value( $val, array $pairs ) {
	if ( preg_match( '/^[aOs]:\d+:/', $val ) || 'b:0;' === $val || 'N;' === $val ) {
		$un = @unserialize( $val );
		if ( false !== $un || 'b:0;' === $val ) {
			return serialize( aisv_recurse( $un, $pairs ) );
		}
	}
	return strtr( $val, $pairs );
}

/**
 * Recurse through decoded serialized data.
 *
 * @param mixed $data  Data.
 * @param array $pairs Map.
 * @return mixed
 */
function aisv_recurse( $data, array $pairs ) {
	if ( is_array( $data ) ) {
		$out = array();
		foreach ( $data as $k => $v ) {
			$out[ is_string( $k ) ? strtr( $k, $pairs ) : $k ] = aisv_recurse( $v, $pairs );
		}
		return $out;
	}
	if ( is_object( $data ) ) {
		foreach ( $data as $k => $v ) {
			$data->$k = aisv_recurse( $v, $pairs );
		}
		return $data;
	}
	if ( is_string( $data ) ) {
		return strtr( $data, $pairs );
	}
	return $data;
}

/* =========================================================================
 * Helpers
 * ====================================================================== */

/** @return string The first-volume .aisv beside this script, or ''. */
function aisv_find_archive() {
	foreach ( glob( __DIR__ . '/*.aisv' ) as $f ) {
		return $f; // First-volume names never end in .aisv.N.
	}
	return '';
}

/** @return string A random 64-char salt line value. */
function aisv_salt() {
	$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()-_ []{}<>~`+=,.;:/?|';
	$s     = '';
	for ( $i = 0; $i < 64; $i++ ) {
		$s .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ];
	}
	return $s;
}

/**
 * Compose a wp-config.php from a template's constants.
 *
 * @param array $c { DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, prefix }.
 * @return string
 */
function aisv_wp_config( array $c ) {
	$keys = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' );
	$salt = '';
	foreach ( $keys as $k ) {
		$salt .= "define( '{$k}', '" . str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), aisv_salt() ) . "' );\n";
	}
	$q = static function ( $v ) {
		return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), (string) $v );
	};
	return "<?php\n"
		. "/* Written by AI-SiteArk recovery installer. */\n"
		. "define( 'DB_NAME', '" . $q( $c['DB_NAME'] ) . "' );\n"
		. "define( 'DB_USER', '" . $q( $c['DB_USER'] ) . "' );\n"
		. "define( 'DB_PASSWORD', '" . $q( $c['DB_PASSWORD'] ) . "' );\n"
		. "define( 'DB_HOST', '" . $q( $c['DB_HOST'] ) . "' );\n"
		. "define( 'DB_CHARSET', 'utf8mb4' );\n"
		. "define( 'DB_COLLATE', '' );\n\n"
		. $salt . "\n"
		. "\$table_prefix = '" . $q( $c['prefix'] ) . "';\n\n"
		. "define( 'WP_DEBUG', false );\n\n"
		. "if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', __DIR__ . '/' ); }\n"
		. "require_once ABSPATH . 'wp-settings.php';\n";
}

/**
 * Download + unzip the matching WordPress core into $dir (core files aren't in the
 * backup). Best-effort: returns an error string, or '' on success.
 *
 * @param string $dir     Target directory.
 * @param string $version WP version from the manifest (or '' for latest).
 * @return string
 */
function aisv_fetch_core( $dir, $version ) {
	if ( is_file( $dir . '/wp-login.php' ) && is_dir( $dir . '/wp-includes' ) ) {
		return ''; // Core already present.
	}
	$url = $version ? "https://wordpress.org/wordpress-{$version}.zip" : 'https://wordpress.org/latest.zip';
	$zip = $dir . '/.aisv-wp-core.zip';
	$ok  = @copy( $url, $zip );
	if ( ! $ok && function_exists( 'curl_init' ) ) {
		$fh = fopen( $zip, 'wb' );
		$ch = curl_init( $url );
		curl_setopt_array( $ch, array( CURLOPT_FILE => $fh, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 300 ) );
		$ok = curl_exec( $ch );
		curl_close( $ch );
		fclose( $fh );
	}
	if ( ! $ok || ! is_file( $zip ) ) {
		return 'Could not download WordPress core from wordpress.org. Upload the WordPress files manually, then re-run.';
	}
	if ( ! class_exists( 'ZipArchive' ) ) {
		@unlink( $zip );
		return 'PHP has no Zip support to unpack WordPress core. Upload the WordPress files manually.';
	}
	$za = new ZipArchive();
	if ( true !== $za->open( $zip ) ) {
		@unlink( $zip );
		return 'The downloaded WordPress core zip could not be opened.';
	}
	// The zip contains a top-level wordpress/ folder — extract, then flatten.
	$tmp = $dir . '/.aisv-core-tmp';
	@mkdir( $tmp, 0755, true );
	$za->extractTo( $tmp );
	$za->close();
	@unlink( $zip );
	$src = $tmp . '/wordpress';
	if ( is_dir( $src ) ) {
		aisv_move_tree( $src, $dir );
	}
	aisv_rmtree( $tmp );
	return is_file( $dir . '/wp-includes/version.php' ) ? '' : 'WordPress core did not unpack correctly.';
}

/** Move a directory tree into $dst without overwriting existing files. */
function aisv_move_tree( $src, $dst ) {
	foreach ( scandir( $src ) as $e ) {
		if ( '.' === $e || '..' === $e ) {
			continue;
		}
		$from = $src . '/' . $e;
		$to   = $dst . '/' . $e;
		if ( is_dir( $from ) ) {
			@mkdir( $to, 0755, true );
			aisv_move_tree( $from, $to );
		} elseif ( ! is_file( $to ) ) {
			@rename( $from, $to );
		}
	}
}

/** Recursively delete a directory. */
function aisv_rmtree( $dir ) {
	if ( ! is_dir( $dir ) ) {
		return;
	}
	foreach ( scandir( $dir ) as $e ) {
		if ( '.' === $e || '..' === $e ) {
			continue;
		}
		$p = $dir . '/' . $e;
		is_dir( $p ) ? aisv_rmtree( $p ) : @unlink( $p );
	}
	@rmdir( $dir );
}

/** Reject path traversal from archive file entries. */
function aisv_safe_rel( $rel ) {
	$rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' );
	if ( '' === $rel || false !== strpos( $rel, '../' ) || '..' === $rel || preg_match( '#^[a-zA-Z]:/#', $rel ) ) {
		return false;
	}
	return $rel;
}

/* =========================================================================
 * The install run (POST)
 * ====================================================================== */

/**
 * Do the whole install and return a log of steps. Throws on a fatal problem.
 *
 * @param string $archive_path Archive path.
 * @param array  $in           POST fields.
 * @return string[] Log lines.
 * @throws RuntimeException On failure.
 */
function aisv_run_install( $archive_path, array $in ) {
	$log = array();
	$dir = __DIR__;

	$arc = new AISV_Archive( $archive_path );
	if ( $arc->is_encrypted() && ! $arc->unlock( $in['password'] ?? '' ) ) {
		throw new RuntimeException( 'The backup is encrypted and the password is missing or wrong.' );
	}

	$man        = $arc->manifest;
	$old_url    = isset( $man['site_url'] ) ? rtrim( (string) $man['site_url'], '/' ) : '';
	$old_path   = isset( $man['abspath'] ) ? (string) $man['abspath'] : '';
	$old_prefix = isset( $man['table_prefix'] ) ? (string) $man['table_prefix'] : 'wp_';

	$new_url  = rtrim( (string) $in['new_url'], '/' );
	$new_path = rtrim( str_replace( '\\', '/', $dir ), '/' ) . '/';
	$prefix   = preg_replace( '/[^A-Za-z0-9_$]/', '', (string) ( $in['prefix'] ?: $old_prefix ) );

	/* ---- 1. Connect to the (empty) database ---- */
	$host = (string) $in['db_host'];
	$port = 0;
	$sock = null;
	if ( false !== strpos( $host, ':' ) ) {
		list( $host, $tail ) = explode( ':', $host, 2 );
		if ( ctype_digit( $tail ) ) {
			$port = (int) $tail;
		} else {
			$sock = $tail;
		}
	}
	mysqli_report( MYSQLI_REPORT_OFF );
	$db = @mysqli_connect( $host, $in['db_user'], $in['db_pass'], $in['db_name'], $port ?: 3306, $sock );
	if ( ! $db ) {
		throw new RuntimeException( 'Could not connect to the database: ' . mysqli_connect_error() );
	}
	$db->set_charset( 'utf8mb4' );
	$log[] = 'Connected to database "' . $in['db_name'] . '".';

	/* ---- 2. Import the database ---- */
	$tables = 0;
	$rows   = 0;
	$arc->each(
		function ( $header, $reader ) use ( $db, $old_prefix, $prefix, &$tables, &$rows ) {
			if ( ( $header['type'] ?? '' ) !== 'db' ) {
				return;
			}
			$sql  = $reader->payload( $header );
			$kind = $header['kind'] ?? 'rows';

			// Rewrite the table prefix if the operator chose a new one.
			if ( $prefix !== $old_prefix && ! empty( $header['table'] ) ) {
				$oldt = (string) $header['table'];
				$newt = $prefix . substr( $oldt, strlen( $old_prefix ) );
				$sql  = str_replace( '`' . $oldt . '`', '`' . $newt . '`', $sql );
			}

			if ( 'create' === $kind ) {
				foreach ( array_filter( array_map( 'trim', explode( ";\n", $sql ) ) ) as $stmt ) {
					if ( '' !== $stmt ) {
						$db->query( $stmt );
					}
				}
				++$tables;
			} elseif ( 'rows' === $kind ) {
				$stmt = rtrim( trim( $sql ), ';' );
				if ( '' !== $stmt && $db->query( $stmt ) ) {
					$rows += $db->affected_rows;
				}
			} else {
				// view / trigger / event — best effort, DEFINER already stripped at backup.
				foreach ( array_filter( array_map( 'trim', explode( ";\n", $sql ) ) ) as $stmt ) {
					if ( '' !== $stmt ) {
						$db->query( $stmt );
					}
				}
			}
		}
	);
	$log[] = "Imported {$tables} tables ({$rows} rows).";

	/* ---- 3. Serialization-safe URL / path rewrite ---- */
	$pairs = array();
	if ( '' !== $old_url && $old_url !== $new_url ) {
		$pairs[ $old_url ] = $new_url;
	}
	if ( '' !== $old_path && rtrim( $old_path, '/' ) !== rtrim( $new_path, '/' ) ) {
		$pairs[ $old_path ]            = $new_path;
		$pairs[ rtrim( $old_path, '/' ) ] = rtrim( $new_path, '/' );
	}
	if ( $pairs ) {
		$changed = aisv_db_replace( $db, $prefix, $pairs );
		$log[]   = "Rewrote the site address for its new location ({$changed} values updated).";
	}

	// Belt-and-braces: make sure the core options point at the new URL.
	$db->query( "UPDATE `{$prefix}options` SET option_value='" . $db->real_escape_string( $new_url ) . "' WHERE option_name IN ('siteurl','home')" );

	/* ---- 4. Extract the files ---- */
	$files = 0;
	$arc->each(
		function ( $header, $reader ) use ( $dir, &$files ) {
			if ( ( $header['type'] ?? '' ) !== 'file' ) {
				return;
			}
			$rel = aisv_safe_rel( $header['path'] ?? '' );
			if ( false === $rel ) {
				return;
			}
			// Backups store paths relative to wp-content.
			$dest = $dir . '/wp-content/' . $rel;
			$sub  = dirname( $dest );
			if ( ! is_dir( $sub ) ) {
				@mkdir( $sub, 0755, true );
			}
			$reader->copy_to( $header, $dest );
			++$files;
		}
	);
	$log[] = "Extracted {$files} files.";

	/* ---- 5. WordPress core (not in the backup) ---- */
	$core_err = aisv_fetch_core( $dir, isset( $man['wp_version'] ) ? preg_replace( '/[^0-9.]/', '', (string) $man['wp_version'] ) : '' );
	$log[]    = '' === $core_err ? 'WordPress core is in place.' : ( 'NOTE: ' . $core_err );

	/* ---- 6. wp-config.php ---- */
	$cfg = aisv_wp_config(
		array(
			'DB_NAME'     => $in['db_name'],
			'DB_USER'     => $in['db_user'],
			'DB_PASSWORD' => $in['db_pass'],
			'DB_HOST'     => $in['db_host'],
			'prefix'      => $prefix,
		)
	);
	if ( false === @file_put_contents( $dir . '/wp-config.php', $cfg ) ) {
		throw new RuntimeException( 'The database and files were restored, but wp-config.php could not be written. Create it by hand from wp-config-sample.php.' );
	}
	$log[] = 'Wrote wp-config.php.';

	$db->close();
	return $log;
}

/**
 * Walk every table's string columns and apply the serialization-safe replace.
 *
 * @param mysqli $db     Connection.
 * @param string $prefix Table prefix.
 * @param array  $pairs  old => new.
 * @return int Values changed.
 */
function aisv_db_replace( $db, $prefix, array $pairs ) {
	$changed = 0;
	$res     = $db->query( "SHOW TABLES LIKE '" . $db->real_escape_string( $prefix ) . "%'" );
	if ( ! $res ) {
		return 0;
	}
	$tables = array();
	while ( $row = $res->fetch_row() ) {
		$tables[] = $row[0];
	}
	$res->free();

	foreach ( $tables as $table ) {
		// Primary key.
		$pk  = '';
		$kr  = $db->query( "SHOW KEYS FROM `{$table}` WHERE Key_name='PRIMARY'" );
		if ( $kr && ( $k = $kr->fetch_assoc() ) ) {
			$pk = $k['Column_name'];
		}
		if ( $kr ) {
			$kr->free();
		}
		if ( '' === $pk ) {
			continue; // Can't safely update rows without a key.
		}

		$offset = 0;
		do {
			$rows = $db->query( "SELECT * FROM `{$table}` LIMIT {$offset}, 200" );
			if ( ! $rows ) {
				break;
			}
			$batch = 0;
			while ( $r = $rows->fetch_assoc() ) {
				++$batch;
				$sets = array();
				foreach ( $r as $col => $val ) {
					if ( ! is_string( $val ) || '' === $val ) {
						continue;
					}
					$new = aisv_replace_value( $val, $pairs );
					if ( $new !== $val ) {
						$sets[] = "`{$col}`='" . $db->real_escape_string( $new ) . "'";
						++$changed;
					}
				}
				if ( $sets ) {
					$db->query( "UPDATE `{$table}` SET " . implode( ',', $sets ) . " WHERE `{$pk}`='" . $db->real_escape_string( $r[ $pk ] ) . "'" );
				}
			}
			$rows->free();
			$offset += 200;
		} while ( $batch === 200 );
	}
	return $changed;
}

/* =========================================================================
 * Web wizard
 * ====================================================================== */

$archive = aisv_find_archive();
$error   = '';
$done    = null;
$manifest = array();

if ( '' !== $archive ) {
	try {
		$probe    = new AISV_Archive( $archive );
		$manifest = $probe->manifest;
	} catch ( Throwable $e ) {
		$error = $e->getMessage();
	}
}

if ( 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) && '' !== $archive && '' === $error ) {
	$in = array(
		'db_host'  => trim( $_POST['db_host'] ?? 'localhost' ),
		'db_name'  => trim( $_POST['db_name'] ?? '' ),
		'db_user'  => trim( $_POST['db_user'] ?? '' ),
		'db_pass'  => (string) ( $_POST['db_pass'] ?? '' ),
		'prefix'   => trim( $_POST['prefix'] ?? '' ),
		'new_url'  => trim( $_POST['new_url'] ?? '' ),
		'password' => (string) ( $_POST['password'] ?? '' ),
	);
	try {
		if ( '' === $in['db_name'] || '' === $in['db_user'] || '' === $in['new_url'] ) {
			throw new RuntimeException( 'Please fill in the database name, database user and the new site address.' );
		}
		$done = aisv_run_install( $archive, $in );
	} catch ( Throwable $e ) {
		$error = $e->getMessage();
	}
}

$default_url = '';
if ( isset( $_SERVER['HTTP_HOST'] ) ) {
	$scheme      = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';
	$default_url = $scheme . '://' . $_SERVER['HTTP_HOST'] . rtrim( dirname( $_SERVER['SCRIPT_NAME'] ?? '' ), '/\\' );
}
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI-SiteArk — Recovery Installer</title>
<style>
	:root { --teal:#0d9488; --teal2:#0f766e; --ink:#1d2327; --muted:#50575e; --line:#e3e5e8; --bg:#f6f7f9; --danger:#d63638; --ok:#00a32a; }
	* { box-sizing:border-box; }
	body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; }
	.wrap { max-width:640px; margin:40px auto; padding:0 20px; }
	.card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:28px; box-shadow:0 1px 3px rgba(16,24,40,.05); }
	h1 { font-size:22px; margin:0 0 4px; }
	.sub { color:var(--muted); margin:0 0 22px; }
	.badge { display:inline-block; background:var(--teal); color:#fff; border-radius:999px; padding:2px 10px; font-size:12px; font-weight:600; vertical-align:middle; margin-left:8px; }
	label { display:block; font-weight:600; margin:16px 0 4px; }
	input[type=text],input[type=password] { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:8px; font-size:14px; }
	.row { display:flex; gap:12px; } .row > div { flex:1; }
	.hint { color:var(--muted); font-size:13px; margin-top:3px; }
	button { margin-top:22px; background:var(--teal); color:#fff; border:0; border-radius:8px; padding:11px 20px; font-size:15px; font-weight:600; cursor:pointer; }
	button:hover { background:var(--teal2); }
	.notice { border-radius:8px; padding:12px 14px; margin:0 0 18px; }
	.err { background:#fcebec; color:var(--danger); border:1px solid #f4c4c6; }
	.okbox { background:#e6f6ea; color:#00450f; border:1px solid #b6e2c2; }
	.info { background:#eef6fb; color:#0b4a6f; border:1px solid #cfe6f5; }
	ul.log { margin:8px 0 0; padding-left:18px; } ul.log li { margin:3px 0; }
	table.meta { width:100%; border-collapse:collapse; margin:0 0 8px; font-size:14px; }
	table.meta td { padding:3px 0; } table.meta td:first-child { color:var(--muted); width:150px; }
	code { background:#f0f1f4; border-radius:4px; padding:1px 5px; font-size:13px; }
	a.btn { display:inline-block; margin-top:14px; background:var(--teal); color:#fff; text-decoration:none; border-radius:8px; padding:10px 18px; font-weight:600; }
</style>
</head>
<body>
<div class="wrap">
	<div class="card">
		<h1>AI-SiteArk Recovery <span class="badge">Installer</span></h1>
		<p class="sub">Rebuild a WordPress site from a <code>.aisv</code> backup — no existing WordPress required.</p>

		<?php if ( '' === $archive ) : ?>
			<div class="notice err"><strong>No backup found.</strong> Put a <code>.aisv</code> file in the same folder as this installer, then reload.</div>
		<?php elseif ( is_array( $done ) ) : ?>
			<div class="notice okbox"><strong>Done — your site has been restored.</strong></div>
			<ul class="log"><?php foreach ( $done as $line ) : ?><li><?php echo htmlspecialchars( $line ); ?></li><?php endforeach; ?></ul>
			<div class="notice info" style="margin-top:18px"><strong>Now, for security, delete this installer and the backup file(s).</strong> They contain your data and database credentials.</div>
			<a class="btn" href="<?php echo htmlspecialchars( rtrim( (string) ( $_POST['new_url'] ?? $default_url ), '/' ) ); ?>/wp-admin/">Go to your dashboard →</a>
		<?php else : ?>
			<?php if ( '' !== $error ) : ?>
				<div class="notice err"><strong>Could not finish:</strong> <?php echo htmlspecialchars( $error ); ?></div>
			<?php endif; ?>

			<table class="meta">
				<tr><td>Backup of</td><td><?php echo htmlspecialchars( $manifest['site_url'] ?? '(unknown)' ); ?></td></tr>
				<tr><td>Created</td><td><?php echo htmlspecialchars( $manifest['created_at'] ?? '?' ); ?></td></tr>
				<tr><td>Table prefix</td><td><code><?php echo htmlspecialchars( $manifest['table_prefix'] ?? 'wp_' ); ?></code></td></tr>
				<tr><td>Encrypted</td><td><?php echo empty( $manifest['encrypted'] ) ? 'no' : 'yes — password required'; ?></td></tr>
			</table>

			<form method="post">
				<?php if ( ! empty( $manifest['encrypted'] ) ) : ?>
					<label>Backup password</label>
					<input type="password" name="password" autocomplete="off" required />
					<div class="hint">This backup is encrypted — enter the password you set when creating it.</div>
				<?php endif; ?>

				<label>New site address</label>
				<input type="text" name="new_url" value="<?php echo htmlspecialchars( $default_url ); ?>" required />
				<div class="hint">Where this site will live now. Links and settings are rewritten to match.</div>

				<div class="row">
					<div>
						<label>Database name</label>
						<input type="text" name="db_name" required />
					</div>
					<div>
						<label>Database host</label>
						<input type="text" name="db_host" value="localhost" required />
					</div>
				</div>
				<div class="row">
					<div>
						<label>Database user</label>
						<input type="text" name="db_user" required />
					</div>
					<div>
						<label>Database password</label>
						<input type="password" name="db_pass" autocomplete="off" />
					</div>
				</div>
				<label>Table prefix</label>
				<input type="text" name="prefix" value="<?php echo htmlspecialchars( $manifest['table_prefix'] ?? 'wp_' ); ?>" />
				<div class="hint">Leave as-is unless you know you need to change it.</div>

				<div class="notice info" style="margin-top:20px">The database you enter must already exist and should be <strong>empty</strong>. This installer will import the backup into it and overwrite files in this folder.</div>
				<button type="submit">Restore this site</button>
			</form>
		<?php endif; ?>
	</div>
</div>
</body>
</html>
