<?php
/**
 * BTFU — standalone rescue & restore engine.
 *
 * This file has ZERO WordPress dependencies. It is the single restore code
 * path: normal in-WP restores include it (with BTFU_RESCUE_EMBEDDED defined)
 * and call btfu_rescue_restore(); emergencies run it directly from the CLI or,
 * password-gated, over the web — even when WordPress itself is a white screen.
 *
 * Generated: {{GENERATED_UTC}}
 * Expires:   {{EXPIRES_HUMAN}} (standalone modes refuse to run after this; the
 *            embedded mode never expires — WordPress regenerates it per use)
 *
 * CLI usage:
 *   php <this-file> --list
 *   php <this-file> --show <id>
 *   php <this-file> --verify <id>
 *   php <this-file> --restore <id> [--strict] [--yes]
 *
 * @package BackTheFUp
 */

/* ---------------------------------------------------------------------------
 * Discovery
 * ------------------------------------------------------------------------ */

/**
 * Walk upward from a directory looking for wp-config.php.
 *
 * @param string $start Starting directory.
 * @return string|false
 */
function btfu_rescue_find_config( $start ) {
	$dir = rtrim( str_replace( '\\', '/', $start ), '/' );

	for ( $i = 0; $i < 6; $i++ ) {
		if ( is_readable( $dir . '/wp-config.php' ) ) {
			return $dir . '/wp-config.php';
		}

		// The config-one-level-up layout: wp-config.php beside the WP dir.
		$parent = dirname( $dir );

		if ( $parent === $dir ) {
			break;
		}

		$dir = $parent;
	}

	return false;
}

/**
 * Parse wp-config.php WITHOUT including it.
 *
 * Including it would bootstrap WordPress — the exact fatal we may be
 * recovering from. token_get_all() reads it as text instead.
 *
 * @param string $file Path to wp-config.php.
 * @return array|false {db_name,db_user,db_password,db_host,table_prefix} or false.
 */
function btfu_rescue_parse_config( $file ) {
	$source = @file_get_contents( $file );

	if ( false === $source ) {
		return false;
	}

	$tokens = token_get_all( $source );
	$wanted = array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST' );
	$out    = array( 'table_prefix' => 'wp_' );

	$count = count( $tokens );

	for ( $i = 0; $i < $count; $i++ ) {
		$token = $tokens[ $i ];

		if ( ! is_array( $token ) ) {
			continue;
		}

		// define('DB_NAME', 'value')
		if ( T_STRING === $token[0] && 'define' === strtolower( $token[1] ) ) {
			$args = btfu_rescue_call_args( $tokens, $i );

			if ( 2 === count( $args ) && in_array( $args[0], $wanted, true ) ) {
				$out[ strtolower( $args[0] ) ] = $args[1];
			}
		}

		// $table_prefix = 'wp_';
		if ( T_VARIABLE === $token[0] && '$table_prefix' === $token[1] ) {
			for ( $j = $i + 1; $j < $count && $j < $i + 6; $j++ ) {
				if ( is_array( $tokens[ $j ] ) && T_CONSTANT_ENCAPSED_STRING === $tokens[ $j ][0] ) {
					$out['table_prefix'] = btfu_rescue_unquote( $tokens[ $j ][1] );
					break;
				}

				if ( ';' === $tokens[ $j ] ) {
					break;
				}
			}
		}
	}

	foreach ( array( 'db_name', 'db_user', 'db_password', 'db_host' ) as $key ) {
		if ( ! array_key_exists( $key, $out ) ) {
			return false;
		}
	}

	return $out;
}

/**
 * Collect the constant-string arguments of a call starting at token $i.
 *
 * Only literal strings count; a define() using concatenation or a variable is
 * returned incomplete and the caller ignores it.
 *
 * @param array $tokens Token stream.
 * @param int   $i      Index of the function-name token.
 * @return array
 */
function btfu_rescue_call_args( $tokens, $i ) {
	$args  = array();
	$count = count( $tokens );
	$depth = 0;

	for ( $j = $i + 1; $j < $count; $j++ ) {
		$token = $tokens[ $j ];

		if ( '(' === $token ) {
			$depth++;
			continue;
		}

		if ( ')' === $token ) {
			$depth--;

			if ( $depth <= 0 ) {
				break;
			}

			continue;
		}

		if ( 0 === $depth ) {
			continue;
		}

		if ( is_array( $token ) && T_CONSTANT_ENCAPSED_STRING === $token[0] ) {
			$args[] = btfu_rescue_unquote( $token[1] );
		}
	}

	return $args;
}

/**
 * Strip quotes and resolve escapes in a PHP string literal.
 *
 * @param string $literal Quoted literal, e.g. "'wp_'".
 * @return string
 */
function btfu_rescue_unquote( $literal ) {
	$quote = $literal[0];
	$inner = substr( $literal, 1, -1 );

	if ( "'" === $quote ) {
		return str_replace( array( "\\'", '\\\\' ), array( "'", '\\' ), $inner );
	}

	return stripcslashes( $inner );
}

/**
 * Find snapshot stores near a WordPress root.
 *
 * @param string $abspath WordPress root.
 * @return array Store paths.
 */
function btfu_rescue_find_stores( $abspath ) {
	$abspath = rtrim( str_replace( '\\', '/', $abspath ), '/' );
	$stores  = array();

	foreach ( array( dirname( $abspath ), $abspath . '/wp-content' ) as $parent ) {
		$matches = glob( $parent . '/.btfu-store-*', GLOB_ONLYDIR );

		if ( $matches ) {
			$stores = array_merge( $stores, $matches );
		}
	}

	sort( $stores );

	return $stores;
}

/**
 * Load a store's index.json (falling back to .bak).
 *
 * @param string $store Store path.
 * @return array|false
 */
function btfu_rescue_load_index( $store ) {
	foreach ( array( '/index.json', '/index.json.bak' ) as $name ) {
		$raw = @file_get_contents( $store . $name );

		if ( false !== $raw ) {
			$data = json_decode( $raw, true );

			if ( is_array( $data ) && isset( $data['snapshots'] ) ) {
				return $data;
			}
		}
	}

	return false;
}

