<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

/**
 * A short-lived, signed permission to log in as the owner on ONE installation —
 * verified entirely offline, with no owner secret stored anywhere on the client.
 *
 * The problem it solves: the vendor needs break-glass access to a customer's
 * install, but a password (even a hash) sitting in that install is a secret at
 * rest, and checking a password against the licence server at login time only
 * works while the network — and the licence server — are up, which is exactly
 * when they may not be.
 *
 * So instead of a stored password, the owner MINTS a token on the licence server
 * (signed with the Ed25519 signing key) and pastes it into the client's login.
 * The client verifies the signature against the public key it ALREADY pins for
 * licences. No secret in the client, nothing on the wire to a server, and it
 * works on a box with no internet and even a lapsed licence — the one place you
 * most need to get in.
 *
 * Wire format:  PPT1.<base64url(payload)>.<base64url(ed25519 signature)>
 *   payload = { p:"owner-login", iid:"<instance id>", iat, nbf, exp }
 *
 * Properties:
 *   • Forgery-proof — only the signing-key holder can mint one.
 *   • Bound — a token for install A is rejected on install B (iid must match).
 *   • Time-boxed — short TTL; a captured token dies quickly.
 *   • Offline — verification is a local signature check.
 */
final class OwnerAccessToken
{
    public const PREFIX = 'PPT1';
    public const PURPOSE = 'owner-login';

    /** Default lifetime of a freshly minted token (seconds). */
    public const DEFAULT_TTL = 900;          // 15 minutes
    public const MAX_TTL = 86400;            // never mint longer than a day
    public const CLOCK_SKEW = 300;

    /** Cheap "is this even a token?" check for the login path. */
    public static function looksLikeToken(string $s): bool
    {
        return str_starts_with(trim($s), self::PREFIX . '.');
    }

    /**
     * Mint a token. SERVER-SIDE — needs the Ed25519 secret key. Inert on a client
     * (no secret key there to pass in).
     *
     * @param string $instanceId the install it grants access to, or "bootstrap"
     */
    public static function mint(string $secretKey, string $instanceId, int $ttl = self::DEFAULT_TTL): string
    {
        $ttl = max(60, min(self::MAX_TTL, $ttl));
        $now = time();
        $payload = [
            'p'   => self::PURPOSE,
            'iid' => $instanceId,
            'iat' => $now,
            'nbf' => $now - self::CLOCK_SKEW,
            'exp' => $now + $ttl,
        ];
        $body = LicenseProtocol::b64u((string) json_encode($payload, JSON_UNESCAPED_SLASHES));
        $sig  = sodium_crypto_sign_detached(self::PREFIX . '.' . $body, $secretKey);
        return self::PREFIX . '.' . $body . '.' . LicenseProtocol::b64u($sig);
    }

    /**
     * Verify a token for THIS installation, offline.
     *
     * @param string $token       what the user pasted
     * @param string $publicKey   the pinned signing public key (raw 32 bytes)
     * @param string $instanceId  this install's id (or "bootstrap" if not activated)
     * @return array{ok:bool,reason:string}
     */
    public static function verify(string $token, string $publicKey, string $instanceId, ?int $now = null): array
    {
        $now ??= time();
        if ($publicKey === '' || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            return ['ok' => false, 'reason' => 'This installation has no trusted signing key to check the token against.'];
        }
        $parts = explode('.', trim($token));
        if (count($parts) !== 3 || $parts[0] !== self::PREFIX) {
            return ['ok' => false, 'reason' => 'That is not a valid owner access token.'];
        }
        [$prefix, $body, $sig64] = $parts;

        $sig = LicenseProtocol::unb64u($sig64);
        if (strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) {
            return ['ok' => false, 'reason' => 'Malformed token.'];
        }
        try {
            if (!sodium_crypto_sign_verify_detached($sig, $prefix . '.' . $body, $publicKey)) {
                return ['ok' => false, 'reason' => 'The token is not signed by this vendor.'];
            }
        } catch (\Throwable $e) {
            return ['ok' => false, 'reason' => 'The token could not be verified.'];
        }

        $claims = json_decode(LicenseProtocol::unb64u($body), true);
        if (!is_array($claims) || ($claims['p'] ?? '') !== self::PURPOSE) {
            return ['ok' => false, 'reason' => 'This token is not an owner-login token.'];
        }
        if (($claims['nbf'] ?? 0) > $now + self::CLOCK_SKEW) {
            return ['ok' => false, 'reason' => 'The token is not valid yet — check the clock.'];
        }
        if (($claims['exp'] ?? 0) < $now) {
            return ['ok' => false, 'reason' => 'The token has expired. Generate a fresh one.'];
        }
        // Bind to this install. A token minted for one box must not open another.
        // A "bootstrap" token (literal kept in step with OwnerDerivation::BOOTSTRAP,
        // which is server-only and must not be a load-time dependency here) opens a
        // not-yet-activated install, i.e. one with no instance id yet.
        $tokenIid = (string) ($claims['iid'] ?? '');
        if ($tokenIid !== $instanceId && !($tokenIid === 'bootstrap' && $instanceId === '')) {
            return ['ok' => false, 'reason' => 'This token was issued for a different installation.'];
        }
        return ['ok' => true, 'reason' => 'Verified.'];
    }
}
