<?php

declare(strict_types=1);

namespace CpCreation\VitiCore\SocialPublishing;

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

final class HttpSocialPublishingHub implements SocialPublishingHub
{
    private const int MAXIMUM_RESPONSE_LENGTH = 1_048_576;

    /** @var array<string, string> */
    private array $accessTokens = [];

    public function __construct(
        private readonly CatalogConfigurationProvider $configurationProvider,
        private readonly HttpClientInterface $httpClient,
    ) {}

    public function isAvailable(): bool
    {
        $configuration = $this->configurationProvider->current();

        return $configuration instanceof CatalogConfigurationSnapshot
            && null !== $configuration->hubUrl
            && null !== $configuration->enrollmentToken
            && '' !== trim($configuration->instanceId);
    }

    public function startConnection(string $provider, string $returnUrl): string
    {
        $payload = $this->requestJson('POST', '/connection-intents', 'social:write', [
            'json' => [
                'provider' => $provider,
                'return_url' => $returnUrl,
            ],
        ]);

        return $this->requiredString($payload, 'authorization_url', 2048);
    }

    public function channels(): array
    {
        $payload = $this->requestJson('GET', '/channels', 'social:read');
        $channels = [];
        foreach ($payload['data'] ?? [] as $channel) {
            if (\is_array($channel)) {
                $channels[] = $this->channel($channel);
            }
        }

        return $channels;
    }

    public function publications(): array
    {
        $payload = $this->requestJson('GET', '/publications', 'social:read');
        $publications = [];
        foreach ($payload['data'] ?? [] as $publication) {
            if (\is_array($publication)) {
                $publications[] = $this->publication($publication);
            }
        }

        return $publications;
    }

    public function publish(PublicationRequest $request): Publication
    {
        $payload = $this->requestJson('POST', '/publications', 'social:write', [
            'headers' => ['Idempotency-Key' => $request->id],
            'json' => [
                'publication_id' => $request->id,
                'message' => $request->message,
                'media_url' => $request->mediaUrl,
                'scheduled_at' => $request->scheduledAt->format(\DateTimeInterface::ATOM),
                'channel_ids' => $request->channelIds,
            ],
        ]);

        return $this->publication($payload);
    }

    public function disconnect(string $channelId): void
    {
        $this->requestJson('DELETE', '/channels/' . rawurlencode($channelId), 'social:write', allowEmpty: true);
    }

    public function cancel(string $publicationId): Publication
    {
        return $this->publication($this->requestJson(
            'POST',
            '/publications/' . rawurlencode($publicationId) . '/cancel',
            'social:write',
        ));
    }

    /**
     * @param array<string, mixed> $options
     *
     * @return array<string, mixed>
     */
    private function requestJson(
        string $method,
        string $path,
        string $scope,
        array $options = [],
        bool $allowEmpty = false,
    ): array {
        $configuration = $this->configuration();
        $options['auth_bearer'] = $this->accessToken($scope, $configuration);
        $options['headers'] = array_merge(
            ['Accept' => 'application/json'],
            \is_array($options['headers'] ?? null) ? $options['headers'] : [],
        );
        $options += [
            'timeout' => 10,
            'max_duration' => 20,
            'max_redirects' => 0,
        ];

        try {
            $response = $this->httpClient->request(
                $method,
                rtrim((string) $configuration->hubUrl, '/')
                    . '/api/social/instances/'
                    . rawurlencode($configuration->instanceId)
                    . $path,
                $options,
            );
            $status = $response->getStatusCode();
            $body = $this->readBody($response);
        } catch (TransportExceptionInterface $exception) {
            throw new SocialPublishingHubException('Le hub de publication est temporairement injoignable.', previous: $exception);
        }

        if ($allowEmpty && 204 === $status) {
            return [];
        }
        $payload = $this->decode($body);
        if ($status < 200 || $status >= 300) {
            $error = \is_array($payload) && \is_string($payload['error'] ?? null)
                ? $payload['error']
                : 'social_hub_error';
            throw new SocialPublishingHubException($error, $status);
        }
        if (!\is_array($payload)) {
            throw new SocialPublishingHubException('Le hub de publication a renvoyé une réponse invalide.');
        }

        return $payload;
    }

