<?php

declare(strict_types=1);

namespace PostService\Node;

final class HmacAuthenticator
{
    private string $nodeId;
    private string $secret;
    private int $maxClockSkewSeconds;

    public function __construct(
        string $nodeId,
        string $secret,
        int $maxClockSkewSeconds
    ) {
        $this->nodeId = $nodeId;
        $this->secret = $secret;
        $this->maxClockSkewSeconds = $maxClockSkewSeconds;
        if (strlen($this->secret) < 32) {
            throw new \InvalidArgumentException('Node HMAC secret must be at least 32 bytes');
        }
    }

    public function authenticate(array $server, string $body): void
    {
        $nodeId = $this->header($server, 'HTTP_X_NODE_ID');
        $timestamp = $this->header($server, 'HTTP_X_TIMESTAMP');
        $requestId = $this->header($server, 'HTTP_X_REQUEST_ID');
        $signature = strtolower($this->header($server, 'HTTP_X_SIGNATURE'));

        if ($nodeId !== $this->nodeId) {
            throw new ApiException('Unknown node identifier', 'authentication_failed', 401);
        }

        if (!ctype_digit($timestamp)) {
            throw new ApiException('Invalid request timestamp', 'authentication_failed', 401);
        }

        if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
            throw new ApiException('Invalid request identifier', 'authentication_failed', 401);
        }

        if (!preg_match('/^[a-f0-9]{64}$/', $signature)) {
            throw new ApiException('Invalid request signature', 'authentication_failed', 401);
        }

        if (abs(time() - (int) $timestamp) > $this->maxClockSkewSeconds) {
            throw new ApiException('Request timestamp is outside the allowed window', 'request_expired', 401);
        }

        $method = strtoupper($server['REQUEST_METHOD'] ?? '');
        $path = parse_url($server['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
        $canonical = implode("\n", [
            $timestamp,
            $requestId,
            $method,
            $path,
            hash('sha256', $body),
        ]);
        $expected = hash_hmac('sha256', $canonical, $this->secret);

        if (!hash_equals($expected, $signature)) {
            throw new ApiException('Request signature mismatch', 'authentication_failed', 401);
        }

        $this->claimRequestId($requestId, (int) $timestamp);
    }

    private function header(array $server, string $key): string
    {
        $value = $server[$key] ?? '';
        if (!is_string($value) || $value === '') {
            throw new ApiException('Required authentication header is missing', 'authentication_failed', 401);
        }
        return trim($value);
    }

    private function claimRequestId(string $requestId, int $timestamp): void
    {
        $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR
            . 'post-node-nonces-' . substr(hash('sha256', $this->nodeId), 0, 16);
        if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
            throw new ApiException('Replay protection storage is unavailable', 'authentication_unavailable', 503);
        }

        $path = $directory . DIRECTORY_SEPARATOR . hash('sha256', $requestId);
        $handle = @fopen($path, 'x');
        if ($handle === false) {
            throw new ApiException('Request identifier has already been used', 'request_replayed', 409);
        }
        fwrite($handle, (string) $timestamp);
        fclose($handle);
        @chmod($path, 0600);

        // Amortized cleanup avoids scanning the nonce directory on every call.
        if (random_int(1, 100) === 1) {
            $expiresBefore = time() - ($this->maxClockSkewSeconds * 2);
            foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $nonceFile) {
                if (is_file($nonceFile) && filemtime($nonceFile) < $expiresBefore) {
                    @unlink($nonceFile);
                }
            }
        }
    }
}
