<?php

declare(strict_types=1);

namespace App\Tests\Integration\Public;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Domain\Time\Clock;
use App\Domain\Time\FixedClock;
use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
use App\Infrastructure\Http\RateLimiter;
use App\Tests\Integration\Support\AppTestCase;
use Psr\Http\Message\ResponseFactoryInterface;

/**
 * Override the AppTestCase to inject a tight rate limit and a fixed clock,
 * then run a burst that exceeds capacity (rate × 2).
 */
final class RateLimitTest extends AppTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Replace the clock + limiter with a tight, fixed-time pair: refill=2/s,
        // capacity=4. A burst of 20 must produce some 429s. We must also rebuild
        // the RateLimitMiddleware singleton — PHP-DI caches the constructed
        // instance, which already holds the wide-open limiter from setup.
        if (method_exists($this->container, 'set')) {
            /** @var \DI\Container $c */
            $c = $this->container;
            $clock = FixedClock::at('2026-04-29T00:00:00Z');
            $limiter = new RateLimiter($clock, 2.0, 4.0);
            $c->set(Clock::class, $clock);
            $c->set(RateLimiter::class, $limiter);
            /** @var ResponseFactoryInterface $rf */
            $rf = $c->get(ResponseFactoryInterface::class);
            $c->set(RateLimitMiddleware::class, new RateLimitMiddleware($limiter, $rf));
            $this->app = \App\App\AppFactory::build($this->container);
        }
    }

    public function testBurstExceedingCapacityProduces429s(): void
    {
        $reporterId = $this->createReporter('web-limited');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
        $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;

        $statuses = [];
        for ($i = 0; $i < 20; $i++) {
            $statuses[] = $this->request('POST', '/api/v1/report', $headers, $body)->getStatusCode();
        }

        $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 202));
        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));

        // At fixed time + capacity=4 we expect exactly 4 successes and 16 throttles.
        self::assertSame(4, $accepted, 'capacity-bounded successes');
        self::assertSame(16, $limited, 'remainder rate-limited');
    }

    public function testRateLimit429IncludesRetryAfter(): void
    {
        $reporterId = $this->createReporter('web-retry');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
        $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
        $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;

        // Drain capacity first.
        for ($i = 0; $i < 4; $i++) {
            $this->request('POST', '/api/v1/report', $headers, $body);
        }
        $resp = $this->request('POST', '/api/v1/report', $headers, $body);
        self::assertSame(429, $resp->getStatusCode());
        self::assertSame('1', $resp->getHeaderLine('Retry-After'));
    }

    /**
     * SEC_REVIEW F29. The /api/v1/admin/* group previously attached only
     * tokenAuth → impersonation → auditContext, no rate limit. Combined
     * with F30 (IP-search full-table scan), F31 (deep-offset audit), and
     * F32 (N+1 enrichment), an authenticated Viewer (the OIDC default
     * role) could drive arbitrarily expensive queries at unbounded rate.
     * RateLimitMiddleware is now attached, so a burst against any admin
     * endpoint must produce 429s once the per-token bucket drains.
     */
    public function testAdminRoutesAreRateLimited(): void
    {
        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $headers = ['Authorization' => 'Bearer ' . $admin];

        $statuses = [];
        for ($i = 0; $i < 20; $i++) {
            $statuses[] = $this->request('GET', '/api/v1/admin/me', $headers)->getStatusCode();
        }

        $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 200));
        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));

        // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429.
        self::assertSame(4, $accepted, 'capacity-bounded successes');
        self::assertSame(16, $limited, 'remainder rate-limited');
    }

    /**
     * SEC_REVIEW F29 — heavier admin endpoints (the very ones called out
     * by F30/F31/F32) are gated by the same per-token bucket so a Viewer
     * cannot pound them at unbounded rate.
     */
    public function testAdminAuditLogIsRateLimitedPerToken(): void
    {
        $viewer = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $headers = ['Authorization' => 'Bearer ' . $viewer];

        $statuses = [];
        for ($i = 0; $i < 20; $i++) {
            $statuses[] = $this->request('GET', '/api/v1/admin/audit-log', $headers)->getStatusCode();
        }

        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
        self::assertGreaterThan(0, $limited, 'admin/audit-log must rate-limit');
    }

    /**
     * SEC_REVIEW F14. The /api/v1/auth/* group previously had only
     * TokenAuth, so a service-token holder could brute-force enumerate
     * users via /users/{id} (F17) at unbounded rate, or burn unbounded
     * upsert calls. RateLimitMiddleware is now attached; a burst against
     * the auth endpoints must produce 429s once the bucket drains.
     */
    public function testAuthGetUserRouteIsRateLimited(): void
    {
        $service = $this->createToken(TokenKind::Service);
        $headers = ['Authorization' => 'Bearer ' . $service];

        $statuses = [];
        for ($i = 0; $i < 20; $i++) {
            $statuses[] = $this->request('GET', '/api/v1/auth/users/1', $headers)->getStatusCode();
        }

        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
        self::assertGreaterThan(
            0,
            $limited,
            'auth/users/{id} must hit the rate-limit ceiling on burst (SEC_REVIEW F14)'
        );
        // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429.
        self::assertSame(16, $limited, 'remainder rate-limited');
    }

    /**
     * SEC_REVIEW F14. upsertLocal/upsertOidc are also gated by the same
     * rate limit — a leaked service token cannot pound them at unbounded
     * rate to amplify other findings (e.g. burning audit rows, retrying
     * upsert combinations).
     */
    public function testAuthUpsertLocalRouteIsRateLimited(): void
    {
        $service = $this->createToken(TokenKind::Service);
        $headers = [
            'Authorization' => 'Bearer ' . $service,
            'Content-Type' => 'application/json',
        ];
        $body = json_encode(['username' => 'admin']) ?: null;

        $statuses = [];
        for ($i = 0; $i < 20; $i++) {
            $statuses[] = $this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body)
                ->getStatusCode();
        }

        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
        self::assertGreaterThan(0, $limited, 'upsert-local must rate-limit');
    }

    /**
     * SEC_REVIEW F27. Auth-failed paths (401 from
     * TokenAuthenticationMiddleware) used to bypass the rate limiter
     * because RateLimitMiddleware sat *inside* TokenAuth. The handler
     * now also runs as the outermost layer, keying on `ip:` when no
     * principal exists, so invalid-bearer-token floods are throttled
     * before the DB lookup.
     */
    public function testInvalidBearerTokenFloodIsRateLimitedBeforeAuth(): void
    {
        $headers = ['Authorization' => 'Bearer ' . str_repeat('a', 32)];

        $statuses = [];
        for ($i = 0; $i < 20; $i++) {
            $statuses[] = $this->request('POST', '/api/v1/report', $headers)->getStatusCode();
        }

        $unauthorized = count(array_filter($statuses, static fn (int $s): bool => $s === 401));
        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));

        // Capacity is 4, so the first 4 attempts incur a token-table lookup
        // and return 401; the rest drain the IP bucket and return 429.
        self::assertSame(4, $unauthorized, 'first 4 attempts hit the auth path');
        self::assertSame(16, $limited, 'remaining attempts must 429 before auth');
    }

    public function testMissingBearerHeaderIsAlsoRateLimitedByIp(): void
    {
        // No Authorization header at all — same protection applies, the
        // request must not reach TokenAuth on every retry.
        $statuses = [];
        for ($i = 0; $i < 10; $i++) {
            $statuses[] = $this->request('GET', '/api/v1/blocklist')->getStatusCode();
        }

        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
        self::assertGreaterThan(0, $limited, 'pre-auth flood must hit 429');
    }
}
