<?php
/**
 * PPPHP Migration Agent  —  runs ON THE DESTINATION server.
 *
 * This single, dependency-free file is uploaded (with the migration bundle) into
 * <docroot>/_ppmigrate/<jobId>/ on the NEW server. The source console then drives
 * it over ordinary HTTPS. Running on the destination is what makes the engine work
 * on EVERY kind of host — cPanel, DirectAdmin, Plesk or a bare VPS — because the
 * target's MySQL is almost always reachable only from localhost, so the database
 * restore has to happen here, not from the old server.
 *
 * It is deliberately self-contained: only PDO + ZipArchive, no PPPHP classes, no
 * Composer. Every request is authenticated with a one-time token (only its SHA-256
 * hash is stored beside this file); every destructive action requires an explicit
 * confirm flag and supports a dry run first. Paths are constrained to prevent
 * directory traversal / zip-slip. Delete the whole _ppmigrate/<jobId>/ folder when
 * the migration is done (the `cleanup` action does this).
 *
 * @package PPPHP\Migrate\Agent
 */

declare(strict_types=1);

error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
ini_set('display_errors', '0');
@set_time_limit(0);
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');

const AGENT_VERSION = '1.0.0';
$BUNDLE = __DIR__;

/** JSON reply and exit. */
function reply(array $data, int $code = 200): never
{
    http_response_code($code);
    echo json_encode($data, JSON_UNESCAPED_SLASHES);
    exit;
}

/** Read a request field from JSON body or form post. */
function input(): array
{
    $raw = file_get_contents('php://input') ?: '';
    $j = json_decode($raw, true);
    if (is_array($j)) { return $j + $_POST + $_GET; }
    return $_POST + $_GET;
}

// ── Auth ──────────────────────────────────────────────────────────────
$meta = @json_decode((string) @file_get_contents($BUNDLE . '/agent-meta.json'), true);
if (!is_array($meta) || empty($meta['tokenHash'])) {
    reply(['ok' => false, 'error' => 'Agent not initialised (no agent-meta.json).'], 500);
}
$req    = input();
$token  = (string) ($req['token'] ?? ($_SERVER['HTTP_X_PP_TOKEN'] ?? ''));
$action = (string) ($req['action'] ?? 'ping');
if ($token === '' || !hash_equals((string) $meta['tokenHash'], hash('sha256', $token))) {
    reply(['ok' => false, 'error' => 'Unauthorised.'], 403);
}

// ── Helpers ───────────────────────────────────────────────────────────

/** Resolve a caller-supplied target root, refusing anything non-absolute/missing. */
function resolveRoot(array $req): ?string
{
    $root = (string) ($req['targetRoot'] ?? '');
    if ($root === '') { return null; }
    $real = realpath($root);
    return ($real !== false && is_dir($real)) ? $real : null;
}

/** A PDO connection to a target database, or null with the error captured. */
function pdoFor(array $c, ?string &$err): ?PDO
{
    try {
        $dsn = sprintf('mysql:host=%s;port=%d;charset=utf8mb4',
            (string) ($c['host'] ?? '127.0.0.1'), (int) ($c['port'] ?? 3306));
        if (!empty($c['dbname'])) { $dsn .= ';dbname=' . $c['dbname']; }
        return new PDO($dsn, (string) ($c['user'] ?? ''), (string) ($c['pass'] ?? ''), [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_TIMEOUT => 15,
        ]);
    } catch (Throwable $e) {
        $err = $e->getMessage();
        return null;
    }
}

/** Split a PPPHP dump into statements (matches DbCloner's ";\n" format). */
function splitSql(string $sql): array
{
    $out = [];
    foreach (preg_split('/;\s*\n/', $sql) ?: [] as $s) {
        $s = trim($s);
        if ($s === '' || str_starts_with($s, '--')) { continue; }
        $out[] = rtrim($s, ';');
    }
    return $out;
}

$manifest = @json_decode((string) @file_get_contents($BUNDLE . '/manifest.json'), true);
$manifest = is_array($manifest) ? $manifest : [];

