<?php

declare(strict_types=1);

namespace App\Infrastructure\Http;

use App\Domain\Time\Clock;

/**
 * In-process token-bucket rate limiter, keyed by an arbitrary string.
 *
 * Callers namespace their keys (`token:42`, `ip:1.2.3.4`) so authenticated
 * and unauthenticated requests draw from independent buckets. The
 * unauthenticated `ip:*` bucket is what throttles invalid-bearer-token
 * floods that would otherwise hit `TokenRepository::findByHash()` for
 * every attempt (SEC_REVIEW F27).
 *
 * Capacity = `API_RATE_LIMIT_PER_SECOND × 2`, refill rate = same per second.
 * Refill is computed lazily on each `tryConsume()` call, so an idle bucket
 * jumps from empty to full once `2/refill` seconds elapse.
 *
 * **Memory bound (SEC_REVIEW F28):** the bucket map is capped at
 * `maxBuckets` entries (default {@see self::DEFAULT_MAX_BUCKETS}). Every
 * `tryConsume()` re-inserts the touched key at the end of the PHP array,
 * so insertion order tracks LRU; on overflow the oldest entries are
 * dropped in batches of {@see self::EVICTION_BATCH} so the eviction cost
 * amortises across requests instead of running on every call once we're
 * at steady state. A dropped bucket comes back as a fresh full-capacity
 * bucket on next access — equivalent to the bucket having idled long
 * enough to refill, so eviction never grants an attacker more tokens
 * than the configured rate would already permit. The cap protects
 * long-lived FrankenPHP workers from token churn (rotated tokens, IP
 * fan-out from unauthenticated traffic) that would otherwise grow
 * `$buckets` without bound.
 *
 * **Multi-replica:** state is per-process and per-replica. SPEC §10
 * documents single-replica as the supported topology for this limiter
 * ("token-bucket; in-process per replica — acceptable for single-replica
 * deployments"). Horizontal `api` scaling requires sticky-LB so a client
 * consistently lands on the same replica's bucket; replacing the
 * limiter with a shared backend (Redis / DB) is tracked separately as
 * future scaling work.
 */
final class RateLimiter
{
    /**
     * Default cap on distinct keys retained in memory. Two orders of
     * magnitude above the realistic concurrent working set (a handful of
     * authenticated tokens plus per-IP unauthenticated buckets) — well
     * below a memory-pressure threshold even at one bucket per row.
     */
    private const DEFAULT_MAX_BUCKETS = 10_000;

    /**
     * When the cap is exceeded, drop this many entries past the cap so
     * eviction amortises across many calls. Must be smaller than
     * `maxBuckets` so we never evict the entry we just inserted.
     */
    private const EVICTION_BATCH = 256;

    /** @var array<string, array{tokens: float, updated: float}> */
    private array $buckets = [];

    private readonly int $maxBuckets;

    public function __construct(
        private readonly Clock $clock,
        private readonly float $refillPerSecond,
        private readonly float $capacity,
        ?int $maxBuckets = null,
    ) {
        $resolved = $maxBuckets ?? self::DEFAULT_MAX_BUCKETS;
        if ($resolved <= self::EVICTION_BATCH) {
            throw new \InvalidArgumentException(
                'maxBuckets must be greater than EVICTION_BATCH (' . self::EVICTION_BATCH . ')'
            );
        }
        $this->maxBuckets = $resolved;
    }

    public function tryConsume(string $key): bool
    {
        $now = (float) $this->clock->now()->getTimestamp() + ((float) $this->clock->now()->format('u') / 1_000_000.0);

        // Re-insert at end of array so PHP's insertion-order semantics
        // give us LRU for free: oldest entry is at the front.
        if (isset($this->buckets[$key])) {
            $state = $this->buckets[$key];
            unset($this->buckets[$key]);
        } else {
            $state = ['tokens' => $this->capacity, 'updated' => $now];
        }

        $elapsed = max(0.0, $now - $state['updated']);
        $tokens = min($this->capacity, $state['tokens'] + ($elapsed * $this->refillPerSecond));

        if ($tokens < 1.0) {
            $this->buckets[$key] = ['tokens' => $tokens, 'updated' => $now];
            $this->enforceCapacity();

            return false;
        }

        $this->buckets[$key] = ['tokens' => $tokens - 1.0, 'updated' => $now];
        $this->enforceCapacity();

        return true;
    }

    /** Test hook: drop all bucket state. */
    public function reset(): void
    {
        $this->buckets = [];
    }

    /** Test hook: how many keys are currently tracked. */
    public function bucketCount(): int
    {
        return count($this->buckets);
    }

    private function enforceCapacity(): void
    {
        $size = count($this->buckets);
        if ($size <= $this->maxBuckets) {
            return;
        }

        // Drop the oldest `(size - maxBuckets) + EVICTION_BATCH` entries.
        // Adding the batch keeps us below the cap for the next batch of
        // unique-key arrivals so we don't pay an eviction on every call.
        $toDrop = ($size - $this->maxBuckets) + self::EVICTION_BATCH;
        $i = 0;
        foreach ($this->buckets as $k => $_) {
            if ($i >= $toDrop) {
                break;
            }
            unset($this->buckets[$k]);
            $i++;
        }
    }
}
