<?php
/**
 * Sitecarry restore installer.
 *
 * Generated for package {{PACKAGE_ID}} by Sitecarry {{VERSION}} on {{GENERATED}}.
 *
 * This file is intentionally standalone: a restore replaces the very PHP files
 * WordPress is running from, so it cannot be performed from inside WordPress.
 * Nothing here loads wp-load.php, and nothing here may depend on WordPress.
 *
 * Upload this file and the package archive to the target directory, then open
 * this file in a browser.
 */

define( 'SCY_INSTALLER', true );
define( 'SCY_VERSION', '{{VERSION}}' );
define( 'SCY_PACKAGE_ID', '{{PACKAGE_ID}}' );
define( 'SCY_ARCHIVE_NAME', '{{ARCHIVE_NAME}}' );

// True only when Sitecarry staged this installer onto a running site, where the
// archives are read straight from its backup storage. A downloaded installer
// expects them to be uploaded beside it.
define( 'SCY_STAGED', '{{STAGED}}' !== '' );

/**
 * The archives to replay, base first.
 *
 * An incremental package is a diff, not a backup: restoring one means applying its
 * whole chain in order, and applying the deletions each step recorded.
 *
 * @return array
 */
function scy_chain() {
	static $chain = null;

	if ( null === $chain ) {
		$chain = json_decode(
			<<<'SCY_CHAIN_JSON'
{{CHAIN}}
SCY_CHAIN_JSON
			,
			true
		);
	}

	return is_array( $chain ) ? $chain : array();
}

/**
 * Where this backup was uploaded, if anywhere.
 *
 * Bucket and region only. The access keys are deliberately not baked in: this file
 * can end up sitting at a public URL, which is the last place to keep a secret.
 * They are typed in when a download is actually needed.
 *
 * @return array
 */
function scy_remote() {
	static $remote = null;

	if ( null === $remote ) {
		$remote = json_decode(
			<<<'SCY_REMOTE_JSON'
{{REMOTE}}
SCY_REMOTE_JSON
			,
			true
		);
	}

	return is_array( $remote ) ? $remote : array();
}
define( 'SCY_PASSWORD_HASH', '{{PASSWORD_HASH}}' );
define( 'SCY_GENERATED', '{{GENERATED}}' );
define( 'SCY_ROOT', str_replace( '\\', '/', __DIR__ ) );

// The token is baked in at download time so these working files are unguessable:
// during a restore they hold the site's database, and a failed run leaves them
// behind. Never name them predictably.
define( 'SCY_STATE_FILE', __DIR__ . '/sitecarry-{{TOKEN}}-state.json' );
define( 'SCY_WORK_SQL', __DIR__ . '/sitecarry-{{TOKEN}}-database.sql' );

@set_time_limit( 0 );
@ignore_user_abort( true );

{{SHARED}}

/* ===================================================================
 * Small helpers
 * ================================================================ */

/**
 * @return float Wall-clock moment this request should stop working.
 */
function scy_deadline() {
	$limit = (int) ini_get( 'max_execution_time' );
	$span  = $limit <= 0 ? 20.0 : min( max( $limit * 0.6, min( 8.0, $limit * 0.8 ) ), 20.0 );

	return microtime( true ) + $span;
}

/**
 * Sends a JSON response and stops.
 *
 * @param bool  $ok      Whether the action succeeded.
 * @param array $payload Response body.
 */
function scy_json( $ok, array $payload = array() ) {
	while ( ob_get_level() ) {
		ob_end_clean();
	}

	header( 'Content-Type: application/json; charset=utf-8' );
	header( 'X-Robots-Tag: noindex, nofollow' );

	echo json_encode( array_merge( array( 'ok' => $ok ), $payload ) );
	exit;
}

/**
 * @param string $message Why the restore cannot continue.
 */
function scy_fail( $message ) {
	scy_json( false, array( 'message' => $message ) );
}

/**
 * @return array Persisted progress.
 */
function scy_state() {
	if ( ! is_readable( SCY_STATE_FILE ) ) {
		return array();
	}

	$state = json_decode( (string) file_get_contents( SCY_STATE_FILE ), true );

	return is_array( $state ) ? $state : array();
}

/**
 * Persists progress. Credentials are never written here — they arrive with every
 * request instead, so a half-finished restore leaves no password on disk.
 *
 * @param array $state Progress to store.
 */
function scy_save_state( array $state ) {
	$temp = SCY_STATE_FILE . '.tmp';

	if ( false === file_put_contents( $temp, json_encode( $state ) ) || ! rename( $temp, SCY_STATE_FILE ) ) {
		scy_fail( 'Could not write to this folder. The web server needs permission to write here.' );
	}
}

/**
 * @param string $key     Field name.
 * @param string $default Value when absent.
 * @return string
 */
function scy_post( $key, $default = '' ) {
	return isset( $_POST[ $key ] ) && is_scalar( $_POST[ $key ] ) ? (string) $_POST[ $key ] : $default;
}

/**
 * Refuses every request that does not carry the package passphrase.
 *
 * A script that can overwrite an entire site must never be reachable without it —
 * this is the failure mode that has produced real vulnerabilities in other
 * migration plugins, where an installer left on a live server let anyone replace
 * the site.
 */
function scy_authorize() {
	if ( ! password_verify( scy_post( 'password' ), SCY_PASSWORD_HASH ) ) {
		// Slow down repeated guesses without holding a worker for long.
		usleep( 400000 );
		scy_json( false, array( 'message' => 'Incorrect passphrase.', 'unauthorized' => true ) );
	}
}

/**
 * Locates the package archive next to this installer.
 *
 * @return string Absolute path.
 */
function scy_locate( array $entry ) {
	if ( SCY_STAGED && ! empty( $entry['path'] ) && is_readable( $entry['path'] ) ) {
		return $entry['path'];
	}

	$beside = SCY_ROOT . '/' . $entry['name'];

	if ( is_readable( $beside ) ) {
		return $beside;
	}

	// A single-package restore is often renamed on the way to the server, so fall
	// back to the only zip sitting beside us. Never guess when a chain is involved:
	// applying archives in the wrong order would corrupt the site.
	if ( 1 === count( scy_chain() ) ) {
		$candidates = glob( SCY_ROOT . '/*.zip' );

		if ( is_array( $candidates ) && 1 === count( $candidates ) ) {
			return $candidates[0];
		}
	}

	return '';
}

/**
 * @return string[] Names of chain archives that are not here.
 */
