<?php

declare(strict_types=1);

namespace Paysera\CheckoutSdk\Service\JWT;

use Firebase\JWT\ExpiredException;
use Paysera\CheckoutSdk\Exception\JwtValidationException;
use Paysera\CheckoutSdk\Service\Provider\PaymentApiUrlProvider;
use Paysera\CheckoutSdk\Util\SensitiveValue;
use Firebase\JWT\CachedKeySet;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;

/**
 * Decodes and validates JWT access tokens from Payment API using JWKS.
 *
 * Validates token signature against Keycloak JWKS endpoint and ensures
 * all required claims are present with correct types.
 */
class JwtDecoder implements PaymentApiJwtDecoderInterface, PaymentApiJwtValidatorInterface
{
    /**
     * Cache JWKS keys for 30 days.
     *
     * Strategy: cold-start fetch + long-lived cache. The /certs endpoint is
     * hit only on the first validation and whenever an incoming JWT carries
     * a `kid` absent from the cached key set (Keycloak rotation escape hatch,
     * handled natively by Firebase\JWT\CachedKeySet on unknown kid).
     *
     * Precondition: the injected CacheItemPoolInterface must be persistent
     * across PHP request lifecycles (Redis / APCu / filesystem). The default
     * InMemoryCache does not survive between requests and negates this
     * strategy - plugins must override it via
     * SdkFacadeBuilder::setCacheItemPool().
     */
    private const JWKS_CACHE_TTL = 30 * 24 * 60 * 60;

    /**
     * Enable Firebase\JWT\CachedKeySet built-in 10-calls-per-minute throttle
     * on the shared PSR-6 pool. Protects /certs from unknown-kid amplification
     * when forged JWTs arrive with random kid values.
     */
    private const JWKS_RATE_LIMIT_ENABLED = true;

    private JwtBridge $bridge;
    private PaymentApiUrlProvider $paymentApiUrlProvider;
    private ClientInterface $httpClient;
    private RequestFactoryInterface $requestFactory;
    private CacheItemPoolInterface $cache;
    private LoggerInterface $logger;
    private ?CachedKeySet $keySet = null;

    public function __construct(
        JwtBridge $bridge,
        PaymentApiUrlProvider $paymentApiUrlProvider,
        ClientInterface $httpClient,
        RequestFactoryInterface $requestFactory,
        CacheItemPoolInterface $cache,
        LoggerInterface $logger
    ) {
        $this->bridge = $bridge;
        $this->paymentApiUrlProvider = $paymentApiUrlProvider;
        $this->httpClient = $httpClient;
        $this->requestFactory = $requestFactory;
        $this->cache = $cache;
        $this->logger = $logger;
    }

    /**
     * @throws JwtValidationException
     * @throws ExpiredException
     */
    public function decode(string $jwt): SensitiveValue
    {
        try {
            if ($this->keySet === null) {
                $this->keySet = new CachedKeySet(
                    $this->paymentApiUrlProvider->getJwksUrl(),
                    $this->httpClient,
                    $this->requestFactory,
                    $this->cache,
                    self::JWKS_CACHE_TTL,
                    self::JWKS_RATE_LIMIT_ENABLED
                );
            }

            $decoded = $this->bridge->decode($jwt, $this->keySet);

            $this->validateRequiredClaims($decoded);

            return new SensitiveValue($decoded);
        } catch (ExpiredException $exception) {
            $this->logger
                ->warning(
                    'JWT token is expired',
                    [
                        'exception' => $exception,
                    ]
                )
            ;

            throw $exception;
        } catch (JwtValidationException $exception) {
            throw $exception;
        } catch (Throwable $exception) {
            $this->logger
                ->error(
                    'JWT validation failed',
                    [
                        'exception' => $exception,
                    ]
                )
            ;

            throw new JwtValidationException(
                sprintf('JWT token validation failed: %s', $exception->getMessage()),
                $exception->getCode(),
                $exception
            );
        }
    }

    /**
     * @throws JwtValidationException
     * @throws ExpiredException
     *
     * @uses decode
     */
    public function validate(string $jwt): void
    {
        $this->decode($jwt);
    }

    /**
     * Validate that required claims are present with correct types.
     *
     * Required claims per Payment API JWT specification:
     * - exp (int): Token expiration timestamp (Unix timestamp)
     * - iat (int): Token issued at timestamp (Unix timestamp)
     * - typ (string): Token type (e.g., "Bearer")
     * - project_id (string): Paysera project UUID
     * - client_id (string): OAuth client identifier
     *
     * @throws JwtValidationException If any claim is missing or has wrong type
     */
    private function validateRequiredClaims(object $claims): void
    {
        if (!isset($claims->exp) || !is_int($claims->exp)) {
            throw new JwtValidationException('Missing or invalid exp claim');
        }
        if (!isset($claims->iat) || !is_int($claims->iat)) {
            throw new JwtValidationException('Missing or invalid iat claim');
        }
        if (!isset($claims->typ) || $claims->typ !== 'Bearer') {
            throw new JwtValidationException('Missing or invalid typ claim');
        }
        if (!isset($claims->project_id) || !is_string($claims->project_id) || trim($claims->project_id) === '') {
            throw new JwtValidationException('Missing or invalid project_id claim');
        }
        if (!isset($claims->client_id) || !is_string($claims->client_id) || trim($claims->client_id) === '') {
            throw new JwtValidationException('Missing or invalid client_id claim');
        }
    }
}
