<?php

declare(strict_types=1);

namespace PpPhp\Modules\Update\Controllers;

use PpPhp\Core\Licensing\LicenseKeys;
use PpPhp\Core\Licensing\LicenseServer;
use PpPhp\Core\Update\UpdateHostConfig;
use PpPhp\Core\Update\UpdateIndex;
use PpPhp\Core\Update\UpdateProtocol;
use PpPhp\Core\Update\UpdateReleases;
use PpPhp\Core\Version;

/**
 * The public Update Engine API — the authority side of "Check for Updates".
 *
 * A hosted installation POSTs {installedVersion, channel} and gets back a single
 * Ed25519-SIGNED feed token describing the newest release and the exact artifact
 * that install should download (a delta from its version if one exists, else the
 * full bundle). The client verifies the signature OFFLINE against the public key
 * pinned in its bundle, so:
 *   • the transport is not trusted — a stripped-TLS or replayed answer still
 *     cannot forge "you're up to date" or redirect a download,
 *   • switching the file host is one edit here; the new URL travels inside the
 *     signature and the estate follows on its next check.
 *
 * This controller ships ONLY in server/authority bundles — the packager strips
 * it (and its route) from client bundles, so a customer install has no code path
 * to becoming an update authority.
 */
class UpdateApiController
{
    private function json(array $payload, int $status = 200): void
    {
        http_response_code($status);
        header('Content-Type: application/json');
        header('Cache-Control: no-store');
        echo json_encode($payload, JSON_UNESCAPED_SLASHES);
        exit;
    }

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

    /**
     * POST /api/update/feed — the one endpoint a customer's "Check for Updates"
     * calls. This is the LICENCE-GATED gateway: a customer install passes THROUGH
     * the primary (or, if it is down, the secondary) licence server, which
     * confirms the install holds a genuine, active licence, and only then answers
     * with the newest release for its version — read from the update host the
     * owner has pointed this server at. The answer is Ed25519-signed so the
     * customer verifies it offline.
     *
     * Never throws to the caller; a missing key, an unlicensed caller or an empty
     * catalogue is a normal "nothing to offer" answer, not an error, so a Check
     * button always gets a clean reply.
     */
    public function feed(): void
    {
        $in        = $this->input();
        $installed = Version::normalise((string) ($in['installedVersion'] ?? '0'));
        $channel   = strtolower(trim((string) ($in['channel'] ?? 'stable')));
        if (!in_array($channel, ['stable', 'beta'], true)) $channel = 'stable';
        // Customers run the client edition; only ever offer them client bundles.
        $edition   = strtolower(trim((string) ($in['edition'] ?? 'client'))) === 'server' ? 'server' : 'client';

        // The authority signs with the same Ed25519 key the licence server holds.
        // No key → this install is not (yet) an update authority.
        $keys = new LicenseKeys();
        if (!$keys->exists()) {
            $this->json(['ok' => true, 'available' => false, 'configured' => false,
                'message' => 'This server is not configured to publish updates.']);
        }

        // ── Licence gate ─────────────────────────────────────────────────
        // Updates are a licensed benefit: the caller proves it is a genuine,
        // active installation with the same id + secret it renews its lease with.
        // Anyone else is politely told nothing is on offer.
        $instanceId = (string) ($in['instanceId'] ?? '');
        $secret     = (string) ($in['secret'] ?? '');
        if (!(new LicenseServer($keys))->isEntitled($instanceId, $secret)) {
            $this->json(['ok' => true, 'available' => false, 'configured' => true, 'licensed' => false,
                'message' => 'A genuine, active licence is required to receive updates. Check your licence status, then try again.']);
        }

        // ── Choose the artifact ──────────────────────────────────────────
        // Prefer the signed index on the update host (it reflects the newest
        // publish from ANY authority — e.g. the developer's laptop — without a
        // shared database). Fall back to this server's own catalogue if the host
        // index is not reachable. Either way the answer stays authoritative and
        // signed.
        $index = $this->hostIndex($keys);
        $artifact = $index !== null
            ? UpdateIndex::select($index, $installed, $channel, $edition)
            : $this->fromCatalogue($installed, $channel, $edition);

        if ($artifact === null) {
            // Over-the-air is DELTA-ONLY. If a newer version exists but has no
            // delta from this install, it's a full-only jump — tell the customer
            // rather than silently reporting "up to date".
            $latest = $index !== null
                ? UpdateIndex::latestVersion($index, $channel, $edition)
                : Version::normalise((string) (UpdateReleases::latest($channel, true, $edition)['version'] ?? '0'));
            if ($latest !== '' && $latest !== '0.0.0' && version_compare($latest, $installed, '>')) {
                $this->json(['ok' => true, 'available' => false, 'configured' => true, 'licensed' => true, 'latest' => $latest,
                    'message' => 'Version ' . $latest . ' is available as a full installer only — over-the-air delivers small deltas. Reinstall with the full installer to jump to it.']);
            }
            $this->json(['ok' => true, 'available' => false, 'configured' => true, 'licensed' => true,
                'message' => 'Up to date.']);
        }

        $payload = [
            'v'        => (string) $artifact['v'],
            'codename' => (string) ($artifact['codename'] ?? ''),
            'minPhp'   => (string) ($artifact['minPhp'] ?? '8.0'),
            'channel'  => $channel,
            'from'     => $installed,
            'kind'     => (string) $artifact['kind'],
            'url'      => (string) $artifact['url'],
            'sha256'   => (string) $artifact['sha256'],
            'size'     => (int) $artifact['size'],
            'notes'    => (string) ($artifact['notes'] ?? ''),
            'iat'      => time(),
        ];

        $token = UpdateProtocol::sign($payload, $keys->secretKey());
        $this->json(['ok' => true, 'available' => true, 'configured' => true, 'licensed' => true, 'feed' => $token]);
    }