// ── Actions ───────────────────────────────────────────────────────────
switch ($action) {

    // Liveness + environment probe. Non-destructive.
    case 'ping':
        reply([
            'ok'        => true,
            'version'   => AGENT_VERSION,
            'jobId'     => (string) ($meta['jobId'] ?? ''),
            'php'       => PHP_VERSION,
            'ext'       => [
                'pdo_mysql' => extension_loaded('pdo_mysql'),
                'zip'       => class_exists('ZipArchive'),
                'openssl'   => extension_loaded('openssl'),
            ],
            'bundleDir'    => $BUNDLE,
            'writable'     => is_writable($BUNDLE),
            'manifest'     => $manifest !== [],
            'suggestedRoot'=> dirname($BUNDLE, 2), // <docroot> above _ppmigrate/<id>
        ]);

    // Recompute SHA-256 of every uploaded artifact and compare to the manifest.
    // This proves the transport was byte-perfect. Non-destructive.
    case 'verify':
        $rows = [];
        $allOk = true;
        foreach (array_merge($manifest['databases'] ?? [], $manifest['files'] ?? []) as $a) {
            $rel = (string) ($a['file'] ?? '');
            if ($rel === '') { continue; }
            $abs = $BUNDLE . '/' . $rel;
            $present = is_file($abs);
            $actual  = $present ? hash_file('sha256', $abs) : '';
            $ok = $present && isset($a['sha256']) && hash_equals((string) $a['sha256'], $actual);
            if (!$ok) { $allOk = false; }
            $rows[] = ['file' => $rel, 'present' => $present, 'ok' => $ok,
                       'expected' => (string) ($a['sha256'] ?? ''), 'actual' => $actual];
        }
        reply(['ok' => $allOk, 'artifacts' => $rows]);

    // Test / create the target databases. `create` attempts CREATE DATABASE (works
    // on a VPS with privilege; on panel hosts the DB is pre-made, so a failed
    // create with a successful connect is still fine). Supports dryRun.
    case 'provision':
        $dbs = (array) ($req['databases'] ?? []);
        $dry = !empty($req['dryRun']);
        $rows = [];
        foreach ($dbs as $c) {
            $name = (string) ($c['dbname'] ?? $c['name'] ?? '');
            $err = null;
            // Connect to the server (no db) first — proves the host/user/password.
            $server = $c; unset($server['dbname']);
            $pdo = pdoFor($server, $err);
            $created = false; $connected = false; $serverOk = (bool) $pdo;
            if ($pdo) {
                if (!$dry && !empty($c['create']) && $name !== '') {
                    try { $pdo->exec('CREATE DATABASE IF NOT EXISTS `' . str_replace('`', '', $name) . '` CHARACTER SET utf8mb4'); $created = true; }
                    catch (Throwable $e) { $err = $e->getMessage(); }
                }
                // Now confirm we can reach the named database.
                $err2 = null; $withDb = $c + ['dbname' => $name];
                $p2 = pdoFor($withDb, $err2);
                $connected = (bool) $p2;
                if (!$connected && $err === null) { $err = $err2; }
            }
            $rows[] = ['name' => $name, 'serverOk' => $serverOk, 'connected' => $connected,
                       'created' => $created, 'error' => $err, 'dryRun' => $dry];
        }
        reply(['ok' => !in_array(false, array_column($rows, 'connected'), true) || $dry, 'databases' => $rows]);

    // Import bundle SQL files into their target databases. Destructive → requires
    // confirm:true; dryRun counts statements without executing.
    case 'restore':
        $map = (array) ($req['databases'] ?? []); // [{file, host,port,user,pass,dbname}]
        $dry = !empty($req['dryRun']);
        if (!$dry && empty($req['confirm'])) {
            reply(['ok' => false, 'error' => 'Restore is destructive — pass confirm:true (or dryRun:true).'], 400);
        }
        $rows = [];
        foreach ($map as $c) {
            $rel = (string) ($c['file'] ?? '');
            // Constrain to the bundle's db/ tree (defends against path traversal).
            $abs = $BUNDLE . '/' . ltrim($rel, '/');
            if (strncmp($abs, $BUNDLE . '/db/', strlen($BUNDLE . '/db/')) !== 0 || !is_file($abs)) {
                $rows[] = ['dbname' => $c['dbname'] ?? '', 'ok' => false, 'error' => 'SQL file not found in bundle: ' . $rel];
                continue;
            }
            $sql = (string) file_get_contents($abs);
            $stmts = splitSql($sql);
            if ($dry) {
                $rows[] = ['dbname' => $c['dbname'] ?? '', 'ok' => true, 'statements' => count($stmts), 'dryRun' => true];
                continue;
            }
            $err = null; $pdo = pdoFor($c, $err);
            if (!$pdo) { $rows[] = ['dbname' => $c['dbname'] ?? '', 'ok' => false, 'error' => $err]; continue; }
            $ran = 0;
            try {
                foreach ($stmts as $st) { $pdo->exec($st); $ran++; }
                $rows[] = ['dbname' => $c['dbname'] ?? '', 'ok' => true, 'statements' => $ran];
            } catch (Throwable $e) {
                $rows[] = ['dbname' => $c['dbname'] ?? '', 'ok' => false, 'statements' => $ran, 'error' => $e->getMessage()];
            }
        }
        reply(['ok' => !in_array(false, array_column($rows, 'ok'), true), 'databases' => $rows]);

    // Extract file archives into the target root. Destructive → confirm:true;
    // dryRun reports entry counts. Zip-slip guarded.
    case 'unpack':
        $root = resolveRoot($req);
        $dry  = !empty($req['dryRun']);
        if (!$root) { reply(['ok' => false, 'error' => 'targetRoot missing or not a directory.'], 400); }
        if (!$dry && empty($req['confirm'])) {
            reply(['ok' => false, 'error' => 'Unpack overwrites files — pass confirm:true (or dryRun:true).'], 400);
        }
        if (!class_exists('ZipArchive')) { reply(['ok' => false, 'error' => 'ZipArchive not available on this host.'], 500); }
        $rows = [];
        foreach (($manifest['files'] ?? []) as $a) {
            $rel = (string) ($a['file'] ?? '');
            $abs = $BUNDLE . '/' . ltrim($rel, '/');
            if (strncmp($abs, $BUNDLE . '/files/', strlen($BUNDLE . '/files/')) !== 0 || !is_file($abs)) {
                $rows[] = ['file' => $rel, 'ok' => false, 'error' => 'archive missing'];
                continue;
            }
            $zip = new ZipArchive();
            if ($zip->open($abs) !== true) { $rows[] = ['file' => $rel, 'ok' => false, 'error' => 'cannot open zip']; continue; }
            $entries = $zip->numFiles;
            if ($dry) { $zip->close(); $rows[] = ['file' => $rel, 'ok' => true, 'entries' => $entries, 'dryRun' => true]; continue; }
            $bad = false;
            for ($i = 0; $i < $entries; $i++) {
                $name = (string) $zip->getNameIndex($i);
                // Zip-slip guard: reject absolute or ../ escaping names.
                if ($name === '' || $name[0] === '/' || str_contains($name, '..')) { $bad = true; break; }
            }
            if ($bad) { $zip->close(); $rows[] = ['file' => $rel, 'ok' => false, 'error' => 'unsafe path in archive']; continue; }
            $ok = $zip->extractTo($root);
            $zip->close();
            $rows[] = ['file' => $rel, 'ok' => (bool) $ok, 'entries' => $entries];
        }
        reply(['ok' => !in_array(false, array_column($rows, 'ok'), true), 'files' => $rows]);

    // Write the target's config/database.php (and record the new base URL).
    // Backs up any existing config first. Destructive → confirm:true.
    case 'rewrite':
        $root = resolveRoot($req);
        $dry  = !empty($req['dryRun']);
        if (!$root) { reply(['ok' => false, 'error' => 'targetRoot missing or not a directory.'], 400); }
        $db = (array) ($req['db'] ?? []);
        $baseUrl = (string) ($req['baseUrl'] ?? '');
        $configDir = $root . '/config';
        $target = $configDir . '/database.php';
        if ($dry) {
            reply(['ok' => true, 'dryRun' => true, 'wouldWrite' => $target,
                   'configDirWritable' => is_dir($configDir) ? is_writable($configDir) : is_writable($root)]);
        }
        if (empty($req['confirm'])) { reply(['ok' => false, 'error' => 'Rewriting config overwrites credentials — pass confirm:true.'], 400); }
        if (!is_dir($configDir) && !@mkdir($configDir, 0755, true)) {
            reply(['ok' => false, 'error' => 'Cannot create config directory.'], 500);
        }
        if (is_file($target)) { @copy($target, $target . '.pre-migrate.bak'); }
        $php = "<?php\n\ndeclare(strict_types=1);\n\n// Written by the PPPHP migration agent.\nreturn [\n"
             . "    'host' => " . var_export((string) ($db['host'] ?? '127.0.0.1'), true) . ",\n"
             . "    'port' => " . var_export((string) ($db['port'] ?? '3306'), true) . ",\n"
             . "    'dbname' => " . var_export((string) ($db['dbname'] ?? ''), true) . ",\n"
             . "    'user' => " . var_export((string) ($db['user'] ?? ''), true) . ",\n"
             . "    'pass' => " . var_export((string) ($db['pass'] ?? ''), true) . ",\n"
             . "];\n";
        $wrote = @file_put_contents($target, $php);
        if ($baseUrl !== '') { @file_put_contents($configDir . '/base-url.txt', $baseUrl . "\n"); }
        reply(['ok' => $wrote !== false, 'wrote' => $target, 'bytes' => (int) $wrote]);

    // Remove the whole _ppmigrate/<jobId>/ folder (bundle + agent + token).
    case 'cleanup':
        $ok = rrmdir($BUNDLE);
        reply(['ok' => $ok, 'removed' => $BUNDLE]);

    default:
        reply(['ok' => false, 'error' => 'Unknown action: ' . $action], 400);
}

/** Recursively delete a directory. */
function rrmdir(string $dir): bool
{
    if (!is_dir($dir)) { return true; }
    $items = scandir($dir) ?: [];
    foreach ($items as $it) {
        if ($it === '.' || $it === '..') { continue; }
        $p = $dir . '/' . $it;
        is_dir($p) ? rrmdir($p) : @unlink($p);
    }
    return @rmdir($dir);
}
