<?php
declare(strict_types=1);

namespace SafeTun\Backend\Crypto;

final class HttpProof
{
    public const PREFIX = "SAFETUN-HTTP-PROOF\0v1\0";

    /** @param array<string,mixed> $body */
    public static function verify(
        array $body,
        string $method,
        string $path,
        string $expectedAudience,
        int $clockSkewSeconds,
        ?int $now = null,
    ): VerifiedProof {
        self::assertExactKeys($body, [
            'request_id', 'nonce', 'timestamp', 'audience', 'api_version', 'payload', 'body_hash', 'sig',
        ]);
        if (!is_string($body['request_id'] ?? null) || !self::isRequestId($body['request_id'])
            || !is_string($body['nonce'] ?? null) || !preg_match('/^[A-Za-z0-9_-]{32,128}$/D', $body['nonce'])
            || !is_int($body['timestamp'] ?? null)
            || !is_string($body['audience'] ?? null) || !hash_equals($expectedAudience, $body['audience'])
            || ($body['api_version'] ?? null) !== 'v1'
            || !is_array($body['payload'] ?? null) || array_is_list($body['payload'])
            || !is_string($body['body_hash'] ?? null) || !preg_match('/^[0-9a-f]{64}$/D', $body['body_hash'])
            || !is_string($body['sig'] ?? null)) {
            throw new \InvalidArgumentException('Malformed signed HTTP proof.');
        }
        if (strtoupper($method) !== 'POST' || !str_starts_with($path, '/api/v1/')) {
            throw new \InvalidArgumentException('Invalid signed HTTP method or path.');
        }
        $now ??= time();
        if (abs($now - $body['timestamp']) > $clockSkewSeconds) {
            throw new \InvalidArgumentException('Signed HTTP proof timestamp is outside the accepted window.');
        }

        $rawPayload = CanonicalJson::encode($body['payload']);
        $calculatedHash = hash('sha256', $rawPayload);
        if (!hash_equals($calculatedHash, $body['body_hash'])) {
            throw new \InvalidArgumentException('Signed HTTP proof body hash mismatch.');
        }
        $nodePublic = $body['payload']['node_public_key'] ?? null;
        if (!is_string($nodePublic)) {
            throw new \InvalidArgumentException('Signed HTTP proof omits node_public_key.');
        }
        $nodePublicBytes = Base64Url::decode($nodePublic, SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES);
        $signature = Base64Url::decode($body['sig'], SODIUM_CRYPTO_SIGN_BYTES);
        $input = self::signatureInput(
            strtoupper($method),
            $path,
            $body['body_hash'],
            $body['timestamp'],
            $body['audience'],
            $body['api_version'],
            $body['request_id'],
            $body['nonce'],
        );
        if (!sodium_crypto_sign_verify_detached($signature, $input, $nodePublicBytes)) {
            throw new \InvalidArgumentException('Signed HTTP proof signature is invalid.');
        }

        return new VerifiedProof(
            $body['request_id'],
            $body['nonce'],
            $body['timestamp'],
            $body['payload'],
            $nodePublicBytes,
            hash('sha256', $nodePublicBytes),
        );
    }

    public static function signatureInput(
        string $method,
        string $path,
        string $bodyHash,
        int $timestamp,
        string $audience,
        string $apiVersion,
        string $requestId,
        string $nonce,
    ): string {
        return self::PREFIX . $method . "\0" . $path . "\0" . $bodyHash . "\0" . (string) $timestamp
            . "\0" . $audience . "\0" . $apiVersion . "\0" . $requestId . "\0" . $nonce;
    }

    public static function isRequestId(string $value): bool
    {
        return (bool) preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D', $value);
    }

    /** @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 HTTP proof has missing or unknown fields.');
        }
    }
}
