<?php

declare(strict_types=1);

namespace PpPhp\Modules\Update\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\Deploy\TransportFactory;
use PpPhp\Core\Licensing\LicenseKeys;
use PpPhp\Core\Security;
use PpPhp\Core\Update\UpdateHostBrowser;
use PpPhp\Core\Update\UpdateHostConfig;
use PpPhp\Core\Update\UpdatePublisher;
use PpPhp\Core\Update\UpdateReleases;
use PpPhp\Core\Version;

/**
 * The Update Engine console — OWNER ONLY (admin-ppradhan).
 *
 * Here the owner describes the server that hosts update files (protocol, host,
 * upload directory, credentials, and the public URL clients download from),
 * tests that connection, and publishes a release (build → delta → upload →
 * catalogue) so every install's "Check for Updates" starts offering it.
 *
 * Owner-tier, exactly like the licence server it rides alongside: the code ships
 * everywhere but the packager strips this controller (and its routes) from client
 * bundles, so a customer install can never become an update authority.
 */
class UpdateServerController
{
    public function __construct(private Config $config) {}

    private function gate(): void
    {
        Security::RequireAuth();
        if (!Security::IsOwner()) {
            redirect('/projects');
        }
    }

    private function json(array $payload, int $status = 200): void
    {
        http_response_code($status);
        header('Content-Type: application/json');
        echo json_encode($payload, JSON_UNESCAPED_SLASHES);
        exit;
    }

    private function input(): array
    {
        $data = json_decode((string) file_get_contents('php://input'), true);
        return is_array($data) ? $data : $_POST;
    }

    private function checkToken(array $in): void
    {
        if (!Security::CSRF()->Check((string) ($in['_token'] ?? ''))) {
            $this->json(['success' => false, 'message' => 'Invalid security token. Reload the page and try again.'], 400);
        }
    }

    public function index(): string
    {
        $this->gate();

        $host      = UpdateHostConfig::forDisplay();
        $secondary = UpdateHostConfig::secondaryForDisplay();
        $gdrive    = UpdateHostConfig::gdriveForDisplay();
        // Drive storage connections the owner can pick as the backup target.
        $driveConns = array_values(array_filter(
            \PpPhp\Core\Storage\StorageConfig::choices(),
            static fn ($c) => ($c['driver'] ?? '') === 'gdrive'
        ));
        $hasKey    = (new LicenseKeys())->exists();
        $channel   = UpdateHostConfig::channel();
        $releases  = $hasKey ? UpdateReleases::all($channel) : [];
        // Attach each release's delta patches so the Releases tab can show them.
        foreach ($releases as &$rel) {
            $rel['deltas'] = UpdateReleases::deltasFor((string) $rel['version'], (string) $rel['channel'], (string) ($rel['edition'] ?? 'client'));
        }
        unset($rel);
        $codeVersion  = Version::PLATFORM;
        $defaultPorts = ['ftp' => 21, 'ftps' => 21, 'sftp' => 22];
        $remoteDir    = UpdateHostConfig::remoteDir();
        // The signed index the whole estate reads — the host-authoritative source
        // of truth. Empty until a public URL is configured.
        $indexUrl     = UpdateHostConfig::publicUrl() !== '' ? UpdateHostConfig::publicUrl() . '/index.json' : '';
        // Owner-managed update mirrors (+ baked defaults) that the whole estate reads.
        $mirrors      = \PpPhp\Core\Update\UpdateMirrors::forDisplay();

        // Unattended-update status for THIS install (the live servers enable this
        // so they self-apply what dev publishes), plus its web-cron trigger URL.
        $auto      = \PpPhp\Core\Update\AutoUpdater::status(constant('BASE_PATH'));
        $baseUrl   = $this->config->get('base_url');
        $scheme    = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $httpHost  = (string) ($_SERVER['HTTP_HOST'] ?? '');
        $autoOrigin = $httpHost !== '' ? $scheme . '://' . $httpHost . $baseUrl : $baseUrl;
        $autoRunUrl = $autoOrigin . '/platform/updates/auto-run?key=' . $auto['secret'];
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();

        // Baselines a downloadable DELTA can diff against, per edition (recorded
        // full builds + published releases). Feeds the Download section.
        $baselines = ['client' => $this->bundleBaselines('client'), 'server' => $this->bundleBaselines('server')];
        $downloadUrl = rtrim((string) $baseUrl, '/') . '/platform/app-bundle/download';

        ob_start();
        require __DIR__ . '/../Views/server.php';
        return (string) ob_get_clean();
    }

