<?php

namespace MintyPHP\Tests\Service\Security;

use MintyPHP\Repository\Security\RateLimitRepositoryInterface;
use MintyPHP\Service\Security\RateLimiterService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class RateLimiterServiceTest extends TestCase
{
    private RateLimitRepositoryInterface&MockObject $repository;
    private RateLimiterService $service;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(RateLimitRepositoryInterface::class);
        $this->service = new RateLimiterService($this->repository);
    }

    // ---------------------------------------------------------------
    // hit — fail-open on empty input
    // ---------------------------------------------------------------

    public function testHitWithEmptyScopeFailsOpen(): void
    {
        $this->repository->expects($this->never())->method('findByScopeAndHash');

        $result = $this->service->hit('', 'user@example.com', 5, 60, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    public function testHitWithEmptySubjectFailsOpen(): void
    {
        $this->repository->expects($this->never())->method('findByScopeAndHash');

        $result = $this->service->hit('login', '', 5, 60, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — new entry creation
    // ---------------------------------------------------------------

    public function testHitCreatesNewEntryOnFirstAttempt(): void
    {
        $this->repository->method('findByScopeAndHash')->willReturn(null);

        $this->repository->expects($this->once())
            ->method('create')
            ->with(
                'login',
                hash('sha256', 'user@example.com'),
                1,
                $this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
                null
            )
            ->willReturn(true);

        $result = $this->service->hit('login', 'user@example.com', 5, 60, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — increment existing entry
    // ---------------------------------------------------------------

    public function testHitIncrementsExistingEntry(): void
    {
        $nowSql = gmdate('Y-m-d H:i:s');

        $this->repository->method('findByScopeAndHash')->willReturn([
            'id' => 42,
            'hits' => 2,
            'window_started_at' => $nowSql,
            'blocked_until' => null,
        ]);

        $this->repository->expects($this->once())
            ->method('updateStateById')
            ->with(
                42,
                3, // 2 + 1
                $this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
                null
            )
            ->willReturn(true);

        $result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — blocks when over limit
    // ---------------------------------------------------------------

    public function testHitBlocksWhenOverLimit(): void
    {
        $nowSql = gmdate('Y-m-d H:i:s');

        $this->repository->method('findByScopeAndHash')->willReturn([
            'id' => 42,
            'hits' => 5, // at maxHits; next increment pushes to 6 > 5
            'window_started_at' => $nowSql,
            'blocked_until' => null,
        ]);

        $this->repository->expects($this->once())
            ->method('updateStateById')
            ->with(
                42,
                6,
                $this->anything(),
                $this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/')
            )
            ->willReturn(true);

        $result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);

        $this->assertFalse($result['allowed']);
        $this->assertGreaterThan(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — returns blocked when already blocked
    // ---------------------------------------------------------------

    public function testHitReturnsBlockedWhenAlreadyBlocked(): void
    {
        $blockedUntil = gmdate('Y-m-d H:i:s', time() + 600);

        $this->repository->method('findByScopeAndHash')->willReturn([
            'id' => 42,
            'hits' => 10,
            'window_started_at' => gmdate('Y-m-d H:i:s'),
            'blocked_until' => $blockedUntil,
        ]);

        // Should not update — early return when already blocked.
        $this->repository->expects($this->never())->method('updateStateById');

        $result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);

        $this->assertFalse($result['allowed']);
        $this->assertGreaterThan(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — sliding window resets expired window
    // ---------------------------------------------------------------

    public function testHitResetsHitsWhenWindowExpired(): void
    {
        $expiredWindowStart = gmdate('Y-m-d H:i:s', time() - 7200);

        $this->repository->method('findByScopeAndHash')->willReturn([
            'id' => 42,
            'hits' => 4,
            'window_started_at' => $expiredWindowStart,
            'blocked_until' => null,
        ]);

        $this->repository->expects($this->once())
            ->method('updateStateById')
            ->with(
                42,
                1, // reset to 0 then incremented to 1
                $this->anything(),
                null
            )
            ->willReturn(true);

        $result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — fail-open on storage exception
    // ---------------------------------------------------------------

    public function testHitFailsOpenOnStorageException(): void
    {
        $this->repository->method('findByScopeAndHash')
            ->willThrowException(new \RuntimeException('DB down'));

        $result = $this->service->hit('login', 'user@example.com', 5, 60, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // hit — scope normalization
    // ---------------------------------------------------------------

    public function testHitNormalizesScopeToLowercaseTrimmed(): void
    {
        $this->repository->expects($this->once())
            ->method('findByScopeAndHash')
            ->with('login', $this->anything())
            ->willReturn(null);

        $this->repository->method('create')->willReturn(true);

        $this->service->hit('  LOGIN  ', 'user@example.com', 5, 60, 300);
    }

    // ---------------------------------------------------------------
    // isBlocked — not blocked
    // ---------------------------------------------------------------

    public function testIsBlockedReturnsTrueWhenBlocked(): void
    {
        $blockedUntil = gmdate('Y-m-d H:i:s', time() + 600);

        $this->repository->method('findByScopeAndHash')->willReturn([
            'id' => 42,
            'hits' => 10,
            'window_started_at' => gmdate('Y-m-d H:i:s'),
            'blocked_until' => $blockedUntil,
        ]);

        $result = $this->service->isBlocked('login', 'user@example.com');

        $this->assertFalse($result['allowed']);
        $this->assertGreaterThan(0, $result['retry_after']);
    }

    public function testIsBlockedClearsExpiredBlock(): void
    {
        $expiredBlock = gmdate('Y-m-d H:i:s', time() - 60);

        $this->repository->method('findByScopeAndHash')->willReturn([
            'id' => 42,
            'hits' => 10,
            'window_started_at' => gmdate('Y-m-d H:i:s'),
            'blocked_until' => $expiredBlock,
        ]);

        $this->repository->expects($this->once())
            ->method('updateStateById')
            ->with(
                42,
                0,
                $this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
                null
            )
            ->willReturn(true);

        $result = $this->service->isBlocked('login', 'user@example.com');

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    public function testIsBlockedReturnsAllowedWhenNoRowExists(): void
    {
        $this->repository->method('findByScopeAndHash')->willReturn(null);

        $result = $this->service->isBlocked('login', 'user@example.com');

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    public function testIsBlockedFailsOpenOnException(): void
    {
        $this->repository->method('findByScopeAndHash')
            ->willThrowException(new \RuntimeException('DB down'));

        $result = $this->service->isBlocked('login', 'user@example.com');

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }

    // ---------------------------------------------------------------
    // reset
    // ---------------------------------------------------------------

    public function testResetDeletesEntry(): void
    {
        $this->repository->expects($this->once())
            ->method('deleteByScopeAndHash')
            ->with('login', hash('sha256', 'user@example.com'))
            ->willReturn(true);

        $this->service->reset('login', 'user@example.com');
    }

    public function testResetIgnoresException(): void
    {
        $this->repository->method('deleteByScopeAndHash')
            ->willThrowException(new \RuntimeException('DB down'));

        // Should not throw — exceptions are silently ignored.
        $this->service->reset('login', 'user@example.com');
        $this->assertTrue(true);
    }

    public function testResetWithEmptyScopeDoesNothing(): void
    {
        $this->repository->expects($this->never())->method('deleteByScopeAndHash');

        $this->service->reset('', 'user@example.com');
    }

    // ---------------------------------------------------------------
    // registerFailure — delegates to apply() same as hit()
    // ---------------------------------------------------------------

    public function testRegisterFailureBehavesLikeHit(): void
    {
        $this->repository->method('findByScopeAndHash')->willReturn(null);
        $this->repository->method('create')->willReturn(true);

        $result = $this->service->registerFailure('login', 'user@example.com', 5, 60, 300);

        $this->assertTrue($result['allowed']);
        $this->assertSame(0, $result['retry_after']);
    }
}
