<?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}}' );

/**
 * 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_ORIGIN_PREFIX', '{{PREFIX}}' );
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 );

/*
 * HTTP for the installer, which has no WordPress and so no HTTP API. cURL first,
 * a stream context after that: the installer runs on whatever server the site is
 * being rebuilt on, and "sorry, no cURL" is not an acceptable answer there.
 */
function sitecarry_installer_http( $method, $url, array $headers, $body, $timeout ) {
	return function_exists( 'curl_init' )
		? sitecarry_installer_http_curl( $method, $url, $headers, $body, $timeout )
		: sitecarry_installer_http_stream( $method, $url, $headers, $body, $timeout );
}

/**
 * @param string $method  HTTP verb.
 * @param string $url     Absolute URL.
 * @param array  $headers Request headers.
 * @param string $body    Request body.
 * @param int    $timeout Seconds.
 * @return array{code:int,body:string,headers:array}
 * @throws RuntimeException On transport failure.
 */
function sitecarry_installer_http_curl( $method, $url, array $headers, $body, $timeout ) {
	$lines = array();

	foreach ( $headers as $name => $value ) {
		$lines[] = $name . ': ' . $value;
	}

	$received = array();
	$handle   = curl_init();

	curl_setopt_array(
		$handle,
		array(
			CURLOPT_URL            => $url,
			CURLOPT_CUSTOMREQUEST  => $method,
			CURLOPT_HTTPHEADER     => $lines,
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_TIMEOUT        => $timeout,
			CURLOPT_POSTFIELDS     => $body,
			CURLOPT_HEADERFUNCTION => static function ( $curl, $line ) use ( &$received ) {
				$parts = explode( ':', $line, 2 );

				if ( 2 === count( $parts ) ) {
					$received[ strtolower( trim( $parts[0] ) ) ] = trim( $parts[1] );
				}

				return strlen( $line );
			},
		)
	);

	$result = curl_exec( $handle );
	$code   = (int) curl_getinfo( $handle, CURLINFO_RESPONSE_CODE );
	$error  = curl_error( $handle );

	curl_close( $handle );

	if ( false === $result ) {
		throw new RuntimeException( 'Could not reach ' . Sitecarry_Http::host_of( $url ) . ': ' . $error );
	}

	return array(
		'code'    => $code,
		'body'    => (string) $result,
		'headers' => $received,
	);
}

/**
 * @param string $method  HTTP verb.
 * @param string $url     Absolute URL.
 * @param array  $headers Request headers.
 * @param string $body    Request body.
 * @param int    $timeout Seconds.
 * @return array{code:int,body:string,headers:array}
 * @throws RuntimeException On transport failure.
 */
function sitecarry_installer_http_stream( $method, $url, array $headers, $body, $timeout ) {
	$lines = array();

	foreach ( $headers as $name => $value ) {
		$lines[] = $name . ': ' . $value;
	}

	$context = stream_context_create(
		array(
			'http' => array(
				'method'        => $method,
				'header'        => implode( "\r\n", $lines ),
				'content'       => $body,
				'timeout'       => $timeout,
				// Needed so a 4xx returns its body instead of throwing a warning
				// and losing the error message the service sent.
				'ignore_errors' => true,
			),
		)
	);

	$response = @file_get_contents( $url, false, $context );

	if ( false === $response ) {
		throw new RuntimeException( 'Could not reach ' . Sitecarry_Http::host_of( $url ) . ', and this server has no cURL.' );
	}

	$code     = 0;
	$received = array();

	foreach ( isset( $http_response_header ) ? $http_response_header : array() as $line ) {
		if ( preg_match( '#^HTTP/\S+\s+(\d{3})#', $line, $matches ) ) {
			$code = (int) $matches[1];
			continue;
		}

		$parts = explode( ':', $line, 2 );

		if ( 2 === count( $parts ) ) {
			$received[ strtolower( trim( $parts[0] ) ) ] = trim( $parts[1] );
		}
	}

	return array(
		'code'    => $code,
		'body'    => $response,
		'headers' => $received,
	);
}

