<?php

declare(strict_types=1);

namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Cache;

use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Persistence\Cache\CachedUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use RuntimeException;

final class CachedUserRepositoryTest extends TestCase
{
    private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

    #[Test]
    public function savePersistsToDoctrineFirst(): void
    {
        $user = $this->createTestUser();

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::once())->method('save')->with($user);

        $cache = $this->createStubCachePool();

        $repository = new CachedUserRepository($doctrine, $cache);
        $repository->save($user);
    }

    #[Test]
    public function saveUpdatesRedisAfterDoctrine(): void
    {
        $user = $this->createTestUser();
        $savedKeys = [];

        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('set')->willReturnSelf();
        $cacheItem->method('isHit')->willReturn(false);
        $cacheItem->method('get')->willReturn([]);

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')
            ->willReturnCallback(static function (string $key) use (&$savedKeys, $cacheItem) {
                $savedKeys[] = $key;

                return $cacheItem;
            });
        $cache->method('save')->willReturn(true);

        $doctrine = $this->createMock(UserRepository::class);
        $repository = new CachedUserRepository($doctrine, $cache);

        $repository->save($user);

        self::assertNotEmpty($savedKeys);
        self::assertContains('user:' . $user->id, $savedKeys);
    }

    #[Test]
    public function findByIdReturnsCachedUserOnHit(): void
    {
        $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');

        $userData = $this->makeSerializedUser('550e8400-e29b-41d4-a716-446655440001');

        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('isHit')->willReturn(true);
        $cacheItem->method('get')->willReturn($userData);

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willReturn($cacheItem);

        $doctrine = $this->createMock(UserRepository::class);
        // Doctrine should NOT be called on cache hit
        $doctrine->expects(self::never())->method('findById');

        $repository = new CachedUserRepository($doctrine, $cache);
        $user = $repository->findById($userId);

        self::assertNotNull($user);
        self::assertSame('550e8400-e29b-41d4-a716-446655440001', (string) $user->id);
    }

    #[Test]
    public function findByIdFallsBackToDoctrineOnCacheMiss(): void
    {
        $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');

        $missItem = $this->createMock(CacheItemInterface::class);
        $missItem->method('isHit')->willReturn(false);
        $missItem->method('set')->willReturnSelf();

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willReturn($missItem);
        $cache->method('save')->willReturn(true);

        $expectedUser = $this->createTestUser();

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::once())->method('findById')->willReturn($expectedUser);

        $repository = new CachedUserRepository($doctrine, $cache);
        $user = $repository->findById($userId);

        self::assertNotNull($user);
    }

    #[Test]
    public function findByIdFallsBackToDoctrineWhenRedisUnavailable(): void
    {
        $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willThrowException(new RuntimeException('Redis connection refused'));

        $expectedUser = $this->createTestUser();

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::once())->method('findById')->willReturn($expectedUser);

        $repository = new CachedUserRepository($doctrine, $cache);
        $user = $repository->findById($userId);

        self::assertNotNull($user);
    }

    #[Test]
    public function saveSucceedsEvenWhenRedisIsDown(): void
    {
        $user = $this->createTestUser();

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willThrowException(new RuntimeException('Redis down'));

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::once())->method('save')->with($user);

        $repository = new CachedUserRepository($doctrine, $cache);

        // Should not throw despite Redis being down
        $repository->save($user);
    }

    #[Test]
    public function findByEmailUsesEmailIndexFromCache(): void
    {
        $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
        $email = new Email('test@example.com');
        $userId = '550e8400-e29b-41d4-a716-446655440001';
        $userData = $this->makeSerializedUser($userId);

        $emailIndexItem = $this->createMock(CacheItemInterface::class);
        $emailIndexItem->method('isHit')->willReturn(true);
        $emailIndexItem->method('get')->willReturn($userId);

        $userItem = $this->createMock(CacheItemInterface::class);
        $userItem->method('isHit')->willReturn(true);
        $userItem->method('get')->willReturn($userData);

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')
            ->willReturnCallback(static function (string $key) use ($emailIndexItem, $userItem, $tenantId) {
                $expectedEmailKey = 'user_email:' . $tenantId . ':test_at_example_dot_com';
                if ($key === $expectedEmailKey) {
                    return $emailIndexItem;
                }

                return $userItem;
            });

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::never())->method('findByEmail');

        $repository = new CachedUserRepository($doctrine, $cache);
        $user = $repository->findByEmail($email, $tenantId);

        self::assertNotNull($user);
    }

    #[Test]
    public function findByEmailFallsBackToDoctrineOnCacheMiss(): void
    {
        $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
        $email = new Email('test@example.com');

        $missItem = $this->createMock(CacheItemInterface::class);
        $missItem->method('isHit')->willReturn(false);
        $missItem->method('set')->willReturnSelf();

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willReturn($missItem);
        $cache->method('save')->willReturn(true);

        $expectedUser = $this->createTestUser();

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::once())->method('findByEmail')->willReturn($expectedUser);

        $repository = new CachedUserRepository($doctrine, $cache);
        $user = $repository->findByEmail($email, $tenantId);

        self::assertNotNull($user);
    }

    #[Test]
    public function findAllByTenantAlwaysGoesToDoctrine(): void
    {
        $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);

        $cache = $this->createStubCachePool();

        $doctrine = $this->createMock(UserRepository::class);
        $doctrine->expects(self::once())
            ->method('findAllByTenant')
            ->with($tenantId)
            ->willReturn([$this->createTestUser()]);

        $repository = new CachedUserRepository($doctrine, $cache);
        $users = $repository->findAllByTenant($tenantId);

        self::assertCount(1, $users);
    }

    #[Test]
    public function saveInvalidatesOldEmailIndexOnEmailChange(): void
    {
        $userId = '550e8400-e29b-41d4-a716-446655440001';
        $oldEmail = 'old@example.com';
        $newEmail = 'new@example.com';

        $oldSerializedUser = $this->makeSerializedUser($userId);
        $oldSerializedUser['email'] = $oldEmail;

        $user = User::reconstitute(
            id: UserId::fromString($userId),
            email: new Email($newEmail),
            roles: [Role::PARENT],
            tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
            schoolName: 'École Test',
            statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE,
            dateNaissance: null,
            createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
            hashedPassword: null,
            activatedAt: null,
            consentementParental: null,
            firstName: '',
            lastName: '',
        );

        $existingItem = $this->createMock(CacheItemInterface::class);
        $existingItem->method('isHit')->willReturn(true);
        $existingItem->method('get')->willReturn($oldSerializedUser);
        $existingItem->method('set')->willReturnSelf();

        $otherItem = $this->createMock(CacheItemInterface::class);
        $otherItem->method('isHit')->willReturn(false);
        $otherItem->method('get')->willReturn([]);
        $otherItem->method('set')->willReturnSelf();

        $deletedKeys = [];
        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')
            ->willReturnCallback(static function (string $key) use ($existingItem, $otherItem, $userId) {
                if ($key === 'user:' . $userId) {
                    return $existingItem;
                }

                return $otherItem;
            });
        $cache->method('save')->willReturn(true);
        $cache->method('deleteItem')
            ->willReturnCallback(static function (string $key) use (&$deletedKeys) {
                $deletedKeys[] = $key;

                return true;
            });

        $doctrine = $this->createMock(UserRepository::class);
        $repository = new CachedUserRepository($doctrine, $cache);

        $repository->save($user);

        // The old email index should have been deleted
        self::assertNotEmpty($deletedKeys, 'Old email index should be invalidated');
        self::assertStringContainsString('user_email:', $deletedKeys[0]);
        self::assertStringContainsString('old_at_example_dot_com', $deletedKeys[0]);
    }

    #[Test]
    public function deserializeHandlesLegacySingleRoleFormat(): void
    {
        $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');

        // Legacy format: 'role' key instead of 'roles'
        $legacyData = $this->makeSerializedUser((string) $userId);
        unset($legacyData['roles']);
        $legacyData['role'] = 'ROLE_PROF';

        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('isHit')->willReturn(true);
        $cacheItem->method('get')->willReturn($legacyData);

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willReturn($cacheItem);

        $doctrine = $this->createMock(UserRepository::class);

        $repository = new CachedUserRepository($doctrine, $cache);
        $user = $repository->findById($userId);

        self::assertNotNull($user);
        self::assertCount(1, $user->roles);
        self::assertSame(Role::PROF, $user->roles[0]);
    }

    private function createTestUser(): User
    {
        return User::creer(
            email: new Email('test@example.com'),
            role: Role::PARENT,
            tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
            schoolName: 'École Test',
            dateNaissance: null,
            createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
        );
    }

    /**
     * @return array<string, mixed>
     */
    private function makeSerializedUser(string $userId): array
    {
        return [
            'id' => $userId,
            'email' => 'test@example.com',
            'roles' => ['ROLE_PARENT'],
            'tenant_id' => self::TENANT_ALPHA_ID,
            'school_name' => 'École Test',
            'statut' => 'pending',
            'hashed_password' => null,
            'date_naissance' => null,
            'created_at' => '2026-01-15T10:00:00+00:00',
            'activated_at' => null,
            'first_name' => '',
            'last_name' => '',
            'invited_at' => null,
            'blocked_at' => null,
            'blocked_reason' => null,
            'consentement_parental' => null,
        ];
    }

    private function createStubCachePool(): CacheItemPoolInterface
    {
        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('set')->willReturnSelf();
        $cacheItem->method('isHit')->willReturn(false);
        $cacheItem->method('get')->willReturn([]);

        $cache = $this->createMock(CacheItemPoolInterface::class);
        $cache->method('getItem')->willReturn($cacheItem);
        $cache->method('save')->willReturn(true);

        return $cache;
    }
}
