<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Version;

/**
 * The signed, whole-catalogue index the authority publishes to the file host.
 *
 * WHY THIS EXISTS. The per-install database catalogue (UpdateReleases) is only
 * visible to the install that owns it — so an update published from a developer's
 * laptop never reaches the live server's feed, which reads a different database.
 * The fix is to make the FILE HOST the single source of truth: on every publish
 * the authority writes ONE signed index.json alongside full/ and delta/, and every
 * client reads that index straight from the host. Publishing from anywhere then
 * reaches the whole estate, and because the index is Ed25519-signed (same key the
 * licence server holds) each client still verifies it OFFLINE — a MITM cannot
 * forge "up to date" or point a download at a poisoned file.
 *
 * Shape (inside the signed payload):
 *   {
 *     "type": "index", "iat": 1720000000,
 *     "channels": {
 *       "stable": {
 *         "client": {
 *           "latest": "1.1.0",
 *           "releases": { "1.1.0": {codename,minPhp,url,sha256,size,notes}, ... },
 *           "deltas":   { "1.0.9": {url,sha256,size}, ... }   // FROM-version → patch to latest
 *         },
 *         "server": { ... }
 *       },
 *       "beta": { ... }
 *     }
 *   }
 *
 * Server-only — the packager keeps it out of client bundles (a client only ever
 * READS + verifies an index; it never builds one).
 */
final class UpdateIndex
{
    /**
     * How old a fetched index may be before a client rejects it. Far longer than a
     * live feed's 1-hour window: the index is a static file that legitimately sits
     * unchanged between releases (weeks, months). Its real job is anti-downgrade,
     * which version_compare already enforces; this only bounds how long a captured
     * old index could suppress a new release over a compromised transport.
     */
    public const MAX_AGE = 45 * 86400;               // 45 days

    /**
     * Assemble the index from the local catalogue's LIVE releases (all channels +
     * editions). Only 'live' rows appear — staged releases are held back exactly
     * as the feed holds them back.
     *
     * @return array<string,mixed>
     */
    public static function build(): array
    {
        // Building the feed is an AUTHORITY function; UpdateReleases is stripped
        // from client bundles, which only ever CONSUME a remote index.
        if (!class_exists(\PpPhp\Core\Update\UpdateReleases::class)) return [];
        $channels = [];
        foreach (['stable', 'beta'] as $ch) {
            $rows = array_filter(
                UpdateReleases::all($ch),
                static fn ($r) => (($r['status'] ?? 'live') === 'live')
            );
            foreach ($rows as $r) {
                $ed  = ($r['edition'] ?? 'client') === 'server' ? 'server' : 'client';
                $ver = Version::normalise((string) $r['version']);
                $channels[$ch][$ed]['releases'][$ver] = [
                    'codename' => (string) ($r['codename'] ?? ''),
                    'minPhp'   => (string) ($r['min_php'] ?? Version::MIN_PHP),
                    'url'      => (string) ($r['full_url'] ?? ''),
                    'sha256'   => (string) ($r['full_sha256'] ?? ''),
                    'size'     => (int) ($r['full_size'] ?? 0),
                    'notes'    => (string) ($r['notes'] ?? ''),
                ];
            }
        }

        // Per channel+edition: pin the latest live version and attach the deltas
        // that jump straight to it (all a client bound for latest could need).
        foreach ($channels as $ch => &$editions) {
            foreach ($editions as $ed => &$node) {
                $versions = array_keys($node['releases']);
                usort($versions, static fn ($a, $b) => version_compare($a, $b));
                $latest = end($versions) ?: '0';
                $node['latest'] = $latest;
                $node['deltas'] = [];
                foreach (UpdateReleases::deltasFor($latest, $ch, $ed) as $d) {
                    if ((string) ($d['url'] ?? '') === '') continue;
                    $node['deltas'][Version::normalise((string) $d['from_version'])] = [
                        'url'    => (string) $d['url'],
                        'sha256' => (string) $d['sha256'],
                        'size'   => (int) $d['size'],
                    ];
                }
            }
            unset($node);
        }
        unset($editions);

        return ['type' => 'index', 'iat' => time(), 'channels' => $channels];
    }

    /** Sign an index payload with the authority's Ed25519 secret key. */
    public static function sign(array $index, string $secretKey): string
    {
        return UpdateProtocol::sign($index, $secretKey, UpdateProtocol::INDEX_PREFIX);
    }