function scy_missing_archives() {
	$missing = array();

	foreach ( scy_chain() as $entry ) {
		if ( '' === scy_locate( $entry ) ) {
			$missing[] = $entry['name'];
		}
	}

	return $missing;
}

/**
 * Whether every missing archive could be downloaded from storage.
 *
 * @return bool
 */
function scy_can_fetch() {
	$remote   = scy_remote();
	$provider = isset( $remote['provider'] ) ? $remote['provider'] : '';

	// Google Drive is reachable only with a token, and a token has no business in
	// a file that can end up at a public URL. Those archives are downloaded by hand.
	if ( 's3' !== $provider && 'ftp' !== $provider ) {
		return false;
	}

	foreach ( scy_chain() as $entry ) {
		if ( '' === scy_locate( $entry ) && empty( $entry['remote'] ) ) {
			return false;
		}
	}

	return true;
}

/**
 * @return string Where the archives were uploaded, for the operator to read.
 */
function scy_remote_label() {
	$remote = scy_remote();

	if ( isset( $remote['provider'] ) && 'ftp' === $remote['provider'] ) {
		return 'the FTP server at ' . ( isset( $remote['host'] ) ? $remote['host'] : 'storage' );
	}

	if ( isset( $remote['provider'] ) && 'drive' === $remote['provider'] ) {
		return 'Google Drive';
	}

	return isset( $remote['bucket'] ) ? $remote['bucket'] : 'storage';
}

/**
 * Builds a storage client from the credentials supplied with this request.
 *
 * @return Sitecarry_Storage
 */
function scy_storage_client() {
	$remote   = scy_remote();
	$provider = isset( $remote['provider'] ) ? $remote['provider'] : 's3';

	if ( 'ftp' === $provider ) {
		return new Sitecarry_Ftp(
			array(
				'host'     => isset( $remote['host'] ) ? $remote['host'] : '',
				'port'     => isset( $remote['port'] ) ? (int) $remote['port'] : 21,
				'user'     => scy_post( 'store_user' ),
				'password' => scy_post( 'store_secret' ),
				'ssl'      => ! empty( $remote['ssl'] ),
				'passive'  => ! isset( $remote['passive'] ) || $remote['passive'],
				// The keys already carry the folder, so it must not be added twice.
				'path'     => '',
			)
		);
	}

	return new Sitecarry_S3(
		array(
			'key'      => scy_post( 'store_user' ),
			'secret'   => scy_post( 'store_secret' ),
			'bucket'   => isset( $remote['bucket'] ) ? $remote['bucket'] : '',
			'region'   => isset( $remote['region'] ) && '' !== $remote['region'] ? $remote['region'] : 'us-east-1',
			'endpoint' => isset( $remote['endpoint'] ) ? $remote['endpoint'] : '',
		)
	);
}

/**
 * @return array The last entry in the chain, which holds the database and manifest.
 */
function scy_newest_entry() {
	$chain = scy_chain();

	if ( ! $chain ) {
		scy_fail( 'This installer has no package list, so there is nothing to restore.' );
	}

	return $chain[ count( $chain ) - 1 ];
}

/**
 * @param array $entry Chain entry.
 * @return ZipArchive
 */
function scy_open_archive( array $entry ) {
	$path = scy_locate( $entry );

	if ( '' === $path ) {
		scy_fail( 'Could not find ' . $entry['name'] . '. Upload every archive in this backup next to the installer.' );
	}

	$zip    = new ZipArchive();
	$opened = $zip->open( $path );

	if ( true !== $opened ) {
		scy_fail( 'The archive ' . $entry['name'] . ' could not be opened (code ' . $opened . '). It may be incomplete.' );
	}

	return $zip;
}

/**
 * Connects using credentials supplied with this request.
 *
 * @return mysqli
 */
function scy_connect() {
	$host = scy_post( 'db_host', 'localhost' );
	$port = null;

	// "localhost:3307" and "127.0.0.1:3307" are both common in shared hosting panels.
	if ( false !== strpos( $host, ':' ) ) {
		list( $host, $port ) = explode( ':', $host, 2 );
		$port                = (int) $port;
	}

	$db = @new mysqli( $host, scy_post( 'db_user' ), scy_post( 'db_pass' ), scy_post( 'db_name' ), $port ? $port : null );

	if ( $db->connect_errno ) {
		scy_fail( 'Database connection failed: ' . $db->connect_error );
	}

	$db->set_charset( 'utf8mb4' );

	return $db;
}

/**
 * Rejects archive entries that would write outside this directory.
 *
 * A zip entry name is attacker-controlled data. An entry called
 * `www/../../../../etc/cron.d/x` would, extracted naively, write anywhere the web
 * server can reach. This is the "zip slip" class of bug.
 *
 * @param string $relative Path from the archive, already stripped of its prefix.
 * @return string|false Absolute target path, or false when it must be refused.
 */
function scy_safe_target( $relative ) {
	if ( '' === $relative || false !== strpos( $relative, "\0" ) ) {
		return false;
	}

	if ( '/' === $relative[0] || '\\' === $relative[0] || preg_match( '#^[a-zA-Z]:#', $relative ) ) {
		return false;
	}

	if ( preg_match( '#(^|/)\.\.(/|$)#', $relative ) ) {
		return false;
	}

	$target = SCY_ROOT . '/' . $relative;

	// Belt and braces: normalise and confirm containment, so a form this check did
	// not anticipate still cannot escape.
	$parts      = array();
	$normalised = array();

	foreach ( explode( '/', str_replace( '\\', '/', $target ) ) as $part ) {
		if ( '..' === $part ) {
			array_pop( $parts );
		} elseif ( '.' !== $part && '' !== $part ) {
			$parts[] = $part;
		}
	}

	foreach ( explode( '/', SCY_ROOT ) as $part ) {
		if ( '' !== $part ) {
			$normalised[] = $part;
		}
	}

	$target_path = implode( '/', $parts );
	$root_path   = implode( '/', $normalised );

	return 0 === strpos( $target_path, $root_path . '/' ) ? $target : false;
}

/* ===================================================================
 * Steps
 * ================================================================ */

/**
 * Downloads any archive that is not on this server.
 *
 * This is the case a backup exists for: the original server is gone, and the only
 * copy is in storage. Downloaded in ranged chunks so a large archive survives the
 * same short execution limits as everything else, into a `.part` file so a partial
 * download is never mistaken for a complete one.
 *
 * @param array $state    Progress.
 * @param float $deadline Stop by this time.
 * @return array Updated progress.
 */
