<?php

declare(strict_types=1);

namespace App\Administration\Application\Service;

use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
use App\Administration\Domain\Exception\TokenReplayDetectedException;
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\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException;

/**
 * Manages the lifecycle of refresh tokens.
 *
 * Responsibilities:
 * - Token creation for new sessions
 * - Token rotation with replay detection
 * - Invalidation of compromised token families
 *
 * @see Story 1.4 - User login
 */
final readonly class RefreshTokenManager
{
    private const int WEB_TTL_SECONDS = 86400; // 1 day for web
    private const int MOBILE_TTL_SECONDS = 604800; // 7 days for mobile

    public function __construct(
        private RefreshTokenRepository $repository,
        private Clock $clock,
    ) {
    }

    /**
     * Creates a new refresh token for a session.
     */
    public function create(
        UserId $userId,
        TenantId $tenantId,
        DeviceFingerprint $deviceFingerprint,
        bool $isMobile = false,
    ): RefreshToken {
        $ttl = $isMobile ? self::MOBILE_TTL_SECONDS : self::WEB_TTL_SECONDS;

        // Add ±10% jitter to avoid simultaneous expirations
        $jitter = (int) ($ttl * 0.1 * (random_int(-100, 100) / 100));
        $ttl += $jitter;

        $token = RefreshToken::create(
            userId: $userId,
            tenantId: $tenantId,
            deviceFingerprint: $deviceFingerprint,
            issuedAt: $this->clock->now(),
            ttlSeconds: $ttl,
        );

        $this->repository->save($token);

        return $token;
    }

    /**
     * Validates and refreshes a token.
     *
     * @throws TokenReplayDetectedException if a replay attack is detected
     * @throws TokenAlreadyRotatedException if the token has already been rotated but is in grace period
     * @throws InvalidArgumentException     if the token is invalid or expired
     *
     * @return RefreshToken the new token after rotation
     */
    public function refresh(
        string $tokenString,
        DeviceFingerprint $deviceFingerprint,
    ): RefreshToken {
        $tokenId = RefreshToken::extractIdFromTokenString($tokenString);
        $token = $this->repository->find($tokenId);
        $now = $this->clock->now();

        if ($token === null) {
            throw new InvalidArgumentException('Token not found');
        }

        // Check expiration
        if ($token->isExpired($now)) {
            $this->repository->delete($tokenId);

            throw new InvalidArgumentException('Token expired');
        }

        // Check device fingerprint
        if (!$token->matchesDevice($deviceFingerprint)) {
            // Potential token theft attempt - invalidate the entire family
            $this->repository->invalidateFamily($token->familyId);

            throw new TokenReplayDetectedException($token->familyId);
        }

        // Detect replay attacks
        if ($token->isRotated) {
            // Token already used!
            if ($token->isInGracePeriod($now)) {
                // In grace period - probably a legitimate race condition
                // We let it pass but don't generate a new token
                // The client should use the most recent token
                // Dedicated exception to NOT delete the cookie during a legitimate race condition
                throw new TokenAlreadyRotatedException();
            }

            // Confirmed replay attack - invalidate the entire family
            $this->repository->invalidateFamily($token->familyId);

            throw new TokenReplayDetectedException($token->familyId);
        }

        // Rotate the token (preserves original TTL)
        [$newToken, $rotatedOldToken] = $token->rotate($now);

        // Save the new token FIRST
        // Important: save the new token FIRST so the family index keeps the correct TTL
        $this->repository->save($newToken);

        // Update the old token as rotated (for grace period)
        $this->repository->save($rotatedOldToken);

        return $newToken;
    }

    /**
     * Revokes a token (logout).
     */
    public function revoke(string $tokenString): void
    {
        try {
            $tokenId = RefreshToken::extractIdFromTokenString($tokenString);
            $token = $this->repository->find($tokenId);

            if ($token !== null) {
                // Invalidate the entire family for a complete logout
                $this->repository->invalidateFamily($token->familyId);
            }
        } catch (InvalidArgumentException) {
            // Invalid token, nothing to do
        }
    }

    /**
     * Invalidates an entire token family.
     *
     * Used when a user is suspended/archived to revoke all their sessions.
     */
    public function invalidateFamily(TokenFamilyId $familyId): void
    {
        $this->repository->invalidateFamily($familyId);
    }
}