    /**
     * The signed index from the update host, decoded + verified, or null when the
     * host is not configured / unreachable / unverifiable (caller then falls back
     * to the local catalogue). Cached briefly so a burst of client checks does not
     * re-fetch it from the host each time.
     *
     * @return array<string,mixed>|null
     */
    private function hostIndex(LicenseKeys $keys): ?array
    {
        // A RESILIENT list of signed-index URLs: the baked-in mirrors EVERY install
        // knows, plus any hosts configured on THIS server. Trying the mirrors — not
        // only the local UpdateHostConfig — is what lets a licence server answer the
        // feed correctly even when IT was never configured with an update host. That
        // was the cause of a stale-catalogue fallback wrongly reporting "up to date"
        // for a release published from a different (authority) machine.
        $urls = [];
        if (class_exists('\PpPhp\Core\Update\UpdateMirrors')) {
            try { foreach (\PpPhp\Core\Update\UpdateMirrors::indexUrls() as $u) $urls[] = (string) $u; }
            catch (\Throwable $e) { /* defaults still work */ }
        }
        if (class_exists(UpdateHostConfig::class)) {
            try { foreach (UpdateHostConfig::indexUrls() as $u) $urls[] = (string) $u; }
            catch (\Throwable $e) { /* fall through */ }
            try { $base = UpdateHostConfig::publicUrl(); if ($base !== '') $urls[] = rtrim($base, '/') . '/index.json'; }
            catch (\Throwable $e) {}
        }
        // Normalise → full index URLs, dedupe, order preserved.
        $seen = []; $list = [];
        foreach ($urls as $u) {
            $u = trim($u);
            if ($u === '') continue;
            if (!preg_match('/\.json$/i', $u)) $u = rtrim($u, '/') . '/index.json';
            if (isset($seen[$u])) continue;
            $seen[$u] = true; $list[] = $u;
        }
        if ($list === []) return null;

        $cacheDir  = constant('BASE_PATH') . '/storage/updates';
        $cacheFile = $cacheDir . '/feed-index.cache';
        // A fresh, still-verifiable cache short-circuits the network.
        if (is_file($cacheFile) && (time() - (int) @filemtime($cacheFile)) < 120) {
            $cached = (string) @file_get_contents($cacheFile);
            if ($cached !== '') {
                $index = UpdateIndex::verify($cached, $keys->publicKey());
                if ($index !== null && UpdateIndex::isFresh($index)) return $index;
            }
        }
        foreach ($list as $u) {
            $token = $this->fetch($u);
            if ($token === '') continue;
            $index = UpdateIndex::verify($token, $keys->publicKey());
            if ($index !== null && UpdateIndex::isFresh($index)) {
                @mkdir($cacheDir, 0775, true); @file_put_contents($cacheFile, $token);
                return $index;
            }
        }
        return null;
    }

    /**
     * The newest live release for this edition, straight from this server's own
     * catalogue (the fallback when the host index is unavailable).
     *
     * @return array{v:string,kind:string,url:string,sha256:string,size:int,codename:string,minPhp:string,notes:string}|null
     */
    private function fromCatalogue(string $installed, string $channel, string $edition): ?array
    {
        $latest = UpdateReleases::latest($channel, true, $edition);
        if ($latest === null) return null;

        $latestVer = Version::normalise((string) $latest['version']);
        if (version_compare($latestVer, $installed, '<=')) return null;

        // OTA is DELTA-ONLY — no delta from this install → no over-the-air update
        // (the caller reports the full-only jump instead).
        $delta = UpdateReleases::delta($installed, $latestVer, $channel, $edition);
        if ($delta === null || (string) $delta['url'] === '') return null;
        $art = ['kind' => 'delta', 'url' => (string) $delta['url'],
                'sha256' => (string) $delta['sha256'], 'size' => (int) $delta['size']];
        return $art + [
            'v'        => $latestVer,
            'codename' => (string) $latest['codename'],
            'minPhp'   => (string) $latest['min_php'],
            'notes'    => (string) $latest['notes'],
        ];
    }

    /** GET a small text resource; '' on any failure. */
    private function fetch(string $url): string
    {
        if (!function_exists('curl_init') || !preg_match('#^https?://#i', $url)) return '';
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS      => 3,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT        => 12,
            CURLOPT_FAILONERROR    => true,
            CURLOPT_USERAGENT      => 'PPPHP-Update/1',
        ]);
        $raw  = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        unset($ch);
        if (!is_string($raw) || $code >= 400) return '';
        return strlen($raw) > 2000000 ? '' : $raw;
    }
}
