<?php
declare(strict_types=1);

namespace SafeTun\Backend\Crypto;

final class SignedEnvelope
{
    public const PREFIX = "SAFETUN-SIGNED-ENVELOPE\0";

    /** @param array<string,mixed> $payload @return array{v:int,domain:string,kid:string,payload:string,sig:string} */
    public static function sign(string $domain, string $kid, array $payload, string $secretKey): array
    {
        $rawPayload = CanonicalJson::encode($payload);
        return self::signRaw($domain, $kid, $rawPayload, $secretKey);
    }

    /** @return array{v:int,domain:string,kid:string,payload:string,sig:string} */
    public static function signRaw(string $domain, string $kid, string $rawPayload, string $secretKey): array
    {
        self::validateLabels($domain, $kid);
        if (strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
            throw new \InvalidArgumentException('Invalid Ed25519 secret-key length.');
        }
        $input = self::signatureInput(1, $domain, $kid, $rawPayload);
        return [
            'v' => 1,
            'domain' => $domain,
            'kid' => $kid,
            'payload' => Base64Url::encode($rawPayload),
            'sig' => Base64Url::encode(sodium_crypto_sign_detached($input, $secretKey)),
        ];
    }

    /** @return array{v:int,domain:string,kid:string,payload:string,sig:string} */
    public static function wrapRaw(string $domain, string $kid, string $rawPayload, string $rawSignature): array
    {
        self::validateLabels($domain, $kid);
        if (strlen($rawSignature) !== SODIUM_CRYPTO_SIGN_BYTES) {
            throw new \InvalidArgumentException('Invalid Ed25519 signature length.');
        }
        return [
            'v' => 1,
            'domain' => $domain,
            'kid' => $kid,
            'payload' => Base64Url::encode($rawPayload),
            'sig' => Base64Url::encode($rawSignature),
        ];
    }

    /**
     * @param array<string,mixed> $envelope
     * @param array<string,string> $publicKeysByKid raw Ed25519 keys
     * @return array<string,mixed>
     */
    public static function verify(
        array $envelope,
        string $expectedDomain,
        array $publicKeysByKid,
        bool $requireCanonicalJson = true,
    ): array {
        $raw = self::verifyRaw($envelope, $expectedDomain, $publicKeysByKid);
        try {
            $decoded = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new \InvalidArgumentException('Signed envelope payload is not valid JSON.', 0, $exception);
        }
        if (!is_array($decoded) || array_is_list($decoded)) {
            throw new \InvalidArgumentException('Signed envelope payload must be a JSON object.');
        }
        if ($requireCanonicalJson && !hash_equals(CanonicalJson::encode($decoded), $raw)) {
            throw new \InvalidArgumentException('Signed envelope payload is not canonical JSON.');
        }
        /** @var array<string,mixed> $decoded */
        return $decoded;
    }

    /** @param array<string,mixed> $envelope @param array<string,string> $publicKeysByKid */
    public static function verifyRaw(array $envelope, string $expectedDomain, array $publicKeysByKid): string
    {
        self::assertExactKeys($envelope, ['v', 'domain', 'kid', 'payload', 'sig']);
        if (($envelope['v'] ?? null) !== 1 || !is_string($envelope['domain'] ?? null)
            || !is_string($envelope['kid'] ?? null) || !is_string($envelope['payload'] ?? null)
            || !is_string($envelope['sig'] ?? null)) {
            throw new \InvalidArgumentException('Malformed signed envelope.');
        }
        if (!hash_equals($expectedDomain, $envelope['domain'])) {
            throw new \InvalidArgumentException('Signed envelope domain mismatch.');
        }
        self::validateLabels($envelope['domain'], $envelope['kid']);
        if (!array_key_exists($envelope['kid'], $publicKeysByKid)) {
            throw new \InvalidArgumentException('Unknown signing key id.');
        }
        $publicKey = $publicKeysByKid[$envelope['kid']];
        if (strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            throw new \InvalidArgumentException('Invalid Ed25519 public-key length.');
        }
        $rawPayload = Base64Url::decode($envelope['payload']);
        if (strlen($rawPayload) > 1024 * 1024) {
            throw new \InvalidArgumentException('Signed payload exceeds safety limit.');
        }
        $signature = Base64Url::decode($envelope['sig'], SODIUM_CRYPTO_SIGN_BYTES);
        $input = self::signatureInput(1, $envelope['domain'], $envelope['kid'], $rawPayload);
        if (!sodium_crypto_sign_verify_detached($signature, $input, $publicKey)) {
            throw new \InvalidArgumentException('Invalid signed-envelope signature.');
        }
        return $rawPayload;
    }

    public static function signatureInput(int $version, string $domain, string $kid, string $rawPayload): string
    {
        return self::PREFIX . (string) $version . "\0" . $domain . "\0" . $kid . "\0" . $rawPayload;
    }

    private static function validateLabels(string $domain, string $kid): void
    {
        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$/D', $domain)
            || !preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/D', $kid)) {
            throw new \InvalidArgumentException('Invalid envelope domain or key id.');
        }
    }

    /** @param array<string,mixed> $value @param list<string> $expected */
    private static function assertExactKeys(array $value, array $expected): void
    {
        $actual = array_keys($value);
        sort($actual, SORT_STRING);
        sort($expected, SORT_STRING);
        if ($actual !== $expected) {
            throw new \InvalidArgumentException('Signed envelope contains missing or unknown fields.');
        }
    }
}
