<?php
declare(strict_types=1);

namespace SafeTun\Backend\Storage;

/** Single-host, lock-protected unique Edge limit for each Hub identity. */
final class PairLimitStore
{
    private const MAX_RECORD_BYTES = 8 * 1024 * 1024;
    private ShardedStore $store;

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

    public function reserve(string $hubHash, string $edgeHash, int $limit): void
    {
        foreach ([$hubHash, $edgeHash] as $hash) {
            if (!preg_match('/^[0-9a-f]{64}$/D', $hash)) {
                throw new \InvalidArgumentException('Pair-limit identity hash is invalid.');
            }
        }
        if ($limit < 1 || $limit > 100000) {
            throw new \InvalidArgumentException('Pair limit is invalid.');
        }
        $path = $this->store->pathFor('pair_limit', $hubHash);
        $lockPath = $this->store->lockPathFor('pair_limit', $hubHash);
        $lock = @fopen($lockPath, 'c+b');
        if (!is_resource($lock)) {
            throw new \RuntimeException('Unable to open pair-limit coordination lock.');
        }
        @chmod($lockPath, 0600);
        if (!flock($lock, LOCK_EX)) {
            fclose($lock);
            throw new \RuntimeException('Unable to lock pair-limit state.');
        }
        $handle = @fopen($path, 'x+b');
        $created = is_resource($handle);
        if (!$created) {
            $info = @lstat($path);
            if (!is_array($info) || is_link($path) || ($info['mode'] & 0170000) !== 0100000) {
                flock($lock, LOCK_UN);
                fclose($lock);
                throw new \RuntimeException('Pair-limit record is unavailable or unsafe.');
            }
            $handle = @fopen($path, 'r+b');
        }
        if (!is_resource($handle)) {
            flock($lock, LOCK_UN);
            fclose($lock);
            throw new \RuntimeException('Unable to open pair-limit state.');
        }
        try {
            if ($created) {
                $record = ['v' => 1, 'hub_hash' => $hubHash, 'edge_hashes' => []];
                @chmod($path, 0600);
            } else {
                $record = SecureFiles::decodeJson(SecureFiles::readBounded($handle, self::MAX_RECORD_BYTES));
                $this->validateRecord($record, $hubHash);
            }
            if (in_array($edgeHash, $record['edge_hashes'], true)) {
                return;
            }
            if (count($record['edge_hashes']) >= $limit) {
                throw new \OverflowException('Maximum unique Edge count reached for this Hub.');
            }
            $record['edge_hashes'][] = $edgeHash;
            sort($record['edge_hashes'], SORT_STRING);
            SecureFiles::replaceLocked($handle, SecureFiles::encodeJson($record));
        } finally {
            fclose($handle);
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }

    /** @param array<string,mixed> $record */
    private function validateRecord(array $record, string $hubHash): void
    {
        $keys = array_keys($record);
        sort($keys, SORT_STRING);
        if ($keys !== ['edge_hashes', 'hub_hash', 'v'] || ($record['v'] ?? null) !== 1
            || !is_string($record['hub_hash'] ?? null) || !hash_equals($hubHash, $record['hub_hash'])
            || !is_array($record['edge_hashes'] ?? null) || !array_is_list($record['edge_hashes'])
            || count($record['edge_hashes']) > 100000) {
            throw new \RuntimeException('Pair-limit state is malformed.');
        }
        $seen = [];
        foreach ($record['edge_hashes'] as $edgeHash) {
            if (!is_string($edgeHash) || !preg_match('/^[0-9a-f]{64}$/D', $edgeHash)
                || isset($seen[$edgeHash])) {
                throw new \RuntimeException('Pair-limit Edge set is malformed.');
            }
            $seen[$edgeHash] = true;
        }
    }
}