/* ---------------------------------------------------------------------------
 * Exclude matching (self-contained port of BTFU_Excludes semantics)
 * ------------------------------------------------------------------------ */

/**
 * Patterns that must survive every restore's delete pass no matter what the
 * manifest says.
 *
 * @return array
 */
function btfu_rescue_mandatory_excludes() {
	// The plugin directory is protected on both sides: never copied into a
	// snapshot, never deleted by the restore delete pass.
	return array( '.btfu-store-*/', 'btfu-rescue-*.php', '.btfu-canary-*', '/plugins/back-the-f-up/' );
}

/**
 * Glob match with an fnmatch fallback.
 *
 * @param string $pattern Pattern.
 * @param string $subject Subject.
 * @return bool
 */
function btfu_rescue_glob( $pattern, $subject ) {
	if ( function_exists( 'fnmatch' ) ) {
		return fnmatch( $pattern, $subject );
	}

	$regex = '#^' . str_replace(
		array( '\*', '\?' ),
		array( '[^/]*', '[^/]' ),
		preg_quote( $pattern, '#' )
	) . '$#';

	return (bool) preg_match( $regex, $subject );
}

/**
 * Should $relative be excluded, per one pattern list?
 *
 * Mirrors BTFU_Excludes::excludes(): trailing slash = directory rule, leading
 * slash or embedded slash = anchored, otherwise matches basename or any
 * ancestor segment at any depth.
 *
 * @param array  $patterns Patterns.
 * @param string $relative Path relative to wp-content, forward slashes.
 * @param bool   $is_dir   Whether it is a directory.
 * @return bool
 */
function btfu_rescue_excluded( $patterns, $relative, $is_dir ) {
	$relative = ltrim( str_replace( '\\', '/', $relative ), '/' );
	$basename = basename( $relative );

	foreach ( $patterns as $pattern ) {
		$pattern = trim( $pattern );

		if ( '' === $pattern || '#' === $pattern[0] ) {
			continue;
		}

		$dir_only = ( '/' === substr( $pattern, -1 ) );
		$pattern  = rtrim( $pattern, '/' );

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

		$anchored = ( '/' === $pattern[0] );
		$pattern  = ltrim( $pattern, '/' );

		if ( $anchored || false !== strpos( $pattern, '/' ) ) {
			if ( ( ( ! $dir_only || $is_dir ) && btfu_rescue_glob( $pattern, $relative ) )
				|| 0 === strpos( $relative, $pattern . '/' ) ) {
				return true;
			}

			continue;
		}

		if ( ! $dir_only || $is_dir ) {
			if ( btfu_rescue_glob( $pattern, $basename ) ) {
				return true;
			}
		}

		// Ancestor directory segments.
		$segments = explode( '/', $relative );
		array_pop( $segments );

		foreach ( $segments as $segment ) {
			if ( btfu_rescue_glob( $pattern, $segment ) ) {
				return true;
			}
		}
	}

	return false;
}

/* ---------------------------------------------------------------------------
 * File restore
 * ------------------------------------------------------------------------ */

/**
 * Copy the snapshot's file tree over the live wp-content.
 *
 * @param string $src Snapshot files dir.
 * @param string $dst Live wp-content.
 * @param array  $log Log lines, by reference.
 * @return int Files placed.
 */
function btfu_rescue_copy_tree( $src, $dst, &$log ) {
	$cursor = array( 'queue' => array( '' ), 'placed' => 0 );
	while ( ! btfu_rescue_copy_tree_step( $src, $dst, $cursor, null, $log ) ) {
		// null deadline runs to completion in one call.
	}
	return $cursor['placed'];
}

/**
 * Resumable copy pass.
 *
 * @param string     $src      Snapshot files dir.
 * @param string     $dst      Live wp-content.
 * @param array      $cursor   { queue:string[], placed:int } (by reference).
 * @param float|null $deadline microtime(true) budget; null = run to completion.
 * @param array      $log      Log lines (by reference).
 * @return bool True when the whole tree is placed.
 */
function btfu_rescue_restore_one_file( $src, $dst, &$log ) {
	$dir = dirname( $dst );

	if ( ! is_dir( $dir ) && ! @mkdir( $dir, 0755, true ) && ! is_dir( $dir ) ) {
		$log[] = 'ERROR could not create ' . $dir;
		return false;
	}

	if ( ! @copy( $src, $dst ) ) {
		$log[] = 'ERROR could not copy ' . $dst;
		return false;
	}

	$mtime = @filemtime( $src );

	if ( $mtime ) {
		@touch( $dst, $mtime );
	}

	return true;
}