function scy_step_fetch( array $state, $deadline ) {
	$chain    = scy_chain();
	$position = isset( $state['fetch_index'] ) ? (int) $state['fetch_index'] : 0;

	while ( $position < count( $chain ) && microtime( true ) < $deadline ) {
		$entry = $chain[ $position ];

		if ( '' !== scy_locate( $entry ) ) {
			++$position;
			continue;
		}

		if ( empty( $entry['remote'] ) ) {
			scy_fail( $entry['name'] . ' is not here and was never uploaded to storage. Upload it next to this installer.' );
		}

		// Downloaded into a .part file so a transfer cut short is never mistaken
		// for a complete archive, and renamed only once it is whole.
		$partial = SCY_ROOT . '/' . $entry['name'] . '.part';

		try {
			$finished = scy_storage_client()->fetch_into( $entry['remote'], $partial, $deadline );
		} catch ( RuntimeException $e ) {
			scy_fail( 'Could not download ' . $entry['name'] . ': ' . $e->getMessage() );
		}

		if ( ! $finished ) {
			break;
		}

		if ( ! rename( $partial, SCY_ROOT . '/' . $entry['name'] ) ) {
			scy_fail( 'Downloaded ' . $entry['name'] . ' but could not put it in place.' );
		}

		$state['fetched'] = isset( $state['fetched'] ) ? (int) $state['fetched'] + 1 : 1;
		++$position;
	}

	$state['fetch_index'] = $position;

	if ( $position >= count( $chain ) ) {
		$state['step'] = 'prepare';
	}

	return $state;
}

/**
 * Reads the manifest and unpacks the SQL dump beside this installer.
 *
 * @param array $state    Progress.
 * @param float $deadline Stop by this time.
 * @return array Updated progress.
 */
function scy_step_prepare( array $state, $deadline ) {
	// The newest package carries the manifest and the database: the database is
	// exported in full every time, so only the last one is ever needed.
	$zip = scy_open_archive( scy_newest_entry() );

	if ( ! isset( $state['origin'] ) ) {
		$raw      = $zip->getFromName( 'manifest.json' );
		$manifest = $raw ? json_decode( $raw, true ) : null;

		if ( ! is_array( $manifest ) || ! isset( $manifest['site']['home_url'] ) ) {
			$zip->close();
			scy_fail( 'This archive has no readable Sitecarry manifest, so it cannot be restored.' );
		}

		$state['origin'] = array(
			'url'    => (string) $manifest['site']['home_url'],
			'path'   => (string) $manifest['site']['abspath'],
			'prefix' => isset( $manifest['database']['prefix'] ) ? (string) $manifest['database']['prefix'] : '',
			'files'  => isset( $manifest['files']['count'] ) ? (int) $manifest['files']['count'] : 0,
			'wp'     => isset( $manifest['site']['wp_version'] ) ? (string) $manifest['site']['wp_version'] : '',
		);

		$state['sql_offset'] = 0;
	}

	$stream = $zip->getStream( 'database.sql' );

	if ( ! $stream ) {
		$zip->close();
		scy_fail( 'The archive does not contain a database export.' );
	}

	$offset = (int) $state['sql_offset'];

	// Compressed zip entries cannot be seeked, so a resumed run re-reads and
	// discards what it already wrote. Costly only when a single request could not
	// finish the copy, which is rare even for large dumps.
	$skipped = 0;

	while ( $skipped < $offset && ! feof( $stream ) ) {
		$chunk    = fread( $stream, min( 1048576, $offset - $skipped ) );
		$skipped += strlen( $chunk );
	}

	$out = fopen( SCY_WORK_SQL, 0 === $offset ? 'wb' : 'ab' );

	if ( ! $out ) {
		fclose( $stream );
		$zip->close();
		scy_fail( 'Could not write the database export. Check that this folder is writable.' );
	}

	// A tick killed after appending but before its offset was saved leaves the work
	// file longer than the checkpoint; without cutting it back the same byte range is
	// written twice and the import later fails on the duplicated SQL.
	if ( $offset > 0 ) {
		ftruncate( $out, $offset );
	}

	$done = false;

	while ( microtime( true ) < $deadline ) {
		$chunk = fread( $stream, 1048576 );

		if ( '' === $chunk || false === $chunk ) {
			$done = true;
			break;
		}

		fwrite( $out, $chunk );
		$offset += strlen( $chunk );

		if ( feof( $stream ) ) {
			$done = true;
			break;
		}
	}

	fclose( $out );
	fclose( $stream );
	$zip->close();

	$state['sql_offset'] = $offset;

	if ( $done ) {
		$state['step']       = 'database';
		$state['sql_offset'] = 0;
	}

	return $state;
}

/**
 * Imports the SQL dump.
 *
 * @param array $state    Progress.
 * @param float $deadline Stop by this time.
 * @return array Updated progress.
 */
function scy_step_database( array $state, $deadline ) {
	$db     = scy_connect();
	$handle = fopen( SCY_WORK_SQL, 'rb' );

	if ( ! $handle ) {
		scy_fail( 'The unpacked database export has gone missing.' );
	}

	fseek( $handle, (int) $state['sql_offset'] );

	$reader   = new Sitecarry_Sql_Reader();
	$executed = isset( $state['statements'] ) ? (int) $state['statements'] : 0;
	$done     = false;

	while ( microtime( true ) < $deadline ) {
		$line = fgets( $handle );

		if ( false === $line ) {
			foreach ( $reader->flush() as $statement ) {
				scy_run_statement( $db, $statement );
				++$executed;
			}

			$done = true;
			break;
		}

		if ( Sitecarry_Sql_Reader::is_comment( $line ) ) {
			continue;
		}

		foreach ( $reader->feed( $line ) as $statement ) {
			scy_run_statement( $db, $statement );
			++$executed;
		}
	}

	// Back the offset up to the start of any statement still sitting unterminated in
	// the reader's buffer, so a multi-line statement cut across ticks is re-read
	// whole. ftell() alone is past those buffered lines and would lose the head of
	// the statement, wedging the restore on malformed SQL every retry.
	$state['sql_offset'] = $done ? 0 : ( ftell( $handle ) - $reader->buffered() );
	$state['statements'] = $executed;

	fclose( $handle );

	if ( $done ) {
		$state['step'] = 'files';
	}

	$db->close();

	return $state;
}

/**
 * @param mysqli $db        Connection.
 * @param string $statement SQL to run.
 */
