<?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 CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogHubConfiguration;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogSecretCipher;
use CpCreation\VitiCore\Commerce\Catalog\Model\CatalogConfiguration;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

/** Cache la génération PKCE, l’échange serveur et la validation du JWT derrière deux opérations. */
final readonly class CatalogHubAuthorizationFlow
{
    private const int AUTHORIZATION_TTL_SECONDS = 600;
    private const int MAXIMUM_RESPONSE_LENGTH = 32_768;
    private const int MAXIMUM_TOKEN_TTL_SECONDS = 315_360_000;

    public function __construct(
        private HttpClientInterface $httpClient,
        private CatalogSecretCipher $cipher,
        private CatalogEnrollmentJwtValidator $jwtValidator,
        private bool $allowInsecure,
    ) {}

    public function begin(
        CatalogConfiguration $configuration,
        string $callbackUrl,
        string $baseUrl,
        string $name,
    ): string {
        $hubUrl = $this->allowedUrl($configuration->getHubUrl());
        $callbackUrl = $this->allowedUrl($callbackUrl);
        $baseUrl = $this->allowedUrl($baseUrl);
        $name = mb_substr(mb_trim($name), 0, 160);
        if ('' === $name) {
            $name = 'Serenity ' . $configuration->getInstanceId()->toString();
        }

        $state = self::base64UrlEncode(random_bytes(32));
        $verifier = self::base64UrlEncode(random_bytes(64));
        $challenge = self::base64UrlEncode(hash('sha256', $verifier, true));
        $configuration->beginAuthorization(
            $state,
            $verifier,
            new \DateTimeImmutable('+' . self::AUTHORIZATION_TTL_SECONDS . ' seconds', new \DateTimeZone('UTC')),
            $this->cipher,
        );

        return $hubUrl . '/admin/integrations/serenity/authorize?' . http_build_query([
            'response_type' => 'code',
            'instance_id' => $configuration->getInstanceId()->toString(),
            'name' => $name,
            'base_url' => $baseUrl,
            'redirect_uri' => $callbackUrl,
            'jwt_audience' => $configuration->getAuthAudience(),
            'code_challenge' => $challenge,
            'code_challenge_method' => 'S256',
            'state' => $state,
        ], '', '&', \PHP_QUERY_RFC3986);
    }

    public function complete(
        CatalogConfiguration $configuration,
        #[\SensitiveParameter]
        string $code,
        #[\SensitiveParameter]
        string $state,
        string $callbackUrl,
    ): void {
        if (!$configuration->matchesAuthorizationState($state)) {
            throw new CatalogHubAuthorizationException('La réponse d’autorisation du hub a expiré ou ne correspond pas à la demande.');
        }
        $verifier = $configuration->revealAuthorizationPkceVerifier($this->cipher);
        if (null === $verifier || 1 !== preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $code)) {
            throw new CatalogHubAuthorizationException('Le code d’autorisation du hub est invalide ou expiré.');
        }

        $callbackUrl = $this->allowedUrl($callbackUrl);
        $hubUrl = $this->serverHubUrl($this->allowedUrl($configuration->getHubUrl()));
        try {
            $response = $this->httpClient->request('POST', $hubUrl . '/api/integrations/serenity/token', [
                'headers' => ['Accept' => 'application/json'],
                'json' => [
                    'grant_type' => 'authorization_code',
                    'code' => $code,
                    'code_verifier' => $verifier,
                    'redirect_uri' => $callbackUrl,
                    'instance_id' => $configuration->getInstanceId()->toString(),
                ],
                'max_redirects' => 0,
                'timeout' => 10,
                'max_duration' => 15,
            ]);
            $statusCode = $response->getStatusCode();
            $body = $this->readBody($response);
        } catch (TransportExceptionInterface $exception) {
            throw new CatalogHubAuthorizationException('Le hub Commerce est temporairement injoignable.', previous: $exception);
        }

        try {
            $payload = json_decode($body, true, 32, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new CatalogHubAuthorizationException('Le hub Commerce a renvoyé une réponse d’autorisation invalide.', previous: $exception);
        }
        if (!\in_array($statusCode, [200, 201], true) || !\is_array($payload) || array_is_list($payload)) {
            throw new CatalogHubAuthorizationException('Le hub Commerce a refusé l’autorisation.');
        }

        $token = $this->requiredString($payload, 'access_token', 16_384);
        $instanceId = $this->requiredString($payload, 'instance_id', 36);
        $issuer = $this->requiredString($payload, 'jwt_issuer', 255);
        $audience = $this->requiredString($payload, 'jwt_audience', 255);
        $keyId = $this->requiredString($payload, 'jwt_key_id', 128);
        $algorithm = $this->requiredString($payload, 'jwt_algorithm', 16);
        $publicKeyPem = $this->requiredString($payload, 'jwt_public_key', 16_384);
        $jti = $this->requiredString($payload, 'jti', 255);
        $expiresIn = $this->requiredInteger($payload, 'expires_in');
        $expiresAt = $this->requiredInteger($payload, 'expires_at');
        if ('Bearer' !== ($payload['token_type'] ?? null)
            || 'catalog:enroll' !== ($payload['scope'] ?? null)
            || 'RS256' !== $algorithm
            || $configuration->getInstanceId()->toString() !== $instanceId
            || $configuration->getAuthAudience() !== $audience
        ) {
            throw new CatalogHubAuthorizationException('Les paramètres d’autorisation retournés par le hub sont invalides.');
        }

        $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
        $expiration = (new \DateTimeImmutable('@' . $expiresAt))->setTimezone(new \DateTimeZone('UTC'));
        if ($expiresIn > self::MAXIMUM_TOKEN_TTL_SECONDS
            || abs(($expiration->getTimestamp() - $now->getTimestamp()) - $expiresIn) > 60
        ) {
            throw new CatalogHubAuthorizationException('La durée du JWT d’enrôlement retournée par le hub est invalide.');
        }
        $this->jwtValidator->validate(
            $token,
            $publicKeyPem,
            $issuer,
            $keyId,
            $instanceId,
            $jti,
            $expiration,
            $now,
        );
        $configuration->applyHubAuthorization(
            $token,
            $expiration,
            $jti,
            $publicKeyPem,
            $issuer,
            $keyId,
            $this->cipher,
        );
    }

    private function readBody(ResponseInterface $response): string
    {
        $body = '';
        foreach ($this->httpClient->stream($response) as $chunk) {
            if ($chunk->isTimeout()) {
                throw new CatalogHubAuthorizationException('Le délai de lecture de la réponse d’autorisation du hub est dépassé.');
            }

            $body .= $chunk->getContent();
            if (mb_strlen($body, '8bit') > self::MAXIMUM_RESPONSE_LENGTH) {
                throw new CatalogHubAuthorizationException('La réponse d’autorisation du hub est trop volumineuse.');
            }
        }

        return $body;
    }

    private function allowedUrl(?string $url): string
    {
        $url = null === $url ? '' : mb_rtrim(mb_trim($url), '/');
        if (!CatalogHubConfiguration::isAllowedAbsoluteUrl($url, $this->allowInsecure)) {
            throw new CatalogHubAuthorizationException('L’URL utilisée pour l’autorisation Commerce est invalide.');
        }

        return $url;
    }

    private function serverHubUrl(string $url): string
    {
        if (!$this->allowInsecure || !is_file('/.dockerenv')) {
            return $url;
        }
        $parts = parse_url($url);
        $host = mb_strtolower((string) ($parts['host'] ?? ''));
        if (!\in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
            return $url;
        }

        $scheme = (string) ($parts['scheme'] ?? 'http');
        $port = isset($parts['port']) ? ':' . $parts['port'] : '';
        $path = (string) ($parts['path'] ?? '');

        return $scheme . '://host.docker.internal' . $port . $path;
    }

    /** @param array<string, mixed> $payload */
    private function requiredString(array $payload, string $key, int $maximumLength): string
    {
        $value = $payload[$key] ?? null;
        if (!\is_string($value) || '' === mb_trim($value) || mb_strlen($value, '8bit') > $maximumLength) {
            throw new CatalogHubAuthorizationException('La réponse d’autorisation du hub est incomplète.');
        }

        return mb_trim($value);
    }

    /** @param array<string, mixed> $payload */
    private function requiredInteger(array $payload, string $key): int
    {
        $value = $payload[$key] ?? null;
        if (!\is_int($value) || $value <= 0) {
            throw new CatalogHubAuthorizationException('La réponse d’autorisation du hub est incomplète.');
        }

        return $value;
    }

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