    private function accessToken(string $scope, CatalogConfigurationSnapshot $configuration): string
    {
        if (isset($this->accessTokens[$scope])) {
            return $this->accessTokens[$scope];
        }

        try {
            $response = $this->httpClient->request(
                'POST',
                rtrim((string) $configuration->hubUrl, '/')
                    . '/api/integrations/serenity/'
                    . rawurlencode($configuration->instanceId)
                    . '/tokens',
                [
                    'auth_bearer' => $configuration->enrollmentToken,
                    'headers' => ['Accept' => 'application/json'],
                    'json' => ['scopes' => [$scope]],
                    'timeout' => 10,
                    'max_duration' => 15,
                    'max_redirects' => 0,
                ],
            );
            $status = $response->getStatusCode();
            $payload = $this->decode($this->readBody($response));
        } catch (TransportExceptionInterface $exception) {
            throw new SocialPublishingHubException('Impossible d’obtenir un jeton social auprès du hub.', previous: $exception);
        }
        if (200 !== $status || !\is_array($payload)) {
            throw new SocialPublishingHubException('Le hub a refusé le jeton social de cette instance.');
        }

        return $this->accessTokens[$scope] = $this->requiredString($payload, 'access_token', 16_384);
    }

    private function configuration(): CatalogConfigurationSnapshot
    {
        $configuration = $this->configurationProvider->current();
        if (!$configuration instanceof CatalogConfigurationSnapshot
            || null === $configuration->hubUrl
            || null === $configuration->enrollmentToken
        ) {
            throw new SocialPublishingHubException('La connexion au hub Commerce doit être activée avant les réseaux sociaux.');
        }

        return $configuration;
    }

    private function readBody(ResponseInterface $response): string
    {
        $body = '';
        foreach ($this->httpClient->stream($response) as $chunk) {
            if ($chunk->isTimeout()) {
                throw new SocialPublishingHubException('Le délai de réponse du hub est dépassé.');
            }
            $body .= $chunk->getContent();
            if (mb_strlen($body, '8bit') > self::MAXIMUM_RESPONSE_LENGTH) {
                throw new SocialPublishingHubException('La réponse du hub est trop volumineuse.');
            }
        }

        return $body;
    }

    /** @return array<string, mixed>|null */
    private function decode(string $body): ?array
    {
        if ('' === trim($body)) {
            return null;
        }
        try {
            $payload = json_decode($body, true, 64, \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }

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

    /** @param array<string, mixed> $payload */
    private function channel(array $payload): Channel
    {
        return new Channel(
            $this->requiredString($payload, 'id', 255),
            $this->requiredString($payload, 'provider', 40),
            $this->requiredString($payload, 'external_id', 255),
            $this->requiredString($payload, 'name', 255),
            $this->requiredString($payload, 'type', 40),
            \is_array($payload['capabilities'] ?? null) ? $payload['capabilities'] : [],
        );
    }

    /** @param array<string, mixed> $payload */
    private function publication(array $payload): Publication
    {
        try {
            $scheduledAt = new \DateTimeImmutable($this->requiredString($payload, 'scheduled_at', 64));
            $publishedAt = \is_string($payload['published_at'] ?? null)
                ? new \DateTimeImmutable($payload['published_at'])
                : null;
        } catch (\Exception $exception) {
            throw new SocialPublishingHubException('Les dates de publication retournées par le hub sont invalides.', previous: $exception);
        }

        $targets = [];
        foreach ($payload['targets'] ?? [] as $target) {
            if (!\is_array($target) || !\is_array($target['channel'] ?? null)) {
                continue;
            }
            $targets[] = new PublicationTarget(
                $this->channel($target['channel']),
                $this->requiredString($target, 'status', 24),
                \is_string($target['external_publication_id'] ?? null) ? $target['external_publication_id'] : null,
                \is_string($target['error'] ?? null) ? $target['error'] : null,
            );
        }

        return new Publication(
            $this->requiredString($payload, 'id', 255),
            \is_string($payload['message'] ?? null) ? $payload['message'] : '',
            \is_string($payload['media_url'] ?? null) ? $payload['media_url'] : null,
            $scheduledAt,
            $this->requiredString($payload, 'status', 24),
            \is_string($payload['error'] ?? null) ? $payload['error'] : null,
            $publishedAt,
            $targets,
        );
    }

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

        return trim($value);
    }
}