function scy_run_statement( $db, $statement ) {
	if ( '' === trim( $statement ) ) {
		return;
	}

	if ( false === $db->query( $statement ) ) {
		scy_fail( 'Database import failed: ' . $db->error . ' — while running: ' . substr( $statement, 0, 160 ) );
	}
}

/**
 * Extracts the site files.
 *
 * @param array $state    Progress.
 * @param float $deadline Stop by this time.
 * @return array Updated progress.
 */
function scy_step_files( array $state, $deadline ) {
	$chain    = scy_chain();
	$position = isset( $state['chain_index'] ) ? (int) $state['chain_index'] : 0;

	if ( $position >= count( $chain ) ) {
		$state['step'] = 'replace';

		return $state;
	}

	$entry   = $chain[ $position ];
	$zip     = scy_open_archive( $entry );
	$index   = isset( $state['zip_index'] ) ? (int) $state['zip_index'] : 0;
	$total   = $zip->numFiles;
	$copied  = isset( $state['files_written'] ) ? (int) $state['files_written'] : 0;
	$refused = isset( $state['files_refused'] ) ? (array) $state['files_refused'] : array();

	while ( $index < $total && microtime( true ) < $deadline ) {
		$name = $zip->getNameIndex( $index );
		++$index;

		if ( false === $name || 0 !== strpos( $name, 'www/' ) ) {
			continue;
		}

		$relative = substr( $name, 4 );

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

		if ( '/' === substr( $name, -1 ) ) {
			$directory = scy_safe_target( rtrim( $relative, '/' ) );

			if ( $directory && ! is_dir( $directory ) ) {
				@mkdir( $directory, 0755, true );
			}

			continue;
		}

		$target = scy_safe_target( $relative );

		if ( false === $target ) {
			if ( count( $refused ) < 50 ) {
				$refused[] = $name;
			}

			continue;
		}

		$directory = dirname( $target );

		if ( ! is_dir( $directory ) && ! @mkdir( $directory, 0755, true ) ) {
			continue;
		}

		$source = $zip->getStream( $name );

		if ( ! $source ) {
			continue;
		}

		$out = @fopen( $target, 'wb' );

		if ( $out ) {
			stream_copy_to_stream( $source, $out );
			fclose( $out );
			++$copied;
		}

		fclose( $source );
	}

	$zip->close();

	$state['files_written'] = $copied;
	$state['files_refused'] = $refused;

	if ( $index < $total ) {
		$state['zip_index'] = $index;

		return $state;
	}

	// This archive is fully applied. Its deletions come after its files, so a file
	// removed at this point in history goes; one removed earlier and restored later
	// survives, because the later archive is applied after this one.
	$removed = isset( $state['files_removed'] ) ? (int) $state['files_removed'] : 0;

	if ( ! empty( $entry['deleted'] ) ) {
		foreach ( (array) $entry['deleted'] as $relative ) {
			$target = scy_safe_target( (string) $relative );

			if ( false !== $target && is_file( $target ) && @unlink( $target ) ) {
				++$removed;
			}
		}
	}

	$state['files_removed'] = $removed;
	$state['chain_index']   = $position + 1;
	$state['zip_index']     = 0;

	if ( $state['chain_index'] >= count( $chain ) ) {
		$state['step'] = 'replace';
	}

	return $state;
}

/**
 * Rewrites the old site's URLs and paths throughout the database.
 *
 * @param array $state    Progress.
 * @param float $deadline Stop by this time.
 * @return array Updated progress.
 */
function scy_step_replace( array $state, $deadline ) {
	$db  = scy_connect();
	$map = Sitecarry_Replacer::map(
		$state['origin']['url'],
		$state['target']['url'],
		$state['origin']['path'],
		SCY_ROOT
	);

	if ( ! $map ) {
		$state['step'] = 'config';
		$db->close();

		return $state;
	}

	if ( ! isset( $state['tables'] ) ) {
		$tables = array();
		$result = $db->query( 'SHOW TABLES' );

		while ( $result && ( $row = $result->fetch_row() ) ) {
			$tables[] = $row[0];
		}

		$state['tables']       = $tables;
		$state['table_index']  = 0;
		$state['row_offset']   = 0;
		$state['rows_changed'] = 0;
		$state['skipped_tables'] = array();
	}

	$tables = (array) $state['tables'];

	while ( microtime( true ) < $deadline ) {
		$position = (int) $state['table_index'];

		if ( $position >= count( $tables ) ) {
			$state['step'] = 'config';
			break;
		}

		$table   = $tables[ $position ];
		$columns = scy_replaceable_columns( $db, $table );

		if ( ! $columns['keys'] || ! $columns['text'] ) {
			// Without a primary key there is no safe way to address a single row for
			// an UPDATE, so the table is reported rather than guessed at.
			if ( ! $columns['keys'] ) {
				$state['skipped_tables'][] = $table;
			}

			$state['table_index'] = $position + 1;
			$state['row_offset']  = 0;
			continue;
		}

		$order  = '`' . implode( '`,`', $columns['keys'] ) . '`';
		$offset = (int) $state['row_offset'];
		$result = $db->query( 'SELECT * FROM `' . $table . '` ORDER BY ' . $order . ' LIMIT ' . $offset . ', 200' );

		if ( ! $result ) {
			$state['table_index'] = $position + 1;
			$state['row_offset']  = 0;
			continue;
		}

		$seen = 0;

		while ( $row = $result->fetch_assoc() ) {
			++$seen;
			$changes = array();

			foreach ( $columns['text'] as $column ) {
				if ( ! isset( $row[ $column ] ) ) {
					continue;
				}

				$replaced = Sitecarry_Replacer::replace( $row[ $column ], $map );

				if ( $replaced !== $row[ $column ] ) {
					$changes[ $column ] = $replaced;
				}
			}

			if ( ! $changes ) {
				continue;
			}

			$set = array();

			foreach ( $changes as $column => $value ) {
				$set[] = '`' . $column . '` = \'' . $db->real_escape_string( $value ) . '\'';
			}

			$where = array();

			foreach ( $columns['keys'] as $key ) {
				$where[] = '`' . $key . '` = \'' . $db->real_escape_string( (string) $row[ $key ] ) . '\'';
			}

			if ( $db->query( 'UPDATE `' . $table . '` SET ' . implode( ',', $set ) . ' WHERE ' . implode( ' AND ', $where ) . ' LIMIT 1' ) ) {
				++$state['rows_changed'];
			}
		}

		$result->free();

		$state['row_offset'] = $offset + $seen;

		if ( $seen < 200 ) {
			$state['table_index'] = $position + 1;
			$state['row_offset']  = 0;
		}
	}

	$db->close();

	return $state;
}