    /**
     * Versions available as a delta baseline for a downloadable patch: every full
     * build recorded here (BaselineStore) plus, for the client edition, the
     * OTA-published releases (same manifest shape).
     *
     * @return array<int,array{version:string,builtAt:string,source:string}>
     */
    private function bundleBaselines(string $edition): array
    {
        $seen = []; $out = [];
        foreach ((new \PpPhp\Core\Update\BaselineStore(constant('BASE_PATH')))->list($edition) as $b) {
            $v = (string) $b['version'];
            if ($v === '' || isset($seen[$v])) continue;
            $seen[$v] = true;
            $out[] = ['version' => $v, 'builtAt' => (string) $b['builtAt'], 'source' => 'built'];
        }
        if ($edition === 'client') {
            foreach (UpdateReleases::all('stable') as $r) {
                $v = Version::normalise((string) $r['version']);
                if ($v === '' || isset($seen[$v]) || empty($r['manifest_json'])) continue;
                $seen[$v] = true;
                $out[] = ['version' => $v, 'builtAt' => (string) ($r['published_at'] ?? ''), 'source' => 'published'];
            }
        }
        usort($out, static fn ($a, $b) => version_compare($b['version'], $a['version']));
        return $out;
    }

    /** Save the host configuration (owner form). */
    public function save(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $res = UpdateHostConfig::save($in);
        if (!$res['ok']) {
            $this->json(['success' => false, 'message' => implode(' ', $res['errors'])], 400);
        }
        // Channel is a top-level publish preference, stored alongside the host.
        $this->json([
            'success'    => true,
            'message'    => 'Update host settings saved.',
            'configured' => UpdateHostConfig::isConfigured(),
        ]);
    }

    /**
     * Save the owner's update-mirror list — the ordered set of https bases the whole
     * estate (servers AND clients) reads a signed index.json from, in order. Baked-in
     * defaults are always appended, so a fresh client always has mirrors to try.
     */
    public function saveMirrors(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        // Accept either an array of URLs or a newline-separated block.
        $urls = $in['mirrors'] ?? [];
        if (is_string($urls)) $urls = preg_split('/[\r\n,]+/', $urls) ?: [];
        if (!is_array($urls)) $urls = [];

        $res = \PpPhp\Core\Update\UpdateMirrors::save($urls);
        if (!$res['ok']) {
            $this->json(['success' => false, 'message' => implode(' ', $res['errors'])], 400);
        }
        // Friendly name customers see in a failure message instead of raw host URLs.
        if (array_key_exists('displayName', $in)) {
            \PpPhp\Core\Update\UpdateHostConfig::setDisplayName((string) $in['displayName']);
        }
        $this->json([
            'success'   => true,
            'message'   => 'Update mirrors saved.',
            'effective' => \PpPhp\Core\Update\UpdateMirrors::bases(),
        ]);
    }

    /** Save the secondary (backup) FTP host only — the primary is untouched. */
    public function saveSecondary(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $res = UpdateHostConfig::saveSecondary($in);
        if (!$res['ok']) {
            $this->json(['success' => false, 'message' => implode(' ', $res['errors'])], 400);
        }
        $this->json([
            'success'    => true,
            'message'    => trim((string) ($in['host'] ?? '')) === ''
                ? 'Secondary host removed.'
                : 'Secondary host saved.',
            'configured' => UpdateHostConfig::hasSecondary(),
        ]);
    }

    /** Save the Google Drive backup toggle + connection only. */
    public function saveGdrive(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $res = UpdateHostConfig::saveGdrive($in);
        if (!$res['ok']) {
            $this->json(['success' => false, 'message' => implode(' ', $res['errors'])], 400);
        }
        $this->json([
            'success'    => true,
            'message'    => 'Google Drive backup settings saved.',
            'configured' => UpdateHostConfig::hasGdrive(),
        ]);
    }

