<?php

declare(strict_types=1);

namespace App\Tests\Unit\Http;

use App\Domain\Time\FixedClock;
use App\Infrastructure\Http\RateLimiter;
use PHPUnit\Framework\TestCase;

/**
 * Token-bucket invariants:
 *  - capacity = 2 × refill, so 6 immediate consumes succeed at refill=3.
 *  - the 7th fails, then advancing 1s restores 3 tokens.
 *  - per-key isolation: two keys do not share a bucket.
 *  - keys are arbitrary strings so callers can namespace tokens vs IPs
 *    (SEC_REVIEW F27).
 */
final class RateLimiterTest extends TestCase
{
    public function testBurstUpToCapacitySucceeds(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);

        for ($i = 0; $i < 6; $i++) {
            self::assertTrue($limiter->tryConsume('token:1'), "consume #{$i} should succeed");
        }
        self::assertFalse($limiter->tryConsume('token:1'), 'consume #7 should be denied');
    }

    public function testRefillRestoresTokensOverTime(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);

        for ($i = 0; $i < 6; $i++) {
            $limiter->tryConsume('token:1');
        }
        self::assertFalse($limiter->tryConsume('token:1'));

        $clock->advance('+1 second');

        // After 1s @ 3/s we should have 3 fresh tokens.
        self::assertTrue($limiter->tryConsume('token:1'));
        self::assertTrue($limiter->tryConsume('token:1'));
        self::assertTrue($limiter->tryConsume('token:1'));
        self::assertFalse($limiter->tryConsume('token:1'));
    }

    public function testKeysAreIsolated(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);

        self::assertTrue($limiter->tryConsume('token:1'));
        self::assertTrue($limiter->tryConsume('token:1'));
        self::assertFalse($limiter->tryConsume('token:1'));

        // Token 2's bucket is untouched.
        self::assertTrue($limiter->tryConsume('token:2'));
        self::assertTrue($limiter->tryConsume('token:2'));
    }

    public function testTokenAndIpNamespacesDoNotShareABucket(): void
    {
        // SEC_REVIEW F27: namespace-prefixed keys mean an authenticated
        // user's `token:42` bucket cannot drain the same shared `ip:…`
        // bucket that throttles unauthenticated traffic from elsewhere.
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);

        self::assertTrue($limiter->tryConsume('token:42'));
        self::assertTrue($limiter->tryConsume('token:42'));
        self::assertFalse($limiter->tryConsume('token:42'));

        // Same numeric tail under a different namespace must be its own
        // bucket — string keys are not silently coerced to int.
        self::assertTrue($limiter->tryConsume('ip:42'));
        self::assertTrue($limiter->tryConsume('ip:42'));
        self::assertFalse($limiter->tryConsume('ip:42'));
    }

    /**
     * SEC_REVIEW F28: the bucket map must not grow without bound. When
     * the cap is exceeded, the oldest entries (LRU front of the PHP
     * insertion-ordered array) get dropped in a batch.
     */
    public function testBucketMapIsBoundedByMaxBucketsCap(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        // Cap at 300 (must exceed the 256-entry eviction batch).
        $limiter = new RateLimiter(
            $clock,
            refillPerSecond: 1.0,
            capacity: 2.0,
            maxBuckets: 300,
        );

        // Touch 600 distinct keys — well past the cap.
        for ($i = 0; $i < 600; $i++) {
            $limiter->tryConsume("ip:10.0.0.{$i}");
        }

        self::assertLessThanOrEqual(
            300,
            $limiter->bucketCount(),
            'bucket count must stay within the configured cap'
        );
    }

    /**
     * SEC_REVIEW F28: an evicted bucket comes back as a fresh full-capacity
     * bucket on its next consume — equivalent to the bucket having idled
     * long enough to refill ($capacity / $refill seconds), so eviction
     * never gives an attacker more tokens than the rate would already
     * permit. This test asserts the behaviour explicitly so a future
     * "make eviction smarter" change cannot regress it into something
     * stricter (and break the documented refill semantics) or looser
     * (and grant tokens beyond the documented refill).
     */
    public function testEvictedBucketReturnsAtFullCapacityOnReuse(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $limiter = new RateLimiter(
            $clock,
            refillPerSecond: 1.0,
            capacity: 2.0,
            maxBuckets: 300,
        );

        // Drain key=A.
        self::assertTrue($limiter->tryConsume('ip:A'));
        self::assertTrue($limiter->tryConsume('ip:A'));
        self::assertFalse($limiter->tryConsume('ip:A'));

        // Force eviction by inserting many fresh keys past the cap.
        for ($i = 0; $i < 600; $i++) {
            $limiter->tryConsume("ip:fan-{$i}");
        }

        // Same wall-clock time — without eviction, A would still be drained.
        // With eviction, A is gone and a re-touch starts a fresh full bucket.
        self::assertTrue($limiter->tryConsume('ip:A'));
        self::assertTrue($limiter->tryConsume('ip:A'));
        self::assertFalse($limiter->tryConsume('ip:A'));
    }

    /**
     * SEC_REVIEW F28: re-touching a key moves it to the end of the PHP
     * insertion-ordered array, so a hot key is the *last* candidate for
     * eviction even under heavy churn from other keys. Asserting this
     * keeps a regression that broke the LRU ordering from silently
     * letting an attacker reset their own bucket by smashing many
     * unrelated keys.
     */
    public function testRecentlyTouchedKeyIsNotEvicted(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $limiter = new RateLimiter(
            $clock,
            refillPerSecond: 1.0,
            capacity: 2.0,
            maxBuckets: 300,
        );

        // Drain ip:hot.
        self::assertTrue($limiter->tryConsume('ip:hot'));
        self::assertTrue($limiter->tryConsume('ip:hot'));
        self::assertFalse($limiter->tryConsume('ip:hot'));

        // Heavy fan-out from other keys, but keep re-touching hot so it
        // stays at the LRU tail.
        for ($i = 0; $i < 600; $i++) {
            $limiter->tryConsume("ip:churn-{$i}");
            $limiter->tryConsume('ip:hot'); // returns false; bucket stays drained
        }

        // hot still drained — eviction picked the cold churn keys, not hot.
        self::assertFalse($limiter->tryConsume('ip:hot'));
    }

    public function testMaxBucketsBelowEvictionBatchIsRejected(): void
    {
        // A pathologically small cap would evict the entry we just
        // inserted; refuse to construct.
        $clock = FixedClock::at('2026-04-29T00:00:00Z');

        $this->expectException(\InvalidArgumentException::class);
        new RateLimiter(
            $clock,
            refillPerSecond: 1.0,
            capacity: 2.0,
            maxBuckets: 10,
        );
    }
}