/**
 * Reports which columns of a table can be rewritten and how to address a row.
 *
 * @param mysqli $db    Connection.
 * @param string $table Table name.
 * @return array{keys:string[],text:string[]}
 */
function scy_replaceable_columns( $db, $table ) {
	$keys = array();
	$text = array();

	$result = $db->query( 'SHOW COLUMNS FROM `' . $table . '`' );

	while ( $result && ( $row = $result->fetch_assoc() ) ) {
		$type = strtolower( (string) $row['Type'] );

		if ( 'PRI' === $row['Key'] ) {
			$keys[] = $row['Field'];
		}

		// Only textual columns. A real binary column may contain byte sequences that
		// happen to match, and rewriting those corrupts the data.
		if ( preg_match( '/^(var)?char|text$|^(tiny|medium|long)text/', $type ) ) {
			$text[] = $row['Field'];
		}
	}

	if ( $result ) {
		$result->free();
	}

	return array(
		'keys' => $keys,
		'text' => $text,
	);
}

/**
 * Points wp-config.php at the new database and site address.
 *
 * @param array $state Progress.
 * @return array Updated progress.
 */
function scy_step_config( array $state ) {
	$path   = SCY_ROOT . '/wp-config.php';
	$config = is_readable( $path ) ? (string) file_get_contents( $path ) : '';

	if ( '' === $config ) {
		$config = scy_fresh_config( $state );
	} else {
		foreach ( array(
			'DB_NAME' => scy_post( 'db_name' ),
			'DB_USER' => scy_post( 'db_user' ),
			'DB_PASSWORD' => scy_post( 'db_pass' ),
			'DB_HOST' => scy_post( 'db_host', 'localhost' ),
		) as $constant => $value ) {
			// Matching the whole line rather than the closing bracket: a password
			// containing ")" would otherwise cut the replacement short.
			$config = preg_replace(
				'/^[ \t]*define\s*\(\s*([\'"])' . $constant . '\1.*$/m',
				"define( '" . $constant . "', '" . scy_php_string( $value ) . "' );",
				$config,
				1
			);
		}

		foreach ( array( 'WP_HOME', 'WP_SITEURL' ) as $constant ) {
			if ( preg_match( '/define\s*\(\s*([\'"])' . $constant . '\1/', $config ) ) {
				$config = preg_replace(
					'/^[ \t]*define\s*\(\s*([\'"])' . $constant . '\1.*$/m',
					"define( '" . $constant . "', '" . scy_php_string( $state['target']['url'] ) . "' );",
					$config,
					1
				);
			}
		}
	}

	if ( false === file_put_contents( $path, $config ) ) {
		scy_fail( 'Could not write wp-config.php. Restore the database credentials by hand before using the site.' );
	}

	$state['step'] = 'done';

	return $state;
}

/**
 * @param string $value Raw value.
 * @return string Safe inside a single-quoted PHP string.
 */
function scy_php_string( $value ) {
	return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $value );
}

/**
 * Builds a wp-config.php when the package did not carry one.
 *
 * @param array $state Progress.
 * @return string
 */
function scy_fresh_config( array $state ) {
	$keys   = '';
	$names  = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' );
	$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#%^&*()-_[]{}<>~`+=,.;:/?|';

	foreach ( $names as $name ) {
		$salt = '';

		for ( $i = 0; $i < 64; $i++ ) {
			$salt .= $alphabet[ random_int( 0, strlen( $alphabet ) - 1 ) ];
		}

		$keys .= "define( '" . $name . "', '" . scy_php_string( $salt ) . "' );\n";
	}

	return "<?php\n"
		. "/** Generated by Sitecarry because the package contained no wp-config.php. */\n"
		. "define( 'DB_NAME', '" . scy_php_string( scy_post( 'db_name' ) ) . "' );\n"
		. "define( 'DB_USER', '" . scy_php_string( scy_post( 'db_user' ) ) . "' );\n"
		. "define( 'DB_PASSWORD', '" . scy_php_string( scy_post( 'db_pass' ) ) . "' );\n"
		. "define( 'DB_HOST', '" . scy_php_string( scy_post( 'db_host', 'localhost' ) ) . "' );\n"
		. "define( 'DB_CHARSET', 'utf8mb4' );\n"
		. "define( 'DB_COLLATE', '' );\n"
		. $keys
		. '$table_prefix = \'' . scy_php_string( $state['origin']['prefix'] ) . "';\n"
		. "define( 'WP_DEBUG', false );\n"
		. "if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', __DIR__ . '/' ); }\n"
		. "require_once ABSPATH . 'wp-settings.php';\n";
}

/**
 * Removes every trace of the restore, including this installer.
 *
 * Leaving these behind is the real danger: the archive is a full copy of the
 * database, and this script can overwrite the site.
 *
 * @return array Report of what was removed.
 */
function scy_cleanup() {
	$removed = array();

	foreach ( array( SCY_STATE_FILE, SCY_WORK_SQL ) as $path ) {
		if ( file_exists( $path ) && @unlink( $path ) ) {
			$removed[] = basename( $path );
		}
	}

	// A staged in-place restore reads from the site's own backup storage. Those are
	// the user's backups, not copies uploaded for this restore, so they stay.
	if ( 'yes' === scy_post( 'remove_archive', 'yes' ) && ! SCY_STAGED ) {
		foreach ( scy_chain() as $entry ) {
			$archive = scy_locate( $entry );

			if ( '' !== $archive && file_exists( $archive ) && @unlink( $archive ) ) {
				$removed[] = basename( $archive );
			}
		}
	}

	$self = __FILE__;

	if ( @unlink( $self ) ) {
		$removed[] = basename( $self );
	}

	return $removed;
}

/* ===================================================================
 * Request handling
 * ================================================================ */

