<?php

declare(strict_types=1);

namespace App\Administration\Infrastructure\Persistence\Redis;

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\Tenant\TenantId;
use DateTimeImmutable;
use DateTimeInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * Redis implementation of the refresh tokens repository.
 *
 * Storage structure:
 * - Individual token: refresh:{token_id} → token JSON data
 * - Family index: refresh_family:{family_id} → set of token_ids in the family
 * - User index: refresh_user:{user_id} → set of family_ids for the user
 *
 * @see Story 1.4 - User login
 */
final readonly class RedisRefreshTokenRepository implements RefreshTokenRepository
{
    private const string TOKEN_PREFIX = 'refresh:';
    private const string FAMILY_PREFIX = 'refresh_family:';
    private const string USER_PREFIX = 'refresh_user:';

    /**
     * Maximum TTL for user index (7 days + 10% jitter margin).
     *
     * Must be >= the longest possible token TTL to ensure invalidateAllForUser() works correctly.
     * RefreshTokenManager applies ±10% jitter, so max token TTL = 604800 * 1.1 = 665280s.
     * We use 8 days (691200s) for a safe margin.
     */
    private const int MAX_USER_INDEX_TTL = 691200;

    public function __construct(
        private CacheItemPoolInterface $refreshTokensCache,
        private LoggerInterface $logger = new NullLogger(),
    ) {
    }

    public function save(RefreshToken $token): void
    {
        // Save the token
        $tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id);
        $tokenItem->set($this->serialize($token));

        // Calculate remaining TTL
        $now = new DateTimeImmutable();
        $ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp();
        if ($ttl > 0) {
            $tokenItem->expiresAfter($ttl);
        }

        $this->refreshTokensCache->save($tokenItem);

        // Add to family index
        // Never reduce the family index TTL
        // The index must survive as long as the most recent token in the family
        $familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId);

        /** @var list<string> $familyTokenIds */
        $familyTokenIds = $familyItem->isHit() ? $familyItem->get() : [];
        $familyTokenIds[] = (string) $token->id;
        $familyItem->set(array_unique($familyTokenIds));

        // Only extend TTL, never reduce
        // For rotated tokens (old), we don't change the index TTL
        if (!$token->isRotated && $ttl > 0) {
            $familyItem->expiresAfter($ttl);
        } elseif (!$familyItem->isHit()) {
            // New index - set initial TTL
            $familyItem->expiresAfter($ttl > 0 ? $ttl : 604800);
        }
        // If it's a rotated token and the index already exists, keep the existing TTL

        $this->refreshTokensCache->save($familyItem);

        // Add to user index (for invalidating all sessions)
        $userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $token->userId);

        /** @var list<string> $userFamilyIds */
        $userFamilyIds = $userItem->isHit() ? $userItem->get() : [];
        $userFamilyIds[] = (string) $token->familyId;
        $userItem->set(array_unique($userFamilyIds));

        // Always use max TTL for user index to ensure it survives as long as any token
        // This prevents invalidateAllForUser() from missing long-lived sessions (e.g., mobile)
        // when a shorter-lived session (e.g., web) is created afterwards
        $userItem->expiresAfter(self::MAX_USER_INDEX_TTL);

        $this->refreshTokensCache->save($userItem);
    }

    public function find(RefreshTokenId $id): ?RefreshToken
    {
        $item = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $id);

        if (!$item->isHit()) {
            return null;
        }

        /** @var array{id: string, family_id: string, user_id: string, tenant_id: string, device_fingerprint: string, issued_at: string, expires_at: string, rotated_from: string|null, is_rotated: bool, rotated_at?: string|null} $data */
        $data = $item->get();

        return $this->deserialize($data);
    }

    public function findByToken(string $tokenValue): ?RefreshToken
    {
        return $this->find(RefreshTokenId::fromString($tokenValue));
    }

    public function delete(RefreshTokenId $id): void
    {
        $this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $id);
    }

    public function invalidateFamily(TokenFamilyId $familyId): void
    {
        $familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $familyId);

        if (!$familyItem->isHit()) {
            return;
        }

        /** @var list<string> $tokenIds */
        $tokenIds = $familyItem->get();

        // Delete all tokens in the family
        foreach ($tokenIds as $tokenId) {
            $this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId);
        }

        // Delete the family index
        $this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId);
    }

    /**
     * Invalidates all refresh token sessions for a user.
     *
     * IMPORTANT: This method relies on the user index (refresh_user:{userId}) which is only
     * created when tokens are saved with this repository version. Tokens created before this
     * code was deployed will NOT have a user index and will not be invalidated.
     *
     * For deployments with existing tokens, either:
     * - Wait for old tokens to naturally expire (max 7 days)
     * - Run a migration script to rebuild user indexes
     * - Force all users to re-login after deployment
     */
    public function invalidateAllForUser(UserId $userId): void
    {
        $userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $userId);

        if (!$userItem->isHit()) {
            // User index doesn't exist - this could mean:
            // 1. User has no active sessions (normal case)
            // 2. User has legacy sessions created before user index was implemented (migration needed)
            // Log at info level to help operators identify migration needs
            $this->logger->info('No user index found when invalidating sessions. Legacy tokens may exist.', [
                'user_id' => (string) $userId,
                'action' => 'invalidateAllForUser',
            ]);

            return;
        }

        /** @var list<string> $familyIds */
        $familyIds = $userItem->get();

        // Invalidate all token families for the user
        foreach ($familyIds as $familyId) {
            $this->invalidateFamily(TokenFamilyId::fromString($familyId));
        }

        // Delete the user index
        $this->refreshTokensCache->deleteItem(self::USER_PREFIX . $userId);
    }

    /**
     * @return array<string, mixed>
     */
    private function serialize(RefreshToken $token): array
    {
        return [
            'id' => (string) $token->id,
            'family_id' => (string) $token->familyId,
            'user_id' => (string) $token->userId,
            'tenant_id' => (string) $token->tenantId,
            'device_fingerprint' => (string) $token->deviceFingerprint,
            'issued_at' => $token->issuedAt->format(DateTimeInterface::ATOM),
            'expires_at' => $token->expiresAt->format(DateTimeInterface::ATOM),
            'rotated_from' => $token->rotatedFrom !== null ? (string) $token->rotatedFrom : null,
            'is_rotated' => $token->isRotated,
            'rotated_at' => $token->rotatedAt?->format(DateTimeInterface::ATOM),
        ];
    }

    /**
     * @param array{
     *     id: string,
     *     family_id: string,
     *     user_id: string,
     *     tenant_id: string,
     *     device_fingerprint: string,
     *     issued_at: string,
     *     expires_at: string,
     *     rotated_from: string|null,
     *     is_rotated: bool,
     *     rotated_at?: string|null
     * } $data
     */
    private function deserialize(array $data): RefreshToken
    {
        $rotatedAt = $data['rotated_at'] ?? null;

        return RefreshToken::reconstitute(
            id: RefreshTokenId::fromString($data['id']),
            familyId: TokenFamilyId::fromString($data['family_id']),
            userId: UserId::fromString($data['user_id']),
            tenantId: TenantId::fromString($data['tenant_id']),
            deviceFingerprint: DeviceFingerprint::fromString($data['device_fingerprint']),
            issuedAt: new DateTimeImmutable($data['issued_at']),
            expiresAt: new DateTimeImmutable($data['expires_at']),
            rotatedFrom: $data['rotated_from'] !== null ? RefreshTokenId::fromString($data['rotated_from']) : null,
            isRotated: $data['is_rotated'],
            rotatedAt: $rotatedAt !== null ? new DateTimeImmutable($rotatedAt) : null,
        );
    }
}
