<?php
/**
 * ============================================================================
 *  Sitemovr Installer (standalone)
 * ----------------------------------------------------------------------------
 *  Este archivo se genera concatenando las clases SMVR_Package y
 *  SMVR_Search_Replace (arriba) + esta plantilla. Se sube por FTP junto a
 *  las partes del paquete y se ejecuta desde el navegador en el servidor DESTINO
 *  (que NO tiene WordPress instalado).
 *
 *  NO editar a mano el archivo generado; editar esta plantilla y reconstruir.
 * ============================================================================
 */

// Errores visibles de forma controlada.
error_reporting( E_ALL & ~E_DEPRECATED & ~E_NOTICE );
@ini_set( 'display_errors', '1' );
@set_time_limit( 0 );

define( 'SMVR_INSTALLER_VERSION', '1.0.0' );
define( 'SMVR_BASE_DIR', __DIR__ );
define( 'SMVR_PARTS_JSON', SMVR_BASE_DIR . '/parts.json' );
define( 'SMVR_PACKAGE_ZIP', SMVR_BASE_DIR . '/smvr-package.zip' );
define( 'SMVR_EXTRACT_DIR', SMVR_BASE_DIR ); // se extrae en la misma carpeta

session_start();

/**
 * Lee y valida parts.json (lista de partes + metadatos del paquete).
 *
 * @return array|null
 */
function smvr_read_parts_json() {
	if ( ! file_exists( SMVR_PARTS_JSON ) ) {
		return null;
	}
	$data = json_decode( file_get_contents( SMVR_PARTS_JSON ), true );
	return is_array( $data ) ? $data : null;
}

/**
 * Comprueba que todas las partes existen junto al installer.
 *
 * @param array $meta parts.json decodificado.
 * @return array { ok: bool, missing: string[], total_bytes: int }
 */
function smvr_check_parts_present( $meta ) {
	$missing = array();
	$bytes   = 0;
	foreach ( $meta['parts'] as $p ) {
		$path = SMVR_BASE_DIR . '/' . $p['name'];
		if ( ! file_exists( $path ) ) {
			$missing[] = $p['name'];
		} else {
			$bytes += filesize( $path );
		}
	}
	return array(
		'ok'          => empty( $missing ),
		'missing'     => $missing,
		'total_bytes' => $bytes,
	);
}

/**
 * Verifica el SHA-256 de cada parte contra parts.json.
 *
 * @param array $meta parts.json decodificado.
 * @return array { ok: bool, bad: string[] }
 */
function smvr_verify_parts( $meta ) {
	$bad = array();
	foreach ( $meta['parts'] as $p ) {
		$path = SMVR_BASE_DIR . '/' . $p['name'];
		if ( ! SMVR_Package::verify_sha256( $path, $p['sha256'] ) ) {
			$bad[] = $p['name'];
		}
	}
	return array(
		'ok'  => empty( $bad ),
		'bad' => $bad,
	);
}

/**
 * Reensambla las partes en smvr-package.zip.
 *
 * @param array $meta parts.json decodificado.
 * @return bool
 */
function smvr_reassemble( $meta ) {
	$paths = array();
	foreach ( $meta['parts'] as $p ) {
		$paths[] = SMVR_BASE_DIR . '/' . $p['name'];
	}
	return SMVR_Package::reassemble( $paths, SMVR_PACKAGE_ZIP );
}

/**
 * Verifica la contraseña del instalador (si el paquete la lleva).
 *
 * @param array  $meta     parts.json.
 * @param string $password Contraseña introducida.
 * @return bool
 */
function smvr_check_password( $meta, $password ) {
	if ( empty( $meta['password_hash'] ) ) {
		return true; // sin contraseña configurada
	}
	return password_verify( $password, $meta['password_hash'] );
}

// ----- Anti-fuerza-bruta: bloqueo por intentos fallidos (basado en archivo) -----
define( 'SMVR_LOCK_FILE', SMVR_BASE_DIR . '/.smvr_lock' );
define( 'SMVR_MAX_ATTEMPTS', 5 );      // intentos antes de bloquear
define( 'SMVR_LOCK_SECONDS', 900 );    // 15 minutos de bloqueo

function smvr_lock_read() {
	if ( ! file_exists( SMVR_LOCK_FILE ) ) {
		return array( 'fails' => 0, 'until' => 0 );
	}
	$d = json_decode( @file_get_contents( SMVR_LOCK_FILE ), true );
	return is_array( $d ) ? array( 'fails' => (int) ( $d['fails'] ?? 0 ), 'until' => (int) ( $d['until'] ?? 0 ) ) : array( 'fails' => 0, 'until' => 0 );
}

/**
 * Segundos restantes de bloqueo (0 si no está bloqueado).
 */
function smvr_lock_remaining() {
	$d = smvr_lock_read();
	$r = $d['until'] - time();
	return $r > 0 ? $r : 0;
}

function smvr_lock_register_fail() {
	$d         = smvr_lock_read();
	$d['fails'] = $d['fails'] + 1;
	if ( $d['fails'] >= SMVR_MAX_ATTEMPTS ) {
		$d['until'] = time() + SMVR_LOCK_SECONDS;
		$d['fails'] = 0; // reinicia el contador tras bloquear
	}
	@file_put_contents( SMVR_LOCK_FILE, json_encode( $d ) );
}

function smvr_lock_reset() {
	if ( file_exists( SMVR_LOCK_FILE ) ) {
		@unlink( SMVR_LOCK_FILE );
	}
}

function smvr_human( $bytes ) {
	$u = array( 'B', 'KB', 'MB', 'GB', 'TB' );
	$i = 0;
	while ( $bytes >= 1024 && $i < count( $u ) - 1 ) {
		$bytes /= 1024;
		$i++;
	}
	return round( $bytes, 2 ) . ' ' . $u[ $i ];
}

function smvr_h( $s ) {
	return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' );
}

/**
 * Extrae y decodifica manifest.json del paquete reensamblado.
 *
 * @return array|null
 */
function smvr_read_manifest() {
	if ( ! file_exists( SMVR_PACKAGE_ZIP ) ) {
		return null;
	}
	$zip = new ZipArchive();
	if ( true !== $zip->open( SMVR_PACKAGE_ZIP ) ) {
		return null;
	}
	$json = $zip->getFromName( 'manifest.json' );
	$zip->close();
	if ( false === $json ) {
		return null;
	}
	$data = json_decode( $json, true );
	return is_array( $data ) ? $data : null;
}

