<?php

declare(strict_types=1);

namespace PpPhp\Modules\Settings\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\Deploy\TransportFactory;
use PpPhp\Core\Migrate\AgentBuilder;
use PpPhp\Core\Migrate\AgentClient;
use PpPhp\Core\Migrate\BundleTransporter;
use PpPhp\Core\Migrate\MigrationFlip;
use PpPhp\Core\Migrate\MigrationInventory;
use PpPhp\Core\Migrate\MigrationJob;
use PpPhp\Core\Migrate\MigrationPlan;
use PpPhp\Core\Migrate\MigrationRunner;
use PpPhp\Core\PlatformSettings;
use PpPhp\Core\Security;

/**
 * Owner-only "Migrate / Transfer Server" console.
 *
 * Phase 1 (this build): a read-only INVENTORY of everything that can move, a saved
 * DESTINATION (SFTP/FTP + optional panel API), and a real PREFLIGHT that probes the
 * target without changing anything. The destructive phases (package → transport →
 * provision → restore → verify → flip) build on top of this, gated behind a
 * verified preflight + explicit confirmation. Nothing here is ever run for a
 * non-owner, and the destination password is encrypted at rest.
 */
class MigrateController
{
    private const SETTING = 'platform.migrate';

    public function __construct(private Config $config) {}

    private function gate(bool $json = false): void
    {
        Security::RequireAuth();
        if (!Security::IsOwner()) {
            if ($json) {
                header('Content-Type: application/json');
                http_response_code(403);
                echo json_encode(['ok' => false, 'message' => 'Server migration is owner-only.']);
                exit;
            }
            redirect('/projects');
        }
    }

    public function index(): string
    {
        $this->gate();
        $baseUrl   = rtrim((string) $this->config->get('base_url'), '/');
        $assetsUrl = (string) $this->config->get('assets_url');
        $inv       = MigrationInventory::summary();
        $dest      = $this->savedDestination();
        $recent    = MigrationJob::recent(8);
        $flip      = MigrationFlip::info();
        $csrf      = Security::CSRF()->Token();
        ob_start();
        require __DIR__ . '/../Views/migrate.php';
        return (string) ob_get_clean();
    }

    /** POST: probe the destination server without changing anything. */
    public function preflight(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }

        $profile = $this->profileFromInput($_POST);
        if ($profile['host'] === '' || $profile['user'] === '') {
            echo json_encode(['ok' => false, 'message' => 'Enter at least the destination host and username.']);
            exit;
        }

        // Fall back to the saved (encrypted) password when the field was left blank.
        if ($profile['pass'] === '') {
            $enc = (string) (PlatformSettings::get(self::SETTING)['pass'] ?? '');
            if ($enc !== '') { $profile['pass'] = PlatformSettings::secretDecrypt($enc); }
        }

        // Persist the destination for convenience (password encrypted at rest).
        if (!empty($_POST['save'])) {
            $this->saveDestination($_POST);
        }

        $report = ['ok' => false, 'protocol' => $profile['protocol'], 'steps' => []];
        try {
            $transport = TransportFactory::make($profile);
            $diag = $transport->diagnose();       // {ok, summary, stages} — never throws
            $transport->close();
            // Normalise the diagnose() report into the flat step list the UI renders.
            $report['steps'] = array_map(static function ($s) {
                return [
                    'ok'     => (bool) ($s['ok'] ?? false),
                    'label'  => (string) ($s['stage'] ?? ''),
                    'detail' => (string) ($s['detail'] ?? ''),
                ];
            }, (array) ($diag['stages'] ?? []));
            $report['ok'] = (bool) ($diag['ok'] ?? false);
            $report['message'] = (string) ($diag['summary'] ?? ($report['ok']
                ? 'Destination reachable and writable — ready to migrate.'
                : 'Some checks failed — see the details below.'));
        } catch (\Throwable $e) {
            $report['message'] = 'Could not reach the destination: ' . $e->getMessage();
        }