    /**
     * Flush Google Drive backups (streaming). Each publish leaves a full-bundle
     * copy on Drive; these accumulate because filenames are timestamped. Delete
     * them to reclaim space. By default the tiny signed index.json is kept (it is
     * a useful last-resort fallback); pass all=1 to remove everything.
     */
    public function flushDriveStream(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        while (ob_get_level() > 0) { @ob_end_flush(); }
        @ini_set('zlib.output_compression', '0');
        @ini_set('implicit_flush', '1');
        ob_implicit_flush(true);
        header('Content-Type: application/x-ndjson; charset=utf-8');
        header('Cache-Control: no-cache, no-transform');
        header('X-Accel-Buffering: no');
        @set_time_limit(0);
        @ignore_user_abort(true);
        $emit = static function (array $ev): void { echo json_encode($ev, JSON_UNESCAPED_SLASHES) . "\n"; @flush(); };

        if (!UpdateHostConfig::hasGdrive()) {
            $emit(['type' => 'error', 'message' => 'Google Drive backup is not configured.']);
            exit;
        }
        $connId = UpdateHostConfig::gdriveConnectionId();
        try {
            $driver = \PpPhp\Core\Storage\StorageRegistry::driver($connId);
        } catch (\Throwable $e) {
            $emit(['type' => 'error', 'message' => 'Could not open the Drive connection: ' . $e->getMessage()]);
            exit;
        }
        if (!($driver instanceof \PpPhp\Core\Storage\GoogleDriveDriver)) {
            $emit(['type' => 'error', 'message' => 'That storage connection is not a Google Drive account.']);
            exit;
        }

        $keepIndex = empty($in['all']);
        $g   = UpdateHostConfig::gdrive();
        $ids = is_array($g['fileIds'] ?? null) ? $g['fileIds'] : [];
        if ($ids === []) {
            $emit(['type' => 'done', 'success' => true, 'message' => 'No Drive backups to flush.']);
            exit;
        }

        $emit(['type' => 'progress', 'icon' => 'run', 'text' => 'Flushing Google Drive backups…']);
        $kept = [];
        $deleted = 0;
        foreach ($ids as $name => $id) {
            if ($keepIndex && $name === 'index.json') { $kept[$name] = $id; continue; }
            $ok = false;
            try { $ok = $driver->delete(['key' => (string) $id]); } catch (\Throwable $e) {}
            if ($ok) {
                $deleted++;
                $emit(['type' => 'progress', 'icon' => 'ok', 'edition' => 'DRIVE', 'text' => 'Deleted ' . $name]);
            } else {
                $kept[$name] = $id;   // keep the id so a failed delete can be retried
                $emit(['type' => 'progress', 'icon' => 'warn', 'edition' => 'DRIVE', 'text' => 'Could not delete ' . $name]);
            }
        }

        $indexUrl = ($keepIndex && isset($kept['index.json'])) ? UpdateHostConfig::gdriveIndexUrl() : '';
        UpdateHostConfig::setGdriveState($indexUrl, $kept);

        $emit(['type' => 'progress', 'icon' => 'ok',
               'text' => $keepIndex ? 'Kept the signed index.json (last-resort fallback).' : 'Removed everything, including the index.']);
        $emit(['type' => 'done', 'success' => true, 'message' => 'Flushed ' . $deleted . ' Drive backup file(s).']);
        exit;
    }