/**
 * Chequeos de compatibilidad del servidor DESTINO contra el manifiesto.
 *
 * @param array $manifest manifest.json.
 * @return array[] Cada item: { level: ok|warn|error, message }.
 */
function smvr_preflight_checks( $manifest ) {
	$src    = isset( $manifest['source'] ) ? $manifest['source'] : array();
	$pkg    = isset( $manifest['package'] ) ? $manifest['package'] : array();
	$checks = array();

	// PHP >= origen.
	$src_php = isset( $src['php_version'] ) ? $src['php_version'] : '0';
	if ( version_compare( PHP_VERSION, $src_php, '>=' ) ) {
		$checks[] = array( 'level' => 'ok', 'message' => 'PHP ' . PHP_VERSION . ' (source: ' . $src_php . ').' );
	} else {
		$checks[] = array( 'level' => 'error', 'message' => 'PHP ' . PHP_VERSION . ' is LOWER than the source (' . $src_php . '). Update PHP on the destination.' );
	}

	// Extensiones necesarias.
	foreach ( array( 'mysqli', 'zip', 'mbstring' ) as $ext ) {
		$checks[] = extension_loaded( $ext )
			? array( 'level' => 'ok', 'message' => 'Extension ' . $ext . ' available.' )
			: array( 'level' => 'error', 'message' => 'Missing PHP extension: ' . $ext . '.' );
	}

	// Espacio en disco >= tamaño descomprimido.
	$need = isset( $pkg['size_uncompressed'] ) ? (int) $pkg['size_uncompressed'] : 0;
	$free = @disk_free_space( SMVR_BASE_DIR );
	if ( $free && $need ) {
		$checks[] = ( $free >= $need )
			? array( 'level' => 'ok', 'message' => 'Enough disk space (' . smvr_human( $free ) . ' free, ' . smvr_human( $need ) . ' needed).' )
			: array( 'level' => 'error', 'message' => 'Not enough space: ' . smvr_human( $free ) . ' free, ' . smvr_human( $need ) . ' needed.' );
	}

	// Permisos de escritura.
	$checks[] = is_writable( SMVR_BASE_DIR )
		? array( 'level' => 'ok', 'message' => 'The folder is writable.' )
		: array( 'level' => 'error', 'message' => 'The folder is NOT writable. Adjust permissions via FTP (755/775).' );

	// Multisite.
	if ( ! empty( $src['is_multisite'] ) ) {
		$checks[] = array( 'level' => 'error', 'message' => 'The source is Multisite, not supported in this version.' );
	}

	return $checks;
}

/**
 * Intenta conectar a la BD destino.
 *
 * @return mysqli|string Conexión o mensaje de error.
 */
function smvr_db_connect( $host, $user, $pass, $name ) {
	mysqli_report( MYSQLI_REPORT_OFF );
	$conn = @mysqli_connect( $host, $user, $pass, $name );
	if ( ! $conn ) {
		return 'Could not connect: ' . mysqli_connect_error();
	}
	return $conn;
}

/**
 * Cuenta cuántas tablas hay en la BD (para avisar de borrado).
 *
 * @param mysqli $conn Conexión.
 * @return int
 */
function smvr_db_table_count( $conn ) {
	$res = @mysqli_query( $conn, 'SHOW TABLES' );
	if ( ! $res ) {
		return 0;
	}
	$n = mysqli_num_rows( $res );
	mysqli_free_result( $res );
	return (int) $n;
}

/**
 * Elimina TODAS las tablas de la BD (cuando el usuario confirmó el borrado).
 *
 * @param mysqli $conn Conexión.
 * @return int Tablas eliminadas.
 */
function smvr_wipe_db( $conn ) {
	$tables = array();
	$res    = mysqli_query( $conn, 'SHOW TABLES' );
	while ( $res && $row = mysqli_fetch_row( $res ) ) {
		$tables[] = $row[0];
	}
	if ( empty( $tables ) ) {
		return 0;
	}
	mysqli_query( $conn, 'SET FOREIGN_KEY_CHECKS = 0' );
	foreach ( $tables as $t ) {
		mysqli_query( $conn, 'DROP TABLE IF EXISTS `' . str_replace( '`', '``', $t ) . '`' );
	}
	mysqli_query( $conn, 'SET FOREIGN_KEY_CHECKS = 1' );
	return count( $tables );
}

/**
 * Importa database.sql en la BD destino.
 *
 * Usa multi_query (MySQL parsea respetando comillas/escapes, evitando los
 * problemas de un troceado por ";" ingenuo).
 *
 * @param mysqli $conn Conexión.
 * @param string $file Ruta de database.sql.
 * @return array { ok: bool, error: string }
 */