function btfu_rescue_copy_tree_step( $src, $dst, &$cursor, $deadline, &$log ) {
	while ( ! empty( $cursor['queue'] ) ) {
		if ( null !== $deadline && microtime( true ) >= $deadline ) {
			return false;
		}

		$rel     = array_pop( $cursor['queue'] );
		$src_dir = ( '' === $rel ) ? $src : $src . '/' . $rel;
		$entries = @scandir( $src_dir );

		if ( false === $entries ) {
			$log[] = 'WARN unreadable snapshot dir: ' . $rel;
			continue;
		}


		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}

			$r = ( '' === $rel ) ? $entry : $rel . '/' . $entry;
			$s = $src . '/' . $r;
			$d = $dst . '/' . $r;

			if ( is_link( $s ) ) {
				$target = @readlink( $s );

				if ( is_link( $d ) && @readlink( $d ) === $target ) {
					$cursor['placed']++;
					continue;
				}

				if ( file_exists( $d ) || is_link( $d ) ) {
					btfu_rescue_rm( $d );
				}

				if ( false !== $target && @symlink( $target, $d ) ) {
					$cursor['placed']++;
				} else {
					$log[] = 'WARN symlink failed: ' . $r;
				}

				continue;
			}

			if ( is_dir( $s ) ) {
				if ( is_link( $d ) || ( file_exists( $d ) && ! is_dir( $d ) ) ) {
					btfu_rescue_rm( $d );
				}

				if ( ! is_dir( $d ) && ! @mkdir( $d, 0755, true ) ) {
					$log[] = 'WARN mkdir failed: ' . $r;
					continue;
				}

				$cursor['queue'][] = $r;
				continue;
			}

			// Regular file. Skip the copy when the live file is already
			// identical (size+mtime) — the common case after a short trip.
			$ss = @stat( $s );
			$ds = ( is_file( $d ) && ! is_link( $d ) ) ? @stat( $d ) : false;

			if ( $ss && $ds && $ss['size'] === $ds['size'] && $ss['mtime'] === $ds['mtime'] ) {
				$cursor['placed']++;
				continue;
			}

			if ( is_link( $d ) || is_dir( $d ) ) {
				btfu_rescue_rm( $d );
			}

			if ( ! @copy( $s, $d ) ) {
				// The usual cause is an existing file we may not write to
				// (read-only, or owned by another user such as root) inside
				// a directory we may write to. Removing it and re-copying
				// needs only the directory permission.
				if ( file_exists( $d ) && @unlink( $d ) && @copy( $s, $d ) ) {
					$log[] = 'WARN replaced unwritable file: ' . $r;
				} else {
					$log[] = 'ERROR copy failed: ' . $r;
					continue;
				}
			}

			if ( $ss ) {
				@chmod( $d, $ss['mode'] & 0777 );
				@touch( $d, $ss['mtime'] );
			}

			$cursor['placed']++;
		}
	}

	return true;
}

/**
 * Delete everything in live wp-content that the snapshot does not contain and
 * the excludes do not protect.
 *
 * @param string $live     Live wp-content.
 * @param string $snapshot Snapshot files dir.
 * @param array  $patterns Exclude patterns (manifest + mandatory).
 * @param array  $log      Log lines, by reference.
 * @return int Entries removed.
 */
function btfu_rescue_delete_pass( $live, $snapshot, $patterns, &$log ) {
	$cursor = array( 'queue' => array( '' ), 'removed' => 0 );
	while ( ! btfu_rescue_delete_pass_step( $live, $snapshot, $patterns, $cursor, null, $log ) ) {
		// null deadline runs to completion.
	}
	return $cursor['removed'];
}

/**
 * Resumable delete pass. Must run only AFTER the copy pass is fully complete.
 *
 * @param string     $live     Live wp-content.
 * @param string     $snapshot Snapshot files dir.
 * @param array      $patterns Exclude patterns.
 * @param array      $cursor   { queue:string[], removed:int } (by reference).
 * @param float|null $deadline microtime(true) budget; null = run to completion.
 * @param array      $log      Log lines (by reference).
 * @return bool True when the whole live tree has been swept.
 */
function btfu_rescue_delete_pass_step( $live, $snapshot, $patterns, &$cursor, $deadline, &$log ) {
	while ( ! empty( $cursor['queue'] ) ) {
		if ( null !== $deadline && microtime( true ) >= $deadline ) {
			return false;
		}

		$rel      = array_pop( $cursor['queue'] );
		$live_dir = ( '' === $rel ) ? $live : $live . '/' . $rel;
		$entries  = @scandir( $live_dir );

		if ( false === $entries ) {
			continue;
		}

		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}

			$r      = ( '' === $rel ) ? $entry : $rel . '/' . $entry;
			$l      = $live . '/' . $r;
			$is_dir = is_dir( $l ) && ! is_link( $l );

			if ( btfu_rescue_excluded( $patterns, $r, $is_dir ) ) {
				continue;
			}

			$in_snap = file_exists( $snapshot . '/' . $r ) || is_link( $snapshot . '/' . $r );

			if ( ! $in_snap ) {
				btfu_rescue_rm( $l );
				$cursor['removed']++;
				continue;
			}

			if ( $is_dir ) {
				$cursor['queue'][] = $r;
			}
		}
	}

	return true;
}

/**
 * Remove a file, link, or directory tree.
 *
 * @param string $path Path.
 * @return void
 */
function btfu_rescue_rm( $path ) {
	if ( is_link( $path ) || is_file( $path ) ) {
		@unlink( $path );
		return;
	}

	if ( ! is_dir( $path ) ) {
		return;
	}

	$entries = @scandir( $path );

	foreach ( (array) $entries as $entry ) {
		if ( '.' !== $entry && '..' !== $entry ) {
			btfu_rescue_rm( $path . '/' . $entry );
		}
	}

	@rmdir( $path );
}

/* ---------------------------------------------------------------------------
 * Database restore
 * ------------------------------------------------------------------------ */

/**
 * Connect to MySQL from parsed config.
 *
 * @param array $cfg Parsed wp-config values.
 * @return mysqli|string mysqli on success, error string on failure.
 */
function btfu_rescue_connect( $cfg ) {
	$host   = $cfg['db_host'];
	$port   = null;
	$socket = null;

	$colon = strpos( $host, ':' );

	if ( false !== $colon ) {
		$after = substr( $host, $colon + 1 );
		$host  = substr( $host, 0, $colon );

		if ( is_numeric( $after ) ) {
			$port = (int) $after;
		} elseif ( '' !== $after ) {
			$socket = $after;
		}
	}

	mysqli_report( MYSQLI_REPORT_OFF );

	$mysqli = @mysqli_connect( $host, $cfg['db_user'], $cfg['db_password'], $cfg['db_name'], $port, $socket );

	if ( ! $mysqli ) {
		return 'MySQL connection failed: ' . mysqli_connect_error();
	}

	mysqli_set_charset( $mysqli, 'utf8mb4' );

	return $mysqli;
}

/**
 * Stream a dump file (.sql or .sql.gz) statement by statement.
 *
 * Both dump engines escape newlines inside values, so a raw newline only ever
 * separates lines of SQL — which makes line-based assembly sound: accumulate
 * until a line ends with ';'.
 *
 * @param string   $file     Dump path.
 * @param callable $callback Receives each complete statement.
 * @return true|string true or error string.
 */