{{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;
}

/**
 * The table prefix this restore is writing to.
 *
 * Defaults to the prefix the package was taken from, so the common case — restoring
 * a site onto a server of its own — behaves exactly as it always has.
 *
 * @param array $state Restore state, for the origin fallback.
 * @return string A validated prefix.
 */
function scy_target_prefix( array $state ) {
	// The state only knows the origin once the archive has been opened, but the
	// database screen needs a default before that, so the builder bakes it in.
	$origin = isset( $state['origin']['prefix'] ) && '' !== $state['origin']['prefix']
		? (string) $state['origin']['prefix']
		: SCY_ORIGIN_PREFIX;

	$asked = trim( scy_post( 'target_prefix', '' ) );

	if ( '' === $asked ) {
		return $origin;
	}

	if ( ! Sitecarry_Prefix::is_valid( $asked ) ) {
		scy_fail( 'The table prefix may only contain letters, numbers and underscores.' );
	}

	return $asked;
}

/**
 * Points one SQL statement at a different table prefix.
 *
 * A thin seam over the shared implementation, kept so the calls below read the
 * same as they always did.
 *
 * @param string $statement One complete SQL statement.
 * @param string $from      Prefix the dump was written with.
 * @param string $to        Prefix to write instead.
 * @return string
 */
function scy_retable( $statement, $from, $to ) {
	return Sitecarry_Prefix::retable( $statement, $from, $to );
}

/**
 * Rewrites the handful of values that carry the prefix inside the data.
 *
 * WordPress stores the prefix in two places that are not table names: the
 * `<prefix>user_roles` option, and every `<prefix>*` key in usermeta — which is
 * where a user's capabilities live. Miss these and the restore succeeds, the site
 * loads, and every account has lost its role.
 *
 * @param mysqli $db   Open connection.
 * @param string $from Origin prefix.
 * @param string $to   Target prefix.
 * @return void
 */
function scy_reprefix_data( $db, $from, $to ) {
	$updates = Sitecarry_Prefix::data_updates(
		$from,
		$to,
		static function ( $value ) use ( $db ) {
			return $db->real_escape_string( $value );
		}
	);

	foreach ( $updates as $sql ) {
		$db->query( $sql );
	}
}

/**
 * Table names in this database that belong to the restore.
 *
 * Scoped to the target prefix on purpose. Before prefixes were supported the
 * restore assumed it owned the whole database, which was true only because it
 * insisted on an empty one — restoring alongside another site would have rewritten
 * that site's URLs too.
 *
 * @param mysqli $db     Open connection.
 * @param string $prefix Target prefix.
 * @return string[]
 */
function scy_owned_tables( $db, $prefix ) {
	$tables = array();

	if ( '' === $prefix ) {
		$result = $db->query( 'SHOW TABLES' );
	} else {
		$like   = Sitecarry_Prefix::like( $prefix );
		$result = $db->query( "SHOW TABLES LIKE '" . $db->real_escape_string( $like ) . "%'" );
	}

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

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

	return $tables;
}

/**
 * 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 ) {
	$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;

		// Settled once, at the start, and carried in the state from here on: the
		// operator must not be able to change it halfway through a resumed restore.
		$state['target_prefix'] = scy_target_prefix( $state );
	}

	$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'] );

	// The export turns foreign key checks off in its first line. That is a session
	// setting and this import spans requests, so by the second tick it is back on
	// and a CREATE TABLE referencing a table the dump has not reached yet fails
	// outright. Per tick is the only scope that covers the work being done.
	$db->query( 'SET FOREIGN_KEY_CHECKS = 0' );

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

	$from = isset( $state['origin']['prefix'] ) ? (string) $state['origin']['prefix'] : '';
	$to   = isset( $state['target_prefix'] ) ? (string) $state['target_prefix'] : $from;

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

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

			$done = true;
			break;
		}

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

		foreach ( $reader->feed( $line ) as $statement ) {
			scy_run_statement( $db, $statement, $from, $to );
			++$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;

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

	fclose( $handle );

	if ( $done ) {
		// Runs once, after every statement is in: the prefix also lives inside the
		// data, and a restore that moves it without fixing those loses every role.
		scy_reprefix_data( $db, $from, $to );
		$state['step'] = 'files';
	}

	$db->close();

	return $state;
}

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

	$statement = scy_retable( $statement, $from, $to );

	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'] ) ) {
		// Only the tables this restore wrote. Rewriting URLs across every table in
		// the database would reach into any other site sharing it.
		$tables = scy_owned_tables(
			$db,
			isset( $state['target_prefix'] ) ? (string) $state['target_prefix'] : ''
		);

		$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
				);
			}
		}

		// The config that came out of the archive still names the prefix the package
		// was taken from. Left alone, WordPress would look for tables this restore
		// never wrote and greet the visitor with a fresh install screen.
		$target = isset( $state['target_prefix'] ) ? (string) $state['target_prefix'] : '';

		if ( '' !== $target && $target !== (string) $state['origin']['prefix'] ) {
			$config = preg_replace(
				'/^[ \t]*\$table_prefix\s*=.*$/m',
				'$table_prefix = \'' . scy_php_string( $target ) . "';",
				$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( isset( $state['target_prefix'] ) ? $state['target_prefix'] : $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 );
		}
	}

	// The archives beside this installer were uploaded (or downloaded) for this
	// restore, so they go with it. A site's own stored backups live in the uploads
	// folder and are never touched.
	if ( 'yes' === scy_post( 'remove_archive', 'yes' ) ) {
		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();
		$state = scy_state();

		// Counted at the prefix the restore will actually write to, not across the
		// whole database: with a prefix of its own, a restore can sit beside another
		// site without touching it, and warning about that site's tables would be
		// alarming and wrong.
		$prefix = isset( $state['target_prefix'] ) ? (string) $state['target_prefix'] : scy_target_prefix( $state );
		$count  = count( scy_owned_tables( $db, $prefix ) );
		$shared = count( scy_owned_tables( $db, '' ) ) - $count;

		$db->close();

		if ( $count > 0 ) {
			$warning = 'This database already contains ' . $count . ' table' . ( 1 === $count ? '' : 's' )
				. ' with the prefix ' . $prefix . '. They will be replaced.';
		} elseif ( $shared > 0 ) {
			$warning = 'The database holds ' . $shared . ' table' . ( 1 === $shared ? '' : 's' )
				. ' under other prefixes. Nothing outside ' . $prefix . ' will be touched.';
		} else {
			$warning = '';
		}

		scy_json(
			true,
			array(
				'tables'  => $count,
				'prefix'  => $prefix,
				'warning' => $warning,
			)
		);
	}

	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; }
	.hint { color: var(--muted); font-size: 13px; line-height: 1.45; margin: 6px 0 0; }
	.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>

	<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="row">
			<div class="field">
				<label for="target_url">New site address</label>
				<input type="text" id="target_url">
			</div>
			<div class="field">
				<label for="target_prefix">Table prefix</label>
				<input type="text" id="target_prefix" value="<?php echo htmlspecialchars( SCY_ORIGIN_PREFIX, ENT_QUOTES, 'UTF-8' ); ?>" spellcheck="false">
				<p class="hint">Leave this alone unless the database already holds another site. Only tables with this prefix are written or changed.</p>
			</div>
		</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( 'target_prefix', $( 'target_prefix' ).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( /\/[^\/]*$/, '' );

	$( '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>
