<?php

declare(strict_types=1);

namespace App\Administration\Infrastructure\Persistence\Redis;

use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Exception\TokenConsumptionInProgressException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;

final readonly class RedisPasswordResetTokenRepository implements PasswordResetTokenRepository
{
    private const string KEY_PREFIX = 'password_reset:';

    /**
     * Cache TTL is 2 hours: 1 hour validity + 1 hour grace period.
     *
     * Keeping tokens longer than their domain expiry allows distinguishing
     * "expired" (410) from "invalid/not found" (400) in API responses.
     */
    private const int TTL_SECONDS = 60 * 60 * 2; // 2 hours

    public function __construct(
        private CacheItemPoolInterface $passwordResetTokensCache,
        private LockFactory $lockFactory,
    ) {
    }

    #[Override]
    public function save(PasswordResetToken $token): void
    {
        // Store by token value for lookup during password reset
        $item = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
        $item->set($this->serialize($token));
        $item->expiresAfter(self::TTL_SECONDS);
        $this->passwordResetTokensCache->save($item);

        // Also store by ID for direct access
        $idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
        $idItem->set($token->tokenValue);
        $idItem->expiresAfter(self::TTL_SECONDS);
        $this->passwordResetTokensCache->save($idItem);

        // Store by user_id for lookup of existing tokens
        $userItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'user:' . $token->userId);
        $userItem->set($token->tokenValue);
        $userItem->expiresAfter(self::TTL_SECONDS);
        $this->passwordResetTokensCache->save($userItem);
    }

    #[Override]
    public function findByTokenValue(string $tokenValue): ?PasswordResetToken
    {
        $item = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . $tokenValue);

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

        /** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} $data */
        $data = $item->get();

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

    #[Override]
    public function getByTokenValue(string $tokenValue): PasswordResetToken
    {
        $token = $this->findByTokenValue($tokenValue);

        if ($token === null) {
            throw PasswordResetTokenNotFoundException::withTokenValue($tokenValue);
        }

        return $token;
    }

    #[Override]
    public function get(PasswordResetTokenId $id): PasswordResetToken
    {
        // First get the token value from the ID index
        $idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);

        if (!$idItem->isHit()) {
            throw PasswordResetTokenNotFoundException::withId($id);
        }

        /** @var string $tokenValue */
        $tokenValue = $idItem->get();
        $token = $this->findByTokenValue($tokenValue);

        if ($token === null) {
            throw PasswordResetTokenNotFoundException::withId($id);
        }

        return $token;
    }

    #[Override]
    public function delete(PasswordResetTokenId $id): void
    {
        // Get token first to clean up all indices
        $idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);

        if ($idItem->isHit()) {
            /** @var string $tokenValue */
            $tokenValue = $idItem->get();
            $token = $this->findByTokenValue($tokenValue);

            if ($token !== null) {
                $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'user:' . $token->userId);
            }

            $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
        }

        $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
    }

    #[Override]
    public function deleteByTokenValue(string $tokenValue): void
    {
        $token = $this->findByTokenValue($tokenValue);

        if ($token !== null) {
            $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
            $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'user:' . $token->userId);
        }

        $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
    }

    #[Override]
    public function findValidTokenForUser(string $userId): ?PasswordResetToken
    {
        $userItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'user:' . $userId);

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

        /** @var string $tokenValue */
        $tokenValue = $userItem->get();
        $token = $this->findByTokenValue($tokenValue);

        if ($token === null) {
            return null;
        }

        // Check if token is still valid (not used and not expired)
        if ($token->isUsed() || $token->isExpired(new DateTimeImmutable())) {
            return null;
        }

        return $token;
    }

    #[Override]
    public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken
    {
        // Use Symfony Lock for atomic lock acquisition (Redis SETNX under the hood)
        $lock = $this->lockFactory->createLock(
            resource: self::KEY_PREFIX . 'lock:' . $tokenValue,
            ttl: 30, // 30 seconds max for password hashing + save
            autoRelease: true,
        );

        // Try to acquire lock without blocking
        if (!$lock->acquire(blocking: false)) {
            // Another request is consuming this token
            // Check if the token was already used by the other request
            $token = $this->findByTokenValue($tokenValue);
            if ($token !== null && $token->isUsed()) {
                $token->validateForUse($at); // Will throw AlreadyUsedException
            }

            // Lock is held but token not yet consumed - client should retry
            throw new TokenConsumptionInProgressException($tokenValue);
        }

        try {
            // Get and validate token
            $token = $this->getByTokenValue($tokenValue);
            $token->validateForUse($at);

            // Mark as used
            $token->use($at);

            // Save the consumed token
            $this->save($token);

            return $token;
        } finally {
            // Always release the lock
            $lock->release();
        }
    }

    /**
     * @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null}
     */
    private function serialize(PasswordResetToken $token): array
    {
        return [
            'id' => (string) $token->id,
            'token_value' => $token->tokenValue,
            'user_id' => $token->userId,
            'email' => $token->email,
            'tenant_id' => (string) $token->tenantId,
            'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
            'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
            'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
        ];
    }

    /**
     * @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} $data
     */
    private function deserialize(array $data): PasswordResetToken
    {
        return PasswordResetToken::reconstitute(
            id: PasswordResetTokenId::fromString($data['id']),
            tokenValue: $data['token_value'],
            userId: $data['user_id'],
            email: $data['email'],
            tenantId: TenantId::fromString($data['tenant_id']),
            createdAt: new DateTimeImmutable($data['created_at']),
            expiresAt: new DateTimeImmutable($data['expires_at']),
            usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
        );
    }
}