function btfu_rescue_sql_stream( $file, $callback ) {
	$gz     = ( '.gz' === substr( $file, -3 ) );
	$handle = $gz ? @gzopen( $file, 'rb' ) : @fopen( $file, 'rb' );

	if ( ! $handle ) {
		return 'cannot open dump: ' . $file;
	}

	$statement = '';

	while ( true ) {
		// No length cap: mysqldump extended-INSERT lines routinely exceed 1 MiB,
		// and a partial read could split a statement and corrupt the restore.
		$line = $gz ? gzgets( $handle ) : fgets( $handle );

		if ( false === $line ) {
			break;
		}

		$trimmed = trim( $line );

		if ( '' === $trimmed || 0 === strpos( $trimmed, '--' ) || 0 === strpos( $trimmed, '/*!' ) ) {
			// Comments and conditional directives: skip when standalone.
			if ( '' === $statement ) {
				continue;
			}
		}

		$statement .= $line;

		if ( ';' === substr( rtrim( $line ), -1 ) ) {
			call_user_func( $callback, $statement );
			$statement = '';
		}
	}

	$gz ? gzclose( $handle ) : fclose( $handle );

	return true;
}

/**
 * Verify a dump ends with a completion marker before trusting it.
 *
 * @param string $file Dump path.
 * @return bool
 */
function btfu_rescue_dump_complete( $file ) {
	$gz   = ( '.gz' === substr( $file, -3 ) );
	$tail = '';

	if ( $gz ) {
		$handle = @gzopen( $file, 'rb' );

		if ( ! $handle ) {
			return false;
		}

		while ( ! gzeof( $handle ) ) {
			$chunk = gzread( $handle, 65536 );

			if ( false === $chunk ) {
				gzclose( $handle );

				return false;
			}

			$tail = substr( $tail . $chunk, -512 );
		}

		gzclose( $handle );
	} else {
		$handle = @fopen( $file, 'rb' );

		if ( ! $handle ) {
			return false;
		}

		$size = filesize( $file );
		fseek( $handle, max( 0, $size - 512 ) );
		$tail = (string) fread( $handle, 512 );
		fclose( $handle );
	}

	return ( false !== strpos( $tail, 'Dump completed' ) || false !== strpos( $tail, 'BTFU dump complete' ) );
}

/**
 * Restore the database from a dump.
 *
 * @param mysqli $mysqli   Connection.
 * @param string $dumpfile Dump path.
 * @param array  $log      Log lines, by reference.
 * @return int|string Statement count, or error string.
 */
function btfu_rescue_restore_db( $mysqli, $dumpfile, &$log ) {
	$cursor = array();

	$r = btfu_rescue_restore_db_step( $mysqli, $dumpfile, $cursor, null, $log );

	if ( is_string( $r ) ) {
		return $r;
	}

	return isset( $cursor['count'] ) ? (int) $cursor['count'] : 0;
}

/**
 * Resumable database restore (Option B: chunked DB import).
 *
 * Streams SQL statements and executes them, resuming across calls via a byte
 * offset. A gzipped dump is decompressed once to a temp .sql so it can be
 * seeked (gzip cannot be cheaply resumed); the temp file is removed when the
 * restore finishes or aborts. The connection is re-established by the caller
 * each step, so session settings (FOREIGN_KEY_CHECKS off) are re-issued here
 * on every call.
 *
 * @param mysqli     $mysqli   Open connection.
 * @param string     $dumpfile Dump path (.sql or .sql.gz).
 * @param array      $cursor   { source, temp, offset, count, errors, started } (by ref).
 * @param float|null $deadline microtime(true) budget; null = run to completion.
 * @param array      $log      Log lines (by ref).
 * @return bool|string true when done, false when more remains, or an error string.
 */
function btfu_rescue_restore_db_step( $mysqli, $dumpfile, &$cursor, $deadline, &$log ) {
	if ( empty( $cursor['started'] ) ) {
		if ( ! btfu_rescue_dump_complete( $dumpfile ) ) {
			return 'dump is missing its completion trailer — refusing to restore a truncated dump';
		}

		if ( '.gz' === substr( $dumpfile, -3 ) ) {
			$temp = $dumpfile . '.restore-tmp.sql';
			$in   = @gzopen( $dumpfile, 'rb' );
			$out  = @fopen( $temp, 'wb' );

			if ( ! $in || ! $out ) {
				return 'could not decompress the dump for chunked restore';
			}

			while ( ! gzeof( $in ) ) {
				$chunk = gzread( $in, 1048576 );

				if ( false === $chunk ) {
					break;
				}

				fwrite( $out, $chunk );
			}

			gzclose( $in );
			fclose( $out );

			$cursor['temp']   = $temp;
			$cursor['source'] = $temp;
		} else {
			$cursor['source'] = $dumpfile;
		}

		$cursor['offset']  = 0;
		$cursor['count']   = 0;
		$cursor['errors']  = array();
		$cursor['started'] = 1;
	}

	// New connection each step -> re-issue session settings every call.
	mysqli_query( $mysqli, 'SET FOREIGN_KEY_CHECKS = 0' );
	mysqli_query( $mysqli, "SET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO'" );

	$handle = @fopen( $cursor['source'], 'rb' );

	if ( ! $handle ) {
		return 'cannot open dump source: ' . $cursor['source'];
	}

	fseek( $handle, (int) $cursor['offset'] );

	$statement = '';
	$done      = false;

	while ( true ) {
		$line = fgets( $handle );

		if ( false === $line ) {
			$done = true;
			break;
		}

		$trimmed = trim( $line );

		if ( ( '' === $trimmed || 0 === strpos( $trimmed, '--' ) || 0 === strpos( $trimmed, '/*!' ) ) && '' === $statement ) {
			$cursor['offset'] = ftell( $handle );
			continue;
		}

		$statement .= $line;

		if ( ';' === substr( rtrim( $line ), -1 ) ) {
			if ( count( $cursor['errors'] ) < 5 ) {
				if ( ! mysqli_query( $mysqli, $statement ) ) {
					$cursor['errors'][] = mysqli_error( $mysqli ) . ' in: ' . substr( trim( $statement ), 0, 120 );
				} else {
					$cursor['count']++;
				}
			}

			$statement        = '';
			$cursor['offset'] = ftell( $handle );

			if ( null !== $deadline && microtime( true ) >= $deadline ) {
				fclose( $handle );

				return false; // More remains; resume next step.
			}
		}
	}

	fclose( $handle );
	mysqli_query( $mysqli, 'SET FOREIGN_KEY_CHECKS = 1' );

	// Finished: drop the temp .sql if we made one.
	if ( ! empty( $cursor['temp'] ) ) {
		@unlink( $cursor['temp'] );
		$cursor['temp'] = null;
	}

	if ( ! empty( $cursor['errors'] ) ) {
		return "database errors:\n  " . implode( "\n  ", $cursor['errors'] );
	}

	$log[] = 'OK database: ' . $cursor['count'] . ' statements executed';

	return true;
}

