<?php

declare(strict_types=1);

namespace apexl\Io\modules\user\Actions;

use apexl\Config\Exceptions\MissingConfigException;
use apexl\Io\exceptions\RecordNotFoundException;
use apexl\Io\modules\user\entities\RefreshToken;
use apexl\Io\modules\user\exceptions\RefreshTokenExpiredException;
use apexl\Io\modules\user\Factories\RefreshTokenFactory;
use apexl\Io\modules\user\services\JwtCreator;
use apexl\Io\modules\user\services\RefreshTokenEntityFinder;
use DI\DependencyException;
use DI\NotFoundException;

use function apexl\Io\config;

final readonly class RefreshAction
{
    /**
     * How long (seconds) a just-rotated refresh token stays valid after being
     * replaced. Clients that race two refreshes (multiple tabs, a retry) — or an
     * older client that hasn't stored the rotated value yet — get a short window
     * where the previous token still works instead of an instant 404/logout.
     */
    private const int ROTATION_GRACE_SECONDS = 60;

    public function __construct(
        private JwtCreator $jwtCreator,
        private RefreshTokenEntityFinder $refreshTokenFinder,
        private RefreshTokenFactory $refreshTokenFactory,
    ) {}

    /**
     * Refresh the access token, optionally rotating the refresh token.
     *
     * Rotation is opt-in via config('auth.refresh.rotate') (default false):
     *
     *  - OFF (default): the existing row's expiry is extended in place and the
     *    second return value is null — the response contract is byte-for-byte
     *    identical to before rotation existed.
     *  - ON: a brand-new refresh-token value is minted (a fresh row inheriting
     *    this session's lifetime + user), and the old row is expired to
     *    now + ROTATION_GRACE_SECONDS rather than killed outright, so a racing or
     *    not-yet-updated client reusing the previous token within the grace
     *    window still succeeds. The new token is returned for the caller to
     *    persist. NOTE: clients MUST store the returned refresh_token, or they
     *    will 404 on their next refresh once the old token leaves its grace
     *    window — only enable rotation for clients that persist it.
     *
     * @return array{0: string, 1: string|null} [access token, new refresh token or null]
     * @throws RecordNotFoundException
     * @throws RefreshTokenExpiredException
     * @throws DependencyException
     * @throws NotFoundException
     * @throws MissingConfigException
     */
    public function handle(string $token, string $sessionId): array
    {
        $refreshTokenEntity = $this->refreshTokenFinder->fromToken($token);

        // Refresh token still valid?
        $user = $refreshTokenEntity->user();

        $authToken = $this->jwtCreator->forUser(
            $user,
            $sessionId,
        );

        if (config('auth.refresh.rotate')) {
            // Rotate: issue a new refresh token that inherits this session's
            // lifetime (so a "Stay signed in" session keeps its long window
            // across refreshes), then let the old token linger for the grace
            // period. ->token is the raw (unhashed) value; the store hook hashes
            // only the DB copy, so the in-memory entity still holds the plaintext.
            $newRefreshToken = $this->refreshTokenFactory->create($user->id, $refreshTokenEntity->lifetime);

            $refreshTokenEntity->expiry = RefreshToken::newExpiryFrom(time() + self::ROTATION_GRACE_SECONDS, 0);
            $refreshTokenEntity->store();

            return [$authToken, $newRefreshToken->token];
        }

        // No rotation (default): extend the existing row in place by its OWN
        // lifetime, not the global default — otherwise a "Stay signed in"
        // (60-day) session would be reset to the short default on its first
        // refresh. NULL lifetime falls back to the config default.
        $refreshTokenEntity->expiry = RefreshToken::newExpiryFrom(null, $refreshTokenEntity->lifetime);
        $refreshTokenEntity->store();

        return [$authToken, null];
    }
}