function smvr_import_sql( $conn, $file ) {
	$sql = file_get_contents( $file );
	if ( false === $sql ) {
		return array( 'ok' => false, 'error' => 'Could not read database.sql.' );
	}
	mysqli_query( $conn, 'SET NAMES utf8mb4' );
	if ( ! mysqli_multi_query( $conn, $sql ) ) {
		return array( 'ok' => false, 'error' => mysqli_error( $conn ) );
	}
	// Consumir TODOS los resultados (obligatorio con multi_query).
	do {
		if ( $res = mysqli_store_result( $conn ) ) {
			mysqli_free_result( $res );
		}
		if ( mysqli_errno( $conn ) ) {
			return array( 'ok' => false, 'error' => mysqli_error( $conn ) );
		}
	} while ( mysqli_more_results( $conn ) && mysqli_next_result( $conn ) );

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

/**
 * Importa database.sql POR LOTES, en streaming y reanudable.
 *
 * Lee desde $offset bytes, ejecuta hasta $max_statements sentencias completas
 * (respetando comillas simples y escapes con backslash, y saltando comentarios)
 * y devuelve el nuevo offset para la siguiente llamada. Memoria acotada: solo
 * mantiene una sentencia en buffer, nunca el archivo entero.
 *
 * @param mysqli $conn           Conexión.
 * @param string $file           Ruta de database.sql.
 * @param int    $offset         Byte por el que empezar (0 la primera vez).
 * @param int    $max_statements Máx. sentencias a ejecutar en esta llamada.
 * @return array { offset, total, executed, done, error }
 */
function smvr_import_sql_batch( $conn, $file, $offset, $max_statements ) {
	$total = filesize( $file );
	$fh    = fopen( $file, 'rb' );
	if ( ! $fh ) {
		return array( 'offset' => $offset, 'total' => $total, 'executed' => 0, 'done' => true, 'error' => 'No se pudo abrir database.sql.' );
	}
	if ( $offset > 0 ) {
		fseek( $fh, $offset );
	}
	// Cada lote usa una conexión nueva: hay que fijar el modo de sesión SIEMPRE,
	// no solo en el primer lote. Si no, fechas 0000-00-00 fallan en MySQL 8 estricto.
	mysqli_query( $conn, "SET SESSION sql_mode = 'NO_AUTO_VALUE_ON_ZERO'" );
	mysqli_query( $conn, 'SET NAMES utf8mb4' );
	mysqli_query( $conn, 'SET FOREIGN_KEY_CHECKS = 0' );

	$stmt       = '';
	$in_str     = false;
	$esc        = false;
	$executed   = 0;
	$checkpoint = $offset; // offset seguro tras la última sentencia completa
	$error      = '';

	while ( $executed < $max_statements && ( $line = fgets( $fh ) ) !== false ) {
		// Saltar comentarios y líneas en blanco solo si no estamos a mitad de una sentencia.
		$trim_line = ltrim( $line );
		if ( '' === trim( $stmt ) && ( '' === trim( $trim_line ) || 0 === strpos( $trim_line, '--' ) ) ) {
			$checkpoint = ftell( $fh );
			continue;
		}

		// Escanear la línea actualizando el estado de comillas.
		$len = strlen( $line );
		for ( $i = 0; $i < $len; $i++ ) {
			$ch = $line[ $i ];
			if ( $in_str ) {
				if ( $esc ) {
					$esc = false;
				} elseif ( '\\' === $ch ) {
					$esc = true;
				} elseif ( "'" === $ch ) {
					$in_str = false;
				}
			} else {
				if ( "'" === $ch ) {
					$in_str = true;
				}
			}
		}

		$stmt .= $line;

		// Sentencia completa: no estamos dentro de string y termina en ';'.
		if ( ! $in_str && ';' === substr( rtrim( $stmt ), -1 ) ) {
			$sql = trim( $stmt );
			if ( '' !== $sql ) {
				if ( ! mysqli_query( $conn, $sql ) ) {
					$error = mysqli_error( $conn );
					fclose( $fh );
					return array( 'offset' => $checkpoint, 'total' => $total, 'executed' => $executed, 'done' => true, 'error' => $error );
				}
				$executed++;
			}
			$stmt       = '';
			$checkpoint = ftell( $fh );
		}
	}

	$eof = feof( $fh );
	fclose( $fh );

	if ( $eof ) {
		mysqli_query( $conn, 'SET FOREIGN_KEY_CHECKS = 1' );
	}

	return array(
		'offset'   => $checkpoint,
		'total'    => $total,
		'executed' => $executed,
		'done'     => $eof,
		'error'    => '',
	);
}

/**
 * Extrae solo las entradas "files/" del paquete a la carpeta destino,
 * quitando ese prefijo (los archivos quedan en la raíz del sitio).
 *
 * @param string $zip_path Paquete reensamblado.
 * @param string $dest     Carpeta destino (raíz del sitio).
 * @return array { ok: bool, count: int, error: string }
 */
/**
 * Normaliza/valida una ruta del paquete (anti Zip Slip): rechaza segmentos ".." y
 * rutas absolutas para impedir escribir fuera de la raíz del sitio.
 *
 * @param string $rel
 * @return string|false
 */
function smvr_safe_rel( $rel ) {
	$rel = str_replace( '\\', '/', (string) $rel );
	$rel = ltrim( $rel, '/' );
	if ( '' === $rel || preg_match( '#^[a-zA-Z]:#', $rel ) ) {
		return false;
	}
	foreach ( explode( '/', $rel ) as $seg ) {
		if ( '..' === $seg ) {
			return false;
		}
	}
	return $rel;
}

function smvr_extract_files( $zip_path, $dest ) {
	$zip = new ZipArchive();
	if ( true !== $zip->open( $zip_path ) ) {
		return array( 'ok' => false, 'count' => 0, 'error' => 'Could not open the package.' );
	}
	$dest  = rtrim( $dest, '/\\' ) . '/';
	$count = 0;
	for ( $i = 0; $i < $zip->numFiles; $i++ ) {
		$name = $zip->getNameIndex( $i );
		if ( 0 !== strpos( $name, 'files/' ) ) {
			continue; // manifest.json y database.sql no van al árbol del sitio
		}
		$relative = smvr_safe_rel( substr( $name, strlen( 'files/' ) ) );
		if ( false === $relative ) {
			continue; // ruta insegura (Zip Slip) o vacía
		}
		$target = $dest . $relative;
		if ( '/' === substr( $name, -1 ) ) {
			@mkdir( $target, 0755, true );
			continue;
		}
		$dir = dirname( $target );
		if ( ! is_dir( $dir ) ) {
			@mkdir( $dir, 0755, true );
		}
		$stream = $zip->getStream( $name );
		if ( ! $stream ) {
			continue;
		}
		$out = fopen( $target, 'wb' );
		if ( $out ) {
			while ( ! feof( $stream ) ) {
				fwrite( $out, fread( $stream, 8192 ) );
			}
			fclose( $out );
			$count++;
		}
		fclose( $stream );
	}
	$zip->close();
	return array( 'ok' => true, 'count' => $count, 'error' => '' );
}

/**
 * Extrae las entradas "files/" del paquete POR LOTES (reanudable por índice).
 *
 * @param string $zip_path Paquete.
 * @param string $dest     Carpeta destino (raíz del sitio).
 * @param int    $start    Índice de entrada por el que empezar.
 * @param int    $max      Máx. de archivos a extraer en esta llamada.
 * @return array { next, total, count, done, error }
 */
function smvr_extract_files_batch( $zip_path, $dest, $start, $max ) {
	$zip = new ZipArchive();
	if ( true !== $zip->open( $zip_path ) ) {
		return array( 'next' => $start, 'total' => 0, 'count' => 0, 'done' => true, 'error' => 'Could not open the package.' );
	}
	$total = $zip->numFiles;
	$dest  = rtrim( $dest, '/\\' ) . '/';
	$count = 0;
	$end   = min( $start + $max, $total );

	for ( $i = $start; $i < $end; $i++ ) {
		$name = $zip->getNameIndex( $i );
		if ( 0 !== strpos( $name, 'files/' ) ) {
			continue;
		}
		$relative = smvr_safe_rel( substr( $name, strlen( 'files/' ) ) );
		if ( false === $relative ) {
			continue; // ruta insegura (Zip Slip) o vacía
		}
		$target = $dest . $relative;
		if ( '/' === substr( $name, -1 ) ) {
			@mkdir( $target, 0755, true );
			continue;
		}
		$dir = dirname( $target );
		if ( ! is_dir( $dir ) ) {
			@mkdir( $dir, 0755, true );
		}
		$stream = $zip->getStream( $name );
		if ( ! $stream ) {
			continue;
		}
		$out = fopen( $target, 'wb' );
		if ( $out ) {
			while ( ! feof( $stream ) ) {
				fwrite( $out, fread( $stream, 8192 ) );
			}
			fclose( $out );
			$count++;
		}
		fclose( $stream );
	}
	$zip->close();

	return array(
		'next'  => $end,
		'total' => $total,
		'count' => $count,
		'done'  => ( $end >= $total ),
		'error' => '',
	);
}

/**
 * Aplica search-replace serializado a UNA tabla.
 *
 * @param mysqli   $conn  Conexión.
 * @param string   $table Tabla.
 * @param string[] $from  Buscar.
 * @param string[] $to    Reemplazar.
 * @return int Filas modificadas.
 */
function smvr_search_replace_table( $conn, $table, $from, $to ) {
	$cols = array();
	$pk   = null;
	$cres = mysqli_query( $conn, 'SHOW COLUMNS FROM `' . str_replace( '`', '``', $table ) . '`' );
	while ( $cres && $col = mysqli_fetch_assoc( $cres ) ) {
		$cols[] = $col['Field'];
		if ( 'PRI' === $col['Key'] && null === $pk ) {
			$pk = $col['Field'];
		}
	}
	if ( empty( $cols ) || null === $pk ) {
		return 0;
	}

	$rows_changed = 0;
	$rres         = mysqli_query( $conn, 'SELECT * FROM `' . str_replace( '`', '``', $table ) . '`' );
	if ( ! $rres ) {
		return 0;
	}
	while ( $r = mysqli_fetch_assoc( $rres ) ) {
		$updates = array();
		foreach ( $r as $field => $value ) {
			if ( ! is_string( $value ) || $field === $pk ) {
				continue;
			}
			$rep = SMVR_Search_Replace::replace_value( $from, $to, $value );
			if ( $rep['changed'] ) {
				$updates[ $field ] = $rep['value'];
			}
		}
		if ( ! empty( $updates ) ) {
			$sets = array();
			foreach ( $updates as $field => $val ) {
				$sets[] = '`' . str_replace( '`', '``', $field ) . "`='" . mysqli_real_escape_string( $conn, $val ) . "'";
			}
			$pk_val = mysqli_real_escape_string( $conn, $r[ $pk ] );
			$sql    = 'UPDATE `' . str_replace( '`', '``', $table ) . '` SET ' . implode( ',', $sets ) .
				" WHERE `" . str_replace( '`', '``', $pk ) . "`='" . $pk_val . "' LIMIT 1";
			if ( mysqli_query( $conn, $sql ) ) {
				$rows_changed++;
			}
		}
	}
	mysqli_free_result( $rres );
	return $rows_changed;
}

/**
 * Una unidad de trabajo de la ejecución (máquina de estados, vía AJAX).
 *
 * Fases: wipe → extract → import → searchreplace → wpconfig → done.
 *
 * @return array Progreso { phase, label, percent, done, error }.
 */
function smvr_exec_step() {
	if ( empty( $_SESSION['smvr_exec'] ) || empty( $_SESSION['smvr_db'] ) ) {
		return array( 'phase' => 'error', 'label' => 'Session not initialized.', 'percent' => 0, 'done' => true, 'error' => 'init' );
	}
	$s    = &$_SESSION['smvr_exec'];
	$db   = $_SESSION['smvr_db'];
	$conn = smvr_db_connect( $db['host'], $db['user'], $db['pass'], $db['name'] );
	if ( is_string( $conn ) ) {
		return array( 'phase' => 'error', 'label' => $conn, 'percent' => 0, 'done' => true, 'error' => $conn );
	}

	$sql_file = SMVR_BASE_DIR . '/database.sql';
	$result   = array( 'phase' => $s['phase'], 'label' => '', 'percent' => 0, 'done' => false, 'error' => '' );

	switch ( $s['phase'] ) {
		case 'wipe':
			if ( ! empty( $s['wipe'] ) ) {
				$s['wiped'] = smvr_wipe_db( $conn );
			}
			$s['phase']      = 'extract';
			$result['label'] = 'Preparing…';
			break;

		case 'extract':
			$ex                = smvr_extract_files_batch( SMVR_PACKAGE_ZIP, SMVR_BASE_DIR, $s['extract_idx'], 300 );
			$s['extract_idx']  = $ex['next'];
			$s['extract_done'] += $ex['count'];
			$result['label']   = 'Extracting files (' . $s['extract_done'] . ')';
			$result['percent'] = $ex['total'] ? (int) floor( $ex['next'] * 100 / $ex['total'] ) : 0;
			if ( $ex['done'] ) {
				smvr_extract_single( SMVR_PACKAGE_ZIP, 'database.sql', $sql_file );
				$s['phase']      = 'import';
				$s['sql_offset'] = 0;
			}
			break;

		case 'import':
			$r                 = smvr_import_sql_batch( $conn, $sql_file, $s['sql_offset'], 200 );
			if ( $r['error'] ) {
				mysqli_close( $conn );
				return array( 'phase' => 'error', 'label' => 'SQL error: ' . $r['error'], 'percent' => 0, 'done' => true, 'error' => $r['error'] );
			}
			$s['sql_offset']   = $r['offset'];
			$result['label']   = 'Importing database';
			$result['percent'] = $r['total'] ? (int) floor( $r['offset'] * 100 / $r['total'] ) : 100;
			if ( $r['done'] ) {
				$s['phase'] = 'sr_init';
			}
			break;

		case 'sr_init':
			$manifest   = smvr_read_manifest();
			$old_url    = isset( $manifest['source']['siteurl'] ) ? $manifest['source']['siteurl'] : '';
			$old_home   = isset( $manifest['source']['home'] ) ? $manifest['source']['home'] : $old_url;
			$old_path   = isset( $manifest['source']['abspath'] ) ? rtrim( $manifest['source']['abspath'], '/' ) : '';
			// URL nueva: la elegida por el usuario, o la auto-detectada como respaldo.
			$new_url    = ! empty( $_SESSION['smvr_db']['newurl'] ) ? rtrim( $_SESSION['smvr_db']['newurl'], '/' ) : smvr_detect_new_url();
			$new_path   = rtrim( SMVR_BASE_DIR, '/\\' );
			$from       = array();
			$to         = array();
			foreach ( array( $old_url, $old_home ) as $o ) {
				if ( '' !== $o && ! in_array( $o, $from, true ) ) {
					$from[] = $o;
					$to[]   = $new_url;
				}
			}
			if ( '' !== $old_path && $old_path !== $new_path ) {
				$from[] = $old_path;
				$to[]   = $new_path;
			}
			$s['sr_from']   = $from;
			$s['sr_to']     = $to;
			$s['new_url']   = $new_url;
			$s['old_url']   = $old_url;
			$s['sr_tables'] = array();
			$res            = mysqli_query( $conn, 'SHOW TABLES' );
			while ( $res && $row = mysqli_fetch_row( $res ) ) {
				$s['sr_tables'][] = $row[0];
			}
			$s['sr_index']     = 0;
			$s['rows_changed'] = 0;
			$s['phase']        = 'searchreplace';
			$result['label']   = 'Updating URLs and paths…';
			break;

		case 'searchreplace':
			$tables = $s['sr_tables'];
			if ( $s['sr_index'] < count( $tables ) ) {
				$s['rows_changed'] += smvr_search_replace_table( $conn, $tables[ $s['sr_index'] ], $s['sr_from'], $s['sr_to'] );
				$s['sr_index']++;
				$result['label']   = 'Updating URLs/paths (' . $s['sr_index'] . '/' . count( $tables ) . ')';
				$result['percent'] = (int) floor( $s['sr_index'] * 100 / max( 1, count( $tables ) ) );
			}
			if ( $s['sr_index'] >= count( $tables ) ) {
				$s['phase'] = 'wpconfig';
			}
			break;

		case 'wpconfig':
			$manifest        = smvr_read_manifest();
			smvr_write_wp_config( $db, $manifest );
			$s['phase']      = 'done';
			$result['label'] = 'wp-config.php generado';
			break;

		case 'done':
			$result['label']        = 'Completado';
			$result['done']         = true;
			$result['percent']      = 100;
			$result['new_url']      = isset( $s['new_url'] ) ? $s['new_url'] : smvr_detect_new_url();
			$result['old_url']      = isset( $s['old_url'] ) ? $s['old_url'] : '';
			$result['rows_changed'] = isset( $s['rows_changed'] ) ? $s['rows_changed'] : 0;
			$_SESSION['smvr_new_url'] = $result['new_url'];
			break;
	}

	$result['phase'] = $s['phase'];
	mysqli_close( $conn );
	return $result;
}

/**
 * Extrae una entrada concreta del paquete a un archivo de disco (streaming).
 *
 * @param string $zip_path  Paquete.
 * @param string $name      Nombre de la entrada (ej. database.sql).
 * @param string $dest_file Archivo destino.
 * @return bool
 */
function smvr_extract_single( $zip_path, $name, $dest_file ) {
	$zip = new ZipArchive();
	if ( true !== $zip->open( $zip_path ) ) {
		return false;
	}
	$stream = $zip->getStream( $name );
	if ( ! $stream ) {
		$zip->close();
		return false;
	}
	$out = fopen( $dest_file, 'wb' );
	if ( ! $out ) {
		fclose( $stream );
		$zip->close();
		return false;
	}
	while ( ! feof( $stream ) ) {
		fwrite( $out, fread( $stream, 8192 ) );
	}
	fclose( $out );
	fclose( $stream );
	$zip->close();
	return true;
}

/**
 * Search-replace serializado sobre TODA la BD.
 *
 * @param mysqli   $conn   Conexión.
 * @param string   $prefix Prefijo de tablas.
 * @param string[] $from   Valores a buscar.
 * @param string[] $to     Valores de reemplazo.
 * @return array { tables: int, rows_changed: int }
 */
function smvr_search_replace_db( $conn, $prefix, $from, $to ) {
	$tables        = array();
	$res           = mysqli_query( $conn, 'SHOW TABLES' );
	while ( $res && $row = mysqli_fetch_row( $res ) ) {
		$tables[] = $row[0];
	}

	$tables_done  = 0;
	$rows_changed = 0;

	foreach ( $tables as $table ) {
		// Columnas + clave primaria.
		$cols = array();
		$pk   = null;
		$cres = mysqli_query( $conn, 'SHOW COLUMNS FROM `' . str_replace( '`', '``', $table ) . '`' );
		while ( $cres && $col = mysqli_fetch_assoc( $cres ) ) {
			$cols[] = $col['Field'];
			if ( 'PRI' === $col['Key'] && null === $pk ) {
				$pk = $col['Field'];
			}
		}
		if ( empty( $cols ) || null === $pk ) {
			continue; // sin PK no actualizamos de forma segura
		}

		$rres = mysqli_query( $conn, 'SELECT * FROM `' . str_replace( '`', '``', $table ) . '`' );
		if ( ! $rres ) {
			continue;
		}
		while ( $r = mysqli_fetch_assoc( $rres ) ) {
			$updates = array();
			foreach ( $r as $field => $value ) {
				if ( ! is_string( $value ) || $field === $pk ) {
					continue;
				}
				$rep = SMVR_Search_Replace::replace_value( $from, $to, $value );
				if ( $rep['changed'] ) {
					$updates[ $field ] = $rep['value'];
				}
			}
			if ( ! empty( $updates ) ) {
				$sets = array();
				foreach ( $updates as $field => $val ) {
					$sets[] = '`' . str_replace( '`', '``', $field ) . "`='" . mysqli_real_escape_string( $conn, $val ) . "'";
				}
				$pk_val = mysqli_real_escape_string( $conn, $r[ $pk ] );
				$sql    = 'UPDATE `' . str_replace( '`', '``', $table ) . '` SET ' . implode( ',', $sets ) .
					" WHERE `" . str_replace( '`', '``', $pk ) . "`='" . $pk_val . "' LIMIT 1";
				if ( mysqli_query( $conn, $sql ) ) {
					$rows_changed++;
				}
			}
		}
		mysqli_free_result( $rres );
		$tables_done++;
	}

	return array( 'tables' => $tables_done, 'rows_changed' => $rows_changed );
}

/**
 * Detecta la URL nueva del sitio según la petición actual.
 *
 * @return string
 */
function smvr_detect_new_url() {
	$scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';
	if ( ! empty( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) {
		$scheme = strtolower( $_SERVER['HTTP_X_FORWARDED_PROTO'] );
	}
	$host = isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : 'localhost';
	$path = isset( $_SERVER['SCRIPT_NAME'] ) ? str_replace( '\\', '/', dirname( $_SERVER['SCRIPT_NAME'] ) ) : '';
	$path = rtrim( $path, '/' );
	return $scheme . '://' . $host . $path;
}

/**
 * Genera una clave aleatoria para los salts de wp-config.
 *
 * @param int $len Longitud.
 * @return string
 */
function smvr_rand_key( $len = 64 ) {
	$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_[]{}<>~`+=,.;:/?|';
	$out   = '';
	$max   = strlen( $chars ) - 1;
	for ( $i = 0; $i < $len; $i++ ) {
		$out .= $chars[ random_int( 0, $max ) ];
	}
	return $out;
}

/**
 * Genera y escribe un wp-config.php nuevo con las credenciales del destino.
 *
 * @param array  $db       Credenciales { host, name, user, pass, prefix }.
 * @param array  $manifest manifest.json (para charset).
 * @return bool
 */
function smvr_write_wp_config( $db, $manifest ) {
	$charset = isset( $manifest['source']['charset'] ) ? $manifest['source']['charset'] : 'utf8mb4';
	$keys    = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' );

	$c  = "<?php\n";
	$c .= "/* Generado por Sitemovr Installer */\n";
	$c .= "define( 'DB_NAME', '" . addslashes( $db['name'] ) . "' );\n";
	$c .= "define( 'DB_USER', '" . addslashes( $db['user'] ) . "' );\n";
	$c .= "define( 'DB_PASSWORD', '" . addslashes( $db['pass'] ) . "' );\n";
	$c .= "define( 'DB_HOST', '" . addslashes( $db['host'] ) . "' );\n";
	$c .= "define( 'DB_CHARSET', '" . addslashes( $charset ) . "' );\n";
	$c .= "define( 'DB_COLLATE', '' );\n\n";
	foreach ( $keys as $k ) {
		$c .= "define( '" . $k . "', '" . addslashes( smvr_rand_key() ) . "' );\n";
	}
	$c .= "\n\$table_prefix = '" . addslashes( $db['prefix'] ) . "';\n\n";
	$c .= "if ( ! defined( 'ABSPATH' ) ) {\n\tdefine( 'ABSPATH', __DIR__ . '/' );\n}\n";
	$c .= "require_once ABSPATH . 'wp-settings.php';\n";

	return false !== file_put_contents( SMVR_BASE_DIR . '/wp-config.php', $c );
}

// ---------------------------------------------------------------------------
// Router muy simple por pasos.
// ---------------------------------------------------------------------------
$meta = smvr_read_parts_json();
$step = isset( $_GET['step'] ) ? preg_replace( '/[^a-z_]/', '', $_GET['step'] ) : 'welcome';

// Barrera de contraseña.
$authed = ! empty( $_SESSION['smvr_authed'] );
$lock_remaining = smvr_lock_remaining();
if ( isset( $_POST['smvr_password'] ) && $meta && ! $authed ) {
	if ( $lock_remaining > 0 ) {
		$auth_error = 'Too many failed attempts. Wait ' . ceil( $lock_remaining / 60 ) . ' minute(s) and try again.';
	} elseif ( smvr_check_password( $meta, $_POST['smvr_password'] ) ) {
		$_SESSION['smvr_authed'] = true;
		$authed                 = true;
		smvr_lock_reset();
	} else {
		smvr_lock_register_fail();
		$lock_remaining = smvr_lock_remaining();
		$auth_error     = $lock_remaining > 0
			? 'Too many failed attempts. Locked for ' . ceil( $lock_remaining / 60 ) . ' minute(s).'
			: 'Incorrect password.';
	}
}
$needs_password = $meta && ! empty( $meta['password_hash'] ) && ! $authed;

// AJAX: ejecutar una unidad de trabajo de la instalación y devolver JSON.
if ( isset( $_GET['ajax'] ) && $meta && ! $needs_password ) {
	header( 'Content-Type: application/json; charset=utf-8' );
	echo json_encode( smvr_exec_step() );
	exit;
}

?><!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sitemovr Installer</title>
<style>
 body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f0f0f1;color:#1d2327;margin:0;padding:40px 16px}
 .box{max-width:760px;margin:0 auto;background:#fff;border:1px solid #c3c4c7;border-radius:8px;padding:24px 28px}
 h1{font-size:22px;margin:0 0 4px}h1 .v{color:#787c82;font-size:13px}
 h2{font-size:17px;border-bottom:1px solid #eee;padding-bottom:8px;margin-top:24px}
 .ok{color:#0a7d27}.warn{color:#b8860b}.err{color:#c00}
 table{width:100%;border-collapse:collapse;margin:10px 0}
 th,td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;font-size:14px;word-break:break-all}
 .btn{display:inline-block;background:#2271b1;color:#fff;border:0;border-radius:4px;padding:9px 16px;font-size:14px;cursor:pointer;text-decoration:none}
 .btn:hover{background:#135e96}.btn.secondary{background:#f6f7f7;color:#2271b1;border:1px solid #2271b1}
 input[type=text],input[type=password]{width:100%;max-width:360px;padding:7px;border:1px solid #8c8f94;border-radius:4px}
 code{background:#f6f7f7;padding:1px 5px;border-radius:3px;font-size:12px}
 ul.checks{list-style:none;padding:0}ul.checks li{padding:3px 0}
 .muted{color:#787c82;font-size:13px}
</style>
</head>
<body>
<div class="box">
<h1>📦 Sitemovr Installer <span class="v">v<?php echo smvr_h( SMVR_INSTALLER_VERSION ); ?></span></h1>
<p class="muted">Migration installer for the destination server.</p>

<?php
if ( ! $meta ) {
	echo '<h2 class="err">parts.json not found</h2>';
	echo '<p>Upload parts.json and all package parts to this folder via FTP, next to this installer.</p>';
	echo '</div></body></html>';
	exit;
}

if ( $needs_password ) {
	echo '<h2>🔒 Protected access</h2>';
	echo '<p>This package is password-protected. Enter it to continue.</p>';
	if ( ! empty( $auth_error ) ) {
		echo '<p class="err">' . smvr_h( $auth_error ) . '</p>';
	}
	echo '<form method="post"><p><input type="password" name="smvr_password" autofocus placeholder="Installer password"></p>';
	echo '<p><button class="btn" type="submit">Enter</button></p></form>';
	echo '</div></body></html>';
	exit;
}

// ===========================================================================
// Router de pasos.
// ===========================================================================
switch ( $step ) {

	// ------------------------------------------------------------------
	case 'preflight':
		$manifest = smvr_read_manifest();
		if ( ! $manifest ) {
			echo '<h2 class="err">Could not read the manifest</h2><p>Reassemble the package first. <a href="?verify=1">Back</a></p>';
			break;
		}
		$checks   = smvr_preflight_checks( $manifest );
		$blocking = false;
		echo '<h2>2. Destination server compatibility</h2><ul class="checks">';
		foreach ( $checks as $c ) {
			$icon = ( 'ok' === $c['level'] ) ? '✅' : ( ( 'warn' === $c['level'] ) ? '⚠️' : '❌' );
			$cls  = ( 'ok' === $c['level'] ) ? 'ok' : ( ( 'warn' === $c['level'] ) ? 'warn' : 'err' );
			echo '<li class="' . $cls . '">' . $icon . ' ' . smvr_h( $c['message'] ) . '</li>';
			if ( 'error' === $c['level'] ) {
				$blocking = true;
			}
		}
		echo '</ul>';
		if ( $blocking ) {
			echo '<p class="err">Fix the items in red before continuing.</p>';
		} else {
			echo '<p><a class="btn" href="?step=dbconfig">Continue to the database →</a></p>';
		}
		break;

	// ------------------------------------------------------------------
	case 'dbconfig':
		$manifest = smvr_read_manifest();
		$prefix   = isset( $manifest['source']['table_prefix'] ) ? $manifest['source']['table_prefix'] : 'wp_';
		$f        = array(
			'host'    => isset( $_POST['db_host'] ) ? trim( $_POST['db_host'] ) : 'localhost',
			'name'    => isset( $_POST['db_name'] ) ? trim( $_POST['db_name'] ) : '',
			'user'    => isset( $_POST['db_user'] ) ? trim( $_POST['db_user'] ) : '',
			'pass'    => isset( $_POST['db_pass'] ) ? $_POST['db_pass'] : '',
			'prefix'  => isset( $_POST['db_prefix'] ) ? trim( $_POST['db_prefix'] ) : $prefix,
			'newurl'  => isset( $_POST['db_newurl'] ) ? rtrim( trim( $_POST['db_newurl'] ), '/' ) : smvr_detect_new_url(),
		);
		$old_url = isset( $manifest['source']['siteurl'] ) ? $manifest['source']['siteurl'] : '';
		$tested = false;
		$error  = '';
		$tcount = 0;

		if ( isset( $_POST['db_test'] ) ) {
			$conn = smvr_db_connect( $f['host'], $f['user'], $f['pass'], $f['name'] );
			if ( is_string( $conn ) ) {
				$error = $conn;
			} else {
				$tested = true;
				$tcount = smvr_db_table_count( $conn );
				mysqli_close( $conn );
				// Guardar credenciales para el paso de ejecución.
				$_SESSION['smvr_db'] = $f;
			}
		}

		echo '<h2>3. Destination database</h2>';
		echo '<p class="muted">Enter the details of the NEW database where the site will be installed.</p>';
		if ( $error ) {
			echo '<p class="err">❌ ' . smvr_h( $error ) . '</p>';
		}
		?>
		<form method="post">
		 <table>
		  <tr><th>Server (host)</th><td><input type="text" name="db_host" value="<?php echo smvr_h( $f['host'] ); ?>"></td></tr>
		  <tr><th>Database name</th><td><input type="text" name="db_name" value="<?php echo smvr_h( $f['name'] ); ?>"></td></tr>
		  <tr><th>User</th><td><input type="text" name="db_user" value="<?php echo smvr_h( $f['user'] ); ?>"></td></tr>
		  <tr><th>Password</th><td><input type="password" name="db_pass" value="<?php echo smvr_h( $f['pass'] ); ?>"></td></tr>
		  <tr><th>Table prefix</th><td><input type="text" name="db_prefix" value="<?php echo smvr_h( $f['prefix'] ); ?>"></td></tr>
		  <tr><th>New site URL</th><td>
		    <input type="text" name="db_newurl" value="<?php echo smvr_h( $f['newurl'] ); ?>">
		    <div class="muted">Source: <code><?php echo smvr_h( $old_url ); ?></code>. Auto-detected by default; change it if you migrate to another domain (e.g. <code>https://midominio.com</code>).</div>
		  </td></tr>
		 </table>
		 <p><button class="btn secondary" type="submit" name="db_test" value="1">Test connection</button></p>
		</form>
		<?php
		if ( $tested ) {
			echo '<p class="ok">✅ Connected to <code>' . smvr_h( $f['name'] ) . '</code>.</p>';
			if ( $tcount > 0 ) {
				?>
				<p class="warn">⚠️ <strong>This database already contains <?php echo (int) $tcount; ?> table(s).</strong>
				Continuing will <strong>DELETE</strong> all its current data. We recommend backing it up first.</p>
				<form method="post" action="?step=execute">
				 <input type="hidden" name="db_newurl" value="<?php echo smvr_h( $f['newurl'] ); ?>">
				 <p><label><input type="checkbox" name="confirm_wipe" value="1" required> I understand the current data in this database will be deleted.</label></p>
				 <p><button class="btn" type="submit">Continue and install →</button></p>
				</form>
				<?php
			} else {
				echo '<p class="ok">The database is empty. Ready to install.</p>';
				echo '<form method="post" action="?step=execute"><input type="hidden" name="db_newurl" value="' . smvr_h( $f['newurl'] ) . '"><p><button class="btn" type="submit">Continue and install →</button></p></form>';
			}
		}
		break;

	// ------------------------------------------------------------------
	case 'execute':
		echo '<h2>4. Installation</h2>';
		if ( empty( $_SESSION['smvr_db'] ) ) {
			echo '<p class="err">No hay credenciales de BD. <a href="?step=dbconfig">Volver al paso 3</a>.</p>';
			break;
		}
		// Conservar la URL nueva elegida por el usuario (campo oculto del paso BD).
		if ( isset( $_POST['db_newurl'] ) && '' !== trim( $_POST['db_newurl'] ) ) {
			$_SESSION['smvr_db']['newurl'] = rtrim( trim( $_POST['db_newurl'] ), '/' );
		}
		// Inicializar la máquina de estados de la ejecución (por lotes vía AJAX).
		$_SESSION['smvr_exec'] = array(
			'phase'        => 'wipe',
			'wipe'         => ! empty( $_POST['confirm_wipe'] ),
			'extract_idx'  => 0,
			'extract_done' => 0,
			'sql_offset'   => 0,
			'sr_index'     => 0,
			'rows_changed' => 0,
		);
		?>
		<p class="muted">Processing in batches. Do not close this page until it finishes.</p>
		<div style="background:#e0e0e0;border-radius:4px;height:20px;overflow:hidden;max-width:560px">
		 <div id="smvr-fill" style="background:#2271b1;height:100%;width:0;transition:width .2s"></div>
		</div>
		<p id="smvr-lbl" class="muted"></p>
		<ul id="smvr-log" class="checks"></ul>
		<div id="smvr-finish" style="display:none"></div>
		<script>
		(function(){
		 var base = location.pathname + '?ajax=1';
		 var last = '';
		 var fill = document.getElementById('smvr-fill');
		 var lbl  = document.getElementById('smvr-lbl');
		 var log  = document.getElementById('smvr-log');
		 function addLog(txt, cls){ var li=document.createElement('li'); li.className=cls||'ok'; li.textContent=txt; log.appendChild(li); }
		 var phaseNames = {wipe:'Preparing', extract:'Files extracted', import:'Database imported', sr_init:'Preparing URLs', searchreplace:'URLs and paths updated', wpconfig:'wp-config.php generated', done:'Completed'};
		 function step(){
		  fetch(base, {headers:{'X-Requested-With':'XMLHttpRequest'}}).then(function(r){return r.json();}).then(function(d){
		   fill.style.width = (d.percent||0) + '%';
		   lbl.textContent = (d.label||'') + ' — ' + (d.percent||0) + '%';
		   if (d.phase === 'error') { addLog('❌ ' + d.label, 'err'); return; }
		   if (d.phase !== last && phaseNames[d.phase] && d.phase!=='wipe') { addLog('✅ ' + phaseNames[last] , 'ok'); }
		   last = d.phase;
		   if (d.done) {
		     fill.style.width='100%';
		     var html = '<p class="ok"><strong>🎉 Migration complete.</strong></p>';
		     html += '<p class="muted">URLs/paths updated: ' + (d.rows_changed||0) + ' rows.</p>';
		     html += '<p><a class="btn" href="' + d.new_url + '" target="_blank">Open the migrated site →</a> ';
		     html += '<a class="btn secondary" href="?step=cleanup">Finish and clean up</a></p>';
		     document.getElementById('smvr-finish').innerHTML = html;
		     document.getElementById('smvr-finish').style.display='block';
		     return;
		   }
		   setTimeout(step, 30);
		  }).catch(function(){ addLog('❌ Communication error with the server.', 'err'); });
		 }
		 step();
		})();
		</script>
		<?php
		break;

	// ------------------------------------------------------------------
	case 'cleanup':
		echo '<h2>5. Finish and clean up</h2>';
		$new_url = isset( $_SESSION['smvr_new_url'] ) ? $_SESSION['smvr_new_url'] : smvr_detect_new_url();

		// Borrar todos los archivos de instalación (seguridad obligatoria).
		$removed = array();

		// Partes + parts.json.
		$pmeta = smvr_read_parts_json();
		if ( $pmeta && ! empty( $pmeta['parts'] ) ) {
			foreach ( $pmeta['parts'] as $p ) {
				if ( @unlink( SMVR_BASE_DIR . '/' . $p['name'] ) ) {
					$removed[] = $p['name'];
				}
			}
		}
		foreach ( array( 'parts.json', 'smvr-package.zip', 'database.sql', '.smvr_lock' ) as $f ) {
			if ( file_exists( SMVR_BASE_DIR . '/' . $f ) && @unlink( SMVR_BASE_DIR . '/' . $f ) ) {
				$removed[] = $f;
			}
		}

		session_destroy();

		echo '<ul class="checks">';
		echo '<li class="ok">✅ Installation files removed (' . count( $removed ) . ').</li>';
		echo '<li class="ok">✅ Session closed.</li>';
		echo '</ul>';
		echo '<p class="warn">⚠️ IMPORTANT: also delete <code>installer.php</code> via FTP if it is still present.</p>';

		// Auto-borrado del propio installer (best-effort).
		@unlink( __FILE__ );

		echo '<p class="ok"><strong>🎉 Done! Your site has been migrated.</strong></p>';
		echo '<p><a class="btn" href="' . smvr_h( $new_url ) . '">Go to the site</a> ';
		echo '<a class="btn secondary" href="' . smvr_h( $new_url ) . '/wp-login.php">Go to admin</a></p>';
		break;

	// ------------------------------------------------------------------
	default: // 'welcome' — detección y verificación de partes.
		$present = smvr_check_parts_present( $meta );
		?>
		<h2>1. Package detected</h2>
		<table>
		 <tr><th>Source</th><td><?php echo smvr_h( isset( $meta['source_url'] ) ? $meta['source_url'] : '—' ); ?></td></tr>
		 <tr><th>Generated</th><td><?php echo smvr_h( isset( $meta['created_at'] ) ? str_replace( 'T', ' ', substr( $meta['created_at'], 0, 16 ) ) : '—' ); ?></td></tr>
		 <tr><th>Parts</th><td><?php echo (int) $meta['total']; ?> · <?php echo smvr_human( $present['total_bytes'] ); ?></td></tr>
		</table>
		<?php
		if ( ! $present['ok'] ) {
			echo '<p class="err">❌ Missing parts to upload: <code>' . smvr_h( implode( ', ', $present['missing'] ) ) . '</code></p>';
			echo '<p class="muted">Upload the missing parts via FTP and reload this page.</p>';
		} elseif ( isset( $_GET['verify'] ) ) {
			$v = smvr_verify_parts( $meta );
			if ( $v['ok'] ) {
				echo '<p class="ok">✅ Checksums verified: the parts are intact.</p>';
				$re = smvr_reassemble( $meta );
				if ( $re && file_exists( SMVR_PACKAGE_ZIP ) ) {
					$z     = new ZipArchive();
					$valid = ( $z->open( SMVR_PACKAGE_ZIP ) === true && $z->numFiles > 0 );
					if ( $valid ) {
						$z->close();
					}
					echo $valid
						? '<p class="ok">✅ Package reassembled and valid (' . smvr_human( filesize( SMVR_PACKAGE_ZIP ) ) . ').</p><p><a class="btn" href="?step=preflight">Continue to the compatibility check →</a></p>'
						: '<p class="err">❌ The reassembled package is not a valid ZIP.</p>';
				} else {
					echo '<p class="err">❌ Could not reassemble the package (write permissions?).</p>';
				}
			} else {
				echo '<p class="err">❌ Bad checksum in: <code>' . smvr_h( implode( ', ', $v['bad'] ) ) . '</code>. Re-upload those parts via FTP.</p>';
			}
		} else {
			echo '<p class="ok">✅ All parts (' . (int) $meta['total'] . ') are present.</p>';
			echo '<p><a class="btn" href="?verify=1">Verify integrity and reassemble →</a></p>';
		}
		break;
}
?>
</div>
</body>
</html>
