<?php

declare(strict_types=1);

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\Commerce\Catalog\Authorization;

use Ramsey\Uuid\Uuid;

/** Vérifie le JWT long avant son stockage, à partir de la clé approuvée par le hub. */
final readonly class CatalogEnrollmentJwtValidator
{
    private const string ISSUER_SUBJECT = 'catalog-enrollment';
    private const string TOKEN_AUDIENCE = 'serenity-commerce-hub';
    private const string SCOPE = 'catalog:enroll';
    private const int MAXIMUM_TOKEN_LENGTH = 16_384;
    private const int MAXIMUM_LIFETIME_SECONDS = 315_360_000;
    private const int CLOCK_LEEWAY_SECONDS = 30;

    public function validate(
        #[\SensitiveParameter]
        string $token,
        string $publicKeyPem,
        string $issuer,
        string $keyId,
        string $instanceId,
        string $jti,
        \DateTimeImmutable $expiresAt,
        ?\DateTimeImmutable $now = null,
    ): void {
        if ('' === $token || mb_strlen($token, '8bit') > self::MAXIMUM_TOKEN_LENGTH || !Uuid::isValid($instanceId)) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }

        $segments = explode('.', $token);
        if (3 !== \count($segments)) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }

        [$encodedHeader, $encodedClaims, $encodedSignature] = $segments;
        $header = $this->decodeObject($encodedHeader);
        $claims = $this->decodeObject($encodedClaims);
        $signature = $this->decodeBase64Url($encodedSignature);
        if ('RS256' !== ($header['alg'] ?? null)
            || 'JWT' !== ($header['typ'] ?? null)
            || $keyId !== ($header['kid'] ?? null)
            || isset($header['crit'])
            || '' === $signature
        ) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }

        $now ??= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
        $timestamp = $now->getTimestamp();
        if ($issuer !== ($claims['iss'] ?? null)
            || self::ISSUER_SUBJECT !== ($claims['sub'] ?? null)
            || self::TOKEN_AUDIENCE !== ($claims['aud'] ?? null)
            || self::SCOPE !== ($claims['scope'] ?? null)
            || Uuid::fromString($instanceId)->toString() !== ($claims['instance_id'] ?? null)
            || $jti !== ($claims['jti'] ?? null)
            || !\is_int($claims['iat'] ?? null)
            || !\is_int($claims['nbf'] ?? null)
            || !\is_int($claims['exp'] ?? null)
            || $claims['nbf'] > $timestamp + self::CLOCK_LEEWAY_SECONDS
            || $claims['iat'] > $timestamp + self::CLOCK_LEEWAY_SECONDS
            || $claims['exp'] <= $timestamp - self::CLOCK_LEEWAY_SECONDS
            || $claims['exp'] <= $claims['iat']
            || $claims['exp'] <= $claims['nbf']
            || ($claims['exp'] - $claims['iat']) > self::MAXIMUM_LIFETIME_SECONDS
            || $claims['exp'] !== $expiresAt->getTimestamp()
        ) {
            throw new CatalogHubAuthorizationException('Les droits portés par le JWT d’enrôlement sont invalides.');
        }

        $publicKey = openssl_pkey_get_public($publicKeyPem);
        if (!$publicKey instanceof \OpenSSLAsymmetricKey) {
            throw new CatalogHubAuthorizationException('La clé publique JWT du hub est invalide.');
        }
        $details = openssl_pkey_get_details($publicKey);
        if (!\is_array($details)
            || \OPENSSL_KEYTYPE_RSA !== ($details['type'] ?? null)
            || !\is_int($details['bits'] ?? null)
            || $details['bits'] < 2048
        ) {
            throw new CatalogHubAuthorizationException('La clé publique JWT du hub est invalide.');
        }

        if (1 !== openssl_verify(
            $encodedHeader . '.' . $encodedClaims,
            $signature,
            $publicKey,
            \OPENSSL_ALGO_SHA256,
        )) {
            throw new CatalogHubAuthorizationException('La signature du JWT d’enrôlement est invalide.');
        }
    }

    /** @return array<string, mixed> */
    private function decodeObject(string $encoded): array
    {
        try {
            $decoded = json_decode($this->decodeBase64Url($encoded), true, 32, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.', previous: $exception);
        }

        if (!\is_array($decoded) || array_is_list($decoded)) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }

        return $decoded;
    }

    private function decodeBase64Url(string $encoded): string
    {
        if ('' === $encoded || 1 !== preg_match('/^[A-Za-z0-9_-]+$/D', $encoded)) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }

        $remainder = mb_strlen($encoded, '8bit') % 4;
        if (1 === $remainder) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }
        $decoded = base64_decode(strtr($encoded, '-_', '+/') . str_repeat('=', (4 - $remainder) % 4), true);
        if (false === $decoded || $encoded !== mb_rtrim(strtr(base64_encode($decoded), '+/', '-_'), '=')) {
            throw new CatalogHubAuthorizationException('Le JWT d’enrôlement fourni par le hub est invalide.');
        }

        return $decoded;
    }
}
