<?php

declare(strict_types=1);

namespace CpCreation\VitiCore\Tests\Commerce\Catalog\Authorization;

use CpCreation\VitiCore\Commerce\Catalog\Authorization\CatalogEnrollmentJwtValidator;
use CpCreation\VitiCore\Commerce\Catalog\Authorization\CatalogHubAuthorizationException;
use CpCreation\VitiCore\Commerce\Catalog\Authorization\CatalogHubAuthorizationFlow;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogSecretCipher;
use CpCreation\VitiCore\Commerce\Catalog\Model\CatalogConfiguration;
use CpCreation\VitiCore\Commerce\Catalog\Model\CatalogConfigurationStatus;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class CatalogHubAuthorizationFlowTest extends TestCase
{
    private \OpenSSLAsymmetricKey $privateKey;
    private string $publicKeyPem;

    protected function setUp(): void
    {
        $key = openssl_pkey_new([
            'private_key_bits' => 2048,
            'private_key_type' => \OPENSSL_KEYTYPE_RSA,
        ]);
        self::assertInstanceOf(\OpenSSLAsymmetricKey::class, $key);
        $details = openssl_pkey_get_details($key);
        self::assertIsArray($details);
        self::assertIsString($details['key'] ?? null);
        $this->privateKey = $key;
        $this->publicKeyPem = $details['key'];
    }

    public function testAuthorizationUsesStateAndPkceThenStoresTheVerifiedToken(): void
    {
        $configuration = new CatalogConfiguration();
        $configuration->setHubUrl('http://127.0.0.1:8085');
        $cipher = new CatalogSecretCipher('a-stable-application-secret');
        $configuration->prepareActivation($cipher);
        $callbackUrl = 'https://boutique.example/admincp/configuration/commerce/callback';
        $now = time();
        $jti = '019d2b76-2277-77c5-bd5b-e11c88660ef7';
        $expiresAt = $now + 31_536_000;
        $token = $this->token($configuration, $jti, $now, $expiresAt);

        $client = new MockHttpClient(function (string $method, string $url, array $options) use ($configuration, $callbackUrl, $expiresAt, $jti, $token): MockResponse {
            self::assertSame('POST', $method);
            self::assertStringEndsWith('/api/integrations/serenity/token', $url);
            $request = json_decode($options['body'], true, flags: \JSON_THROW_ON_ERROR);
            self::assertSame('authorization_code', $request['grant_type']);
            self::assertSame($callbackUrl, $request['redirect_uri']);
            self::assertSame($configuration->getInstanceId()->toString(), $request['instance_id']);
            self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]{43,128}$/D', $request['code_verifier']);

            return new MockResponse(json_encode([
                'token_type' => 'Bearer',
                'access_token' => $token,
                'expires_in' => 31_536_000,
                'expires_at' => $expiresAt,
                'scope' => 'catalog:enroll',
                'instance_id' => $configuration->getInstanceId()->toString(),
                'jti' => $jti,
                'jwt_issuer' => 'serenity-commerce-hub',
                'jwt_audience' => $configuration->getAuthAudience(),
                'jwt_key_id' => 'hub-main',
                'jwt_algorithm' => 'RS256',
                'jwt_public_key' => $this->publicKeyPem,
            ], \JSON_THROW_ON_ERROR));
        });
        $flow = new CatalogHubAuthorizationFlow(
            $client,
            $cipher,
            new CatalogEnrollmentJwtValidator(),
            true,
        );

        $authorizationUrl = $flow->begin(
            $configuration,
            $callbackUrl,
            'https://boutique.example',
            'Champagne Exemple',
        );
        parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query);
        self::assertSame('code', $query['response_type']);
        self::assertSame('S256', $query['code_challenge_method']);
        self::assertTrue($configuration->matchesAuthorizationState($query['state']));

        $flow->complete(
            $configuration,
            str_repeat('A', 43),
            $query['state'],
            $callbackUrl,
        );

        self::assertSame($token, $configuration->revealEnrollmentToken($cipher));
        self::assertSame('hub-main', $configuration->getJwtKeyId());
        self::assertSame(CatalogConfigurationStatus::PENDING_ENROLLMENT, $configuration->getStatus());
        self::assertFalse($configuration->matchesAuthorizationState($query['state']));
    }

    public function testItRejectsAnUnknownAuthorizationStateBeforeCallingTheHub(): void
    {
        $configuration = new CatalogConfiguration();
        $configuration->setHubUrl('http://127.0.0.1:8085');
        $cipher = new CatalogSecretCipher('a-stable-application-secret');
        $configuration->prepareActivation($cipher);
        $client = new MockHttpClient();
        $flow = new CatalogHubAuthorizationFlow($client, $cipher, new CatalogEnrollmentJwtValidator(), true);
        $flow->begin(
            $configuration,
            'https://boutique.example/admincp/configuration/commerce/callback',
            'https://boutique.example',
            'Champagne Exemple',
        );

        $this->expectException(CatalogHubAuthorizationException::class);
        try {
            $flow->complete(
                $configuration,
                str_repeat('A', 43),
                'another-state',
                'https://boutique.example/admincp/configuration/commerce/callback',
            );
        } finally {
            self::assertSame(0, $client->getRequestsCount());
        }
    }

    public function testAuthorizationUsesASerenityFallbackName(): void
    {
        $configuration = new CatalogConfiguration();
        $configuration->setHubUrl('http://127.0.0.1:8085');
        $cipher = new CatalogSecretCipher('a-stable-application-secret');
        $configuration->prepareActivation($cipher);
        $flow = new CatalogHubAuthorizationFlow(
            new MockHttpClient(),
            $cipher,
            new CatalogEnrollmentJwtValidator(),
            true,
        );

        $authorizationUrl = $flow->begin(
            $configuration,
            'https://boutique.example/admincp/configuration/commerce/callback',
            'https://boutique.example',
            '   ',
        );
        parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query);

        self::assertSame('Serenity ' . $configuration->getInstanceId()->toString(), $query['name']);
    }

    public function testItRejectsANonNumericTokenExpirationReturnedByTheHub(): void
    {
        $configuration = new CatalogConfiguration();
        $configuration->setHubUrl('http://127.0.0.1:8085');
        $cipher = new CatalogSecretCipher('a-stable-application-secret');
        $configuration->prepareActivation($cipher);
        $client = new MockHttpClient(new MockResponse(json_encode([
            'token_type' => 'Bearer',
            'access_token' => 'header.payload.signature',
            'expires_in' => 31_536_000,
            'expires_at' => '2030-01-01T00:00:00+00:00',
            'scope' => 'catalog:enroll',
            'instance_id' => $configuration->getInstanceId()->toString(),
            'jti' => 'token-jti',
            'jwt_issuer' => 'serenity-commerce-hub',
            'jwt_audience' => $configuration->getAuthAudience(),
            'jwt_key_id' => 'hub-main',
            'jwt_algorithm' => 'RS256',
            'jwt_public_key' => $this->publicKeyPem,
        ], \JSON_THROW_ON_ERROR)));
        $flow = new CatalogHubAuthorizationFlow($client, $cipher, new CatalogEnrollmentJwtValidator(), true);
        $callbackUrl = 'https://boutique.example/admincp/configuration/commerce/callback';
        $authorizationUrl = $flow->begin($configuration, $callbackUrl, 'https://boutique.example', 'Champagne Exemple');
        parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query);

        $this->expectException(CatalogHubAuthorizationException::class);
        $flow->complete($configuration, str_repeat('A', 43), $query['state'], $callbackUrl);
    }

    public function testItStopsStreamingAnOversizedTokenResponse(): void
    {
        $configuration = new CatalogConfiguration();
        $configuration->setHubUrl('http://127.0.0.1:8085');
        $cipher = new CatalogSecretCipher('a-stable-application-secret');
        $configuration->prepareActivation($cipher);
        $client = new MockHttpClient(new MockResponse(str_repeat('x', 32_769)));
        $flow = new CatalogHubAuthorizationFlow($client, $cipher, new CatalogEnrollmentJwtValidator(), true);
        $callbackUrl = 'https://boutique.example/admincp/configuration/commerce/callback';
        $authorizationUrl = $flow->begin($configuration, $callbackUrl, 'https://boutique.example', 'Champagne Exemple');
        parse_str((string) parse_url($authorizationUrl, \PHP_URL_QUERY), $query);

        $this->expectException(CatalogHubAuthorizationException::class);
        $this->expectExceptionMessage('trop volumineuse');
        $flow->complete($configuration, str_repeat('A', 43), $query['state'], $callbackUrl);
    }

    private function token(CatalogConfiguration $configuration, string $jti, int $issuedAt, int $expiresAt): string
    {
        $header = $this->encode(['alg' => 'RS256', 'typ' => 'JWT', 'kid' => 'hub-main']);
        $claims = $this->encode([
            'iss' => 'serenity-commerce-hub',
            'sub' => 'catalog-enrollment',
            'aud' => 'serenity-commerce-hub',
            'scope' => 'catalog:enroll',
            'instance_id' => $configuration->getInstanceId()->toString(),
            'iat' => $issuedAt,
            'nbf' => $issuedAt - 5,
            'exp' => $expiresAt,
            'jti' => $jti,
        ]);
        $unsigned = $header . '.' . $claims;
        self::assertTrue(openssl_sign($unsigned, $signature, $this->privateKey, \OPENSSL_ALGO_SHA256));

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

    /** @param array<string, mixed> $value */
    private function encode(array $value): string
    {
        return $this->base64Url(json_encode($value, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES));
    }

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