/**
 * Drop prefix tables that exist live but are not part of the snapshot.
 *
 * Strict mode only. Safe mode leaves them: an unknown table is inert until
 * some plugin asks about it, whereas a dropped one is gone.
 *
 * @param mysqli $mysqli   Connection.
 * @param string $prefix   Table prefix.
 * @param array  $expected Tables the snapshot contains.
 * @param array  $log      Log lines, by reference.
 * @return int Tables dropped.
 */
function btfu_rescue_drop_orphans( $mysqli, $prefix, $expected, &$log ) {
	$like = str_replace( array( '\\', '_', '%' ), array( '\\\\', '\\_', '\\%' ), $prefix ) . '%';
	$res  = mysqli_query( $mysqli, "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" );

	if ( ! $res ) {
		return 0;
	}

	$dropped = 0;

	while ( $row = mysqli_fetch_row( $res ) ) {
		$table = $row[0];

		if ( 0 !== strpos( $table, $prefix ) ) {
			continue;
		}

		if ( in_array( $table, $expected, true ) ) {
			continue;
		}

		if ( mysqli_query( $mysqli, 'DROP TABLE `' . str_replace( '`', '', $table ) . '`' ) ) {
			$log[] = 'OK dropped orphan table: ' . $table;
			$dropped++;
		}
	}

	return $dropped;
}

/* ---------------------------------------------------------------------------
 * Restore orchestration — the one code path (decision D5)
 * ------------------------------------------------------------------------ */

/**
 * Restore a snapshot: files, then database.
 *
 * @param string $store   Store path.
 * @param string $id      Snapshot id.
 * @param array  $options {abspath, content_dir, strict(bool), skip_db(bool), skip_files(bool), config(array|null)}.
 * @return array {ok:bool, log:array}
 */
function btfu_rescue_restore( $store, $id, $options = array() ) {
	$log = array();
	$out = function ( $line ) use ( &$log ) {
		$log[] = $line;
	};

	$store = rtrim( str_replace( '\\', '/', $store ), '/' );
	$snap  = $store . '/' . $id;

	// ---- Validate the snapshot ----
	$manifest = json_decode( (string) @file_get_contents( $snap . '/manifest.json' ), true );

	if ( ! is_array( $manifest ) ) {
		return array( 'ok' => false, 'log' => array( 'ERROR no readable manifest for ' . $id ) );
	}

	$index = btfu_rescue_load_index( $store );
	$entry = null;

	foreach ( (array) ( $index ? $index['snapshots'] : array() ) as $candidate ) {
		if ( isset( $candidate['id'] ) && $candidate['id'] === $id ) {
			$entry = $candidate;
			break;
		}
	}

	if ( $entry && 'complete' !== $entry['status'] ) {
		return array( 'ok' => false, 'log' => array( 'ERROR snapshot ' . $id . ' is marked "' . $entry['status'] . '" — refusing to restore an incomplete snapshot' ) );
	}

	if ( ! is_dir( $snap . '/files' ) ) {
		return array( 'ok' => false, 'log' => array( 'ERROR snapshot has no files directory' ) );
	}

	// ---- Resolve targets ----
	$abspath = isset( $options['abspath'] )
		? rtrim( str_replace( '\\', '/', $options['abspath'] ), '/' )
		: ( isset( $manifest['site']['abspath'] ) ? $manifest['site']['abspath'] : null );

	$content = isset( $options['content_dir'] )
		? rtrim( str_replace( '\\', '/', $options['content_dir'] ), '/' )
		: ( $abspath ? $abspath . '/wp-content' : null );

	if ( ! $content || ! is_dir( $content ) ) {
		return array( 'ok' => false, 'log' => array( 'ERROR cannot resolve live wp-content directory' ) );
	}

	$out( 'Restoring ' . $id . ( isset( $manifest['note'] ) && '' !== $manifest['note'] ? ' — "' . $manifest['note'] . '"' : '' ) );
	$out( 'Target: ' . $content );

	$patterns = array_merge(
		btfu_rescue_mandatory_excludes(),
		isset( $manifest['excludes'] ) && is_array( $manifest['excludes'] ) ? $manifest['excludes'] : array()
	);

	// Reach the database BEFORE touching any files: bad credentials or a
	// stopped MySQL should fail the restore while the site is still whole,
	// not after the file tree has already been overwritten.
	$db_mysqli = null;
	$db_cfg    = false;
	$db_dump   = null;

	if ( empty( $options['skip_db'] ) ) {
		$db_file = isset( $manifest['db']['file'] ) ? $manifest['db']['file'] : 'db.sql';
		$db_dump = $snap . '/' . $db_file;

		if ( ! is_readable( $db_dump ) ) {
			return array( 'ok' => false, 'log' => array_merge( $log, array( 'ERROR dump not readable: ' . $db_dump ) ) );
		}

		$db_cfg = isset( $options['config'] ) && is_array( $options['config'] ) ? $options['config'] : false;

		if ( ! $db_cfg && $abspath ) {
			$config_file = btfu_rescue_find_config( $abspath );
			$db_cfg      = $config_file ? btfu_rescue_parse_config( $config_file ) : false;
		}

		if ( ! $db_cfg ) {
			return array( 'ok' => false, 'log' => array_merge( $log, array( 'ERROR could not read database credentials from wp-config.php' ) ) );
		}

		$db_mysqli = btfu_rescue_connect( $db_cfg );

		if ( ! ( $db_mysqli instanceof mysqli ) ) {
			return array( 'ok' => false, 'log' => array_merge( $log, array( 'ERROR ' . $db_mysqli . ' (checked before touching files)' ) ) );
		}

		$out( 'OK database reachable (verified before file changes)' );
	}

	// ---- Files ----
	if ( empty( $options['skip_files'] ) ) {
		$placed = btfu_rescue_copy_tree( $snap . '/files', $content, $log );
		$out( "OK files: $placed placed" );

		$removed = btfu_rescue_delete_pass( $content, $snap . '/files', $patterns, $log );
		$out( "OK delete pass: $removed removed" );
	}

	// ---- Database ---- (connection already established above)
	if ( empty( $options['skip_db'] ) ) {
		$cfg    = $db_cfg;
		$mysqli = $db_mysqli;
		$dump   = $db_dump;

		$result = btfu_rescue_restore_db( $mysqli, $dump, $log );

		if ( ! is_int( $result ) ) {
			mysqli_close( $mysqli );

			return array( 'ok' => false, 'log' => array_merge( $log, array( 'ERROR ' . $result ) ) );
		}

		if ( ! empty( $options['strict'] ) && isset( $manifest['db']['tables'] ) ) {
			// Deliberately-excluded (structure-only) tables are EXPECTED:
			// strict mode must never drop the table someone excluded
			// precisely to protect its data.
			$expected = array_merge(
				$manifest['db']['tables'],
				isset( $manifest['db']['schema_only'] ) && is_array( $manifest['db']['schema_only'] )
					? $manifest['db']['schema_only']
					: array()
			);

			$dropped = btfu_rescue_drop_orphans( $mysqli, $cfg['table_prefix'], $expected, $log );
			$out( "OK strict mode: $dropped orphan tables dropped" );
		}

		mysqli_close( $mysqli );
	}

	$errors = array_values(
		array_filter(
			$log,
			function ( $line ) {
				return 0 === strpos( $line, 'ERROR' );
			}
		)
	);

	if ( $errors ) {
		$out( 'Restore finished WITH ' . count( $errors ) . ' error(s) — see ERROR lines above. Treat the site as partially restored.' );

		return array( 'ok' => false, 'log' => $log );
	}

	$out( 'Restore finished.' );

	return array( 'ok' => true, 'log' => $log );
}