    /** Verify the selected Google Drive connection can mint an access token. */
    public function testGdrive(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $connId = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($in['connection'] ?? ''));
        if ($connId === '') {
            $this->json(['success' => false, 'message' => 'Pick a Google Drive connection first.'], 200);
        }
        try {
            $driver = \PpPhp\Core\Storage\StorageRegistry::driver($connId);
            if (!($driver instanceof \PpPhp\Core\Storage\GoogleDriveDriver)) {
                $this->json(['success' => false, 'message' => 'That connection is not a Google Drive account.'], 200);
            }
            $r = $driver->test();
            $this->json(['success' => (bool) ($r['ok'] ?? false), 'message' => (string) ($r['message'] ?? '')]);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }
    }

    /**
     * Verify that the Public download URL actually serves the Upload directory:
     * write a tiny marker file over FTP, fetch it back over HTTPS, and confirm it
     * matches. This catches the #1 "uploaded but 404" misconfiguration (the upload
     * folder and the public URL pointing at different directories) WITHOUT a full
     * 30 MB publish. Cleans the marker up afterwards.
     */
    public function verifyUrl(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        $remoteDir = '/' . trim((string) ($in['remoteDir'] ?? ''), '/');
        $publicUrl = rtrim(trim((string) ($in['publicUrl'] ?? '')), '/');
        if ($remoteDir === '/' || $publicUrl === '') {
            $this->json(['success' => false, 'message' => 'Enter the Upload directory and Public download URL first.'], 400);
        }
        try {
            $profile = UpdateHostConfig::transportProfileFrom($in);
            if (trim((string) $profile['host']) === '') {
                $this->json(['success' => false, 'message' => 'Enter and test the host connection first.'], 400);
            }
            $token = bin2hex(random_bytes(10));
            $name  = 'ppphp-url-check-' . $token . '.txt';
            $tmp   = (string) tempnam(sys_get_temp_dir(), 'ppurl');
            @file_put_contents($tmp, $token);
            $remotePath = $remoteDir . '/' . $name;
            $probeUrl   = $publicUrl . '/' . $name;

            $transport = TransportFactory::make($profile);
            $transport->connect();
            $wrote = false;
            try {
                $transport->ensureDir($remoteDir);
                $wrote = $transport->put($tmp, $remotePath);
            } catch (\Throwable $e) {
                $wrote = false;
            }
            $code = 0; $match = false;
            if ($wrote) {
                [$code, $body] = $this->httpGet($probeUrl);
                $match = is_string($body) && strpos($body, $token) !== false;
            }
            try { $transport->delete($remotePath); } catch (\Throwable $e) {}
            try { $transport->close(); } catch (\Throwable $e) {}
            @unlink($tmp);

            if (!$wrote) {
                $this->json(['success' => false, 'message' => 'Could not write a test file to the Upload directory — check the path and permissions.']);
            }
            if ($match) {
                $this->json(['success' => true, 'message' => 'Verified — the Public URL serves the Upload directory. Published files will be reachable.', 'url' => $probeUrl]);
            }
            $this->json([
                'success' => false,
                'message' => 'The Upload directory and Public URL point at DIFFERENT folders: the test file written to ' . $remoteDir
                    . ' is not reachable at ' . $probeUrl . ($code ? ' (HTTP ' . $code . ')' : ' (no response)')
                    . '. In cPanel → Domains, set the Upload directory to this domain’s Document Root, or correct the Public URL.',
                'url' => $probeUrl,
            ]);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }
    }

    /** Minimal HTTPS GET → [httpCode, body]. Used by verifyUrl(). @return array{0:int,1:string} */
    private function httpGet(string $url): array
    {
        if (!function_exists('curl_init') || !preg_match('#^https?://#i', $url)) return [0, ''];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_CONNECTTIMEOUT => 6,
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_USERAGENT      => 'PPPHP-Update/1',
        ]);
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return [$code, is_string($body) ? $body : ''];
    }

    /** Test the connection to the host — a staged, non-throwing probe. */
    public function test(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        try {
            $profile   = UpdateHostConfig::transportProfileFrom($in);
            if (trim((string) $profile['host']) === '') {
                $this->json(['success' => false, 'message' => 'Enter a host first.'], 400);
            }
            $transport = TransportFactory::make($profile);
            $report    = $transport->diagnose();
            try { $transport->close(); } catch (\Throwable $e) {}
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }

        // A successful probe also reads the remote directory so the browser can
        // populate immediately (best-effort — a listing failure never fails Test).
        $listing = null;
        if (!empty($report['ok'])) {
            try {
                $start = trim((string) ($in['remoteDir'] ?? '')) !== '' ? (string) $in['remoteDir'] : '/';
                $listing = (new UpdateHostBrowser($profile))->list($start);
            } catch (\Throwable $e) {
                // The configured dir may not exist yet — fall back to root.
                try { $listing = (new UpdateHostBrowser($profile))->list('/'); } catch (\Throwable $e2) {}
            }
        }

        $this->json([
            'success' => (bool) ($report['ok'] ?? false),
            'summary' => (string) ($report['summary'] ?? ''),
            'stages'  => $report['stages'] ?? [],
            'listing' => $listing,
        ]);
    }

    /** Browse a remote directory (owner picks the upload dir from here). */
    public function browse(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        try {
            $profile = UpdateHostConfig::transportProfileFrom($in);
            if (trim((string) $profile['host']) === '') {
                $this->json(['success' => false, 'message' => 'Enter and test the host connection first.'], 400);
            }
            $listing = (new UpdateHostBrowser($profile))->list((string) ($in['path'] ?? '/'));
            $this->json(['success' => true] + $listing);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }
    }

    /** Create a folder on the host. */
    public function mkdir(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        $parent = (string) ($in['path'] ?? '/');
        $name   = trim(str_replace(['/', '\\'], '', (string) ($in['name'] ?? '')));
        if ($name === '') {
            $this->json(['success' => false, 'message' => 'Enter a folder name.'], 400);
        }
        try {
            $profile = UpdateHostConfig::transportProfileFrom($in);
            (new UpdateHostBrowser($profile))->makeDir(rtrim($parent, '/') . '/' . $name);
            $listing = (new UpdateHostBrowser($profile))->list($parent);
            $this->json(['success' => true, 'message' => 'Folder created.'] + $listing);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }
    }

    /** Delete an empty folder on the host. */
    public function rmdir(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        try {
            $profile = UpdateHostConfig::transportProfileFrom($in);
            $target  = (string) ($in['target'] ?? '');
            (new UpdateHostBrowser($profile))->removeDir($target);
            $parent  = (string) ($in['path'] ?? '/');
            $listing = (new UpdateHostBrowser($profile))->list($parent);
            $this->json(['success' => true, 'message' => 'Folder deleted.'] + $listing);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }
    }

    /** Delete a single file on the host (folder-browser trash button). */
    public function deleteFile(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        $target = (string) ($in['target'] ?? '');
        if (trim($target) === '') {
            $this->json(['success' => false, 'message' => 'No file specified.'], 400);
        }
        try {
            $profile = UpdateHostConfig::transportProfileFrom($in);
            $res     = (new UpdateHostBrowser($profile))->removeFiles([$target]);
            $parent  = (string) ($in['path'] ?? '/');
            $listing = (new UpdateHostBrowser($profile))->list($parent);
            $ok = ($res['deleted'] ?? 0) > 0 && ($res['failed'] ?? 0) === 0;
            $this->json([
                'success' => $ok,
                'message' => $ok ? ('Deleted ' . basename($target) . '.') : ('Could not delete ' . basename($target) . ($res['error'] !== '' ? ' (' . $res['error'] . ')' : '') . '.'),
            ] + $listing);
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 200);
        }
    }

    /** Build → delta → upload → catalogue a release. */
    public function publish(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        if (!(new LicenseKeys())->exists()) {
            $this->json(['success' => false, 'message' => 'Create the signing key on the Licence server first — the update feed is signed with it.'], 400);
        }
        if (!UpdateHostConfig::isConfigured()) {
            $this->json(['success' => false, 'message' => 'Configure and save the update host (upload directory + public URL) first.'], 400);
        }

        @set_time_limit(0);
        @ignore_user_abort(true);

        $variant = strtolower(trim((string) ($in['variant'] ?? 'client')));
        $opts = [
            'version' => trim((string) ($in['version'] ?? '')),
            'notes'   => trim((string) ($in['notes'] ?? '')),
            'channel' => strtolower(trim((string) ($in['channel'] ?? UpdateHostConfig::channel()))),
            'kind'    => in_array(($in['kind'] ?? 'both'), ['full', 'delta', 'both'], true) ? (string) $in['kind'] : 'both',
            'ota'     => !isset($in['ota']) || (bool) $in['ota'],
        ];

        try {
            if ($variant === 'both') {
                // One-click both editions: your live servers get the server build,
                // customers get the client build, and the index flips once.
                $many = (new UpdatePublisher())->publishMany($opts, ['server', 'client']);
                $this->json([
                    'success'  => true,
                    'version'  => $many['version'],
                    'editions' => $many['editions'],
                    'messages' => $many['messages'],
                    'message'  => 'Published ' . $many['version'] . ' — server + client editions. '
                        . 'Your live servers and customers will pick it up on their next check.',
                ]);
            }
            $result = (new UpdatePublisher())->publish(
                ['edition' => $variant === 'server' ? 'server' : 'client'] + $opts
            );
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => $e->getMessage()], 500);
        }

        $result['success'] = true;
        $result['message'] = 'Published ' . $result['version'] . ' (' . $result['status'] . ') — '
            . ($result['status'] === 'live' ? 'clients will see it on their next check.' : 'staged; flip it live from the Releases tab when ready.');
        $this->json($result);
    }

    /**
     * Streaming publish: same as publish() but pushes each step to the browser as
     * it happens (newline-delimited JSON), so the owner sees real-time progress and
     * any error is obvious the moment it occurs instead of after a long silent wait.
     */
    public function publishStream(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        // Open a long-lived, unbuffered response. X-Accel-Buffering asks nginx /
        // LiteSpeed not to hold the stream; if a host buffers anyway, the client
        // still receives every event — just batched at the end (graceful).
        while (ob_get_level() > 0) { @ob_end_flush(); }
        @ini_set('zlib.output_compression', '0');
        @ini_set('output_buffering', '0');
        @ini_set('implicit_flush', '1');
        ob_implicit_flush(true);
        header('Content-Type: application/x-ndjson; charset=utf-8');
        header('Cache-Control: no-cache, no-transform');
        header('X-Accel-Buffering: no');
        @set_time_limit(0);
        @ignore_user_abort(true);

        $emit = static function (array $ev): void {
            echo json_encode($ev, JSON_UNESCAPED_SLASHES) . "\n";
            @flush();
        };

        if (!(new LicenseKeys())->exists()) {
            $emit(['type' => 'error', 'message' => 'Create the signing key on the Licence server first — the update feed is signed with it.']);
            exit;
        }
        if (!UpdateHostConfig::isConfigured()) {
            $emit(['type' => 'error', 'message' => 'Configure and save the update host (upload directory + public URL) first.']);
            exit;
        }

        $variant = strtolower(trim((string) ($in['variant'] ?? 'client')));
        $opts = [
            'version'      => trim((string) ($in['version'] ?? '')),
            'notes'        => trim((string) ($in['notes'] ?? '')),
            'channel'      => strtolower(trim((string) ($in['channel'] ?? UpdateHostConfig::channel()))),
            'kind'         => in_array(($in['kind'] ?? 'both'), ['full', 'delta', 'both'], true) ? (string) $in['kind'] : 'both',
            'ota'          => !isset($in['ota']) || (bool) $in['ota'],
            'allowFlagged' => !empty($in['allowFlagged']),
            'targets'      => [
                'secondary' => !empty($in['toSecondary']),
                'gdrive'    => !empty($in['toGdrive']),
            ],
            'onProgress'   => $emit,
        ];

        $emit(['type' => 'progress', 'icon' => 'run', 'text' => 'Starting publish…']);
        try {
            if ($variant === 'both') {
                $many = (new UpdatePublisher())->publishMany($opts, ['server', 'client']);
                $emit(['type' => 'done', 'success' => true, 'version' => $many['version'], 'editions' => $many['editions'],
                    'messages' => $many['messages'], 'message' => 'Published ' . $many['version'] . ' — server + client editions.']);
            } else {
                $result = (new UpdatePublisher())->publish(['edition' => $variant === 'server' ? 'server' : 'client'] + $opts);
                $emit(['type' => 'done', 'success' => true, 'version' => $result['version'], 'status' => $result['status'],
                    'full' => $result['full'], 'deltas' => $result['deltas'], 'messages' => $result['messages'],
                    'message' => 'Published ' . $result['version'] . ' (' . $result['status'] . ').']);
            }
        } catch (\Throwable $e) {
            $emit(['type' => 'error', 'message' => $e->getMessage()]);
        }
        exit;
    }

    /** Flip a release between live (served) and staged (held back). */
    public function releaseStatus(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        $version = (string) ($in['version'] ?? '');
        $channel = (string) ($in['channel'] ?? 'stable');
        $status  = (string) ($in['status'] ?? '');
        $edition = (string) ($in['edition'] ?? 'client');
        if (!UpdateReleases::setStatus($version, $channel, $status, $edition)) {
            $this->json(['success' => false, 'message' => 'That release was not found.'], 404);
        }
        $note = $this->refreshIndex();
        $this->json(['success' => true, 'message' => 'Release is now ' . ($status === 'staged' ? 'staged' : 'live') . '.' . $note]);
    }

    /**
     * Remove a release (and its deltas) from the catalogue AND delete their
     * files from the update host, so the host does not fill up over many
     * releases. The signed index is rebuilt from the (now smaller) catalogue so
     * the feed stops offering the deleted version.
     */
    public function releaseDelete(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        $version = (string) ($in['version'] ?? '');
        $channel = (string) ($in['channel'] ?? 'stable');
        $edition = (string) ($in['edition'] ?? 'client');

        $release = UpdateReleases::release($version, $channel, $edition);
        if ($release === null) {
            $this->json(['success' => false, 'message' => 'That release was not found.'], 404);
        }

        // Collect the public URLs this release owns BEFORE removing the rows: its
        // full bundle + every delta patch that targets this version.
        $urls = [];
        if (!empty($release['full_url'])) $urls[] = (string) $release['full_url'];
        foreach (UpdateReleases::deltasFor($version, $channel, $edition) as $d) {
            if (!empty($d['url'])) $urls[] = (string) $d['url'];
        }

        // Best-effort host cleanup, then always drop the catalogue rows + reindex
        // (so a flaky FTP link can never leave the version stuck in the feed).
        $files = $this->deleteRemoteFiles($urls);
        UpdateReleases::deleteRelease($version, $channel, $edition);
        $note = $this->refreshIndex();

        $msg = 'Release removed from the catalogue.';
        if ($files['attempted'] > 0) {
            $msg .= ' ' . $files['deleted'] . ' of ' . $files['attempted'] . ' file(s) deleted from the update host';
            $msg .= ($files['failed'] > 0 && $files['error'] !== '') ? ' (' . $files['error'] . ').' : '.';
        }
        $this->json(['success' => true, 'message' => $msg . $note]);
    }

    /**
     * Streaming version of releaseDelete: removes a release's files from BOTH the
     * primary and the secondary host (per-file, live), drops the catalogue rows,
     * and republishes the signed index to every active host — pushing each step to
     * the browser as newline-delimited JSON so the owner sees exactly what happened
     * on which host.
     */
    public function releaseDeleteStream(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);

        while (ob_get_level() > 0) { @ob_end_flush(); }
        @ini_set('zlib.output_compression', '0');
        @ini_set('implicit_flush', '1');
        ob_implicit_flush(true);
        header('Content-Type: application/x-ndjson; charset=utf-8');
        header('Cache-Control: no-cache, no-transform');
        header('X-Accel-Buffering: no');
        @set_time_limit(0);
        @ignore_user_abort(true);
        $emit = static function (array $ev): void { echo json_encode($ev, JSON_UNESCAPED_SLASHES) . "\n"; @flush(); };

        $version = (string) ($in['version'] ?? '');
        $channel = (string) ($in['channel'] ?? 'stable');
        $edition = (string) ($in['edition'] ?? 'client');

        $release = UpdateReleases::release($version, $channel, $edition);
        if ($release === null) {
            $emit(['type' => 'error', 'message' => 'That release was not found.']);
            exit;
        }

        // The public (primary) URLs this release owns: its full + every delta.
        $urls = [];
        if (!empty($release['full_url'])) $urls[] = (string) $release['full_url'];
        foreach (UpdateReleases::deltasFor($version, $channel, $edition) as $d) {
            if (!empty($d['url'])) $urls[] = (string) $d['url'];
        }

        // ── Primary host ──
        $primaryPaths = [];
        foreach ($urls as $u) {
            $p = UpdateHostConfig::remotePathForUrl($u);
            if ($p !== null && $p !== '') $primaryPaths[] = $p;
        }
        if ($primaryPaths !== [] && UpdateHostConfig::isConfigured()) {
            $emit(['type' => 'progress', 'icon' => 'run', 'text' => 'Deleting ' . count($primaryPaths) . ' file(s) from the primary host…']);
            try {
                (new UpdateHostBrowser(UpdateHostConfig::transportProfile()))->removeFiles(
                    $primaryPaths,
                    static function (string $path, bool $ok) use ($emit): void {
                        $emit(['type' => 'progress', 'icon' => $ok ? 'ok' : 'warn', 'edition' => 'PRI',
                               'text' => ($ok ? 'Deleted ' : 'Could not delete ') . basename($path)]);
                    }
                );
            } catch (\Throwable $e) {
                $emit(['type' => 'progress', 'icon' => 'warn', 'edition' => 'PRI', 'text' => 'Primary cleanup error: ' . $e->getMessage()]);
            }
        }

        // ── Secondary host ──
        if (UpdateHostConfig::hasSecondary()) {
            $secPaths = [];
            foreach ($urls as $u) {
                $p = UpdateHostConfig::secondaryRemotePathForUrl($u);
                if ($p !== null && $p !== '') $secPaths[] = $p;
            }
            if ($secPaths !== []) {
                $emit(['type' => 'progress', 'icon' => 'run', 'text' => 'Deleting ' . count($secPaths) . ' file(s) from the secondary host…']);
                try {
                    (new UpdateHostBrowser(UpdateHostConfig::secondaryTransportProfile()))->removeFiles(
                        $secPaths,
                        static function (string $path, bool $ok) use ($emit): void {
                            $emit(['type' => 'progress', 'icon' => $ok ? 'ok' : 'warn', 'edition' => 'SEC',
                                   'text' => ($ok ? 'Deleted ' : 'Could not delete ') . basename($path)]);
                        }
                    );
                } catch (\Throwable $e) {
                    $emit(['type' => 'progress', 'icon' => 'warn', 'edition' => 'SEC', 'text' => 'Secondary cleanup error: ' . $e->getMessage()]);
                }
            }
        }

        // ── Catalogue + index ──
        UpdateReleases::deleteRelease($version, $channel, $edition);
        $emit(['type' => 'progress', 'icon' => 'ok', 'text' => "Removed {$version} ({$edition}) from the catalogue."]);

        $targets = 'primary' . (UpdateHostConfig::hasSecondary() ? ' + secondary' : '') . (UpdateHostConfig::hasGdrive() ? ' + Google Drive' : '');
        $emit(['type' => 'progress', 'icon' => 'run', 'text' => "Republishing the signed index to {$targets}…"]);
        try {
            $idx = UpdatePublisher::syncIndex();
            $emit(['type' => 'progress', 'icon' => $idx['ok'] ? 'ok' : 'warn',
                   'text' => $idx['ok'] ? "Signed index republished → {$idx['url']}" : ('Index not refreshed: ' . $idx['message'])]);
        } catch (\Throwable $e) {
            $emit(['type' => 'progress', 'icon' => 'warn', 'text' => 'Index not refreshed: ' . $e->getMessage()]);
        }

        $emit(['type' => 'done', 'success' => true, 'message' => "Deleted {$version} ({$edition}) and cleaned every active host."]);
        exit;
    }

    /**
     * Delete a release's published files from the update host. Maps each stored
     * public URL back to its remote path and removes it over one connection.
     * Never throws — returns a summary so a host problem still lets the catalogue
     * row be removed.
     *
     * @param array<int,string> $urls
     * @return array{attempted:int, deleted:int, failed:int, error:string}
     */
    private function deleteRemoteFiles(array $urls): array
    {
        $out = ['attempted' => 0, 'deleted' => 0, 'failed' => 0, 'error' => ''];
        if (!UpdateHostConfig::isConfigured() || $urls === []) return $out;

        $paths = [];
        foreach ($urls as $u) {
            $p = UpdateHostConfig::remotePathForUrl((string) $u);
            if ($p !== null && $p !== '') $paths[] = $p;
        }
        if ($paths === []) return $out;

        try {
            return (new UpdateHostBrowser(UpdateHostConfig::transportProfile()))->removeFiles($paths);
        } catch (\Throwable $e) {
            return ['attempted' => count($paths), 'deleted' => 0, 'failed' => count($paths), 'error' => $e->getMessage()];
        }
    }

    /**
     * Rebuild + re-upload the signed index on demand (e.g. after changing hosts,
     * or to repair a missing index.json without re-publishing a release).
     */
    public function reindex(): void
    {
        $this->gate();
        $in = $this->input();
        $this->checkToken($in);
        try {
            $r = UpdatePublisher::syncIndex();
        } catch (\Throwable $e) {
            $this->json(['success' => false, 'message' => 'Could not publish the index: ' . $e->getMessage()], 500);
        }
        $this->json([
            'success' => $r['ok'],
            'message' => $r['ok'] ? ('Signed index published → ' . $r['url']) : ('Index not published: ' . $r['message'] . '.'),
            'url'     => $r['url'],
        ], $r['ok'] ? 200 : 400);
    }

    /**
     * Re-publish the signed index to the host after a catalogue change (status
     * flip or delete) so live clients see it immediately. Returns a short suffix
     * for the response message; never throws.
     */
    private function refreshIndex(): string
    {
        try {
            $r = UpdatePublisher::syncIndex();
            return $r['ok'] ? ' Update index refreshed.' : ' (Index not refreshed: ' . $r['message'] . '.)';
        } catch (\Throwable $e) {
            return ' (Index not refreshed: ' . $e->getMessage() . '.)';
        }
    }
}
