<?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\Enrollment;

use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogHubConfiguration;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final readonly class HttpCatalogHubRegistrar implements CatalogHubRegistrar
{
    private const array RETRYABLE_STATUS_CODES = [408, 425, 429];
    private const int MAXIMUM_RESPONSE_LENGTH = 32_768;

    public function __construct(
        private CatalogHubConfiguration $configuration,
        private HttpClientInterface $httpClient,
    ) {}

    public function register(CatalogEnrollmentPayload $payload): CatalogEnrollmentResponse
    {
        try {
            $response = $this->httpClient->request('PUT', $this->endpoint($payload->instanceId), [
                'auth_bearer' => $this->configuration->enrollmentToken(),
                'headers' => [
                    'Accept' => 'application/json',
                    'Idempotency-Key' => $payload->instanceId,
                ],
                'json' => $payload->toArray(),
                'timeout' => 10,
                'max_duration' => 15,
                'max_redirects' => 0,
            ]);
            $statusCode = $response->getStatusCode();
            $body = $this->readBody($response);
        } catch (TransportExceptionInterface $exception) {
            throw new CatalogHubRegistrationException(
                'Erreur réseau pendant l’enrôlement du catalogue dans le hub.',
                retryable: true,
                previous: $exception,
            );
        }

        $decoded = $this->decode($body);
        if (!\in_array($statusCode, [200, 201], true)) {
            throw new CatalogHubRegistrationException(
                \sprintf('Le hub Commerce a répondu avec le statut HTTP %d.', $statusCode),
                retryable: \in_array($statusCode, self::RETRYABLE_STATUS_CODES, true) || $statusCode >= 500,
                statusCode: $statusCode,
                response: $decoded,
            );
        }

        if (!\is_array($decoded)) {
            throw new CatalogHubRegistrationException(
                'Le hub Commerce a renvoyé une réponse JSON invalide.',
                retryable: false,
                statusCode: $statusCode,
            );
        }

        $instanceId = $this->requiredString($decoded, 'instance_id', 36);
        $sourceId = $this->requiredString($decoded, 'source_id', 255);
        $webhookUrl = $this->requiredString($decoded, 'webhook_url', 2048);
        $hubStatus = $this->requiredString($decoded, 'status', 64);
        $jwtIssuer = $this->requiredString($decoded, 'jwt_issuer', 255);
        $jwtAudience = $this->requiredString($decoded, 'jwt_audience', 255);
        $jwtKeyId = $this->requiredString($decoded, 'jwt_key_id', 128);
        $jwtAlgorithm = $this->requiredString($decoded, 'jwt_algorithm', 16);
        $jwtPublicKeyPem = $this->requiredString($decoded, 'jwt_public_key', 16_384);

        if ($payload->instanceId !== $instanceId) {
            throw new CatalogHubRegistrationException(
                'Le hub Commerce a renvoyé un identifiant d’instance différent de la demande.',
                retryable: false,
                statusCode: $statusCode,
                response: $decoded,
            );
        }

        if ('RS256' !== $jwtAlgorithm
            || !hash_equals($payload->authAudience, $jwtAudience)
            || !$this->isStrongRsaPublicKey($jwtPublicKeyPem)
        ) {
            throw new CatalogHubRegistrationException(
                'Le hub Commerce a renvoyé une configuration JWT invalide.',
                retryable: false,
                statusCode: $statusCode,
                response: $decoded,
            );
        }

        if (!CatalogHubConfiguration::isAllowedAbsoluteUrl(
            $webhookUrl,
            $this->configuration->allowsInsecureTransport(),
        )) {
            throw new CatalogHubRegistrationException(
                'Le hub Commerce a renvoyé une URL de webhook invalide.',
                retryable: false,
                statusCode: $statusCode,
                response: $decoded,
            );
        }

        return new CatalogEnrollmentResponse(
            $instanceId,
            $sourceId,
            $webhookUrl,
            $hubStatus,
            $jwtIssuer,
            $jwtAudience,
            $jwtKeyId,
            $jwtPublicKeyPem,
            $decoded,
        );
    }

    private function endpoint(string $instanceId): string
    {
        return mb_rtrim($this->configuration->hubUrl(), '/')
            . '/api/integrations/serenity/'
            . rawurlencode($instanceId);
    }

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

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

        return $body;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function decode(string $body): ?array
    {
        if ('' === mb_trim($body)) {
            return null;
        }

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

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

    /**
     * @param array<string, mixed> $data
     */
    private function requiredString(array $data, string $key, int $maximumLength): string
    {
        $value = $data[$key] ?? null;
        if (!\is_string($value)
            || '' === mb_trim($value)
            || mb_strlen($value, '8bit') > $maximumLength
        ) {
            throw new CatalogHubRegistrationException(
                \sprintf('La réponse du hub Commerce ne contient pas le champ requis « %s ».', $key),
                retryable: false,
                response: $data,
            );
        }

        return mb_trim($value);
    }

    private function isStrongRsaPublicKey(string $publicKeyPem): bool
    {
        if (mb_strlen($publicKeyPem, '8bit') > 16_384) {
            return false;
        }

        $publicKey = openssl_pkey_get_public($publicKeyPem);
        if (!$publicKey instanceof \OpenSSLAsymmetricKey) {
            return false;
        }

        $details = openssl_pkey_get_details($publicKey);

        return \is_array($details)
            && \OPENSSL_KEYTYPE_RSA === ($details['type'] ?? null)
            && \is_int($details['bits'] ?? null)
            && $details['bits'] >= 2048;
    }
}