        // Environment/local readiness the target migration will rely on.
        $report['local'] = [
            'zip'      => class_exists('ZipArchive'),
            'openssl'  => extension_loaded('openssl'),
            'pdo'      => extension_loaded('pdo_mysql'),
            'phpVersion' => PHP_VERSION,
        ];
        echo json_encode($report);
        exit;
    }

    /**
     * POST: create a migration job and run the implemented pipeline (preflight +
     * snapshot). Returns the job id so the browser can poll status().
     */
    public function start(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }

        // Persist the destination as typed (encrypts a new password if given).
        $this->saveDestination($_POST);

        $inv  = MigrationInventory::summary();
        $plan = MigrationPlan::fromInput($_POST, $inv);
        $job  = MigrationJob::create($plan);

        // Resolve the destination password (posted, else the saved encrypted one).
        $pass = (string) ($_POST['pass'] ?? '');
        if ($pass === '') {
            $enc = (string) (PlatformSettings::get(self::SETTING)['pass'] ?? '');
            if ($enc !== '') { $pass = PlatformSettings::secretDecrypt($enc); }
        }

        // Return the job id to the browser NOW, then run the (long) package/transport
        // AFTER the response is flushed. Running it before responding meant the whole
        // migration happened inside this one request — so on any host with a request
        // timeout (cPanel PHP-FPM / LiteSpeed) the connection was killed before the id
        // was ever delivered ("network connection was lost" → "Migration failed to
        // start"), and the UI could never poll live progress. The UI now gets the id
        // immediately and polls status() for live phase/log updates.
        $this->respondAndContinue(['ok' => true, 'id' => $job->id(), 'job' => $job->toArray()]);

        @ignore_user_abort(true);
        @set_time_limit(0);
        (new MigrationRunner($job, $plan))->run($pass !== '' ? $pass : null);
        exit;
    }

    /**
     * Send a JSON response to the client immediately and keep the PHP process running
     * so long-running work (packaging, transport, restore) continues in the
     * "background" of the same request. Uses the SAPI's finish-request hook where
     * available (PHP-FPM / LiteSpeed), else closes the connection with a fixed length.
     */
    private function respondAndContinue(array $payload): void
    {
        @ignore_user_abort(true);
        $json = json_encode($payload);

        if (function_exists('fastcgi_finish_request')) {
            echo $json;
            fastcgi_finish_request();
            return;
        }

        // Fallback for mod_php / CGI: tell the browser exactly how much to read and to
        // close, flush, then continue. LiteSpeed has its own finish hook.
        while (ob_get_level() > 0) { ob_end_clean(); }
        ignore_user_abort(true);
        ob_start();
        echo $json;
        header('Content-Length: ' . ob_get_length());
        header('Connection: close');
        ob_end_flush();
        flush();
        if (function_exists('litespeed_finish_request')) { litespeed_finish_request(); }
    }

    /**
     * POST: real cutover — package, transport, then commit on the destination
     * (provision + restore the platform DB + unpack files + rewrite config) using
     * the supplied target database credentials. Destructive on the destination;
     * the agent still confirms each step.
     */
    public function commit(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }
        if (empty($_POST['confirm'])) {
            echo json_encode(['ok' => false, 'message' => 'Commit must be explicitly confirmed.']);
            exit;
        }

        $this->saveDestination($_POST);
        $inv  = MigrationInventory::summary();
        $plan = MigrationPlan::fromInput($_POST, $inv);
        if (trim((string) $plan->destination['host']) === '' || trim((string) $plan->destination['newBaseUrl']) === '') {
            echo json_encode(['ok' => false, 'message' => 'A destination host and new site URL are required to commit.']);
            exit;
        }

        $job = MigrationJob::create($plan);

        $pass = (string) ($_POST['pass'] ?? '');
        if ($pass === '') {
            $enc = (string) (PlatformSettings::get(self::SETTING)['pass'] ?? '');
            if ($enc !== '') { $pass = PlatformSettings::secretDecrypt($enc); }
        }

        $runner = new MigrationRunner($job, $plan);
        $runner->setTarget([
            'host'   => (string) ($_POST['targetDbHost'] ?? 'localhost'),
            'port'   => (int) ($_POST['targetDbPort'] ?? 3306),
            'user'   => (string) ($_POST['targetDbUser'] ?? ''),
            'pass'   => (string) ($_POST['targetDbPass'] ?? ''),
            'dbname' => (string) ($_POST['targetDbName'] ?? ''),
            'create' => !empty($_POST['targetDbCreate']),
        ]);

        // Same as start(): hand the browser the job id first, then run the destructive
        // commit after the response is flushed, so the UI polls live progress instead
        // of the request timing out before it can report the id.
        $this->respondAndContinue(['ok' => true, 'id' => $job->id(), 'job' => $job->toArray()]);

        @ignore_user_abort(true);
        @set_time_limit(0);
        $runner->run($pass !== '' ? $pass : null, true);
        exit;
    }

    /**
     * POST: restore ONE managed user database on the destination, with its own
     * target credentials. (The platform DB is restored by the main commit; managed
     * databases are restored individually because each may live on its own target
     * database with distinct credentials.)
     */
    public function restoreDb(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }
        $job = MigrationJob::load((string) ($_POST['id'] ?? ''));
        if (!$job) { echo json_encode(['ok' => false, 'message' => 'Unknown migration job.']); exit; }
        $plan = $job->plan();
        $newUrl = (string) ($plan?->destination['newBaseUrl'] ?? '');
        if ($newUrl === '') { echo json_encode(['ok' => false, 'message' => 'This job has no destination agent URL.']); exit; }

        // Locate the requested managed DB file in the manifest.
        $man = json_decode((string) @file_get_contents($job->bundleDir() . '/manifest.json'), true) ?: [];
        $file = ''; $srcName = '';
        foreach ((array) ($man['databases'] ?? []) as $d) {
            if (($d['kind'] ?? '') === 'managed' && (int) ($d['id'] ?? 0) === (int) ($_POST['dbId'] ?? -1)) {
                $file = (string) $d['file']; $srcName = (string) ($d['name'] ?? ''); break;
            }
        }
        if ($file === '') { echo json_encode(['ok' => false, 'message' => 'That database is not in this bundle.']); exit; }

        $creds = [
            'host'   => (string) ($_POST['dbHost'] ?? 'localhost') ?: 'localhost',
            'port'   => (int) ($_POST['dbPort'] ?? 3306) ?: 3306,
            'user'   => trim((string) ($_POST['dbUser'] ?? '')),
            'pass'   => (string) ($_POST['dbPass'] ?? ''),
            'dbname' => trim((string) ($_POST['dbName'] ?? '')),
            'create' => !empty($_POST['dbCreate']),
        ];
        if ($creds['user'] === '' || $creds['dbname'] === '') {
            echo json_encode(['ok' => false, 'message' => 'Target DB user and name are required.']); exit;
        }

        @set_time_limit(0);
        $client = new AgentClient(AgentClient::endpointFor($newUrl, $job->id()), $job->agentToken());
        $prov = $client->provision([$creds], false);
        $pr = ((array) ($prov['databases'] ?? []))[0] ?? [];
        if (!($pr['connected'] ?? false)) {
            echo json_encode(['ok' => false, 'message' => 'Cannot reach target DB: ' . ($pr['error'] ?? 'unknown')]); exit;
        }
        $res = $client->restore([['file' => $file, 'dbname' => $creds['dbname']] + $creds], true, false);
        $row = ((array) ($res['databases'] ?? []))[0] ?? [];
        if (!($row['ok'] ?? false)) {
            echo json_encode(['ok' => false, 'message' => 'Restore failed: ' . ($row['error'] ?? 'unknown')]); exit;
        }
        $job->log('Restored managed DB `' . $srcName . '` → ' . $creds['dbname'] . ' (' . ($row['statements'] ?? 0) . ' statements).');
        echo json_encode(['ok' => true, 'statements' => (int) ($row['statements'] ?? 0),
                          'message' => 'Restored ' . $srcName . ' → ' . $creds['dbname'] . '.']);
        exit;
    }

    /** POST: engage the cut-over — mark THIS server migrated to the new URL. */
    public function flip(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }
        if (empty($_POST['confirm'])) { echo json_encode(['ok' => false, 'message' => 'Cut-over must be confirmed.']); exit; }
        $newUrl = trim((string) ($_POST['newBaseUrl'] ?? '')) ?: (string) ($this->savedDestination()['newBaseUrl'] ?? '');
        if ($newUrl === '') { echo json_encode(['ok' => false, 'message' => 'A new site URL is required to flip.']); exit; }
        $ok = MigrationFlip::engage($newUrl, (string) ($_POST['id'] ?? ''));
        echo json_encode(['ok' => $ok, 'engaged' => MigrationFlip::isEngaged(), 'newUrl' => $newUrl,
                          'message' => $ok ? 'This server now shows a "moved" notice to visitors.' : 'Could not write the flag.']);
        exit;
    }

    /** POST: revert the cut-over — bring THIS server back into service. */
    public function unflip(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }
        $ok = MigrationFlip::revert();
        echo json_encode(['ok' => $ok, 'engaged' => MigrationFlip::isEngaged(),
                          'message' => $ok ? 'This server is back in service.' : 'Could not remove the flag.']);
        exit;
    }

    /** GET: current state of a running/finished job, for the progress panel. */
    public function status(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');
        $job = MigrationJob::load((string) ($_GET['id'] ?? ''));
        if (!$job) {
            echo json_encode(['ok' => false, 'message' => 'Unknown migration job.']);
            exit;
        }
        echo json_encode(['ok' => true, 'job' => $job->toArray()]);
        exit;
    }

    /**
     * POST: list folders on the destination for the remote-path picker, so the
     * owner can navigate and click a folder instead of typing it. Read-only.
     */
    public function browse(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }
        $profile = $this->profileFromInput($_POST);
        if ($profile['pass'] === '') {
            $enc = (string) (PlatformSettings::get(self::SETTING)['pass'] ?? '');
            if ($enc !== '') { $profile['pass'] = PlatformSettings::secretDecrypt($enc); }
        }
        if ($profile['host'] === '' || $profile['user'] === '') {
            echo json_encode(['ok' => false, 'message' => 'Enter the host and username first.']);
            exit;
        }

        $path = trim((string) ($_POST['path'] ?? ''));
        if ($path === '') { $path = '/'; }
        // Normalise: collapse duplicate slashes, resolve a trailing name.
        $path = '/' . trim(preg_replace('#/+#', '/', $path), '/');

        try {
            $t = TransportFactory::make($profile);
            $t->connect();
            $entries = $t->listDir($path);
            $t->close();
        } catch (\Throwable $e) {
            echo json_encode(['ok' => false, 'message' => 'Could not list "' . $path . '": ' . $e->getMessage()]);
            exit;
        }

        $dirs = [];
        foreach ($entries as $e) {
            if (($e['type'] ?? '') !== 'dir') { continue; }
            $name = (string) ($e['name'] ?? '');
            if ($name === '' || $name === '.' || $name === '..') { continue; }
            $dirs[] = ['name' => $name, 'path' => rtrim($path, '/') . '/' . $name];
        }
        usort($dirs, static fn ($a, $b) => strcasecmp($a['name'], $b['name']));

        $parent = $path === '/' ? null : ('/' . trim(dirname($path), '/'));
        echo json_encode(['ok' => true, 'path' => $path, 'parent' => $parent, 'dirs' => $dirs,
                          'empty' => $dirs === []]);
        exit;
    }

    /**
     * POST: test the TARGET database credentials by briefly deploying the agent to
     * a temporary folder on the destination, asking it to connect, then removing it.
     * This is the only way to reach a target DB that is (as usual) localhost-only on
     * the new server. Non-destructive: it only connects, never writes.
     */
    public function testDb(): void
    {
        $this->gate(true);
        header('Content-Type: application/json');
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            echo json_encode(['ok' => false, 'message' => 'Your session expired — reload and try again.']);
            exit;
        }
        $profile = $this->profileFromInput($_POST);
        if ($profile['pass'] === '') {
            $enc = (string) (PlatformSettings::get(self::SETTING)['pass'] ?? '');
            if ($enc !== '') { $profile['pass'] = PlatformSettings::secretDecrypt($enc); }
        }
        $newUrl   = rtrim(trim((string) ($_POST['newBaseUrl'] ?? '')), '/');
        $basePath = trim((string) ($_POST['basePath'] ?? ''));
        $creds = [
            'host'   => trim((string) ($_POST['targetDbHost'] ?? 'localhost')) ?: 'localhost',
            'port'   => (int) ($_POST['targetDbPort'] ?? 3306) ?: 3306,
            'user'   => trim((string) ($_POST['targetDbUser'] ?? '')),
            'pass'   => (string) ($_POST['targetDbPass'] ?? ''),
            'dbname' => trim((string) ($_POST['targetDbName'] ?? '')),
            'create' => false,
        ];
        if ($profile['host'] === '' || $newUrl === '') {
            echo json_encode(['ok' => false, 'message' => 'Set the destination host and the new site URL first (the DB test runs through the agent on the new server).']);
            exit;
        }
        if ($creds['user'] === '' || $creds['dbname'] === '') {
            echo json_encode(['ok' => false, 'message' => 'Enter the target DB name and user to test.']);
            exit;
        }

        $probeId = 'probe-' . bin2hex(random_bytes(4));
        $tmp = rtrim(constant('BASE_PATH'), '/') . '/storage/migrations/' . $probeId . '/bundle';
        @mkdir($tmp, 0700, true);
        $agent = AgentBuilder::install($tmp, $probeId, (string) ($_SERVER['HTTP_HOST'] ?? ''));
        $remoteBase = rtrim($basePath, '/') . '/' . AgentBuilder::remoteSubdir($probeId);

        @set_time_limit(0);
        $result = ['ok' => false];
        try {
            $t = TransportFactory::make($profile);
            $tx = new BundleTransporter($t, $tmp, $remoteBase);
            $up = $tx->upload(static fn ($l) => null);
            if (!($up['ok'] ?? false)) {
                $result = ['ok' => false, 'message' => 'Could not upload the probe: ' . implode(', ', $up['failed'] ?? [])];
            } else {
                $client = new AgentClient(AgentClient::endpointFor($newUrl, $probeId), $agent['token'], 45);
                $ping = $client->ping();
                if (!($ping['ok'] ?? false)) {
                    $result = ['ok' => false, 'message' => 'Agent not reachable at ' . $newUrl . ' — check the new site URL and base path.'];
                } else {
                    $prov = $client->provision([$creds], true);
                    $row = ((array) ($prov['databases'] ?? []))[0] ?? [];
                    if ($row['connected'] ?? false) {
                        $result = ['ok' => true, 'message' => 'Connected to `' . $creds['dbname'] . '` on the new server.'];
                    } elseif ($row['serverOk'] ?? false) {
                        $result = ['ok' => false, 'message' => 'MySQL login OK, but database `' . $creds['dbname'] . '` was not found — create it on the new server (or tick "create" for a VPS).'];
                    } else {
                        $result = ['ok' => false, 'message' => 'Could not connect to MySQL: ' . ($row['error'] ?? 'check host/user/password')];
                    }
                    $client->cleanup();
                }
            }
        } catch (\Throwable $e) {
            $result = ['ok' => false, 'message' => 'Test failed: ' . $e->getMessage()];
        }
        // Always remove the local probe folder.
        $this->rrmdir(dirname($tmp));
        echo json_encode($result);
        exit;
    }

    /** Recursively remove a directory (probe cleanup). */
    private function rrmdir(string $dir): void
    {
        if (!is_dir($dir)) { return; }
        foreach (scandir($dir) ?: [] as $f) {
            if ($f === '.' || $f === '..') { continue; }
            $p = $dir . '/' . $f;
            is_dir($p) ? $this->rrmdir($p) : @unlink($p);
        }
        @rmdir($dir);
    }

    /** Build a TransportFactory profile from posted destination fields. */
    private function profileFromInput(array $in): array
    {
        $protocol = in_array(strtolower((string) ($in['protocol'] ?? 'sftp')), ['sftp', 'ftp', 'ftps'], true)
            ? strtolower((string) $in['protocol']) : 'sftp';
        return [
            'protocol' => $protocol,
            'host'     => trim((string) ($in['host'] ?? '')),
            'port'     => (int) ($in['port'] ?? 0) ?: TransportFactory::defaultPort($protocol),
            'user'     => trim((string) ($in['user'] ?? '')),
            'pass'     => (string) ($in['pass'] ?? ''),
            'passive'  => true,
            'timeout'  => 20,
        ];
    }

    /** Saved destination for pre-filling the form (never returns the raw password). */
    private function savedDestination(): array
    {
        $d = PlatformSettings::get(self::SETTING);
        return [
            'protocol'  => (string) ($d['protocol'] ?? 'sftp'),
            'host'      => (string) ($d['host'] ?? ''),
            'port'      => (int) ($d['port'] ?? 0),
            'user'      => (string) ($d['user'] ?? ''),
            'hasPass'   => trim((string) ($d['pass'] ?? '')) !== '',
            'basePath'  => (string) ($d['basePath'] ?? ''),
            'panel'     => (string) ($d['panel'] ?? ''),
            'newBaseUrl' => (string) ($d['newBaseUrl'] ?? ''),
        ];
    }

    private function saveDestination(array $in): void
    {
        $cur = PlatformSettings::get(self::SETTING);
        $save = [
            'protocol'   => strtolower((string) ($in['protocol'] ?? 'sftp')),
            'host'       => trim((string) ($in['host'] ?? '')),
            'port'       => (int) ($in['port'] ?? 0),
            'user'       => trim((string) ($in['user'] ?? '')),
            'basePath'   => trim((string) ($in['basePath'] ?? '')),
            'panel'      => trim((string) ($in['panel'] ?? '')),
            'newBaseUrl' => trim((string) ($in['newBaseUrl'] ?? '')),
        ];
        // Keep the previous password unless a new one was typed; store encrypted.
        $pass = (string) ($in['pass'] ?? '');
        $save['pass'] = $pass !== '' ? PlatformSettings::secretEncrypt($pass) : (string) ($cur['pass'] ?? '');
        PlatformSettings::set(self::SETTING, $save);
    }
}
