<?php

declare(strict_types=1);

namespace PpPhp\Modules\Settings\Controllers;

use PpPhp\Core\Build\BuildEngine;
use PpPhp\Core\Config;
use PpPhp\Core\ModuleManager;
use PpPhp\Core\ModuleState;
use PpPhp\Core\Security;

/**
 * Owner deliverables — OWNER ONLY (admin-ppradhan).
 *
 * The two shippable parts of the PPPHP product that the owner distributes by
 * hand — the **marketing website** (static) and the **community installer**
 * (the community forum built as a standalone install.php bundle) — packaged
 * from WITHIN the app so the owner can rebuild + download them on demand
 * instead of hunting through output directories.
 *
 * Each ZIP is built to a temp file, streamed, then deleted — nothing sensitive
 * persists on the server. Not available to superadmin, developer, or anyone else.
 */
class DeliverablesController
{
    private const COMMUNITY_SLUG = 'community';

    public function __construct(
        private Config $config,
        private ModuleManager $moduleManager,
    ) {}

    /** Hard owner gate — bounce anyone else back to Projects. */
    private function gateOwner(): void
    {
        Security::RequireAuth();
        if (!Security::IsOwner()) {
            redirect('/projects');
        }
    }

    /* ───────────────────────── Marketing website ───────────────────────── */

    private function marketingDir(): string
    {
        // Lives in the safe, human-named Extra/ folder (never under output/).
        return constant('BASE_PATH') . '/Extra/MarketingSite';
    }

    /** Owner page: what's in the marketing bundle + a download button. */
    public function marketing(): string
    {
        $this->gateOwner();

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $ownerName = Security::ownerUsername();

        $dir   = $this->marketingDir();
        $stats = $this->dirStats($dir);
        $analytics = $this->analyticsConfig();
        $csrf = Security::CSRF()->Token();

        ob_start();
        include __DIR__ . '/../Views/deliverables_marketing.php';
        return (string) ob_get_clean();
    }

    /** Zip ppradhan.com/ → stream → delete. */
    public function marketingDownload(): void
    {
        $this->gateOwner();

        $dir = $this->marketingDir();
        if (!is_dir($dir)) {
            $this->fail(404, 'The marketing site source (ppradhan.com/) was not found in this install.');
        }

        $zip = $this->tempZip('ppphp-marketing');
        try {
            // Skip dev-only bookkeeping; ship the deployable site with the analytics
            // beacon auto-injected (when a marketing Site ID is configured).
            $this->zipDir($dir, $zip, ['README.md', '.DS_Store'], fn ($rel, $path) => $this->injectMarketingBeacon($rel, $path));
        } catch (\Throwable $e) {
            @unlink($zip);
            $this->fail(500, 'Could not package the marketing site: ' . $e->getMessage());
        }

        $this->stream($zip, 'ppradhan-marketing-' . date('Ymd') . '.zip');
    }

    /* ───────────────────────── Community installer ───────────────────────── */

    /** The community project's configured build output directory (may be absent). */
    private function communityOutputDir(): string
    {
        $out = ModuleState::load(self::COMMUNITY_SLUG, 'output');
        return trim((string) ($out['general']['outputDir'] ?? ''));
    }

    /** Whether the community project is set to emit a standalone installer. */
    private function communityInstallerMode(): bool
    {
        $out = ModuleState::load(self::COMMUNITY_SLUG, 'output');
        return (bool) ($out['advanced']['standaloneInstaller'] ?? false);
    }

