<?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\Api\Security;

use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogHubConfiguration;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogIntegrationConfiguration;

final readonly class CatalogHubJwtVerifier
{
    private const string ALGORITHM = 'RS256';
    private const string SUBJECT = 'catalog-sync';
    private const int MAXIMUM_LIFETIME_SECONDS = 600;
    private const int CLOCK_LEEWAY_SECONDS = 30;
    private const int MAXIMUM_TOKEN_LENGTH = 16_384;

    public function __construct(
        private CatalogHubConfiguration $hubConfiguration,
        private CatalogIntegrationConfiguration $catalogConfiguration,
    ) {}

    public function isValid(
        #[\SensitiveParameter]
        string $token,
        ?\DateTimeImmutable $now = null,
    ): bool {
        if (!$this->hubConfiguration->isJwtVerificationReady()
            || !$this->catalogConfiguration->hasValidInstanceId()
            || '' === $token
            || mb_strlen($token, '8bit') > self::MAXIMUM_TOKEN_LENGTH
        ) {
            return false;
        }

        $segments = explode('.', $token);
        if (3 !== \count($segments)) {
            return false;
        }

        [$encodedHeader, $encodedPayload, $encodedSignature] = $segments;
        $header = $this->decodeJsonObject($encodedHeader);
        $claims = $this->decodeJsonObject($encodedPayload);
        $signature = $this->decodeBase64Url($encodedSignature);
        if (null === $header || null === $claims || null === $signature || '' === $signature) {
            return false;
        }

        if (self::ALGORITHM !== ($header['alg'] ?? null)
            || $this->hubConfiguration->jwtKeyId() !== ($header['kid'] ?? null)
            || isset($header['crit'])
        ) {
            return false;
        }

        if (!$this->hasValidClaims($claims, $now ?? new \DateTimeImmutable())) {
            return false;
        }

        $publicKey = $this->loadPublicKey();
        if (null === $publicKey) {
            return false;
        }

        return 1 === openssl_verify(
            $encodedHeader . '.' . $encodedPayload,
            $signature,
            $publicKey,
            \OPENSSL_ALGO_SHA256,
        );
    }

    /**
     * @param array<string, mixed> $claims
     */
    private function hasValidClaims(array $claims, \DateTimeImmutable $now): bool
    {
        if ($this->hubConfiguration->jwtIssuer() !== ($claims['iss'] ?? null)
            || self::SUBJECT !== ($claims['sub'] ?? null)
            || !$this->hasExpectedAudience($claims['aud'] ?? null)
            || $this->catalogConfiguration->instanceId() !== ($claims['instance_id'] ?? null)
            || !\is_int($claims['exp'] ?? null)
            || !\is_int($claims['nbf'] ?? null)
            || !\is_int($claims['iat'] ?? null)
            || !\is_string($claims['jti'] ?? null)
            || '' === mb_trim($claims['jti'])
            || mb_strlen($claims['jti']) > 255
        ) {
            return false;
        }

        $timestamp = $now->getTimestamp();
        $expiresAt = $claims['exp'];
        $notBefore = $claims['nbf'];
        $issuedAt = $claims['iat'];

        return $expiresAt > ($timestamp - self::CLOCK_LEEWAY_SECONDS)
            && $notBefore <= ($timestamp + self::CLOCK_LEEWAY_SECONDS)
            && $issuedAt <= ($timestamp + self::CLOCK_LEEWAY_SECONDS)
            && $expiresAt > $notBefore
            && $expiresAt > $issuedAt
            && ($expiresAt - $issuedAt) <= self::MAXIMUM_LIFETIME_SECONDS;
    }

    private function hasExpectedAudience(mixed $audience): bool
    {
        $expected = $this->hubConfiguration->jwtAudience();
        if (\is_string($audience)) {
            return hash_equals($expected, $audience);
        }

        if (!\is_array($audience) || !array_is_list($audience)) {
            return false;
        }

        foreach ($audience as $candidate) {
            if (\is_string($candidate) && hash_equals($expected, $candidate)) {
                return true;
            }
        }

        return false;
    }

    private function loadPublicKey(): ?\OpenSSLAsymmetricKey
    {
        try {
            $contents = $this->hubConfiguration->publicKeyPem();
        } catch (\LogicException) {
            return null;
        }

        $publicKey = openssl_pkey_get_public($contents);
        if (false === $publicKey) {
            return null;
        }

        $details = openssl_pkey_get_details($publicKey);
        if (false === $details
            || \OPENSSL_KEYTYPE_RSA !== ($details['type'] ?? null)
            || !\is_int($details['bits'] ?? null)
            || $details['bits'] < 2048
        ) {
            return null;
        }

        return $publicKey;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function decodeJsonObject(string $encoded): ?array
    {
        $json = $this->decodeBase64Url($encoded);
        if (null === $json || !str_starts_with(mb_ltrim($json), '{')) {
            return null;
        }

        try {
            $decoded = json_decode($json, true, flags: \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }

        return \is_array($decoded) && !array_is_list($decoded) ? $decoded : null;
    }

    private function decodeBase64Url(string $encoded): ?string
    {
        if ('' === $encoded || 1 !== preg_match('/^[A-Za-z0-9_-]+$/D', $encoded)) {
            return null;
        }

        $remainder = mb_strlen($encoded, '8bit') % 4;
        if (1 === $remainder) {
            return null;
        }

        $base64 = strtr($encoded, '-_', '+/');
        if (0 !== $remainder) {
            $base64 .= str_repeat('=', 4 - $remainder);
        }

        $decoded = base64_decode($base64, true);
        if (false === $decoded || $encoded !== $this->encodeBase64Url($decoded)) {
            return null;
        }

        return $decoded;
    }

    private function encodeBase64Url(string $decoded): string
    {
        return mb_rtrim(strtr(base64_encode($decoded), '+/', '-_'), '=');
    }
}