if ( isset( $_POST['scy_action'] ) ) {
	$action = scy_post( 'scy_action' );

	if ( 'preflight' === $action ) {
		scy_authorize();

		$chain   = scy_chain();
		$remote  = scy_remote();
		$missing = scy_missing_archives();
		$fetch   = $missing && scy_can_fetch();

		// Naming the missing files matters: a chain restore fails uselessly if the
		// operator only uploaded the newest archive, which is the obvious mistake.
		if ( ! $missing ) {
			$archives = count( $chain ) . ( 1 === count( $chain ) ? ' archive' : ' archives, all present' );
		} elseif ( $fetch ) {
			$archives = count( $missing ) . ' to download from storage';
		} else {
			$archives = count( $missing ) . ' missing: ' . implode( ', ', array_slice( $missing, 0, 4 ) );
		}

		$manifest = null;

		// The manifest lives in the newest archive, which may itself be one of the
		// files still to be downloaded.
		if ( ! $missing ) {
			$zip = scy_open_archive( scy_newest_entry() );
			$raw = $zip->getFromName( 'manifest.json' );
			$zip->close();

			$manifest = $raw ? json_decode( $raw, true ) : null;

			if ( ! is_array( $manifest ) ) {
				scy_fail( 'This archive has no readable Sitecarry manifest.' );
			}
		}

		$checks = array(
			array( 'PHP 7.4 or newer', version_compare( PHP_VERSION, '7.4', '>=' ), PHP_VERSION ),
			array( 'Zip extension', class_exists( 'ZipArchive' ), class_exists( 'ZipArchive' ) ? 'available' : 'missing' ),
			array( 'MySQL extension', class_exists( 'mysqli' ), class_exists( 'mysqli' ) ? 'available' : 'missing' ),
			array( 'This folder is writable', is_writable( SCY_ROOT ), SCY_ROOT ),
			array( 'Every archive in this backup', ! $missing || $fetch, $archives ),
		);

		scy_json(
			true,
			array(
				'checks'   => $checks,
				// Tells the page to ask for storage credentials before starting.
				'fetch'    => $fetch,
				'provider' => isset( $remote['provider'] ) ? $remote['provider'] : '',
				'where'    => scy_remote_label(),
				'blocked'  => $missing && ! $fetch,
				'manifest' => array(
					'url'     => isset( $manifest['site']['home_url'] ) ? $manifest['site']['home_url'] : '',
					'path'    => isset( $manifest['site']['abspath'] ) ? $manifest['site']['abspath'] : '',
					'files'   => isset( $manifest['files']['count'] ) ? (int) $manifest['files']['count'] : 0,
					'prefix'  => isset( $manifest['database']['prefix'] ) ? $manifest['database']['prefix'] : '',
					'created' => isset( $manifest['created_at'] ) ? $manifest['created_at'] : '',
					'wp'      => isset( $manifest['site']['wp_version'] ) ? $manifest['site']['wp_version'] : '',
				),
			)
		);
	}

	if ( 'dbtest' === $action ) {
		scy_authorize();

		$db     = scy_connect();
		$result = $db->query( 'SHOW TABLES' );
		$count  = $result ? $result->num_rows : 0;

		if ( $result ) {
			$result->free();
		}

		$db->close();

		scy_json(
			true,
			array(
				'tables'  => $count,
				'warning' => $count > 0
					? 'This database already contains ' . $count . ' tables. Any with the same names will be replaced.'
					: '',
			)
		);
	}

	if ( 'start' === $action ) {
		scy_authorize();

		$scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';
		$host   = isset( $_SERVER['HTTP_HOST'] ) ? preg_replace( '/[^A-Za-z0-9\-\.\:]/', '', (string) $_SERVER['HTTP_HOST'] ) : 'localhost';
		$folder = rtrim( str_replace( '\\', '/', dirname( (string) $_SERVER['SCRIPT_NAME'] ) ), '/' );

		// Always starts at fetch: it skips archives already here and falls straight
		// through, so there is one path rather than two.
		scy_save_state(
			array(
				'step'   => 'fetch',
				'target' => array(
					'url'  => scy_post( 'target_url', $scheme . '://' . $host . $folder ),
					'path' => SCY_ROOT,
				),
			)
		);

		scy_json( true, array( 'step' => 'prepare' ) );
	}

	if ( 'tick' === $action ) {
		scy_authorize();

		$state = scy_state();

		if ( ! $state ) {
			scy_fail( 'The restore has not been started, or its progress file was removed.' );
		}

		$deadline = scy_deadline();
		$labels   = array(
			'fetch'    => 'Downloading archives from storage',
			'prepare'  => 'Unpacking the database export',
			'database' => 'Importing the database',
			'files'    => 'Extracting site files',
			'replace'  => 'Updating URLs and paths',
			'config'   => 'Writing wp-config.php',
		);

		$order = array_keys( $labels );
		$step  = isset( $state['step'] ) ? $state['step'] : 'prepare';

		if ( 'done' !== $step ) {
			switch ( $step ) {
				case 'fetch':
					$state = scy_step_fetch( $state, $deadline );
					break;
				case 'prepare':
					$state = scy_step_prepare( $state, $deadline );
					break;
				case 'database':
					$state = scy_step_database( $state, $deadline );
					break;
				case 'files':
					$state = scy_step_files( $state, $deadline );
					break;
				case 'replace':
					$state = scy_step_replace( $state, $deadline );
					break;
				case 'config':
					$state = scy_step_config( $state );
					break;
				default:
					scy_fail( 'Unknown restore step: ' . $step );
			}

			scy_save_state( $state );
		}

		$now      = isset( $state['step'] ) ? $state['step'] : 'done';
		$position = array_search( $now, $order, true );
		$percent  = 'done' === $now ? 100 : (int) round( ( ( false === $position ? 0 : $position ) / count( $order ) ) * 100 );

		scy_json(
			true,
			array(
				'step'    => $now,
				'label'   => 'done' === $now ? 'Finished' : $labels[ $now ],
				'percent' => $percent,
				'stats'   => array(
					'files'    => isset( $state['files_written'] ) ? (int) $state['files_written'] : 0,
					'rows'     => isset( $state['rows_changed'] ) ? (int) $state['rows_changed'] : 0,
					'refused'  => isset( $state['files_refused'] ) ? count( (array) $state['files_refused'] ) : 0,
					'removed'  => isset( $state['files_removed'] ) ? (int) $state['files_removed'] : 0,
					'fetched'  => isset( $state['fetched'] ) ? (int) $state['fetched'] : 0,
					'archives' => count( scy_chain() ),
					'skipped'  => isset( $state['skipped_tables'] ) ? (array) $state['skipped_tables'] : array(),
				),
			)
		);
	}

	if ( 'finish' === $action ) {
		scy_authorize();

		$state = scy_state();

		scy_json(
			true,
			array(
				'removed' => scy_cleanup(),
				'login'   => ( isset( $state['target']['url'] ) ? $state['target']['url'] : '' ) . '/wp-login.php',
			)
		);
	}

	scy_fail( 'Unknown action.' );
}

