<?php

declare(strict_types=1);

namespace App\Tests\Unit\Administration\Application\Service;

use App\Administration\Application\Service\RefreshTokenManager;
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
use App\Administration\Domain\Exception\TokenReplayDetectedException;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class RefreshTokenManagerTest extends TestCase
{
    private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';

    private RefreshTokenRepository $repository;
    private Clock $clock;
    private RefreshTokenManager $manager;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(RefreshTokenRepository::class);
        $this->clock = new class implements Clock {
            public DateTimeImmutable $now;

            public function __construct()
            {
                $this->now = new DateTimeImmutable('2026-01-31 10:00:00');
            }

            public function now(): DateTimeImmutable
            {
                return $this->now;
            }
        };

        $this->manager = new RefreshTokenManager($this->repository, $this->clock);
    }

    #[Test]
    public function createGeneratesAndSavesNewToken(): void
    {
        $userId = UserId::generate();
        $tenantId = TenantId::fromString(self::TENANT_ID);
        $fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');

        $this->repository->expects(self::once())
            ->method('save')
            ->with(self::isInstanceOf(RefreshToken::class));

        $token = $this->manager->create($userId, $tenantId, $fingerprint);

        self::assertSame($userId, $token->userId);
        self::assertSame($tenantId, $token->tenantId);
    }

    #[Test]
    public function refreshThrowsForTokenNotFound(): void
    {
        $this->repository->method('find')->willReturn(null);

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Token not found');

        // Use a valid UUID format for the token ID
        $validUuid = '550e8400-e29b-41d4-a716-446655440099';
        $this->manager->refresh(
            base64_encode($validUuid),
            DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
        );
    }

    #[Test]
    public function refreshRotatesTokenAndReturnsNew(): void
    {
        $existingToken = $this->createExistingToken(isRotated: false);
        $tokenString = $existingToken->toTokenString();
        $fingerprint = $existingToken->deviceFingerprint;

        $this->repository->method('find')
            ->willReturn($existingToken);

        $this->repository->expects(self::exactly(2))
            ->method('save');

        $newToken = $this->manager->refresh($tokenString, $fingerprint);

        self::assertNotEquals($existingToken->id, $newToken->id);
        self::assertEquals($existingToken->familyId, $newToken->familyId);
    }

    #[Test]
    public function refreshThrowsForExpiredToken(): void
    {
        $expiredToken = RefreshToken::create(
            UserId::generate(),
            TenantId::fromString(self::TENANT_ID),
            DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
            new DateTimeImmutable('2026-01-01 10:00:00'), // Issued long ago
            3600, // 1 hour TTL - expired
        );

        $this->repository->method('find')->willReturn($expiredToken);
        $this->repository->expects(self::once())->method('delete');

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('expired');

        $this->manager->refresh(
            $expiredToken->toTokenString(),
            $expiredToken->deviceFingerprint,
        );
    }

    #[Test]
    public function refreshThrowsAndInvalidatesFamilyForWrongDevice(): void
    {
        $existingToken = $this->createExistingToken();
        $differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');

        $this->repository->method('find')->willReturn($existingToken);
        $this->repository->expects(self::once())
            ->method('invalidateFamily')
            ->with($existingToken->familyId);

        $this->expectException(TokenReplayDetectedException::class);

        $this->manager->refresh($existingToken->toTokenString(), $differentFingerprint);
    }

    #[Test]
    public function refreshThrowsAndInvalidatesFamilyForReplayAttack(): void
    {
        // Token rotaté il y a plus de 30 secondes (hors grace period)
        $rotatedToken = $this->createExistingToken(
            isRotated: true,
            issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
            rotatedAt: new DateTimeImmutable('2026-01-31 09:30:00'), // Rotaté 30 min avant "now"
        );

        $this->repository->method('find')->willReturn($rotatedToken);
        $this->repository->expects(self::once())
            ->method('invalidateFamily')
            ->with($rotatedToken->familyId);

        $this->expectException(TokenReplayDetectedException::class);

        $this->manager->refresh(
            $rotatedToken->toTokenString(),
            $rotatedToken->deviceFingerprint,
        );
    }

    #[Test]
    public function refreshThrowsTokenAlreadyRotatedForGracePeriod(): void
    {
        // Token rotaté il y a 10 secondes (dans la grace period de 30s)
        $rotatedToken = $this->createExistingToken(
            isRotated: true,
            issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
            rotatedAt: new DateTimeImmutable('2026-01-31 09:59:50'), // Rotaté 10s avant "now" (10:00:00)
        );

        $this->repository->method('find')->willReturn($rotatedToken);

        // Ne doit PAS invalider la famille
        $this->repository->expects(self::never())->method('invalidateFamily');

        $this->expectException(TokenAlreadyRotatedException::class);

        $this->manager->refresh(
            $rotatedToken->toTokenString(),
            $rotatedToken->deviceFingerprint,
        );
    }

    #[Test]
    public function revokeInvalidatesTokenFamily(): void
    {
        $existingToken = $this->createExistingToken();

        $this->repository->method('find')->willReturn($existingToken);
        $this->repository->expects(self::once())
            ->method('invalidateFamily')
            ->with($existingToken->familyId);

        $this->manager->revoke($existingToken->toTokenString());
    }

    private function createExistingToken(
        bool $isRotated = false,
        ?DateTimeImmutable $issuedAt = null,
        ?DateTimeImmutable $rotatedAt = null,
    ): RefreshToken {
        $issuedAt ??= new DateTimeImmutable('2026-01-31 09:00:00');

        return RefreshToken::reconstitute(
            id: RefreshTokenId::generate(),
            familyId: TokenFamilyId::generate(),
            userId: UserId::generate(),
            tenantId: TenantId::fromString(self::TENANT_ID),
            deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
            issuedAt: $issuedAt,
            expiresAt: $issuedAt->modify('+7 days'),
            rotatedFrom: null,
            isRotated: $isRotated,
            rotatedAt: $rotatedAt,
        );
    }
}
