<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

/**
 * Single sign-on token core (platform feature C1) — a compact Ed25519-signed JWT
 * that lets the console hand a logged-in identity to a generated app (e.g. the
 * community forum) WITHOUT sharing passwords or 2FA. The console signs with an
 * SSO private key (a DEDICATED key, never the licence signing key); the app ships
 * only the matching public key and can verify but never forge.
 *
 * Pure crypto (keys passed in) → unit-testable. Asymmetric on purpose: a leaked
 * client bundle contains only the public key, so it can't mint identities.
 *
 * Token = base64url(header).base64url(payload).base64url(sig), alg "EdDSA".
 * Short TTL (default 5 min): it is a one-shot login handoff, not a session.
 */
final class Sso
{
    /**
     * Sign claims into a token. iat/exp are added here; never pass them in.
     *
     * @param array<string,mixed> $claims  e.g. ['sub'=>'42','name'=>'Ada','email'=>'a@b.c','role'=>'member']
     * @param string $secretKey  Ed25519 secret key (raw bytes)
     */
    public static function issue(array $claims, string $secretKey, int $ttl = 300): string
    {
        $now = time();
        unset($claims['iat'], $claims['exp']);
        $payload = $claims + ['iat' => $now, 'exp' => $now + max(1, $ttl)];
        $seg = self::b64((string) json_encode(['alg' => 'EdDSA', 'typ' => 'JWT']))
            . '.' . self::b64((string) json_encode($payload));
        $sig = sodium_crypto_sign_detached($seg, $secretKey);
        return $seg . '.' . self::b64($sig);
    }

    /**
     * Verify a token's signature + expiry and return its claims, or null if it is
     * malformed, tampered, signed by the wrong key, expired, or issued in the
     * future (beyond a small clock-skew tolerance).
     *
     * @param string $publicKey Ed25519 public key (raw bytes)
     * @return array<string,mixed>|null
     */
    public static function verify(string $token, string $publicKey, ?int $now = null): ?array
    {
        $now ??= time();
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            return null;
        }
        [$h, $p, $s] = $parts;
        $sig = self::unb64($s);
        if ($sig === null || strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) {
            return null;
        }
        if (strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            return null;
        }
        if (!sodium_crypto_sign_verify_detached($sig, $h . '.' . $p, $publicKey)) {
            return null;
        }
        $raw = self::unb64($p);
        $payload = $raw !== null ? json_decode($raw, true) : null;
        if (!is_array($payload)) {
            return null;
        }
        if (isset($payload['exp']) && $now > (int) $payload['exp']) {
            return null;                                   // expired
        }
        if (isset($payload['iat']) && (int) $payload['iat'] > $now + 60) {
            return null;                                   // issued in the future (>60s skew)
        }
        return $payload;
    }

    private static function b64(string $s): string
    {
        return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
    }

    private static function unb64(string $s): ?string
    {
        $d = base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4), true);
        return $d === false ? null : $d;
    }
}