/**
 * Verify a snapshot's integrity without touching anything.
 *
 * @param string $store Store path.
 * @param string $id    Snapshot id.
 * @return array {ok:bool, log:array}
 */
function btfu_rescue_verify( $store, $id ) {
	$log  = array();
	$ok   = true;
	$snap = rtrim( $store, '/' ) . '/' . $id;

	$manifest = json_decode( (string) @file_get_contents( $snap . '/manifest.json' ), true );

	if ( ! is_array( $manifest ) ) {
		return array( 'ok' => false, 'log' => array( 'FAIL manifest missing or unreadable' ) );
	}

	$log[] = 'OK manifest readable';

	$db_file = isset( $manifest['db']['file'] ) ? $manifest['db']['file'] : 'db.sql';

	if ( ! is_readable( $snap . '/' . $db_file ) ) {
		$log[] = 'FAIL dump missing: ' . $db_file;
		$ok    = false;
	} elseif ( ! btfu_rescue_dump_complete( $snap . '/' . $db_file ) ) {
		$log[] = 'FAIL dump truncated (no completion trailer)';
		$ok    = false;
	} else {
		$log[] = 'OK dump present with completion trailer';

		if ( isset( $manifest['db']['md5'] ) && $manifest['db']['md5'] ) {
			if ( md5_file( $snap . '/' . $db_file ) === $manifest['db']['md5'] ) {
				$log[] = 'OK dump md5 matches manifest';
			} else {
				$log[] = 'FAIL dump md5 mismatch — the dump changed after the snapshot completed';
				$ok    = false;
			}
		}
	}

	if ( ! is_dir( $snap . '/files' ) ) {
		$log[] = 'FAIL files directory missing';
		$ok    = false;
	} else {
		$expected = isset( $manifest['files']['count'] ) ? (int) $manifest['files']['count'] : -1;
		$actual   = 0;
		$queue    = array( $snap . '/files' );

		while ( $queue ) {
			$dir     = array_pop( $queue );
			$entries = @scandir( $dir );

			foreach ( (array) $entries as $entry ) {
				if ( '.' === $entry || '..' === $entry ) {
					continue;
				}

				$path = $dir . '/' . $entry;

				if ( is_dir( $path ) && ! is_link( $path ) ) {
					$queue[] = $path;
				} else {
					$actual++;
				}
			}
		}

		if ( $expected >= 0 && $expected !== $actual ) {
			$log[] = "FAIL file count: manifest says $expected, found $actual";
			$ok    = false;
		} else {
			$log[] = "OK file count: $actual";
		}
	}

	return array( 'ok' => $ok, 'log' => $log );
}

/* ---------------------------------------------------------------------------
 * Standalone entry points (CLI + web). Skipped entirely when embedded.
 * ------------------------------------------------------------------------ */