header( 'X-Robots-Tag: noindex, nofollow' );

?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Sitecarry — Restore</title>
<style>
	:root { color-scheme: light dark; --bg:#f6f7f7; --panel:#fff; --ink:#1d2327; --muted:#646970; --line:#dcdcde; --accent:#2271b1; --bad:#d63638; --good:#00a32a; }
	@media (prefers-color-scheme: dark) {
		:root { --bg:#16181a; --panel:#1f2225; --ink:#e6e7e8; --muted:#9fa4a8; --line:#33373b; --accent:#4f94d4; }
	}
	* { box-sizing: border-box; }
	body { background: var(--bg); color: var(--ink); font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
	.wrap { margin: 0 auto; max-width: 720px; }
	h1 { font-size: 22px; margin: 0 0 4px; }
	h2 { font-size: 16px; margin: 0 0 12px; }
	p.sub { color: var(--muted); margin: 0 0 24px; }
	.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 20px; padding: 24px; }
	label { display: block; font-weight: 600; margin-bottom: 6px; }
	input[type=text], input[type=password] { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; color: var(--ink); font: inherit; padding: 9px 11px; width: 100%; }
	.field { margin-bottom: 16px; }
	.row { display: flex; gap: 16px; flex-wrap: wrap; }
	.row .field { flex: 1 1 220px; }
	button { background: var(--accent); border: 0; border-radius: 4px; color: #fff; cursor: pointer; font: inherit; font-weight: 600; padding: 11px 22px; }
	button[disabled] { cursor: not-allowed; opacity: .5; }
	.panel.warn { border-left: 4px solid var(--bad); }
	.panel.warn h2 { color: var(--bad); }
	.msg { border-left: 4px solid var(--bad); margin-top: 16px; padding: 10px 14px; }
	.msg.good { border-color: var(--good); }
	.bar { background: var(--line); border-radius: 999px; height: 10px; overflow: hidden; }
	.bar span { background: var(--accent); display: block; height: 100%; width: 0; transition: width .25s ease; }
	@media (prefers-reduced-motion: reduce) { .bar span { transition: none; } }
	ul.checks { list-style: none; margin: 0; padding: 0; }
	ul.checks li { border-bottom: 1px solid var(--line); display: flex; gap: 10px; justify-content: space-between; padding: 8px 0; }
	ul.checks li:last-child { border-bottom: 0; }
	.tag { color: var(--muted); font-size: 13px; }
	.ok::before { color: var(--good); content: "\2713 "; }
	.no::before { color: var(--bad); content: "\2717 "; }
	code { background: rgba(127,127,127,.15); border-radius: 3px; padding: 1px 5px; }
	[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="wrap">
	<h1>Sitecarry — Restore</h1>
	<p class="sub">Package <code><?php echo htmlspecialchars( SCY_PACKAGE_ID, ENT_QUOTES, 'UTF-8' ); ?></code>, created <?php echo htmlspecialchars( SCY_GENERATED, ENT_QUOTES, 'UTF-8' ); ?>.</p>

	<?php if ( SCY_STAGED ) : ?>
	<div class="panel warn">
		<h2>This replaces the site running here</h2>
		<p>You started this from the Sitecarry screen of the site in this folder, so the restore will overwrite its files and database with the contents of this package. There is no undo.</p>
		<p>Your stored backups are left alone. If you change your mind, close this page and use <strong>Remove staged installer</strong> on the Sitecarry screen.</p>
	</div>
	<?php endif; ?>

	<div class="panel" id="panel-auth">
		<h2>Passphrase</h2>
		<p class="sub">Sitecarry showed this when the package was built. It is required so that nobody else can use this installer to overwrite the site.</p>
		<div class="field">
			<label for="password">Package passphrase</label>
			<input type="password" id="password" autocomplete="off" spellcheck="false">
		</div>
		<button type="button" id="unlock">Unlock</button>
		<div class="msg" id="auth-error" hidden></div>
	</div>

	<div class="panel" id="panel-checks" hidden>
		<h2>Server check</h2>
		<ul class="checks" id="checks"></ul>
	</div>

	<div class="panel" id="panel-fetch" hidden>
		<h2>Storage credentials</h2>
		<p class="sub" id="fetch-note"></p>
		<div class="row">
			<div class="field"><label for="store_user" id="store_user_label">Access key</label><input type="text" id="store_user" autocomplete="off" spellcheck="false"></div>
			<div class="field"><label for="store_secret" id="store_secret_label">Secret key</label><input type="password" id="store_secret" autocomplete="off"></div>
		</div>
		<p class="sub">
			These are used only to download the archives, and are not written to disk.
			A key restricted to this one bucket is enough — it needs to read, nothing more.
		</p>
	</div>

	<div class="panel" id="panel-db" hidden>
		<h2>Target database</h2>
		<p class="sub">Everything in this database with matching table names will be replaced. Use an empty database unless you mean to overwrite an existing site.</p>
		<div class="row">
			<div class="field"><label for="db_host">Host</label><input type="text" id="db_host" value="localhost"></div>
			<div class="field"><label for="db_name">Database name</label><input type="text" id="db_name"></div>
		</div>
		<div class="row">
			<div class="field"><label for="db_user">User</label><input type="text" id="db_user"></div>
			<div class="field"><label for="db_pass">Password</label><input type="password" id="db_pass" autocomplete="off"></div>
		</div>
		<div class="field">
			<label for="target_url">New site address</label>
			<input type="text" id="target_url">
		</div>
		<button type="button" id="test">Test connection</button>
		<button type="button" id="run" hidden>Start restore</button>
		<div class="msg" id="db-message" hidden></div>
	</div>

	<div class="panel" id="panel-run" hidden>
		<h2>Restoring</h2>
		<div class="bar"><span id="fill"></span></div>
		<p id="run-status" role="status" aria-live="polite"></p>
		<div class="msg" id="run-error" hidden></div>
	</div>

	<div class="panel" id="panel-done" hidden>
		<h2>Done</h2>
		<div id="done-body"></div>
	</div>
</div>

<script>
( function () {
	'use strict';

	var $ = function ( id ) { return document.getElementById( id ); };
	var password = '';

	function creds( extra ) {
		var body = new FormData();
		body.append( 'password', password );
		body.append( 'db_host', $( 'db_host' ).value );
		body.append( 'db_name', $( 'db_name' ).value );
		body.append( 'db_user', $( 'db_user' ).value );
		body.append( 'db_pass', $( 'db_pass' ).value );
		body.append( 'target_url', $( 'target_url' ).value );
		body.append( 'store_user', $( 'store_user' ).value );
		body.append( 'store_secret', $( 'store_secret' ).value );
		Object.keys( extra || {} ).forEach( function ( key ) { body.append( key, extra[ key ] ); } );
		return body;
	}

	function call( action, extra ) {
		var body = creds( extra );
		body.append( 'scy_action', action );

		return fetch( window.location.href, { method: 'POST', body: body } )
			.then( function ( r ) { return r.json(); } )
			.then( function ( json ) {
				if ( ! json || ! json.ok ) {
					throw new Error( ( json && json.message ) || 'The server returned an unexpected response.' );
				}
				return json;
			} );
	}

	function show( id, message, good ) {
		var box = $( id );
		box.textContent = message;
		box.className = good ? 'msg good' : 'msg';
		box.hidden = ! message;
	}

	$( 'target_url' ).value = window.location.href.replace( /\/[^\/]*$/, '' );

	// A staged restore already knows the site it is running on. The database
	// password is deliberately not carried over — it has to be typed.
	var prefill = {{PREFILL}};

	Object.keys( prefill ).forEach( function ( field ) {
		var input = $( field );

		if ( input && prefill[ field ] ) {
			input.value = prefill[ field ];
		}
	} );

	$( 'unlock' ).addEventListener( 'click', function () {
		password = $( 'password' ).value;
		show( 'auth-error', '' );

		call( 'preflight', {} ).then( function ( data ) {
			var list = $( 'checks' );
			var blocked = false;
			list.innerHTML = '';

			data.checks.forEach( function ( check ) {
				var li = document.createElement( 'li' );
				var name = document.createElement( 'span' );
				var tag = document.createElement( 'span' );
				name.className = check[ 1 ] ? 'ok' : 'no';
				name.textContent = check[ 0 ];
				tag.className = 'tag';
				tag.textContent = check[ 2 ];
				li.appendChild( name );
				li.appendChild( tag );
				list.appendChild( li );
				if ( ! check[ 1 ] ) { blocked = true; }
			} );

			var li = document.createElement( 'li' );
			li.innerHTML = '<span>Package source</span><span class="tag">' +
				data.manifest.url.replace( /[<>&]/g, '' ) + ' · ' + data.manifest.files + ' files · WordPress ' +
				data.manifest.wp.replace( /[<>&]/g, '' ) + '</span>';
			list.appendChild( li );

			$( 'panel-auth' ).hidden = true;
			$( 'panel-checks' ).hidden = false;
			$( 'panel-db' ).hidden = blocked;

			var where = ( data.where || 'storage' ).replace( /[<>&]/g, '' );

			if ( data.fetch ) {
				$( 'panel-fetch' ).hidden = false;
				$( 'fetch-note' ).textContent =
					'Some archives are not on this server. They will be downloaded from ' +
					where + ' before the restore starts.';

				if ( 'ftp' === data.provider ) {
					$( 'store_user_label' ).textContent = 'FTP username';
					$( 'store_secret_label' ).textContent = 'FTP password';
				}
			}

			// Drive archives cannot be fetched — say what to do instead, rather than
			// leaving a failed check with no way forward.
			if ( data.blocked ) {
				show(
					'auth-error',
					'drive' === data.provider
						? 'Some archives are missing. Download them from ' + where +
							' in a browser and upload them next to this installer.'
						: 'Some archives are missing. Upload every archive in this backup next to this installer.'
				);
			}

			if ( blocked ) {
				show( 'auth-error', 'This server cannot run the restore until the failing checks above are resolved.' );
				$( 'panel-auth' ).hidden = false;
			}
		} ).catch( function ( error ) {
			show( 'auth-error', error.message );
		} );
	} );

	$( 'test' ).addEventListener( 'click', function () {
		show( 'db-message', '' );

		call( 'dbtest', {} ).then( function ( data ) {
			show( 'db-message', data.warning || 'Connected. The database is empty and ready.', ! data.warning );
			$( 'run' ).hidden = false;
		} ).catch( function ( error ) {
			show( 'db-message', error.message );
			$( 'run' ).hidden = true;
		} );
	} );

	function tick() {
		return call( 'tick', {} ).then( function ( data ) {
			$( 'fill' ).style.width = data.percent + '%';
			$( 'run-status' ).textContent = data.label + ' — ' + data.percent + '%' +
				( data.stats.fetched ? ' · ' + data.stats.fetched + ' archives downloaded' : '' ) +
				( data.stats.files ? ' · ' + data.stats.files + ' files' : '' ) +
				( data.stats.rows ? ' · ' + data.stats.rows + ' rows updated' : '' );

			if ( 'done' !== data.step ) {
				return tick();
			}

			return data;
		} );
	}

	$( 'run' ).addEventListener( 'click', function () {
		$( 'run' ).disabled = true;
		$( 'test' ).disabled = true;
		$( 'panel-run' ).hidden = false;
		show( 'run-error', '' );

		call( 'start', {} )
			.then( tick )
			.then( function ( data ) {
				return call( 'finish', {} ).then( function ( end ) {
					var notes = '';

					if ( data.stats.archives > 1 ) {
						notes += '<p>Replayed ' + data.stats.archives + ' archives in order.' +
							( data.stats.removed ? ' ' + data.stats.removed + ' files deleted since the base backup were removed again.' : '' ) +
							'</p>';
					}

					if ( data.stats.refused ) {
						notes += '<p>' + data.stats.refused + ' archive entries were refused because their paths pointed outside this folder.</p>';
					}

					if ( data.stats.skipped.length ) {
						notes += '<p>Tables without a primary key were left untouched: <code>' +
							data.stats.skipped.join( '</code>, <code>' ) + '</code></p>';
					}

					$( 'panel-run' ).hidden = true;
					$( 'panel-done' ).hidden = false;
					$( 'done-body' ).innerHTML =
						'<p>The site has been restored. ' + data.stats.rows + ' database rows were updated to the new address.</p>' +
						notes +
						'<p>Removed: <code>' + end.removed.join( '</code>, <code>' ) + '</code></p>' +
						'<p><strong>Log in and re-save Settings → Permalinks</strong> so the rewrite rules match this server.</p>' +
						'<p><a href="' + end.login + '">Go to the login page</a></p>';
				} );
			} )
			.catch( function ( error ) {
				show( 'run-error', error.message );
				$( 'run' ).disabled = false;
				$( 'test' ).disabled = false;
			} );
	} );
}() );
</script>
</body>
</html>
