<?php
declare(strict_types=1);

namespace SafeTun\Backend\Storage;

final class NonceStore
{
    private ShardedStore $store;

    public function __construct(string $root)
    {
        $this->store = new ShardedStore($root);
    }

    public function issue(string $nonce, string $clientHash, string $requestId, int $expiresAt): void
    {
        $this->validateArguments($nonce, $clientHash, $requestId);
        if ($expiresAt <= time()) {
            throw new \InvalidArgumentException('Nonce expiry must be in the future.');
        }
        $path = $this->store->pathFor('nonce', $nonce);
        $lockPath = $this->store->lockPathFor('nonce', $nonce);
        $lock = @fopen($lockPath, 'c+b');
        if (!is_resource($lock)) {
            throw new \RuntimeException('Unable to open nonce coordination lock.');
        }
        @chmod($lockPath, 0600);
        if (!flock($lock, LOCK_EX)) {
            fclose($lock);
            throw new \RuntimeException('Unable to lock nonce state.');
        }
        $handle = @fopen($path, 'x+b');
        if (!is_resource($handle)) {
            flock($lock, LOCK_UN);
            fclose($lock);
            throw new \RuntimeException('Unable to create nonce state.');
        }
        try {
            SecureFiles::writeAll($handle, SecureFiles::encodeJson([
                'v' => 1,
                'client_hash' => $clientHash,
                'request_id' => $requestId,
                'expires_at' => $expiresAt,
                'used' => false,
            ]));
            @chmod($path, 0600);
        } finally {
            fclose($handle);
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    public function consume(string $nonce, string $clientHash, string $requestId, ?int $now = null): void
    {
        $this->validateArguments($nonce, $clientHash, $requestId);
        $now ??= time();
        $path = $this->store->pathFor('nonce', $nonce, false);
        $lockPath = $this->store->lockPathFor('nonce', $nonce, false);
        $lock = @fopen($lockPath, 'r+b');
        if (!is_resource($lock)) {
            throw new \InvalidArgumentException('Nonce is missing or expired.');
        }
        if (!flock($lock, LOCK_EX)) {
            fclose($lock);
            throw new \RuntimeException('Unable to lock nonce state.');
        }
        $handle = @fopen($path, 'r+b');
        if (!is_resource($handle)) {
            flock($lock, LOCK_UN);
            fclose($lock);
            throw new \InvalidArgumentException('Nonce is missing or expired.');
        }
        try {
            $record = SecureFiles::decodeJson(SecureFiles::readBounded($handle));
            if (($record['v'] ?? null) !== 1 || !is_string($record['client_hash'] ?? null)
                || !is_string($record['request_id'] ?? null) || !is_int($record['expires_at'] ?? null)
                || !is_bool($record['used'] ?? null)) {
                throw new \RuntimeException('Malformed nonce state.');
            }
            if ($record['used'] || $record['expires_at'] < $now
                || !hash_equals($record['client_hash'], $clientHash)
                || !hash_equals($record['request_id'], $requestId)) {
                throw new \InvalidArgumentException('Nonce is invalid, expired, or already used.');
            }

            // Mark before issuing any credential. A crash can consume a nonce but cannot replay it.
            $record['used'] = true;
            $record['consumed_at'] = $now;
            SecureFiles::replaceLocked($handle, SecureFiles::encodeJson($record));
        } finally {
            fclose($handle);
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    public function cleanup(int $olderThan, int $maximumFiles = 200): int
    {
        return $this->store->cleanup($olderThan, $maximumFiles);
    }

    private function validateArguments(string $nonce, string $clientHash, string $requestId): void
    {
        if (!preg_match('/^[A-Za-z0-9_-]{32,128}$/D', $nonce)
            || !preg_match('/^[0-9a-f]{64}$/D', $clientHash)
            || !preg_match('/^[0-9a-f-]{36}$/D', $requestId)) {
            throw new \InvalidArgumentException('Invalid nonce-store key material.');
        }
    }
}