    /** Newest mtime across the community project's editable source (config + custom files). */
    private function communitySourceMtime(): int
    {
        $src = \PpPhp\Core\ProjectLocator::dir(self::COMMUNITY_SLUG);
        $newest = 0;
        foreach ([$src . '/config', $src . '/custom'] as $dir) {
            if (!is_dir($dir)) continue;
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
            );
            foreach ($it as $f) {
                if ($f->isFile()) $newest = max($newest, (int) $f->getMTime());
            }
        }
        return $newest;
    }

    /** When the current build was produced (install.php mtime), or 0 if never built. */
    private function communityBuildMtime(): int
    {
        $dir = $this->communityOutputDir();
        if ($dir === '') return 0;
        foreach (['/install.php', '/manifest.json'] as $marker) {
            if (is_file($dir . $marker)) return (int) filemtime($dir . $marker);
        }
        return 0;
    }

    /**
     * Is the built output behind the project source? True when never built, or
     * when a source file changed after the last build. (Forum CONTENT changes
     * live in the DB and aren't visible to an mtime check — the page notes this.)
     */
    private function communityStale(): bool
    {
        $built = $this->communityBuildMtime();
        return $built === 0 || $this->communitySourceMtime() > $built;
    }

    /** Owner page: build status + rebuild + download for the community installer. */
    public function community(): string
    {
        $this->gateOwner();

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $ownerName = Security::ownerUsername();
        $token     = Security::CSRF()->Token();

        $dir           = $this->communityOutputDir();
        $installerMode = $this->communityInstallerMode();
        $hasInstaller  = $dir !== '' && is_file($dir . '/install.php');
        $builtAt       = $hasInstaller ? date('Y-m-d H:i', (int) filemtime($dir . '/install.php')) : null;
        $hasCreds      = $dir !== '' && is_file($dir . '/config/database.php');
        $stale         = $this->communityStale();
        $sourceChanged = ($m = $this->communitySourceMtime()) ? date('Y-m-d H:i', $m) : null;
        $analytics     = $this->analyticsConfig();
        $updatePackages = $this->listCommunityUpdates();
        $hasSigningKey  = (new \PpPhp\Core\Licensing\LicenseKeys())->exists();

        ob_start();
        include __DIR__ . '/../Views/deliverables_community.php';
        return (string) ob_get_clean();
    }

    /** POST: rebuild the community app (as configured) → JSON. */
    public function communityRebuild(): void
    {
        $this->gateOwner();
        header('Content-Type: application/json; charset=utf-8');

        $input = json_decode((string) file_get_contents('php://input'), true) ?: $_POST;
        if (!Security::CSRF()->Check((string) ($input['_token'] ?? ''))) {
            http_response_code(400);
            echo json_encode(['success' => false, 'message' => 'Your security token expired. Reload the page and try again.']);
            return;
        }

        @set_time_limit(0);
        try {
            $res = (new BuildEngine())->build(self::COMMUNITY_SLUG, []);
            $dir = $this->communityOutputDir();
            echo json_encode([
                'success'       => (bool) $res->success,
                'message'       => (string) $res->message,
                'installerMode' => $this->communityInstallerMode(),
                'hasInstaller'  => $dir !== '' && is_file($dir . '/install.php'),
            ]);
        } catch (\Throwable $e) {
            http_response_code(500);
            echo json_encode(['success' => false, 'message' => 'Build failed: ' . $e->getMessage()]);
        }
    }

    /** Zip the community build output (installer, secret-free) → stream → delete. */
    public function communityDownload(): void
    {
        $this->gateOwner();
        @set_time_limit(0);

        $dir = $this->communityOutputDir();

        // Rebuild-before-download safety: if the output is missing (e.g. someone
        // cleaned the output folder) or the source changed since the last build,
        // rebuild first so the owner can never ship a stale/absent bundle.
        if ($dir === '' || !is_dir($dir) || !is_file($dir . '/install.php') || $this->communityStale()) {
            try {
                (new BuildEngine())->build(self::COMMUNITY_SLUG, []);
                $dir = $this->communityOutputDir();
            } catch (\Throwable $e) {
                $this->fail(500, 'Auto-rebuild before download failed: ' . $e->getMessage());
            }
        }

        if ($dir === '' || !is_dir($dir)) {
            $this->fail(404, 'The community app could not be built. Open the Community Installer page and check the project.');
        }
        if (!is_file($dir . '/install.php')) {
            $this->fail(409, 'This build has no install.php. Turn on “Include standalone install.php” in the community project’s Output settings, then Rebuild.');
        }

        $zip = $this->tempZip('ppphp-community');
        try {
            // Belt-and-braces: never ship live credentials or an install lock,
            // even though installer-mode builds don't write them. Bake the community
            // Site ID + collector into the forum shell so a fresh install reports in.
            $this->zipDir($dir, $zip, ['config/database.php', 'config/installed.lock', '.DS_Store'], fn ($rel, $path) => $this->communityTransform($rel, $path));
        } catch (\Throwable $e) {
            @unlink($zip);
            $this->fail(500, 'Could not package the community installer: ' . $e->getMessage());
        }

        $this->stream($zip, 'community-installer-' . date('Ymd') . '.zip');
    }

    /** Non-public folder where generated update packages are held for download.
     *  NOT under public/ — nothing is ever served automatically; the owner
     *  downloads a package, hands it off, then deletes it here. */
    private function communityUpdatesDir(): string
    {
        return constant('BASE_PATH') . '/storage/community-updates';
    }

    /** Only accept our own package filenames — guards download/delete. */
    private function isUpdatePackageName(string $name): bool
    {
        return (bool) preg_match('/^community-update-\d+(\.\d+){1,3}\.zip$/', $name);
    }

    /** @return array<int,array{file:string,version:string,size:int,mtime:int}> newest first */
    public function listCommunityUpdates(): array
    {
        $out = [];
        foreach ((array) @glob($this->communityUpdatesDir() . '/community-update-*.zip') as $p) {
            $name = basename((string) $p);
            if (!$this->isUpdatePackageName($name)) continue;
            preg_match('/^community-update-(.+)\.zip$/', $name, $m);
            $out[] = ['file' => $name, 'version' => $m[1] ?? '', 'size' => (int) @filesize((string) $p), 'mtime' => (int) @filemtime((string) $p)];
        }
        usort($out, static fn($a, $b) => $b['mtime'] <=> $a['mtime']);
        return $out;
    }

    /**
     * OWNER: build + SIGN the community bundle and package it as ONE downloadable
     * update ZIP held in storage/community-updates/. The owner downloads it and
     * uploads it into the forum's Admin → Software updates (the forum is hosted
     * elsewhere). Nothing is served from this app; the package can be deleted after.
     *
     * Package shape (what the forum's forum_update_apply_upload() expects):
     *   community-update-<version>.zip
     *     ├─ update.json   PPCU1.<b64url(manifest)>.<b64url(ed25519 sig)>
     *     └─ bundle.zip     the forum files to overlay (secret-free, key-baked)
     */
    public function generateCommunityUpdate(): void
    {
        $this->gateOwner();
        @set_time_limit(0);
        $in = json_decode((string) file_get_contents('php://input'), true);
        if (!is_array($in)) $in = $_POST;
        if (!Security::CSRF()->Check((string) ($in['_token'] ?? ''))) {
            $this->fail(400, 'Invalid security token. Reload and try again.');
        }

        $keys = new \PpPhp\Core\Licensing\LicenseKeys();
        if (!$keys->exists()) {
            $this->fail(409, 'No signing key exists yet. Create one in Licensing → Signing key, then generate the package.');
        }

        // Always package from a FRESH build so it matches current source.
        $dir = $this->communityOutputDir();
        if ($dir === '' || !is_dir($dir) || !is_file($dir . '/install.php') || $this->communityStale()) {
            try {
                (new BuildEngine())->build(self::COMMUNITY_SLUG, []);
                $dir = $this->communityOutputDir();
            } catch (\Throwable $e) {
                $this->fail(500, 'Rebuild before packaging failed: ' . $e->getMessage());
            }
        }
        if ($dir === '' || !is_dir($dir)) {
            $this->fail(404, 'There is no community build to package. Open the Community Installer page and check the project.');
        }

        // Version is the single source of truth in the built forum/version.php.
        $version = '';
        $vf = $dir . '/forum/version.php';
        if (is_file($vf)) { $version = trim((string) @include $vf); }
        if ($version === '' || !preg_match('/^\d+(\.\d+){1,3}$/', $version)) {
            $this->fail(409, 'The build has no valid forum/version.php. Set a version like 1.0.1 in the community source and rebuild.');
        }

        $outDir = $this->communityUpdatesDir();
        if (!is_dir($outDir)) @mkdir($outDir, 0775, true);
        if (!is_dir($outDir) || !is_writable($outDir)) {
            $this->fail(500, 'storage/community-updates is not writable, so the package cannot be generated.');
        }

        // 1. Package the payload (the same secret-free, key-baked bundle the installer ships).
        $bundlePath = $this->tempZip('ppphp-cbundle');
        try {
            $this->zipDir($dir, $bundlePath, ['config/database.php', 'config/installed.lock', '.DS_Store'], fn ($rel, $path) => $this->communityTransform($rel, $path));
        } catch (\Throwable $e) {
            @unlink($bundlePath);
            $this->fail(500, 'Could not package the update payload: ' . $e->getMessage());
        }
        $sha = (string) hash_file('sha256', $bundlePath);

        // 2. Sign a manifest that binds to the payload's hash.
        $manifest = [
            'version'      => $version,
            'notes'        => substr(trim((string) ($in['notes'] ?? '')), 0, 2000),
            'sha256'       => $sha,
            'size'         => (int) filesize($bundlePath),
            'min_php'      => '8.0',
            'published_at' => date('c'),
        ];
        $body  = rtrim(strtr(base64_encode((string) json_encode($manifest, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
        $msg   = 'PPCU1.' . $body;
        $sig   = sodium_crypto_sign_detached($msg, $keys->secretKey());
        $token = $msg . '.' . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');

        // 3. Wrap manifest + payload into ONE downloadable container.
        $pkgName = 'community-update-' . $version . '.zip';
        $pkgPath = $outDir . '/' . $pkgName;
        @unlink($pkgPath);
        $pkg = new \ZipArchive();
        if ($pkg->open($pkgPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            @unlink($bundlePath);
            $this->fail(500, 'Could not create the update package.');
        }
        $pkg->addFromString('update.json', $token);
        $pkg->addFile($bundlePath, 'bundle.zip');
        $pkg->setCompressionName('bundle.zip', \ZipArchive::CM_STORE);   // already compressed
        $pkg->close();
        @unlink($bundlePath);

        header('Content-Type: application/json');
        echo json_encode([
            'success' => true,
            'version' => $version,
            'file'    => $pkgName,
            'size'    => (int) @filesize($pkgPath),
            'keyId'   => $keys->keyId(),
        ]);
    }

    /** OWNER: stream a generated update package for download. */
    public function communityUpdateDownload(): void
    {
        $this->gateOwner();
        $name = basename((string) ($_GET['file'] ?? ''));
        if (!$this->isUpdatePackageName($name)) $this->fail(400, 'Unknown package.');
        $path = $this->communityUpdatesDir() . '/' . $name;
        if (!is_file($path)) $this->fail(404, 'That package no longer exists — generate it again.');
        $this->stream($path, $name, false);   // do NOT delete on download — the owner deletes explicitly
    }

    /** OWNER: delete a generated update package from the server. */
    public function communityUpdateDelete(): void
    {
        $this->gateOwner();
        $in = json_decode((string) file_get_contents('php://input'), true);
        if (!is_array($in)) $in = $_POST;
        if (!Security::CSRF()->Check((string) ($in['_token'] ?? ''))) {
            $this->fail(400, 'Invalid security token.');
        }
        $name = basename((string) ($in['file'] ?? ''));
        if (!$this->isUpdatePackageName($name)) $this->fail(400, 'Unknown package.');
        $path = $this->communityUpdatesDir() . '/' . $name;
        $ok = is_file($path) ? @unlink($path) : true;   // already gone = success
        header('Content-Type: application/json');
        echo json_encode(['success' => (bool) $ok]);
    }

    /* ───────────────────────── shared helpers ───────────────────────── */

    private function tempZip(string $prefix): string
    {
        return rtrim(sys_get_temp_dir(), '/\\') . '/' . $prefix . '-' . bin2hex(random_bytes(8)) . '.zip';
    }

    /**
     * Recursively zip $srcDir into $zipPath. $excludes are relative paths (files)
     * or path prefixes to skip (compared against the forward-slashed relative path).
     */
    private function zipDir(string $srcDir, string $zipPath, array $excludes = [], ?callable $transform = null): void
    {
        $srcDir = rtrim(str_replace('\\', '/', realpath($srcDir) ?: $srcDir), '/');
        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            throw new \RuntimeException('cannot create archive');
        }
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($srcDir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($it as $file) {
            $path = str_replace('\\', '/', $file->getPathname());
            $rel  = ltrim(substr($path, strlen($srcDir)), '/');
            if ($rel === '') continue;
            foreach ($excludes as $ex) {
                if ($rel === $ex || str_starts_with($rel, rtrim($ex, '/') . '/') || basename($rel) === $ex) {
                    continue 2;
                }
            }
            if ($file->isDir()) {
                $zip->addEmptyDir($rel);
            } elseif ($transform !== null && ($mod = $transform($rel, $path)) !== null) {
                $zip->addFromString($rel, (string) $mod);   // ship a modified copy; source untouched
            } else {
                $zip->addFile($path, $rel);
            }
        }
        $zip->close();
    }

    /* ─────────────────── Auto-configure analytics on the deliverables ─────────────────── */

    /**
     * The collector + per-property Site IDs baked into the two deliverables at
     * download time, so a fresh install of the marketing site / community portal
     * reports into this Visitor console with nothing to wire by hand.
     *
     * @return array{collector:string,assets:string,marketing_site:string,community_site:string}
     */
    private function analyticsConfig(): array
    {
        $c = [];
        try { $c = (array) \PpPhp\Core\PlatformSettings::get('platform.deliverables_analytics'); } catch (\Throwable $e) {}
        $collector = trim((string) ($c['collector'] ?? ''));
        if ($collector === '') $collector = 'https://ppphp.ppradhan.com/api/analytics/collect';
        return [
            'collector'      => $collector,
            'assets'         => (string) preg_replace('#/api/analytics/collect/?$#i', '', $collector),
            'marketing_site' => trim((string) ($c['marketing_site'] ?? '')),
            'community_site' => trim((string) ($c['community_site'] ?? '')),
        ];
    }

    /** Owner: save the deliverables' analytics site IDs + collector URL. */
    public function saveAnalytics(): void
    {
        $this->gateOwner();
        $in = json_decode((string) file_get_contents('php://input'), true);
        if (!is_array($in)) $in = $_POST;
        if (!Security::CSRF()->Check((string) ($in['_token'] ?? ''))) {
            http_response_code(400); echo json_encode(['success' => false, 'message' => 'Bad token.']); return;
        }
        $ok = false; $created = false;
        // Resolve a submitted site value to a VALID registered property id for the
        // given channel: 'auto' → find-or-create; a real registered id → keep;
        // anything else (blank / typo / unregistered) → '' (off). This is what
        // guarantees the baked id is one the collector will actually accept.
        $resolve = static function (string $val, string $channel, bool &$created): string {
            $val = trim($val);
            if ($val === '') return '';
            $S = \PpPhp\Core\Analytics\AnalyticsSites::class;
            if (strtolower($val) === 'auto') {
                $r = $S::ensureForChannel($channel, ucfirst($channel) . ' portal');
                $created = $created || !empty($r['created']);
                return (string) $r['site_id'];
            }
            return ($S::isRegistered($val)) ? $val : '';   // reject unregistered ids
        };
        try {
            // Merge: a save from the marketing page must not wipe the community id.
            $raw = (array) \PpPhp\Core\PlatformSettings::get('platform.deliverables_analytics');
            if (array_key_exists('collector', $in)) {
                $c = trim((string) $in['collector']);
                $raw['collector'] = ($c === '' || preg_match('#^https?://#i', $c)) ? $c : (string) ($raw['collector'] ?? '');
            }
            if (array_key_exists('marketing_site', $in)) {
                $raw['marketing_site'] = $resolve((string) $in['marketing_site'], 'marketing', $created);
            }
            if (array_key_exists('community_site', $in)) {
                $raw['community_site'] = $resolve((string) $in['community_site'], 'community', $created);
            }
            $ok = \PpPhp\Core\PlatformSettings::set('platform.deliverables_analytics', $raw);
        } catch (\Throwable $e) { /* ok stays false */ }
        header('Content-Type: application/json');
        echo json_encode(['success' => $ok, 'created' => $created]);
    }

    /** Inject the analytics beacon before </body> in a marketing HTML file. */
    private function injectMarketingBeacon(string $rel, string $path): ?string
    {
        if (!preg_match('/\.html?$/i', $rel)) return null;
        $cfg = $this->analyticsConfig();
        if ($cfg['marketing_site'] === '') return null;
        $html = (string) @file_get_contents($path);
        if ($html === '' || stripos($html, '__PP_ANALYTICS') !== false) return null;   // already wired
        $snippet = "\n<script>window.__PP_ANALYTICS=" . json_encode(['url' => $cfg['collector'], 'site' => $cfg['marketing_site']], JSON_UNESCAPED_SLASHES) . ";</script>\n"
                 . '<script src="' . htmlspecialchars($cfg['assets'], ENT_QUOTES) . '/assets/js/pp-analytics.js" defer></script>' . "\n";
        $pos = stripos($html, '</body>');
        return $pos === false ? $html . $snippet : substr($html, 0, $pos) . $snippet . substr($html, $pos);
    }

    /** Bake the community Site ID + collector into the built forum shell as defaults. */
    private function patchForumShell(string $rel, string $path): ?string
    {
        if (!str_ends_with($rel, 'custom/forum/_shell.php') && basename($rel) !== '_shell.php') return null;
        $cfg = $this->analyticsConfig();
        if ($cfg['community_site'] === '') return null;
        $php = (string) @file_get_contents($path);
        if ($php === '' || strpos($php, 'forum_analytics_beacon') === false) return null;
        $php = str_replace("forum_setting('analytics_site', '')", "forum_setting('analytics_site', " . var_export($cfg['community_site'], true) . ")", $php);
        $php = preg_replace("#forum_setting\('analytics_url', '[^']*'\)#", "forum_setting('analytics_url', " . var_export($cfg['collector'], true) . ')', $php);
        return $php;
    }

    /**
     * Bake the publisher's PUBLIC key into the forum's update-key.php at download,
     * so the shipped forum trusts (only) updates this console signs. Leaves it
     * empty — updates disabled — when no signing key exists yet.
     */
    private function bakeUpdateKey(string $rel, string $path): ?string
    {
        if (basename(str_replace('\\', '/', $rel)) !== 'update-key.php') return null;
        try {
            $keys = new \PpPhp\Core\Licensing\LicenseKeys();
            if (!$keys->exists()) return null;
            $b64 = $keys->publicKeyB64();
        } catch (\Throwable $e) { return null; }
        if ($b64 === '') return null;
        return "<?php\n/* Pinned PPPHP publisher key — baked at download; do not edit. */\nreturn " . var_export($b64, true) . ";\n";
    }

    /** Combined community download transform: analytics bake + update-key bake. */
    private function communityTransform(string $rel, string $path): ?string
    {
        return $this->patchForumShell($rel, $path) ?? $this->bakeUpdateKey($rel, $path);
    }

    /** Quick, cheap directory summary for the page (file count + total bytes). */
    private function dirStats(string $dir): array
    {
        if (!is_dir($dir)) return ['files' => 0, 'bytes' => 0, 'exists' => false];
        $files = 0; $bytes = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
        );
        foreach ($it as $f) {
            if ($f->isFile()) { $files++; $bytes += (int) $f->getSize(); }
        }
        return ['files' => $files, 'bytes' => $bytes, 'exists' => true];
    }

    private function stream(string $zipPath, string $filename, bool $delete = true): void
    {
        @set_time_limit(0);
        ignore_user_abort(true);
        while (ob_get_level() > 0) ob_end_clean();

        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Content-Length: ' . (string) filesize($zipPath));
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: no-store');
        readfile($zipPath);
        if ($delete) @unlink($zipPath);   // temp bundles self-clean; persisted packages don't
        exit;
    }

    private function fail(int $code, string $message): void
    {
        http_response_code($code);
        header('Content-Type: text/plain; charset=utf-8');
        echo $message;
        exit;
    }
}
