<?php

declare(strict_types=1);

namespace App\Tests\Unit\Auth;

use App\Auth\LoginThrottle;
use Monolog\Handler\NullHandler;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
 * Brute-force lockout progression: 5/10/15 attempts trigger 60/300/1800-second
 * locks. The (username, ip) tuple gates the bucket so the legit admin from
 * another IP isn't locked out by an attacker spraying one address.
 */
final class LoginThrottleTest extends TestCase
{
    public function testFastRetryUnderFive(): void
    {
        $t = $this->throttle();
        for ($i = 0; $i < 4; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
    }

    public function testFifthFailureLocksForOneMinute(): void
    {
        $t = $this->throttle();
        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        self::assertTrue($t->isLocked('admin', '10.0.0.1'));
        self::assertGreaterThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
        self::assertLessThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
    }

    public function testTenthFailureLocksForFiveMinutes(): void
    {
        $now = 1000000;
        $t = $this->throttle($now);
        for ($i = 0; $i < 10; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
    }

    public function testFifteenthFailureLocksForThirtyMinutes(): void
    {
        $now = 1000000;
        $t = $this->throttle($now);
        for ($i = 0; $i < 15; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
    }

    public function testLockoutExpiresAfterTimeAdvance(): void
    {
        $now = 1000000;
        $t = new LoginThrottle($this->logger(), function () use (&$now): int {
            return $now;
        });
        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        self::assertTrue($t->isLocked('admin', '10.0.0.1'));
        $now += 61;
        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
    }

    public function testDifferentIpHasIndependentBucket(): void
    {
        $t = $this->throttle();
        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        self::assertTrue($t->isLocked('admin', '10.0.0.1'));
        self::assertFalse($t->isLocked('admin', '10.0.0.2'));
    }

    public function testClearResetsBucket(): void
    {
        $t = $this->throttle();
        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        $t->clear('admin', '10.0.0.1');
        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
        self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
    }

    public function testUsernameCaseDoesNotMultiplyBuckets(): void
    {
        $t = $this->throttle();
        for ($i = 0; $i < 3; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        for ($i = 0; $i < 2; ++$i) {
            $t->recordFailure('ADMIN', '10.0.0.1');
        }
        self::assertTrue($t->isLocked('Admin', '10.0.0.1'));
    }

    public function testPerUsernameBucketLocksOutAcrossDistinctIps(): void
    {
        $t = $this->throttle();
        // 24 failures from 24 distinct IPs — each is a fresh per-IP bucket
        // (only 1 attempt apiece), but the per-username counter accumulates.
        for ($i = 0; $i < 24; ++$i) {
            $t->recordFailure('admin', '10.0.0.' . $i);
        }
        self::assertFalse($t->isLocked('admin', '10.0.0.99'));
        // 25th attempt from yet another IP trips the per-username lockout.
        $t->recordFailure('admin', '10.0.0.99');
        self::assertTrue($t->isLocked('admin', '10.0.1.1'));
    }

    public function testPerUsernameLadderProgresses(): void
    {
        $now = 1000000;
        $t = $this->throttle($now);
        for ($i = 0; $i < 25; ++$i) {
            $t->recordFailure('admin', '10.0.0.' . $i);
        }
        self::assertSame(60, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
        for ($i = 25; $i < 50; ++$i) {
            $t->recordFailure('admin', '10.0.0.' . $i);
        }
        self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
        for ($i = 50; $i < 100; ++$i) {
            $t->recordFailure('admin', '10.0.0.' . $i);
        }
        self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
    }

    public function testLockoutSecondsRemainingReturnsLargerOfBuckets(): void
    {
        $now = 1000000;
        $t = $this->throttle($now);
        // 5 failures from one IP → per-IP locked for 60s.
        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }
        // Plus 45 more from distinct IPs → per-username at count=50 → 300s.
        for ($i = 0; $i < 45; ++$i) {
            $t->recordFailure('admin', '10.1.0.' . $i);
        }
        // Asked from yet-another-IP, only the per-username lock applies — 300s.
        self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
        // Asked from the per-IP bucket's IP, 300s still wins (max of 60/300).
        self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
    }

    public function testClearResetsBothBuckets(): void
    {
        $t = $this->throttle();
        // Build per-username pressure.
        for ($i = 0; $i < 25; ++$i) {
            $t->recordFailure('admin', '10.0.0.' . $i);
        }
        self::assertTrue($t->isLocked('admin', '10.99.0.1'));
        $t->clear('admin', '10.0.0.0');
        self::assertFalse($t->isLocked('admin', '10.99.0.1'));
        self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
    }

    public function testEmptyUsernameStillBucketsPerUsername(): void
    {
        $t = $this->throttle();
        for ($i = 0; $i < 25; ++$i) {
            $t->recordFailure('', '10.0.0.' . $i);
        }
        self::assertTrue($t->isLocked('', '10.99.0.1'));
    }

    public function testRecordFailureLogRateIsCappedToFirstWarningPerBucket(): void
    {
        // SEC_REVIEW F74: a sustained brute-force attack must not
        // pour one structured log line per failed attempt into the
        // disk/SIEM pipeline. The throttle now emits a single
        // `warning` per (username, ip) bucket — at the first
        // failure — and stays silent until a lockout fires (which
        // emits an `error`). The lockout's `failure_count` field
        // captures the burst size, so total visibility is preserved
        // without per-attempt spam.
        $handler = new TestHandler();
        $logger = new Logger('test');
        $logger->pushHandler($handler);
        $t = new LoginThrottle($logger);

        // Drive 5 failures: counts 1, 2, 3, 4, 5. Lockout fires at
        // 5. Expected log emissions: 1 warning (count=1) + 1 error
        // (count=5 lockout) = 2 lines, NOT 5.
        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }

        $warnings = array_filter(
            $handler->getRecords(),
            static fn ($r) => $r->level->name === 'Warning',
        );
        $errors = array_filter(
            $handler->getRecords(),
            static fn ($r) => $r->level->name === 'Error',
        );
        self::assertCount(1, $warnings, 'expected a single warning at the first failure');
        self::assertGreaterThanOrEqual(1, count($errors), 'expected at least one lockout error');

        // The single warning must record `failure_count = 1`.
        /** @var \Monolog\LogRecord $w */
        $w = array_values($warnings)[0];
        self::assertSame(1, $w->context['failure_count']);
    }

    public function testHighRateBruteForceDoesNotSpamPerFailureWarnings(): void
    {
        // 100 attempts from one IP for one username: pre-fix would
        // have emitted ~4 warnings per bucket (counts 1..4) before
        // lockout. With the rate cap, exactly 1 warning fires.
        // Subsequent attempts past the lockout are not recorded
        // (the controller-side `isLocked` check skips
        // `recordFailure`), so the test models the bucket-internal
        // behaviour by calling `recordFailure` directly 100 times —
        // we want to see the cap survive even if a future change
        // slipped past the controller-level guard.
        $handler = new TestHandler();
        $logger = new Logger('test');
        $logger->pushHandler($handler);
        $t = new LoginThrottle($logger);

        for ($i = 0; $i < 100; ++$i) {
            $t->recordFailure('admin', '10.0.0.1');
        }

        $warnings = array_filter(
            $handler->getRecords(),
            static fn ($r) => $r->level->name === 'Warning',
        );
        // No matter how many failures we drive into the same bucket,
        // we get a single warning. The error count is bounded by
        // the lockout ladder (5 / 10 / 15 IP-bucket triggers and
        // 25 / 50 / 100 username-bucket triggers).
        self::assertCount(1, $warnings);
    }

    public function testRecordFailureLogsFingerprintsNotRawIdentifiers(): void
    {
        // SEC_REVIEW F34: a SIEM operator reading these logs must not see
        // the attempted username (which can be a password typed in the
        // wrong field) or the raw client IP. They must see a stable
        // fingerprint instead so triage can still correlate.
        $handler = new TestHandler();
        $logger = new Logger('test');
        $logger->pushHandler($handler);
        $t = new LoginThrottle($logger);

        $rawUser = 'hunter2-typed-into-username-field';
        $rawIp = '198.51.100.77';

        for ($i = 0; $i < 5; ++$i) {
            $t->recordFailure($rawUser, $rawIp);
        }

        self::assertNotEmpty($handler->getRecords(), 'expected lockout/failure events to be logged');
        foreach ($handler->getRecords() as $record) {
            $serialized = json_encode([
                'message' => $record->message,
                'context' => $record->context,
            ], \JSON_THROW_ON_ERROR);
            self::assertStringNotContainsString($rawUser, $serialized);
            self::assertStringNotContainsString($rawIp, $serialized);
            self::assertArrayHasKey('username_fp', $record->context);
            self::assertArrayHasKey('source_ip_fp', $record->context);
            self::assertArrayNotHasKey('username', $record->context);
            self::assertArrayNotHasKey('source_ip', $record->context);
        }
    }

    private function throttle(?int $fixedTime = null): LoginThrottle
    {
        if ($fixedTime === null) {
            return new LoginThrottle($this->logger());
        }

        return new LoginThrottle($this->logger(), static fn (): int => $fixedTime);
    }

    private function logger(): LoggerInterface
    {
        $l = new Logger('test');
        $l->pushHandler(new NullHandler());

        return $l;
    }
}
