<?php

declare(strict_types=1);

namespace App\Administration\Domain\Model\RefreshToken;

use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;

/**
 * Represents a refresh token for silent session renewal.
 *
 * Security strategy:
 * - Rotation: each use generates a new token and invalidates the old one
 * - Family tracking: all tokens from a session share a family_id
 * - Replay detection: if an already-used token is presented, the entire family is invalidated
 * - Device binding: the token is bound to a device fingerprint
 * - Grace period: 30s tolerance for multi-tab race conditions
 *
 * Note on static methods:
 * This class uses static factory methods (create(), reconstitute()) following standard
 * DDD patterns for Aggregate creation. Although the project follows Elegant Objects
 * "No Static" principles, factory methods for Aggregates are a documented exception
 * as they encapsulate instantiation logic and keep the constructor private, thus
 * preserving domain invariants.
 *
 * @see Story 1.4 - User login
 */
final readonly class RefreshToken
{
    /**
     * Default TTL: 7 days (604800 seconds).
     *
     * This TTL is used if none is specified at token creation.
     * RefreshTokenManager uses 1 day (86400s) for web sessions to
     * limit exposure in case of cookie theft on browsers.
     */
    private const int DEFAULT_TTL_SECONDS = 604800;

    /**
     * Grace period after rotation to handle multi-tab race conditions.
     * If two tabs refresh simultaneously, the second will receive a benign
     * error instead of invalidating the entire token family.
     */
    private const int GRACE_PERIOD_SECONDS = 30;

    private function __construct(
        public RefreshTokenId $id,
        public TokenFamilyId $familyId,
        public UserId $userId,
        public TenantId $tenantId,
        public DeviceFingerprint $deviceFingerprint,
        public DateTimeImmutable $issuedAt,
        public DateTimeImmutable $expiresAt,
        public ?RefreshTokenId $rotatedFrom,
        public bool $isRotated,
        public ?DateTimeImmutable $rotatedAt = null,
    ) {
    }

    /**
     * Creates a new refresh token for a new session.
     */
    public static function create(
        UserId $userId,
        TenantId $tenantId,
        DeviceFingerprint $deviceFingerprint,
        DateTimeImmutable $issuedAt,
        int $ttlSeconds = self::DEFAULT_TTL_SECONDS,
    ): self {
        return new self(
            id: RefreshTokenId::generate(),
            familyId: TokenFamilyId::generate(),
            userId: $userId,
            tenantId: $tenantId,
            deviceFingerprint: $deviceFingerprint,
            issuedAt: $issuedAt,
            expiresAt: $issuedAt->modify("+{$ttlSeconds} seconds"),
            rotatedFrom: null,
            isRotated: false,
            rotatedAt: null,
        );
    }

    /**
     * Performs token rotation (generates a new token, marks the old one as rotated).
     *
     * The new token preserves the same TTL as the original to respect the session policy
     * (web = 1 day, mobile = 7 days). The old token is marked with rotatedAt for the grace period.
     *
     * @return array{0: self, 1: self} The new token and the old token marked as rotated
     */
    public function rotate(DateTimeImmutable $at): array
    {
        // Preserve original TTL to respect session policy (web = 1 day, mobile = 7 days)
        $originalTtlSeconds = $this->expiresAt->getTimestamp() - $this->issuedAt->getTimestamp();

        $newToken = new self(
            id: RefreshTokenId::generate(),
            familyId: $this->familyId, // Same family
            userId: $this->userId,
            tenantId: $this->tenantId,
            deviceFingerprint: $this->deviceFingerprint,
            issuedAt: $at,
            expiresAt: $at->modify("+{$originalTtlSeconds} seconds"),
            rotatedFrom: $this->id, // Traceability
            isRotated: false,
            rotatedAt: null,
        );

        $rotatedOldToken = new self(
            id: $this->id,
            familyId: $this->familyId,
            userId: $this->userId,
            tenantId: $this->tenantId,
            deviceFingerprint: $this->deviceFingerprint,
            issuedAt: $this->issuedAt,
            expiresAt: $this->expiresAt,
            rotatedFrom: $this->rotatedFrom,
            isRotated: true,
            rotatedAt: $at, // For the grace period
        );

        return [$newToken, $rotatedOldToken];
    }

    /**
     * Checks if the token is expired.
     */
    public function isExpired(DateTimeImmutable $at): bool
    {
        return $at > $this->expiresAt;
    }

    /**
     * Checks if the token is in the grace period after rotation.
     *
     * The grace period handles race conditions when multiple tabs attempt to
     * refresh the token simultaneously. It is based on the rotation time,
     * not the initial token issuance.
     */
    public function isInGracePeriod(DateTimeImmutable $at): bool
    {
        if (!$this->isRotated || $this->rotatedAt === null) {
            return false;
        }

        $gracePeriodEnd = $this->rotatedAt->modify('+' . self::GRACE_PERIOD_SECONDS . ' seconds');

        return $at <= $gracePeriodEnd;
    }

    /**
     * Checks if the device fingerprint matches.
     */
    public function matchesDevice(DeviceFingerprint $fingerprint): bool
    {
        return $this->deviceFingerprint->equals($fingerprint);
    }

    /**
     * Generates the token string to store in the cookie.
     *
     * The format is opaque to the client: base64(id)
     */
    public function toTokenString(): string
    {
        return base64_encode((string) $this->id);
    }

    /**
     * Extracts the ID from a token string.
     */
    public static function extractIdFromTokenString(string $tokenString): RefreshTokenId
    {
        $decoded = base64_decode($tokenString, true);

        if ($decoded === false) {
            throw new InvalidArgumentException('Invalid token format');
        }

        return RefreshTokenId::fromString($decoded);
    }

    /**
     * Reconstitutes a RefreshToken from storage.
     *
     * @internal For Infrastructure use only
     */
    public static function reconstitute(
        RefreshTokenId $id,
        TokenFamilyId $familyId,
        UserId $userId,
        TenantId $tenantId,
        DeviceFingerprint $deviceFingerprint,
        DateTimeImmutable $issuedAt,
        DateTimeImmutable $expiresAt,
        ?RefreshTokenId $rotatedFrom,
        bool $isRotated,
        ?DateTimeImmutable $rotatedAt = null,
    ): self {
        return new self(
            id: $id,
            familyId: $familyId,
            userId: $userId,
            tenantId: $tenantId,
            deviceFingerprint: $deviceFingerprint,
            issuedAt: $issuedAt,
            expiresAt: $expiresAt,
            rotatedFrom: $rotatedFrom,
            isRotated: $isRotated,
            rotatedAt: $rotatedAt,
        );
    }
}