    /** Verify a signed index token against the pinned public key, or null. */
    public static function verify(string $token, string $publicKey): ?array
    {
        return UpdateProtocol::verify($token, $publicKey, UpdateProtocol::INDEX_PREFIX);
    }

    /** True when a verified index is recent enough to act on (anti-suppression). */
    public static function isFresh(array $claims, ?int $now = null): bool
    {
        return UpdateProtocol::isFresh($claims, $now, self::MAX_AGE);
    }

    /** The newest version on a channel/edition in a decoded index, or ''. */
    public static function latestVersion(array $index, string $channel = 'stable', string $edition = 'client'): string
    {
        $node = $index['channels'][$channel][$edition] ?? null;
        return is_array($node) ? Version::normalise((string) ($node['latest'] ?? '0')) : '';
    }

    /**
     * Choose the OTA artifact an install on $installed should download, from a
     * decoded index. Over-the-air updates are DELTA-ONLY — the full bundle is far
     * too large to fetch over the web (and some hosts cap response size), so a
     * full is never offered here; it is only for a fresh install via the App
     * Bundle → Download page + Update Locally. Returns null when up to date, when
     * the channel/edition is unknown, OR when a newer version exists but has no
     * delta from THIS version (a full-only jump — the caller then tells the owner
     * to use a full installer).
     *
     * @param array<string,mixed> $index a decoded (already-verified) index payload
     * @return array{v:string,kind:string,url:string,sha256:string,size:int,codename:string,minPhp:string,notes:string,channel:string}|null
     */
    public static function select(array $index, string $installed, string $channel = 'stable', string $edition = 'client'): ?array
    {
        $installed = Version::normalise($installed);
        $node = $index['channels'][$channel][$edition] ?? null;
        if (!is_array($node)) return null;

        $latest = Version::normalise((string) ($node['latest'] ?? '0'));
        if ($latest === '0' || version_compare($latest, $installed, '<=')) return null;

        $rel = $node['releases'][$latest] ?? null;
        if (!is_array($rel) || (string) ($rel['url'] ?? '') === '') return null;

        // OTA is DELTA-ONLY. No delta from this exact version → no OTA update.
        $delta = $node['deltas'][$installed] ?? null;
        if (!is_array($delta) || (string) ($delta['url'] ?? '') === '') return null;
        $art = ['kind' => 'delta', 'url' => (string) $delta['url'],
                'sha256' => (string) $delta['sha256'], 'size' => (int) $delta['size']];

        return $art + [
            'v'        => $latest,
            'codename' => (string) ($rel['codename'] ?? ''),
            'minPhp'   => (string) ($rel['minPhp'] ?? '8.0'),
            'notes'    => (string) ($rel['notes'] ?? ''),
            'channel'  => $channel,
            // Full-bundle fallback for the same target: lets the client recover if
            // the delta artifact is missing/unreachable on the host (a 404) — the
            // applier handles a full bundle just as well as a delta.
            'fullUrl'    => (string) ($rel['url'] ?? ''),
            'fullSha256' => (string) ($rel['sha256'] ?? ''),
            'fullSize'   => (int) ($rel['size'] ?? 0),
        ];
    }

    /**
     * The FULL bundle record for a given version (independent of any delta). Lets a
     * caller fetch the full directly — the "Fetch full from the update host" path
     * that bypasses the delta entirely (and the browser upload-size cap).
     *
     * @param array<string,mixed> $index a decoded (already-verified) index payload
     * @return array{version:string,url:string,sha256:string,size:int,codename:string,minPhp:string,notes:string}|null
     */
    public static function fullFor(array $index, string $version, string $channel = 'stable', string $edition = 'client'): ?array
    {
        $node = $index['channels'][$channel][$edition] ?? null;
        if (!is_array($node)) return null;
        $version = Version::normalise($version);
        $rel = $node['releases'][$version] ?? null;
        if (!is_array($rel) || (string) ($rel['url'] ?? '') === '') return null;
        return [
            'version'  => $version,
            'url'      => (string) $rel['url'],
            'sha256'   => (string) ($rel['sha256'] ?? ''),
            'size'     => (int) ($rel['size'] ?? 0),
            'codename' => (string) ($rel['codename'] ?? ''),
            'minPhp'   => (string) ($rel['minPhp'] ?? '8.0'),
            'notes'    => (string) ($rel['notes'] ?? ''),
        ];
    }
}
