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

use CpCreation\VitiCore\Commerce\Catalog\Api\Security\CatalogHubJwtVerifier;
use CpCreation\VitiCore\Commerce\Catalog\Api\Security\CommerceAccessTokenHandler;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogConfigurationProvider;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogConfigurationSnapshot;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogHubConfiguration;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogIntegrationConfiguration;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class CatalogHubJwtVerifierTest extends TestCase
{
    private const string INSTANCE_ID = '7ad9e145-910f-4de4-9cec-5f832494adef';
    private const string KEY_ID = 'hub-main';
    private const string NOW = '2026-07-22T12:00:00+00:00';

    private static string $privateKey;
    private static string $publicKey;
    private static string $publicKeyPath;

    public static function setUpBeforeClass(): void
    {
        $key = openssl_pkey_new([
            'private_key_bits' => 2048,
            'private_key_type' => \OPENSSL_KEYTYPE_RSA,
        ]);
        self::assertNotFalse($key);
        self::assertTrue(openssl_pkey_export($key, $privateKey));
        $details = openssl_pkey_get_details($key);
        self::assertIsArray($details);

        $path = tempnam(sys_get_temp_dir(), 'viticore-hub-jwt-');
        self::assertNotFalse($path);
        self::assertNotFalse(file_put_contents($path, $details['key']));

        self::$privateKey = $privateKey;
        self::$publicKey = $details['key'];
        self::$publicKeyPath = $path;
    }

    public static function tearDownAfterClass(): void
    {
        if (isset(self::$publicKeyPath) && is_file(self::$publicKeyPath)) {
            unlink(self::$publicKeyPath);
        }
    }

    public function testItAcceptsAValidShortRs256Token(): void
    {
        $verifier = $this->verifier();
        $token = $this->token($this->claims());

        self::assertTrue($verifier->isValid($token, $this->now()));

        $handler = new CommerceAccessTokenHandler($this->catalogConfiguration(), $verifier);
        $actualTimestamp = new \DateTimeImmutable()->getTimestamp();
        $token = $this->token($this->claims([
            'iat' => $actualTimestamp,
            'nbf' => $actualTimestamp,
            'exp' => $actualTimestamp + 300,
        ]));
        self::assertSame(
            'commerce-sync:' . self::INSTANCE_ID,
            $handler->getUserBadgeFrom($token)->getUserIdentifier(),
        );
    }

    public function testItAcceptsTheExpectedAudienceInsideAStandardAudienceList(): void
    {
        self::assertTrue($this->verifier()->isValid(
            $this->token($this->claims(['aud' => ['another-audience', 'serenity:catalog']])),
            $this->now(),
        ));
    }

    public function testItVerifiesWithThePublicPemStoredInPersistentConfiguration(): void
    {
        $provider = $this->createStub(CatalogConfigurationProvider::class);
        $provider->method('current')->willReturn(new CatalogConfigurationSnapshot(
            enabled: true,
            hubUrl: 'https://hub.example',
            instanceId: self::INSTANCE_ID,
            currency: 'EUR',
            authAudience: 'serenity:catalog',
            enrollmentToken: 'long-lived-enrollment-token',
            webhookSecret: '0123456789abcdef0123456789abcdef',
            webhookUrl: null,
            jwtIssuer: 'serenity-commerce-hub',
            jwtKeyId: self::KEY_ID,
            jwtPublicKeyPem: self::$publicKey,
        ));
        $catalog = new CatalogIntegrationConfiguration(false, '', '', 'USD', $provider);
        $hub = new CatalogHubConfiguration('', '', '', '', '', '', false, $provider);

        self::assertTrue(new CatalogHubJwtVerifier($hub, $catalog)->isValid(
            $this->token($this->claims()),
            $this->now(),
        ));
    }

    public function testItRejectsATamperedSignature(): void
    {
        $token = $this->token($this->claims());
        [$header, $payload, $signature] = explode('.', $token);
        $signature[0] = 'A' === $signature[0] ? 'B' : 'A';

        self::assertFalse($this->verifier()->isValid(
            $header . '.' . $payload . '.' . $signature,
            $this->now(),
        ));
    }

    #[DataProvider('invalidHeaderProvider')]
    public function testItRejectsAnUnexpectedProtectedHeader(array $header): void
    {
        self::assertFalse($this->verifier()->isValid(
            $this->token($this->claims(), $header),
            $this->now(),
        ));
    }

    public static function invalidHeaderProvider(): iterable
    {
        yield 'algorithm confusion' => [['alg' => 'HS256', 'kid' => self::KEY_ID]];
        yield 'wrong key id' => [['alg' => 'RS256', 'kid' => 'rotated-key']];
        yield 'unsupported critical header' => [[
            'alg' => 'RS256',
            'kid' => self::KEY_ID,
            'crit' => ['custom'],
            'custom' => true,
        ]];
    }

    #[DataProvider('invalidClaimsProvider')]
    public function testItRejectsInvalidIdentityAndTimeClaims(array $overrides): void
    {
        self::assertFalse($this->verifier()->isValid(
            $this->token($this->claims($overrides)),
            $this->now(),
        ));
    }

    public static function invalidClaimsProvider(): iterable
    {
        $now = new \DateTimeImmutable(self::NOW);
        $timestamp = $now->getTimestamp();

        yield 'wrong issuer' => [['iss' => 'another-hub']];
        yield 'enrollment subject cannot read catalog' => [['sub' => 'catalog-enrollment']];
        yield 'wrong audience' => [['aud' => 'serenity:enrollment']];
        yield 'wrong instance' => [['instance_id' => 'e1f7fbf7-b430-44cc-91a6-29e0503be011']];
        yield 'missing jti' => [['jti' => null]];
        yield 'string timestamp' => [['exp' => (string) ($timestamp + 300)]];
        yield 'expired beyond leeway' => [['exp' => $timestamp - 31]];
        yield 'not active beyond leeway' => [['nbf' => $timestamp + 31]];
        yield 'issued in future beyond leeway' => [['iat' => $timestamp + 31]];
        yield 'lifetime longer than ten minutes' => [[
            'iat' => $timestamp,
            'exp' => $timestamp + 601,
        ]];
    }

    public function testThirtySecondClockLeewayIsBounded(): void
    {
        $timestamp = $this->now()->getTimestamp();
        self::assertTrue($this->verifier()->isValid($this->token($this->claims([
            'iat' => $timestamp + 30,
            'nbf' => $timestamp + 30,
            'exp' => $timestamp + 300,
        ])), $this->now()));
        self::assertFalse($this->verifier()->isValid($this->token($this->claims([
            'iat' => $timestamp + 31,
            'nbf' => $timestamp + 31,
            'exp' => $timestamp + 300,
        ])), $this->now()));
    }

    private function verifier(): CatalogHubJwtVerifier
    {
        return new CatalogHubJwtVerifier(
            new CatalogHubConfiguration(
                'https://hub.example',
                'long-lived-enrollment-token',
                self::$publicKeyPath,
                'serenity-commerce-hub',
                'serenity:catalog',
                self::KEY_ID,
                false,
            ),
            $this->catalogConfiguration(),
        );
    }

    private function catalogConfiguration(): CatalogIntegrationConfiguration
    {
        return new CatalogIntegrationConfiguration(true, self::INSTANCE_ID, '', 'EUR');
    }

    /**
     * @param array<string, mixed> $overrides
     *
     * @return array<string, mixed>
     */
    private function claims(array $overrides = []): array
    {
        $timestamp = $this->now()->getTimestamp();

        return array_replace([
            'iss' => 'serenity-commerce-hub',
            'sub' => 'catalog-sync',
            'aud' => 'serenity:catalog',
            'instance_id' => self::INSTANCE_ID,
            'iat' => $timestamp,
            'nbf' => $timestamp,
            'exp' => $timestamp + 300,
            'jti' => '019f8a68-1cbd-7504-9c1e-9c6d7fc8268d',
        ], $overrides);
    }

    /**
     * @param array<string, mixed> $claims
     * @param array<string, mixed> $header
     */
    private function token(array $claims, array $header = ['alg' => 'RS256', 'kid' => self::KEY_ID]): string
    {
        $encodedHeader = $this->base64Url(json_encode($header, \JSON_THROW_ON_ERROR));
        $encodedClaims = $this->base64Url(json_encode($claims, \JSON_THROW_ON_ERROR));
        $signingInput = $encodedHeader . '.' . $encodedClaims;
        self::assertTrue(openssl_sign($signingInput, $signature, self::$privateKey, \OPENSSL_ALGO_SHA256));

        return $signingInput . '.' . $this->base64Url($signature);
    }

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

    private function now(): \DateTimeImmutable
    {
        return new \DateTimeImmutable(self::NOW);
    }
}
