<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Tests\Repositories;

use App\Repositories\AuthThrottleRepository;
use App\Tests\TestCase;
use DateTimeImmutable;
use DateTimeZone;

/**
 * R01-N06: persistent (ip, email) throttle for /auth/local. Tests pin the
 * lock policy matrix and the window-rollover behaviour without sleeping —
 * the production code accepts an injected $now for exactly this reason.
 */
final class AuthThrottleRepositoryTest extends TestCase
{
    private static function at(string $iso): DateTimeImmutable
    {
        return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
    }

    public function testNoLockoutWhenRowMissing(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $this->assertNull(
            $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:00:00Z'))
        );
    }

    public function testFirstFourFailuresDoNotLock(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 4; $i++) {
            $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
            $this->assertNull($lock, "attempt {$i} must not lock");
        }
        $this->assertNull(
            $repo->lockoutFor('1.2.3.4', 'admin@example.com', $now)
        );
    }

    public function testFifthFailureLocksForFiveMinutes(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 4; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        }
        $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        $this->assertNotNull($lock);
        $this->assertSame(
            '2026-05-07T10:05:00Z',
            $lock->format('Y-m-d\TH:i:s\Z')
        );
    }

    public function testTenthFailureEscalatesToThirtyMinutes(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 9; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        }
        $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        $this->assertNotNull($lock);
        $this->assertSame(
            '2026-05-07T10:30:00Z',
            $lock->format('Y-m-d\TH:i:s\Z')
        );
    }

    public function testTwentiethFailureEscalatesToOneHour(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 19; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        }
        $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        $this->assertNotNull($lock);
        $this->assertSame(
            '2026-05-07T11:00:00Z',
            $lock->format('Y-m-d\TH:i:s\Z')
        );
    }

    public function testLockoutExpiresAfterDuration(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $start = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 5; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $start);
        }

        // Locked at +0..+4:59
        $this->assertNotNull(
            $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:04:59Z'))
        );
        // Unlocked at +5:00 (lock_until is exclusive: until > now ⇒ locked).
        $this->assertNull(
            $repo->lockoutFor('1.2.3.4', 'admin@example.com', self::at('2026-05-07T10:05:00Z'))
        );
    }

    public function testCounterResetsAfterIdleWindow(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $start = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 4; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $start);
        }
        // 16 minutes later → window is stale (15-min idle threshold).
        $afterIdle = self::at('2026-05-07T10:16:00Z');
        $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $afterIdle);
        $this->assertNull($lock, 'counter must reset to 1 after idle window');
    }

    public function testCounterDoesNotResetWithinWindow(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $start = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 4; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $start);
        }
        // 14 minutes later → still inside the window.
        $stillInside = self::at('2026-05-07T10:14:00Z');
        $lock = $repo->recordFailure('1.2.3.4', 'admin@example.com', $stillInside);
        $this->assertNotNull($lock, 'fifth attempt within the window must lock');
    }

    public function testClearRemovesRow(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 5; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        }
        $this->assertNotNull($repo->lockoutFor('1.2.3.4', 'admin@example.com', $now));

        $repo->clear('1.2.3.4', 'admin@example.com');

        $this->assertNull($repo->lockoutFor('1.2.3.4', 'admin@example.com', $now));
        $next = $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        $this->assertNull($next, 'after clear the next single failure must not lock');
    }

    public function testKeysAreCaseInsensitiveOnEmail(): void
    {
        // An attacker varying the case of the email shouldn't get extra
        // attempts (`Admin@…` vs `admin@…`); the bucket is canonicalised.
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 4; $i++) {
            $repo->recordFailure('1.2.3.4', 'Admin@example.com', $now);
        }
        $lock = $repo->recordFailure('1.2.3.4', 'admin@EXAMPLE.com', $now);
        $this->assertNotNull($lock, 'case-varied email must hit the same bucket');
    }

    public function testIpsBucketSeparately(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $now  = self::at('2026-05-07T10:00:00Z');
        for ($i = 1; $i <= 5; $i++) {
            $repo->recordFailure('1.2.3.4', 'admin@example.com', $now);
        }
        // Different IP → not throttled even though the same email is
        // currently locked from another source.
        $this->assertNull(
            $repo->lockoutFor('5.6.7.8', 'admin@example.com', $now),
            'a second attacker IP must not inherit the first IP\'s lock'
        );
    }

    public function testComputeLockoutPolicyMatrix(): void
    {
        $now = self::at('2026-05-07T10:00:00Z');
        $this->assertNull(AuthThrottleRepository::computeLockout(0, $now));
        $this->assertNull(AuthThrottleRepository::computeLockout(4, $now));

        $r5 = AuthThrottleRepository::computeLockout(5, $now);
        $this->assertNotNull($r5);
        $this->assertSame('2026-05-07T10:05:00Z', $r5->format('Y-m-d\TH:i:s\Z'));

        $r9 = AuthThrottleRepository::computeLockout(9, $now);
        $this->assertNotNull($r9);
        $this->assertSame('2026-05-07T10:05:00Z', $r9->format('Y-m-d\TH:i:s\Z'));

        $r10 = AuthThrottleRepository::computeLockout(10, $now);
        $this->assertNotNull($r10);
        $this->assertSame('2026-05-07T10:30:00Z', $r10->format('Y-m-d\TH:i:s\Z'));

        $r19 = AuthThrottleRepository::computeLockout(19, $now);
        $this->assertNotNull($r19);
        $this->assertSame('2026-05-07T10:30:00Z', $r19->format('Y-m-d\TH:i:s\Z'));

        $r20 = AuthThrottleRepository::computeLockout(20, $now);
        $this->assertNotNull($r20);
        $this->assertSame('2026-05-07T11:00:00Z', $r20->format('Y-m-d\TH:i:s\Z'));

        $r99 = AuthThrottleRepository::computeLockout(99, $now);
        $this->assertNotNull($r99);
        $this->assertSame('2026-05-07T11:00:00Z', $r99->format('Y-m-d\TH:i:s\Z'));
    }

    public function testPurgeExpiredDropsOnlyStaleRows(): void
    {
        $repo = new AuthThrottleRepository($this->makeDb());
        $repo->recordFailure('1.2.3.4', 'a@x', self::at('2026-05-07T08:00:00Z'));
        $repo->recordFailure('1.2.3.4', 'b@x', self::at('2026-05-07T11:00:00Z'));

        // Cutoff at 10:00 — 'a@x' (08:00) is older, 'b@x' (11:00) is fresh.
        $deleted = $repo->purgeExpired(self::at('2026-05-07T10:00:00Z'));
        $this->assertSame(1, $deleted);

        $this->assertNull(
            $repo->lockoutFor('1.2.3.4', 'a@x', self::at('2026-05-07T11:30:00Z'))
        );
    }
}
