<?php
declare(strict_types=1);

namespace SafeTun\Backend\Service;

use SafeTun\Backend\Config\ValidatedConfig;
use SafeTun\Backend\Crypto\Base64Url;
use SafeTun\Backend\Crypto\HttpProof;
use SafeTun\Backend\Crypto\KeyLoader;
use SafeTun\Backend\Crypto\SignedEnvelope;
use SafeTun\Backend\Crypto\VerifiedProof;
use SafeTun\Backend\Exception\PublicApiException;
use SafeTun\Backend\Net\CidrMatcher;
use SafeTun\Backend\Storage\NonceStore;
use SafeTun\Backend\Storage\PairLimitStore;

final class AuthorizationService
{
    public const LEASE_DOMAIN = 'SAFETUN-ACCESS-LEASE-v1';
    public const HUB_PAIR_DOMAIN = 'SAFETUN-HUB-CREDENTIAL-v1';
    public const EDGE_PAIR_DOMAIN = 'SAFETUN-EDGE-CREDENTIAL-v1';

    /** @var array<string,mixed> */
    private array $config;
    private NonceStore $nonces;
    private ?PairLimitStore $pairLimits = null;

    public function __construct(private readonly ValidatedConfig $validated)
    {
        $this->config = $validated->values;
        $this->nonces = new NonceStore($this->config['runtime']['nonce_dir']);
        if ($this->config['authorization']['max_edges_per_hub'] !== null) {
            $this->pairLimits = new PairLimitStore(
                rtrim($this->config['runtime']['cache_dir'], '/\\') . DIRECTORY_SEPARATOR . 'pair-limits',
            );
        }
    }

    public function assertAllowed(string $clientIp): void
    {
        $authorization = $this->config['authorization'];
        $matcher = new CidrMatcher($authorization['allowed_iran_cidrs'], $authorization['denied_cidrs']);
        if (!$matcher->isAllowed($clientIp, $authorization['dangerously_allow_all'])) {
            throw new PublicApiException('ACCESS_NOT_ALLOWED', 403, 'Observed source is not authorized.');
        }
    }

    /** @return array<string,mixed> */
    public function challenge(string $clientIp): array
    {
        $this->assertAllowed($clientIp);
        $requestId = Ids::requestId();
        $nonce = Ids::opaque(32);
        $expiresAt = time() + $this->config['api']['challenge_ttl_seconds'];
        try {
            $this->nonces->issue($nonce, $this->clientHash($clientIp), $requestId, $expiresAt);
        } catch (\Throwable $exception) {
            throw new PublicApiException('SERVICE_UNAVAILABLE', 503, 'Unable to persist challenge state.');
        }
        return [
            'request_id' => $requestId,
            'nonce' => $nonce,
            'expires_at' => $expiresAt,
            'server_time' => time(),
            'audience' => $this->config['app']['audience'],
            'api_version' => 'v1',
        ];
    }

    /** @param array<string,mixed> $body @return array<string,mixed> */
    public function authorize(array $body, string $path, string $clientIp): array
    {
        $this->assertAllowed($clientIp);
        $proof = $this->verifyAndConsume($body, $path, $clientIp);
        $this->exactPayload($proof->payload, ['node_public_key', 'requested_features']);
        $this->assertNodeNotRevoked($proof->nodeKeyHash);
        $features = $this->requestedFeatures($proof->payload['requested_features'] ?? null, 'hub_features');
        return ['lease' => $this->issueLease($proof->nodeKeyHash, $clientIp, $features)];
    }

    /** @param array<string,mixed> $body @return array<string,mixed> */
    public function renew(array $body, string $path, string $clientIp): array
    {
        $this->assertAllowed($clientIp);
        $proof = $this->verifyAndConsume($body, $path, $clientIp);
        $this->exactPayload($proof->payload, ['node_public_key', 'lease']);
        $this->assertNodeNotRevoked($proof->nodeKeyHash);
        $lease = $this->validateLease($proof->payload['lease'] ?? null, $proof->nodeKeyHash, $clientIp, true);
        /** @var list<string> $features */
        $features = $lease['features'];
        sort($features, SORT_STRING);
        return ['lease' => $this->issueLease($proof->nodeKeyHash, $clientIp, $features)];
    }

