<?php

declare(strict_types=1);

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

use App\Administration\Domain\Event\ToutesSessionsInvalidees;
use App\Administration\Domain\Exception\SessionNotFoundException;
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\Session\DeviceInfo;
use App\Administration\Domain\Model\Session\Location;
use App\Administration\Domain\Model\Session\Session;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Administration\Domain\Repository\SessionRepository;
use App\Administration\Infrastructure\Api\Controller\SessionsController;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;

final class SessionsControllerTest extends TestCase
{
    private Security&MockObject $security;
    private SessionRepository&MockObject $sessionRepository;
    private RefreshTokenRepository&MockObject $refreshTokenRepository;
    private MessageBusInterface&MockObject $eventBus;
    private Clock&MockObject $clock;
    private SessionsController $controller;

    private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
    private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
    private const string OTHER_TENANT_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';

    protected function setUp(): void
    {
        $this->security = $this->createMock(Security::class);
        $this->sessionRepository = $this->createMock(SessionRepository::class);
        $this->refreshTokenRepository = $this->createMock(RefreshTokenRepository::class);
        $this->eventBus = $this->createMock(MessageBusInterface::class);
        $this->clock = $this->createMock(Clock::class);

        $this->controller = new SessionsController(
            $this->security,
            $this->sessionRepository,
            $this->refreshTokenRepository,
            $this->eventBus,
            $this->clock,
        );
    }

    #[Test]
    public function revokeReturnsNotFoundWhenFamilyIdIsMalformed(): void
    {
        $this->mockAuthenticatedUser();

        // Assert: SessionRepository::getByFamilyId() should NEVER be called
        // because we fail before any business logic (InvalidArgumentException on UUID parse)
        $this->sessionRepository
            ->expects($this->never())
            ->method('getByFamilyId');

        $this->expectException(NotFoundHttpException::class);
        $this->expectExceptionMessage('Session not found');

        $request = Request::create('/api/me/sessions/not-a-uuid', 'DELETE');
        $this->controller->revoke('not-a-uuid', $request);
    }

    #[Test]
    public function revokeReturnsNotFoundWhenSessionDoesNotExist(): void
    {
        $this->mockAuthenticatedUser();

        $familyId = TokenFamilyId::generate();

        $this->sessionRepository
            ->expects($this->once())
            ->method('getByFamilyId')
            ->with($this->callback(static fn (TokenFamilyId $id) => $id->equals($familyId)))
            ->willThrowException(new SessionNotFoundException($familyId));

        $this->expectException(NotFoundHttpException::class);
        $this->expectExceptionMessage('Session not found');

        $request = Request::create('/api/me/sessions/' . $familyId, 'DELETE');
        $this->controller->revoke((string) $familyId, $request);
    }

    #[Test]
    public function listReturnsSessionsForCurrentUserAndTenant(): void
    {
        $this->mockAuthenticatedUser();

        $familyId = TokenFamilyId::generate();
        $userId = UserId::fromString(self::USER_ID);
        $tenantId = TenantId::fromString(self::TENANT_ID);
        $now = new DateTimeImmutable('2024-01-15 10:00:00');

        $session = Session::create(
            familyId: $familyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'),
            location: Location::fromIp('192.168.1.1', 'France', 'Paris'),
            createdAt: $now,
        );

        $this->sessionRepository
            ->expects($this->once())
            ->method('findAllByUserId')
            ->with($this->callback(static fn (UserId $id) => $id->equals($userId)))
            ->willReturn([$session]);

        $request = Request::create('/api/me/sessions', 'GET');
        $response = $this->controller->list($request);

        $data = json_decode($response->getContent(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertCount(1, $data['sessions']);
        self::assertSame((string) $familyId, $data['sessions'][0]['family_id']);
        self::assertSame('Desktop', $data['sessions'][0]['device']);
        self::assertSame('Chrome 120', $data['sessions'][0]['browser']);
        self::assertSame('Windows 10', $data['sessions'][0]['os']);
        self::assertSame('France, Paris', $data['sessions'][0]['location']);
        self::assertFalse($data['sessions'][0]['is_current']);
    }

    #[Test]
    public function listFilterOutSessionsFromOtherTenants(): void
    {
        $this->mockAuthenticatedUser();

        $userId = UserId::fromString(self::USER_ID);
        $currentTenantId = TenantId::fromString(self::TENANT_ID);
        $otherTenantId = TenantId::fromString(self::OTHER_TENANT_ID);
        $now = new DateTimeImmutable('2024-01-15 10:00:00');

        $sessionSameTenant = Session::create(
            familyId: TokenFamilyId::generate(),
            userId: $userId,
            tenantId: $currentTenantId,
            deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'),
            location: Location::unknown(),
            createdAt: $now,
        );

        $sessionOtherTenant = Session::create(
            familyId: TokenFamilyId::generate(),
            userId: $userId,
            tenantId: $otherTenantId,
            deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'iPhone'),
            location: Location::unknown(),
            createdAt: $now,
        );

        $this->sessionRepository
            ->method('findAllByUserId')
            ->willReturn([$sessionSameTenant, $sessionOtherTenant]);

        $request = Request::create('/api/me/sessions', 'GET');
        $response = $this->controller->list($request);

        $data = json_decode($response->getContent(), true);

        // Should only return the session from the current tenant
        self::assertCount(1, $data['sessions']);
        self::assertSame('Desktop', $data['sessions'][0]['device']);
    }

    #[Test]
    public function listMarksCurrentSessionCorrectly(): void
    {
        $this->mockAuthenticatedUser();

        $currentFamilyId = TokenFamilyId::generate();
        $otherFamilyId = TokenFamilyId::generate();
        $userId = UserId::fromString(self::USER_ID);
        $tenantId = TenantId::fromString(self::TENANT_ID);
        $now = new DateTimeImmutable('2024-01-15 10:00:00');

        $currentSession = Session::create(
            familyId: $currentFamilyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'UA1'),
            location: Location::unknown(),
            createdAt: $now,
        );

        $otherSession = Session::create(
            familyId: $otherFamilyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'UA2'),
            location: Location::unknown(),
            createdAt: $now,
        );

