<?php

declare(strict_types=1);

namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;

use ApiPlatform\Metadata\Post;
use App\Administration\Application\Service\RefreshTokenManager;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\SessionRepository;
use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
use App\Administration\Infrastructure\Api\Resource\RefreshTokenInput;
use App\Administration\Infrastructure\Api\Resource\RefreshTokenOutput;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Administration\Infrastructure\Security\SecurityUserFactory;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use DateTimeImmutable;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;

/**
 * Tests for RefreshTokenProcessor.
 *
 * Key invariants:
 * - Refresh token is read from HttpOnly cookie, not from body
 * - Token replay triggers family invalidation and cookie clearing
 * - Suspended users cannot refresh tokens
 * - Cross-tenant token usage is rejected
 */
final class RefreshTokenProcessorTest extends TestCase
{
    private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
    private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

    private InMemoryRefreshTokenRepository $refreshTokenRepository;
    private InMemoryUserRepository $userRepository;
    private RequestStack $requestStack;
    private JWTTokenManagerInterface $jwtManager;
    private TenantResolver $tenantResolver;
    private MessageBusInterface $eventBus;
    private Clock $clock;

    protected function setUp(): void
    {
        Request::setTrustedHosts(['^.*$']);
        $this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
        $this->userRepository = new InMemoryUserRepository();
        $this->requestStack = new RequestStack();
        $this->jwtManager = $this->createMock(JWTTokenManagerInterface::class);
        $this->tenantResolver = $this->createMock(TenantResolver::class);
        $this->eventBus = $this->createMock(MessageBusInterface::class);
        $this->clock = new class implements Clock {
            public function now(): DateTimeImmutable
            {
                return new DateTimeImmutable('2026-02-15 10:00:00');
            }
        };
    }

