<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

/**
 * The signed statement the Update Engine hands to a client's "Check for Updates".
 *
 * A FEED is an Ed25519-signed answer to "what is the newest release, and where
 * do I get exactly the artifact my version needs?". It is produced by the update
 * authority (the same install that runs the licence server, holding the same
 * signing key) and verified OFFLINE by every client against the public key
 * already pinned in each bundle (\PpPhp\Core\Licensing\LicenseTrust).
 *
 * Because it is signed:
 *   • a man-in-the-middle cannot forge a "you're up to date" or point a client
 *     at a poisoned download,
 *   • switching the file host is a one-line change on the authority that the
 *     whole estate follows on its next check — the new URL rides inside the
 *     signature, so it cannot be swapped in transit.
 *
 * Wire format — deliberately identical shape to a licence lease, but a DIFFERENT
 * prefix so a lease can never be replayed as a feed or vice-versa:
 *
 *     PPU1.<base64url(json payload)>.<base64url(ed25519 signature)>
 *
 * Everything a client acts on lives INSIDE the signed payload.
 */
final class UpdateProtocol
{
    /** Domain-separation prefix — distinct from the licence lease's PPL1. */
    public const TOKEN_PREFIX = 'PPU1';

    /**
     * A DIFFERENT prefix for the whole-catalogue index the authority publishes to
     * the file host (a signed list of every live release + delta). Domain-separated
     * so a single-release feed can never be replayed as an index, or vice-versa.
     */
    public const INDEX_PREFIX = 'PPUX';

    /**
     * A DISTINCT prefix for an Owner-pushed advisory feed (the signed list of
     * notices/patches the authority sends a client on check-in). Domain-separated
     * from PPU1/PPUX so an advisory token can never be replayed as a release feed
     * or a catalogue index, or vice-versa — even though all three are signed by
     * the same vendor Ed25519 key.
     */
    public const ADVISORY_PREFIX = 'PPUD';

    /**
     * How long a feed answer is considered fresh (seconds). A feed is fetched
     * live on every check, so this only bounds replay of a captured response —
     * an attacker cannot use yesterday's "no update" to hide today's security fix
     * for more than this window.
     */
    public const MAX_AGE = 3600;                 // 1 hour

    /** Tolerated clock skew when checking issued-at (seconds). */
    public const CLOCK_SKEW = 300;

    public static function b64u(string $raw): string
    {
        return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
    }

    public static function unb64u(string $s): string
    {
        $pad = strlen($s) % 4;
        if ($pad) $s .= str_repeat('=', 4 - $pad);
        return (string) base64_decode(strtr($s, '-_', '+/'), true);
    }

    /**
     * Sign a feed payload. Authority-side only — needs the 64-byte Ed25519 secret
     * (the same key the licence server signs leases with).
     */
    public static function sign(array $payload, string $secretKey, string $prefix = self::TOKEN_PREFIX): string
    {
        $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        $body = self::b64u($json);
        $sig  = sodium_crypto_sign_detached($prefix . '.' . $body, $secretKey);
        return $prefix . '.' . $body . '.' . self::b64u($sig);
    }

    /**
     * Verify a feed token's signature and return its claims, or null when the
     * token is malformed or not signed by the holder of $publicKey. The signature
     * is checked BEFORE the payload is trusted for anything.
     */
    public static function verify(string $token, string $publicKey, string $expectPrefix = self::TOKEN_PREFIX): ?array
    {
        $parts = explode('.', trim($token));
        if (count($parts) !== 3 || $parts[0] !== $expectPrefix) return null;
        [$prefix, $body, $sig64] = $parts;

        $sig = self::unb64u($sig64);
        if (strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) return null;
        if (strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) return null;

        try {
            if (!sodium_crypto_sign_verify_detached($sig, $prefix . '.' . $body, $publicKey)) {
                return null;
            }
        } catch (\Throwable $e) {
            return null;
        }
        $claims = json_decode(self::unb64u($body), true);
        return is_array($claims) ? $claims : null;
    }

    /**
     * True when a verified feed is too old to trust (replay guard). Kept separate
     * from verify() so a caller with a deliberately supplied clock (tests) can opt
     * out. $now defaults to the wall clock.
     */
    public static function isFresh(array $claims, ?int $now = null, ?int $maxAge = null): bool
    {
        $now ??= time();
        $maxAge ??= self::MAX_AGE;
        $iat = (int) ($claims['iat'] ?? 0);
        if ($iat <= 0) return true;                       // no timestamp → don't reject
        if ($iat - self::CLOCK_SKEW > $now) return false; // issued in the future
        return ($now - $iat) <= $maxAge + self::CLOCK_SKEW;
    }
}
