<?php

declare(strict_types=1);

namespace App\Tests\Unit\Auth;

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

/**
 * SEC_REVIEW F6 regression: persisting the throttle counters to a file
 * shared by all FrankenPHP workers must survive a worker recycle (a fresh
 * `LoginThrottle` instance over the same path) and serialise updates from
 * concurrent workers.
 */
final class FileThrottleStoreTest extends TestCase
{
    private string $path;

    protected function setUp(): void
    {
        $this->path = sys_get_temp_dir() . '/irdb_throttle_test_' . bin2hex(random_bytes(8)) . '.json';
    }

    protected function tearDown(): void
    {
        @unlink($this->path);
        @unlink($this->path . '.lock');
    }

    public function testFailureRecordedOnOneInstanceIsVisibleToAnother(): void
    {
        $a = $this->throttle();
        for ($i = 0; $i < 4; ++$i) {
            $a->recordFailure('admin', '10.0.0.1');
        }
        self::assertFalse($a->isLocked('admin', '10.0.0.1'));

        // Simulates a FrankenPHP worker recycle: throw away the old
        // singleton, build a new LoginThrottle pointing at the same file.
        $b = $this->throttle();
        self::assertFalse($b->isLocked('admin', '10.0.0.1'));

        // The 5th failure recorded by the second instance trips the lockout
        // because it accumulates against the persisted count from the first.
        $b->recordFailure('admin', '10.0.0.1');
        self::assertTrue($b->isLocked('admin', '10.0.0.1'));

        // And the first instance, on its next read, sees the lockout too.
        self::assertTrue($a->isLocked('admin', '10.0.0.1'));
    }

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

        $b = $this->throttle();
        $b->clear('admin', '10.0.0.1');

        self::assertFalse($a->isLocked('admin', '10.0.0.1'));
    }

    public function testMissingFileLoadsAsEmpty(): void
    {
        // No prior writes — load() must return both empty buckets without
        // creating the file.
        $store = new FileThrottleStore($this->path, $this->logger());
        $state = $store->load();

        self::assertSame(['ip' => [], 'username' => []], $state);
        self::assertFileDoesNotExist($this->path);
    }

    public function testCorruptFileIsTreatedAsEmpty(): void
    {
        file_put_contents($this->path, '{not json');

        $t = $this->throttle();
        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
        // And we can still write afterwards (the next mutate replaces the
        // corrupt content via temp + rename).
        $t->recordFailure('admin', '10.0.0.1');
        self::assertGreaterThan(0, filesize($this->path));
    }

    public function testStaleEntriesGarbageCollected(): void
    {
        $now = 1_000_000;
        $store = new FileThrottleStore($this->path, $this->logger(), function () use (&$now): int {
            return $now;
        });
        $throttle = new LoginThrottle($this->logger(), function () use (&$now): int {
            return $now;
        }, $store);

        // Trip a per-IP lockout (5 failures).
        for ($i = 0; $i < 5; ++$i) {
            $throttle->recordFailure('admin', '10.0.0.1');
        }
        self::assertTrue($throttle->isLocked('admin', '10.0.0.1'));

        // Advance well past the stale-lock GC window. The next mutation
        // (any recordFailure) must drop the dormant per-IP entry from disk.
        $now += 86400 + 3600;
        $throttle->recordFailure('other', '10.0.0.99');

        $raw = (string) file_get_contents($this->path);
        $decoded = json_decode($raw, true);
        self::assertIsArray($decoded);
        self::assertArrayNotHasKey('admin|10.0.0.1', $decoded['ip']);
        self::assertArrayHasKey('other|10.0.0.99', $decoded['ip']);
    }

    public function testWritesGoThroughTempPlusRename(): void
    {
        // Sanity: after a mutation, no `.tmp.*` siblings should leak in
        // the directory. Confirms the rename path completes cleanly.
        $t = $this->throttle();
        $t->recordFailure('admin', '10.0.0.1');

        $dir = dirname($this->path);
        $base = basename($this->path);
        $leftovers = glob($dir . '/' . $base . '.tmp.*');
        self::assertSame([], $leftovers !== false ? $leftovers : []);
    }

    public function testResetUnlinksFile(): void
    {
        $t = $this->throttle();
        $t->recordFailure('admin', '10.0.0.1');
        self::assertFileExists($this->path);

        $t->reset();
        self::assertFileDoesNotExist($this->path);
        self::assertFileDoesNotExist($this->path . '.lock');
    }

    private function throttle(): LoginThrottle
    {
        return new LoginThrottle(
            $this->logger(),
            null,
            new FileThrottleStore($this->path, $this->logger()),
        );
    }

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

        return $l;
    }
}