    #[Test]
    public function throwsWhenNoRequestAvailable(): void
    {
        $processor = $this->createProcessor();

        $this->expectException(UnauthorizedHttpException::class);
        $this->expectExceptionMessage('Request not available');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function throwsWhenRefreshTokenCookieMissing(): void
    {
        $request = Request::create('/api/token/refresh', 'POST');
        $this->requestStack->push($request);

        $processor = $this->createProcessor();

        $this->expectException(UnauthorizedHttpException::class);
        $this->expectExceptionMessage('Refresh token not found');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function returnsNewJwtOnSuccessfulRefresh(): void
    {
        $user = $this->createAndSaveActiveUser();
        $token = $this->createAndSaveRefreshToken($user);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', $token->toTokenString());
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $this->jwtManager->method('create')->willReturn('new-jwt-token');

        $processor = $this->createProcessor();
        $output = $processor->process(new RefreshTokenInput(), new Post());

        self::assertInstanceOf(RefreshTokenOutput::class, $output);
        self::assertSame('new-jwt-token', $output->token);
    }

    #[Test]
    public function setsRefreshTokenCookieOnSuccess(): void
    {
        $user = $this->createAndSaveActiveUser();
        $token = $this->createAndSaveRefreshToken($user);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', $token->toTokenString());
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $this->jwtManager->method('create')->willReturn('jwt');

        $processor = $this->createProcessor();
        $processor->process(new RefreshTokenInput(), new Post());

        $cookie = $request->attributes->get('_refresh_token_cookie');
        self::assertNotNull($cookie, 'Refresh token cookie should be set in request attributes');
        self::assertSame('refresh_token', $cookie->getName());
    }

    #[Test]
    public function throwsAccessDeniedWhenUserSuspended(): void
    {
        $user = $this->createAndSaveSuspendedUser();
        $token = $this->createAndSaveRefreshToken($user);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', $token->toTokenString());
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $processor = $this->createProcessor();

        $this->expectException(AccessDeniedHttpException::class);
        $this->expectExceptionMessage('Account is no longer active');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function throwsAccessDeniedOnTokenReplayDetection(): void
    {
        $user = $this->createAndSaveActiveUser();
        $token = $this->createAndSaveRefreshToken($user);
        $tokenString = $token->toTokenString();

        // Simulate rotation: mark the token as rotated (beyond grace period)
        [$newToken, $rotatedToken] = $token->rotate(new DateTimeImmutable('2026-02-15 09:00:00'));
        $this->refreshTokenRepository->save($newToken);
        $this->refreshTokenRepository->save($rotatedToken);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', $tokenString);
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $this->eventBus->expects(self::once())
            ->method('dispatch')
            ->willReturnCallback(static fn (object $msg) => new Envelope($msg));

        $processor = $this->createProcessor();

        $this->expectException(AccessDeniedHttpException::class);
        $this->expectExceptionMessage('Session compromise detected');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function throwsConflictOnTokenAlreadyRotatedInGracePeriod(): void
    {
        $user = $this->createAndSaveActiveUser();
        $token = $this->createAndSaveRefreshToken($user);
        $tokenString = $token->toTokenString();

        // Simulate rotation within grace period (rotatedAt is close to now)
        [$newToken, $rotatedToken] = $token->rotate(new DateTimeImmutable('2026-02-15 09:59:50'));
        $this->refreshTokenRepository->save($newToken);
        $this->refreshTokenRepository->save($rotatedToken);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', $tokenString);
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $processor = $this->createProcessor();

        $this->expectException(ConflictHttpException::class);
        $this->expectExceptionMessage('Token already rotated');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function throwsUnauthorizedOnInvalidToken(): void
    {
        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', 'invalid-not-base64!!!');
        $this->requestStack->push($request);

        $processor = $this->createProcessor();

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

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function rejectsCrossTenantTokenUsage(): void
    {
        $differentTenantId = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
        $user = $this->createAndSaveActiveUser();

        // Create token for a DIFFERENT tenant
        $token = RefreshToken::create(
            userId: $user->id,
            tenantId: $differentTenantId,
            deviceFingerprint: DeviceFingerprint::fromRequest('TestBrowser/1.0'),
            issuedAt: new DateTimeImmutable('2026-02-15 09:00:00'),
            ttlSeconds: 86400,
        );
        $this->refreshTokenRepository->save($token);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'ecole-alpha.classeo.fr']);
        $request->cookies->set('refresh_token', $token->toTokenString());
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $tenantConfig = new TenantConfig(
            \App\Shared\Infrastructure\Tenant\TenantId::fromString(self::TENANT_ID),
            'ecole-alpha',
            'sqlite:///:memory:',
        );
        $this->tenantResolver->method('resolve')->willReturn($tenantConfig);
        $this->jwtManager->method('create')->willReturn('jwt');

        $processor = $this->createProcessor();

        $this->expectException(AccessDeniedHttpException::class);
        $this->expectExceptionMessage('Invalid token for this tenant');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function rejectsUnknownHostInProduction(): void
    {
        $user = $this->createAndSaveActiveUser();
        $token = $this->createAndSaveRefreshToken($user);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'evil.example.com']);
        $request->cookies->set('refresh_token', $token->toTokenString());
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $this->tenantResolver->method('resolve')
            ->willThrowException(TenantNotFoundException::withSubdomain('evil'));
        $this->jwtManager->method('create')->willReturn('jwt');

        $processor = $this->createProcessor();

        $this->expectException(AccessDeniedHttpException::class);
        $this->expectExceptionMessage('Invalid host for token refresh');

        $processor->process(new RefreshTokenInput(), new Post());
    }

    #[Test]
    public function skipsHostValidationForLocalhost(): void
    {
        $user = $this->createAndSaveActiveUser();
        $token = $this->createAndSaveRefreshToken($user);

        $request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
        $request->cookies->set('refresh_token', $token->toTokenString());
        $request->headers->set('User-Agent', 'TestBrowser/1.0');
        $this->requestStack->push($request);

        $this->jwtManager->method('create')->willReturn('jwt');
        $this->tenantResolver->expects(self::never())->method('resolve');

        $processor = $this->createProcessor();
        $output = $processor->process(new RefreshTokenInput(), new Post());

        self::assertSame('jwt', $output->token);
    }

    private function createProcessor(): RefreshTokenProcessor
    {
        $refreshTokenManager = new RefreshTokenManager(
            $this->refreshTokenRepository,
            $this->clock,
        );

        $sessionRepository = new class implements SessionRepository {
            public function save(\App\Administration\Domain\Model\Session\Session $session, int $ttlSeconds): void
            {
            }

            public function getByFamilyId(TokenFamilyId $familyId): \App\Administration\Domain\Model\Session\Session
            {
                throw new \App\Administration\Domain\Exception\SessionNotFoundException('');
            }

            public function findByFamilyId(TokenFamilyId $familyId): ?\App\Administration\Domain\Model\Session\Session
            {
                return null;
            }

            public function findAllByUserId(UserId $userId): array
            {
                return [];
            }

            public function delete(TokenFamilyId $familyId): void
            {
            }

            public function deleteAllExcept(UserId $userId, TokenFamilyId $exceptFamilyId): array
            {
                return [];
            }

            public function updateActivity(TokenFamilyId $familyId, DateTimeImmutable $at, int $ttlSeconds): void
            {
            }
        };

        return new RefreshTokenProcessor(
            $refreshTokenManager,
            $this->jwtManager,
            $this->userRepository,
            $sessionRepository,
            $this->requestStack,
            new SecurityUserFactory(),
            $this->tenantResolver,
            $this->eventBus,
            $this->clock,
        );
    }

    private function createAndSaveActiveUser(): User
    {
        $user = User::reconstitute(
            id: UserId::fromString(self::USER_ID),
            email: new Email('user@example.com'),
            roles: [Role::PROF],
            tenantId: TenantId::fromString(self::TENANT_ID),
            schoolName: 'Ecole Test',
            statut: StatutCompte::ACTIF,
            dateNaissance: null,
            createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
            hashedPassword: '$argon2id$hashed',
            activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
            consentementParental: null,
            firstName: 'Jean',
            lastName: 'Dupont',
        );
        $this->userRepository->save($user);

        return $user;
    }

    private function createAndSaveSuspendedUser(): User
    {
        $user = User::reconstitute(
            id: UserId::fromString(self::USER_ID),
            email: new Email('user@example.com'),
            roles: [Role::PROF],
            tenantId: TenantId::fromString(self::TENANT_ID),
            schoolName: 'Ecole Test',
            statut: StatutCompte::SUSPENDU,
            dateNaissance: null,
            createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
            hashedPassword: '$argon2id$hashed',
            activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
            consentementParental: null,
            firstName: 'Jean',
            lastName: 'Dupont',
            blockedAt: new DateTimeImmutable('2026-02-10T10:00:00+00:00'),
            blockedReason: 'Comportement inapproprie',
        );
        $this->userRepository->save($user);

        return $user;
    }

    private function createAndSaveRefreshToken(User $user): RefreshToken
    {
        $token = RefreshToken::create(
            userId: $user->id,
            tenantId: $user->tenantId,
            deviceFingerprint: DeviceFingerprint::fromRequest('TestBrowser/1.0'),
            issuedAt: new DateTimeImmutable('2026-02-15 09:00:00'),
            ttlSeconds: 86400,
        );
        $this->refreshTokenRepository->save($token);

        return $token;
    }
}