    /** @param array<string,mixed> $body @return array<string,mixed> */
    public function pairCredential(array $body, string $path, string $clientIp): array
    {
        $this->assertAllowed($clientIp);
        $proof = $this->verifyAndConsume($body, $path, $clientIp);
        $this->exactPayload($proof->payload, ['node_public_key', 'lease', 'edge_key_hash', 'requested_features']);
        $this->assertNodeNotRevoked($proof->nodeKeyHash);
        $lease = $this->validateLease($proof->payload['lease'] ?? null, $proof->nodeKeyHash, $clientIp, true);
        $edgeHash = $proof->payload['edge_key_hash'] ?? null;
        if (!is_string($edgeHash) || !preg_match('/^[0-9a-f]{64}$/D', $edgeHash)) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'Invalid Edge public-key hash.');
        }
        $features = $this->requestedPairFeatures(
            $proof->payload['requested_features'] ?? null,
            $lease['features'],
        );
        $pairId = Ids::opaque(16);
        $now = time();
        $expiresAt = min($now + $this->config['pair']['ttl_seconds'], (int) $lease['grace_until']);
        if ($expiresAt <= $now) {
            throw new PublicApiException('AUTHENTICATION_FAILED', 401, 'Lease has no remaining pairing lifetime.');
        }
        $graceUntil = min(
            $expiresAt + $this->config['pair']['offline_grace_seconds'],
            (int) $lease['grace_until'],
        );
        if ($this->pairLimits !== null) {
            try {
                $this->pairLimits->reserve(
                    $proof->nodeKeyHash,
                    $edgeHash,
                    (int) $this->config['authorization']['max_edges_per_hub'],
                );
            } catch (\OverflowException) {
                throw new PublicApiException('ACCESS_NOT_ALLOWED', 403, 'Maximum Edge count reached for this Hub.');
            } catch (\Throwable) {
                throw new PublicApiException('SERVICE_UNAVAILABLE', 503, 'Pair-limit state is unavailable.');
            }
        }
        $common = [
            'iss' => $this->config['app']['issuer'],
            'aud' => $this->config['app']['audience'],
            'pair_id' => $pairId,
            'hub_key_hash' => $proof->nodeKeyHash,
            'edge_key_hash' => $edgeHash,
            'features' => $features,
            'iat' => $now,
            'nbf' => $now - $this->config['lease']['clock_skew_seconds'],
            'exp' => $expiresAt,
            'grace_until' => $graceUntil,
            'jti' => Ids::opaque(16),
            'kid' => $this->config['pair']['active_key_id'],
        ];
        $hubPayload = array_merge($common, [
            'role' => 'iran',
        ]);
        $edgePayload = array_merge($common, [
            'role' => 'kharej',
            'jti' => Ids::opaque(16),
        ]);
        return [
            'pair_id' => $pairId,
            'hub_credential' => $this->signPair(self::HUB_PAIR_DOMAIN, $hubPayload),
            'edge_credential' => $this->signPair(self::EDGE_PAIR_DOMAIN, $edgePayload),
        ];
    }

    /** @param array<string,mixed> $body */
    private function verifyAndConsume(array $body, string $path, string $clientIp): VerifiedProof
    {
        try {
            $proof = HttpProof::verify(
                $body,
                'POST',
                $path,
                $this->config['app']['audience'],
                $this->config['api']['request_clock_skew_seconds'],
            );
            $this->nonces->consume($proof->nonce, $this->clientHash($clientIp), $proof->requestId);
            return $proof;
        } catch (\InvalidArgumentException $exception) {
            throw new PublicApiException('AUTHENTICATION_FAILED', 401, 'Signed proof or nonce is invalid.');
        } catch (\RuntimeException $exception) {
            throw new PublicApiException('SERVICE_UNAVAILABLE', 503, 'Replay state is unavailable.');
        }
    }

    /** @param list<string> $features @return array<string,mixed> */
    private function issueLease(string $nodeHash, string $clientIp, array $features): array
    {
        $now = time();
        $payload = [
            'iss' => $this->config['app']['issuer'],
            'aud' => $this->config['app']['audience'],
            'role' => 'iran',
            'node_key_hash' => $nodeHash,
            'observed_ip_hash' => $this->clientHash($clientIp),
            'features' => $features,
            'config_hash' => $this->validated->sha256,
            'iat' => $now,
            'nbf' => $now - $this->config['lease']['clock_skew_seconds'],
            'exp' => $now + $this->config['lease']['ttl_seconds'],
            'grace_until' => $now + $this->config['lease']['ttl_seconds'] + $this->config['lease']['offline_grace_seconds'],
            'jti' => Ids::opaque(16),
            'kid' => $this->config['lease']['active_key_id'],
        ];
        $secret = KeyLoader::secret($this->config['lease']['private_key_path']);
        try {
            return SignedEnvelope::sign(self::LEASE_DOMAIN, $this->config['lease']['active_key_id'], $payload, $secret);
        } finally {
            sodium_memzero($secret);
        }
    }

    /** @param mixed $rawEnvelope @return array<string,mixed> */
    private function validateLease(mixed $rawEnvelope, string $nodeHash, string $clientIp, bool $allowGrace): array
    {
        if (!is_array($rawEnvelope) || array_is_list($rawEnvelope)) {
            throw new PublicApiException('AUTHENTICATION_FAILED', 401, 'Lease envelope is malformed.');
        }
        try {
            $keys = [];
            foreach ($this->config['lease']['verification_keys'] as $kid => $path) {
                $keys[$kid] = KeyLoader::public($path);
            }
            $lease = SignedEnvelope::verify($rawEnvelope, self::LEASE_DOMAIN, $keys);
        } catch (\Throwable $exception) {
            throw new PublicApiException('AUTHENTICATION_FAILED', 401, 'Lease signature is invalid.');
        }
        $required = [
            'iss', 'aud', 'role', 'node_key_hash', 'features', 'config_hash', 'iat', 'nbf',
            'exp', 'grace_until', 'jti', 'kid', 'observed_ip_hash',
        ];
        $this->exactPayload($lease, $required, 'AUTHENTICATION_FAILED');
        $now = time();
        $deadline = $allowGrace ? ($lease['grace_until'] ?? 0) : ($lease['exp'] ?? 0);
        $featuresValid = is_array($lease['features'] ?? null) && array_is_list($lease['features']);
        if ($featuresValid) {
            foreach ($lease['features'] as $feature) {
                if (!is_string($feature) || !in_array($feature, $this->config['access_policy']['hub_features'], true)) {
                    $featuresValid = false;
                    break;
                }
            }
            $featuresValid = $featuresValid && count($lease['features']) === count(array_unique($lease['features']));
        }
        if (($lease['iss'] ?? null) !== $this->config['app']['issuer']
            || ($lease['aud'] ?? null) !== $this->config['app']['audience']
            || ($lease['role'] ?? null) !== 'iran'
            || !is_string($lease['node_key_hash'] ?? null) || !preg_match('/^[0-9a-f]{64}$/D', $lease['node_key_hash'])
            || !hash_equals($nodeHash, $lease['node_key_hash'])
            || !is_string($lease['observed_ip_hash'] ?? null) || !preg_match('/^[0-9a-f]{64}$/D', $lease['observed_ip_hash'])
            || !hash_equals($this->clientHash($clientIp), $lease['observed_ip_hash'])
            || !is_string($lease['config_hash'] ?? null) || !preg_match('/^[0-9a-f]{64}$/D', $lease['config_hash'])
            || !is_string($lease['jti'] ?? null) || $lease['jti'] === '' || strlen($lease['jti']) > 256
            || !is_string($lease['kid'] ?? null) || !hash_equals((string) $rawEnvelope['kid'], $lease['kid'])
            || !$featuresValid
            || !is_int($lease['iat'] ?? null) || !is_int($lease['nbf'] ?? null)
            || !is_int($lease['exp'] ?? null) || !is_int($lease['grace_until'] ?? null)
            || $lease['iat'] <= 0 || $lease['nbf'] <= 0 || $lease['exp'] <= $lease['nbf']
            || $lease['grace_until'] < $lease['exp'] || $lease['iat'] > $now + $this->config['lease']['clock_skew_seconds']
            || $lease['nbf'] > $now + $this->config['lease']['clock_skew_seconds']
            || !is_int($deadline) || $deadline < $now - $this->config['lease']['clock_skew_seconds']) {
            throw new PublicApiException('AUTHENTICATION_FAILED', 401, 'Lease binding or lifetime is invalid.');
        }
        $this->assertNodeNotRevoked($nodeHash);
        return $lease;
    }

    /** @param array<string,mixed> $payload @return array<string,mixed> */
    private function signPair(string $domain, array $payload): array
    {
        $secret = KeyLoader::secret($this->config['pair']['private_key_path']);
        try {
            return SignedEnvelope::sign($domain, $this->config['pair']['active_key_id'], $payload, $secret);
        } finally {
            sodium_memzero($secret);
        }
    }

    /** @return list<string> */
    private function requestedFeatures(mixed $requested, string $policyKey): array
    {
        if (!is_array($requested) || !array_is_list($requested)) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'requested_features must be a list.');
        }
        $allowed = $this->config['access_policy'][$policyKey];
        $result = $requested === [] ? $allowed : $requested;
        foreach ($result as $feature) {
            if (!is_string($feature) || !in_array($feature, $allowed, true)) {
                throw new PublicApiException('INVALID_REQUEST', 400, 'Unsupported requested feature.');
            }
        }
        if (count($result) !== count(array_unique($result))) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'Duplicate requested feature.');
        }
        sort($result, SORT_STRING);
        return $result;
    }

    /** @param mixed $requested @param list<string> $leaseFeatures @return list<string> */
    private function requestedPairFeatures(mixed $requested, array $leaseFeatures): array
    {
        if (!is_array($requested) || !array_is_list($requested)) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'requested_features must be a list.');
        }
        $edgePolicy = $this->config['access_policy']['edge_features'];
        $result = $requested === []
            ? array_values(array_intersect($edgePolicy, $leaseFeatures))
            : $requested;
        foreach ($result as $feature) {
            if (!is_string($feature) || !in_array($feature, $edgePolicy, true)
                || !in_array($feature, $leaseFeatures, true)) {
                throw new PublicApiException(
                    'INVALID_REQUEST',
                    400,
                    'Pair feature is not authorized by both policy and the verified Hub lease.',
                );
            }
        }
        if (count($result) !== count(array_unique($result))) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'Duplicate requested feature.');
        }
        sort($result, SORT_STRING);
        return $result;
    }

    private function assertNodeNotRevoked(string $nodeHash): void
    {
        foreach ($this->config['authorization']['revoked_node_key_hashes'] as $revoked) {
            if (hash_equals($revoked, $nodeHash)) {
                throw new PublicApiException('ACCESS_NOT_ALLOWED', 403, 'Node key is revoked.');
            }
        }
    }

    /** @param array<string,mixed> $payload @param list<string> $expected */
    private function exactPayload(array $payload, array $expected, string $errorCode = 'INVALID_REQUEST'): void
    {
        $actual = array_keys($payload);
        sort($actual, SORT_STRING);
        sort($expected, SORT_STRING);
        if ($actual !== $expected) {
            $status = $errorCode === 'INVALID_REQUEST' ? 400 : 401;
            throw new PublicApiException($errorCode, $status, 'Payload contains missing or unknown fields.');
        }
    }

    private function clientHash(string $clientIp): string
    {
        return hash('sha256', "SAFETUN-IP-BIND-v1\0" . $clientIp);
    }
}