        $this->sessionRepository
            ->method('findAllByUserId')
            ->willReturn([$currentSession, $otherSession]);

        // Create a real RefreshToken to identify the current session
        $refreshToken = RefreshToken::reconstitute(
            id: RefreshTokenId::generate(),
            familyId: $currentFamilyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
            issuedAt: $now,
            expiresAt: $now->modify('+1 day'),
            rotatedFrom: null,
            isRotated: false,
        );

        $this->refreshTokenRepository
            ->method('find')
            ->willReturn($refreshToken);

        // Create request with refresh token cookie
        $tokenValue = $refreshToken->toTokenString();
        $request = Request::create('/api/me/sessions', 'GET');
        $request->cookies->set('refresh_token', $tokenValue);

        $response = $this->controller->list($request);
        $data = json_decode($response->getContent(), true);

        // Current session should be first (sorted) and marked as current
        self::assertCount(2, $data['sessions']);
        self::assertTrue($data['sessions'][0]['is_current']);
        self::assertSame((string) $currentFamilyId, $data['sessions'][0]['family_id']);
        self::assertFalse($data['sessions'][1]['is_current']);
    }

    #[Test]
    public function revokeAllDeletesAllSessionsExceptCurrent(): void
    {
        $this->mockAuthenticatedUser();

        $currentFamilyId = TokenFamilyId::generate();
        $deletedFamilyId1 = TokenFamilyId::generate();
        $deletedFamilyId2 = TokenFamilyId::generate();
        $userId = UserId::fromString(self::USER_ID);
        $tenantId = TenantId::fromString(self::TENANT_ID);
        $now = new DateTimeImmutable('2024-01-15 10:00:00');

        $this->clock->method('now')->willReturn($now);

        // Create a real RefreshToken for current session identification
        $refreshToken = RefreshToken::reconstitute(
            id: RefreshTokenId::generate(),
            familyId: $currentFamilyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
            issuedAt: $now,
            expiresAt: $now->modify('+1 day'),
            rotatedFrom: null,
            isRotated: false,
        );
        $this->refreshTokenRepository->method('find')->willReturn($refreshToken);

        // Mock deleteAllExcept
        $this->sessionRepository
            ->expects($this->once())
            ->method('deleteAllExcept')
            ->with(
                $this->callback(static fn (UserId $id) => $id->equals($userId)),
                $this->callback(static fn (TokenFamilyId $id) => $id->equals($currentFamilyId)),
            )
            ->willReturn([$deletedFamilyId1, $deletedFamilyId2]);

        // Expect invalidateFamily to be called for each deleted session
        $this->refreshTokenRepository
            ->expects($this->exactly(2))
            ->method('invalidateFamily');

        // Expect event to be dispatched
        $this->eventBus
            ->expects($this->once())
            ->method('dispatch')
            ->with($this->isInstanceOf(ToutesSessionsInvalidees::class))
            ->willReturn(new Envelope(new stdClass()));

        $tokenValue = $refreshToken->toTokenString();
        $request = Request::create('/api/me/sessions', 'DELETE');
        $request->cookies->set('refresh_token', $tokenValue);
        $request->headers->set('User-Agent', 'Test Browser');

        $response = $this->controller->revokeAll($request);
        $data = json_decode($response->getContent(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertSame('All other sessions revoked', $data['message']);
        self::assertSame(2, $data['revoked_count']);
    }

    #[Test]
    public function revokeAllDoesNotDispatchEventWhenNoSessionsRevoked(): void
    {
        $this->mockAuthenticatedUser();

        $currentFamilyId = TokenFamilyId::generate();
        $userId = UserId::fromString(self::USER_ID);
        $tenantId = TenantId::fromString(self::TENANT_ID);
        $now = new DateTimeImmutable('2024-01-15 10:00:00');

        $this->clock->method('now')->willReturn($now);

        // Create a real RefreshToken
        $refreshToken = RefreshToken::reconstitute(
            id: RefreshTokenId::generate(),
            familyId: $currentFamilyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
            issuedAt: $now,
            expiresAt: $now->modify('+1 day'),
            rotatedFrom: null,
            isRotated: false,
        );
        $this->refreshTokenRepository->method('find')->willReturn($refreshToken);

        // No sessions to delete
        $this->sessionRepository
            ->method('deleteAllExcept')
            ->willReturn([]);

        // Event should NOT be dispatched
        $this->eventBus
            ->expects($this->never())
            ->method('dispatch');

        $tokenValue = $refreshToken->toTokenString();
        $request = Request::create('/api/me/sessions', 'DELETE');
        $request->cookies->set('refresh_token', $tokenValue);

        $response = $this->controller->revokeAll($request);
        $data = json_decode($response->getContent(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertSame(0, $data['revoked_count']);
    }

    private function mockAuthenticatedUser(): void
    {
        $securityUser = new SecurityUser(
            UserId::fromString(self::USER_ID),
            'test@example.com',
            'hashed_password',
            TenantId::fromString(self::TENANT_ID),
            ['ROLE_USER'],
        );

        $this->security->method('getUser')->willReturn($securityUser);
    }
}