if ( ! defined( 'BTFU_RESCUE_EMBEDDED' ) ) {

	// The un-rendered template inside the plugin directory must never run its
	// standalone main. Only BTFU_Rescue::render() (which installs a copy under
	// a random filename) replaces this marker, so a direct HTTP request to the
	// template itself is refused before any store discovery happens.
	if ( 'yes' !== '{{INSTALLED}}' ) {
		if ( 'cli' !== PHP_SAPI && ! headers_sent() ) {
			header( 'HTTP/1.1 403 Forbidden' );
		}
		echo "This is the BTFU rescue template, not an installed rescue script.\n";
		echo "Generate a rescue script from BTFU \u2192 Settings.\n";
		exit( 1 );
	}

	/**
	 * Auth for web mode: password hash + attempt lockout, all file-based —
	 * the database may be exactly what is broken.
	 *
	 * @param string $store    Store path.
	 * @param string $password Submitted password.
	 * @return true|string true or a refusal message.
	 */
	function btfu_rescue_web_auth( $store, $password ) {
		$auth_file = $store . '/auth.json';
		$auth      = json_decode( (string) @file_get_contents( $auth_file ), true );

		if ( ! is_array( $auth ) || empty( $auth['hash'] ) ) {
			return 'Web mode is not enabled: no password has been set for this store. Use the CLI, or set a password from the plugin settings.';
		}

		$attempts_file = $store . '/auth-attempts.json';
		$attempts      = json_decode( (string) @file_get_contents( $attempts_file ), true );
		$attempts      = is_array( $attempts ) ? $attempts : array( 'count' => 0, 'until' => 0 );

		if ( $attempts['until'] > time() ) {
			return 'Locked out after repeated failures. Try again in ' . ceil( ( $attempts['until'] - time() ) / 60 ) . ' minutes, or use the CLI.';
		}

		// A short-lived session token lets follow-up forms authenticate
		// without the password ever being echoed back into HTML.
		$session_file = $store . '/auth-session.json';

		if ( '' !== $password && 0 === strpos( $password, 'tok:' ) ) {
			$session = json_decode( (string) @file_get_contents( $session_file ), true );

			if ( is_array( $session )
				&& ! empty( $session['hash'] )
				&& $session['expires'] > time()
				&& hash_equals( $session['hash'], hash( 'sha256', substr( $password, 4 ) ) ) ) {
				return true;
			}

			return 'Session expired. Enter the rescue password again.';
		}

		if ( ! password_verify( (string) $password, $auth['hash'] ) ) {
			$attempts['count']++;

			if ( $attempts['count'] >= 5 ) {
				$attempts = array( 'count' => 0, 'until' => time() + 900 );
			}

			@file_put_contents( $attempts_file, json_encode( $attempts ) );

			return 'Wrong password.';
		}

		@unlink( $attempts_file );

		$GLOBALS['btfu_session_token'] = bin2hex( random_bytes( 16 ) );

		@file_put_contents(
			$session_file,
			json_encode(
				array(
					'hash'    => hash( 'sha256', $GLOBALS['btfu_session_token'] ),
					'expires' => time() + 900,
				)
			)
		);

		return true;
	}

	$btfu_expires = (int) '{{EXPIRES_TS}}';

	if ( $btfu_expires > 0 && time() > $btfu_expires ) {
		if ( 'cli' !== PHP_SAPI ) {
			header( 'HTTP/1.1 410 Gone' );
		}

		echo "This rescue script expired on {{EXPIRES_HUMAN}} and has deleted itself. Generate a fresh one from the plugin, or restore by hand from the snapshot store.\n";
		@unlink( __FILE__ );
		exit( 1 );
	}

	$btfu_here    = dirname( str_replace( '\\', '/', __FILE__ ) );
	$btfu_config  = btfu_rescue_find_config( $btfu_here );
	$btfu_abspath = $btfu_config ? dirname( $btfu_config ) : $btfu_here;
	$btfu_stores  = btfu_rescue_find_stores( $btfu_abspath );

	if ( 'cli' === PHP_SAPI ) {
		// ------------------------------ CLI ------------------------------
		// No password here by design: anyone with shell access can read the
		// snapshots directly, so a password would only strand the operator.
		$args = $GLOBALS['argv'];
		array_shift( $args );

		$flags = array( 'strict' => false, 'yes' => false, 'skip_db' => false, 'skip_files' => false );
		$words = array();

		foreach ( $args as $arg ) {
			if ( '--strict' === $arg ) {
				$flags['strict'] = true;
			} elseif ( '--yes' === $arg ) {
				$flags['yes'] = true;
			} elseif ( '--skip-db' === $arg ) {
				$flags['skip_db'] = true;
			} elseif ( '--skip-files' === $arg ) {
				$flags['skip_files'] = true;
			} else {
				$words[] = $arg;
			}
		}

		$command = isset( $words[0] ) ? ltrim( $words[0], '-' ) : 'list';
		$id      = isset( $words[1] ) ? $words[1] : null;

		if ( ! $btfu_stores ) {
			fwrite( STDERR, "No snapshot store found near $btfu_abspath\n" );
			exit( 1 );
		}

		$store = $btfu_stores[0];

		if ( count( $btfu_stores ) > 1 ) {
			echo 'NOTE: multiple stores found, using ' . $store . "\n";
		}

		switch ( $command ) {
			case 'list':
				$index = btfu_rescue_load_index( $store );

				if ( ! $index || empty( $index['snapshots'] ) ) {
					echo "No snapshots in $store\n";
					exit( 0 );
				}

				$rows = $index['snapshots'];

				usort( $rows, function ( $a, $b ) {
					return strcmp( $b['id'], $a['id'] );
				} );

				echo "Snapshots in $store:\n\n";

				foreach ( $rows as $row ) {
					printf(
						"  %s  %-8s  %s%s\n",
						$row['id'],
						$row['status'],
						isset( $row['note'] ) && '' !== $row['note'] ? $row['note'] : '(no note)',
						! empty( $row['pinned'] ) ? '  ★' : ''
					);
				}

				exit( 0 );

			case 'show':
			case 'verify':
				if ( ! $id ) {
					fwrite( STDERR, "Usage: --$command <id>\n" );
					exit( 1 );
				}

				if ( 'show' === $command ) {
					echo (string) @file_get_contents( $store . '/' . $id . '/manifest.json' ), "\n";
					exit( 0 );
				}

				$result = btfu_rescue_verify( $store, $id );
				echo implode( "\n", $result['log'] ), "\n";
				exit( $result['ok'] ? 0 : 1 );

			case 'restore':
				if ( ! $id ) {
					fwrite( STDERR, "Usage: --restore <id> [--strict] [--skip-db] [--skip-files] [--yes]\n" );
					exit( 1 );
				}

				if ( $flags['skip_db'] && $flags['skip_files'] ) {
					fwrite( STDERR, "Nothing to restore: --skip-db and --skip-files together.\n" );
					exit( 1 );
				}

				$scope = $flags['skip_db'] ? 'files only' : ( $flags['skip_files'] ? 'database only' : 'files + database' );

				if ( ! $flags['yes'] ) {
					echo "About to restore $id over the live site ($scope).\n";
					echo "Type the snapshot id to confirm: ";

					$typed = trim( (string) fgets( STDIN ) );

					if ( $typed !== $id ) {
						echo "Confirmation did not match. Nothing was touched.\n";
						exit( 1 );
					}
				}

				$result = btfu_rescue_restore(
					$store,
					$id,
					array(
						'abspath'    => $btfu_abspath,
						'strict'     => $flags['strict'],
						'skip_db'    => $flags['skip_db'],
						'skip_files' => $flags['skip_files'],
						'config'     => $btfu_config ? btfu_rescue_parse_config( $btfu_config ) : null,
					)
				);

				echo implode( "\n", $result['log'] ), "\n";
				exit( $result['ok'] ? 0 : 1 );

			default:
				echo "Usage: php " . basename( __FILE__ ) . " --list | --show <id> | --verify <id> | --restore <id> [--strict] [--skip-db] [--skip-files] [--yes]\n";
				exit( 1 );
		}
	}

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

	$is_local = in_array( $_SERVER['REMOTE_ADDR'] ?? '', array( '127.0.0.1', '::1' ), true );
	$is_tls   = ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'];

	$notice = '';

	if ( ! $is_local && ! $is_tls ) {
		$notice = 'WARNING: this connection is neither local nor HTTPS — the password will cross the network in cleartext.';
	}

	if ( ! $btfu_stores ) {
		echo '<h1>BTFU rescue</h1><p>No snapshot store found.</p>';
		exit;
	}

	$store    = $btfu_stores[0];
	$password = isset( $_POST['btfu_pw'] ) ? (string) $_POST['btfu_pw'] : null;

	echo '<!doctype html><meta name="robots" content="noindex"><title>BTFU rescue</title>'
		. '<style>body{font:15px/1.5 system-ui;max-width:640px;margin:3em auto;padding:0 1em;color:#222}'
		. 'input,button{font:inherit;padding:.4em .7em}.warn{color:#b91c1c}.log{background:#f3f4f6;padding:1em;white-space:pre-wrap;border-radius:6px}</style>'
		. '<h1>BTFU — rescue</h1>';

	if ( $notice ) {
		echo '<p class="warn">' . htmlspecialchars( $notice, ENT_QUOTES ) . '</p>';
	}

	if ( null === $password ) {
		echo '<form method="post"><p><label>Rescue password: <input type="password" name="btfu_pw" autofocus></label> <button>Unlock</button></p></form>';
		exit;
	}

	$auth = btfu_rescue_web_auth( $store, $password );

	// After a fresh password auth a new token was minted; after token auth,
	// keep using the presented token.
	$form_token = isset( $GLOBALS['btfu_session_token'] )
		? 'tok:' . $GLOBALS['btfu_session_token']
		: ( 0 === strpos( (string) $password, 'tok:' ) ? $password : '' );

	if ( true !== $auth ) {
		echo '<p class="warn">' . htmlspecialchars( $auth, ENT_QUOTES ) . '</p>'
			. '<form method="post"><p><label>Rescue password: <input type="password" name="btfu_pw"></label> <button>Unlock</button></p></form>';
		exit;
	}

	$restore_id = isset( $_POST['btfu_restore'] ) ? (string) $_POST['btfu_restore'] : '';
	$confirm    = isset( $_POST['btfu_confirm'] ) ? (string) $_POST['btfu_confirm'] : '';

	if ( '' !== $restore_id && $confirm === $restore_id ) {
		$result = btfu_rescue_restore(
			$store,
			$restore_id,
			array(
				'abspath' => $btfu_abspath,
				'strict'  => ! empty( $_POST['btfu_strict'] ),
				'config'  => $btfu_config ? btfu_rescue_parse_config( $btfu_config ) : null,
			)
		);

		echo '<h2>' . ( $result['ok'] ? 'Restore complete' : 'Restore FAILED' ) . '</h2>'
			. '<div class="log">' . htmlspecialchars( implode( "\n", $result['log'] ), ENT_QUOTES ) . '</div>';

		if ( $result['ok'] ) {
			// One successful web restore is this script's whole job.
			echo '<p>This rescue script has now deleted itself.</p>';
			@unlink( __FILE__ );
		}

		exit;
	}

	if ( '' !== $restore_id ) {
		echo '<p class="warn">Confirmation did not match the snapshot id. Nothing was touched.</p>';
	}

	$index = btfu_rescue_load_index( $store );
	$rows  = $index ? $index['snapshots'] : array();

	usort( $rows, function ( $a, $b ) {
		return strcmp( $b['id'], $a['id'] );
	} );

	echo '<p>Store: <code>' . htmlspecialchars( $store, ENT_QUOTES ) . '</code></p>';

	foreach ( $rows as $row ) {
		if ( 'complete' !== $row['status'] ) {
			continue;
		}

		echo '<form method="post" style="border-top:1px solid #ddd;padding:.7em 0">'
			. '<input type="hidden" name="btfu_pw" value="' . htmlspecialchars( $form_token, ENT_QUOTES ) . '">'
			. '<input type="hidden" name="btfu_restore" value="' . htmlspecialchars( $row['id'], ENT_QUOTES ) . '">'
			. '<strong>' . htmlspecialchars( $row['id'], ENT_QUOTES ) . '</strong> — '
			. htmlspecialchars( isset( $row['note'] ) && '' !== $row['note'] ? $row['note'] : '(no note)', ENT_QUOTES )
			. '<br><label>Type the id to restore: <input name="btfu_confirm" autocomplete="off"></label> '
			. '<label><input type="checkbox" name="btfu_strict" value="1"> strict (drop orphan tables)</label> '
			. '<button>Restore</button></form>';
	}
}
