<?php

declare(strict_types=1);

namespace App\Shopware;

use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class StoreApiClient
{
    public function __construct(
        private HttpClientInterface $httpClient,
        private string $baseUrl,
        private string $salesChannelAccessKey,
        private int $timeoutSeconds = 5,
    ) {
    }

    /**
     * @throws TransportExceptionInterface
     * @throws ServerExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws ClientExceptionInterface
     * @throws StoreApiException
     */
    public function searchProducts(array $criteria): array
    {
        $url = rtrim($this->baseUrl, '/') . '/store-api/search';
        $sanitizedCriteria = $this->sanitizeValue($criteria);

        try {
            $body = json_encode(
                $sanitizedCriteria,
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
            );
        } catch (\JsonException $e) {
            throw new RuntimeException('Failed to encode Store API criteria as valid JSON.', 0, $e);
        }

        $response = $this->httpClient->request('POST', $url, [
            'headers' => [
                'Content-Type' => 'application/json; charset=utf-8',
                'Accept' => 'application/json',
                'sw-access-key' => $this->salesChannelAccessKey,
            ],
            'body' => $body,
            // Keep Shopware calls bounded. During SSE responses the browser only
            // receives data between blocking HTTP calls, so long Store API waits
            // can look like a broken stream to proxies or the browser.
            'timeout' => $this->timeoutSeconds,
            'max_duration' => max(1, $this->timeoutSeconds + 1),
        ]);

        $statusCode = $response->getStatusCode();
        $content = $response->getContent(false);
        $content = $this->sanitizeString($content);

        if ($statusCode < 200 || $statusCode >= 300) {
            throw $this->buildHttpFailure($statusCode, $content);
        }

        $data = json_decode($content, true);

        if (!is_array($data)) {
            throw new StoreApiException(
                'Shopware Store API returned invalid JSON.',
                $statusCode,
                true,
                $this->containsUtf8FailureSignal($content),
                true
            );
        }

        return $data;
    }

    private function buildHttpFailure(int $statusCode, string $content): StoreApiException
    {
        $preview = mb_substr(trim($content), 0, 1000);
        $utf8Failure = $this->containsUtf8FailureSignal($preview);
        $normalizedPreview = mb_strtolower($preview, 'UTF-8');
        $accessKeyFailure = $statusCode === 401
            || $statusCode === 403
            || str_contains($preview, 'FRAMEWORK__API_INVALID_ACCESS_KEY')
            || str_contains($normalizedPreview, 'access key is invalid');
        $serverFailure = $statusCode >= 500 || $accessKeyFailure;

        $hint = '';

        if ($accessKeyFailure) {
            $hint = ' Hint: The configured Shopware Sales Channel access key is invalid or does not match the Store API endpoint.';
        } elseif ($utf8Failure) {
            $hint = ' Hint: The request body was valid JSON; this Shopware error usually means Shopware failed while encoding response/product data as UTF-8.';
        }

        return new StoreApiException(
            sprintf(
                'Shopware Store API request failed with status %d. Response: %s%s',
                $statusCode,
                $preview,
                $hint
            ),
            $statusCode,
            $serverFailure,
            $utf8Failure,
            $serverFailure || $utf8Failure || $accessKeyFailure
        );
    }

    private function containsUtf8FailureSignal(string $content): bool
    {
        $normalized = mb_strtolower($content, 'UTF-8');

        return str_contains($normalized, 'malformed utf-8')
            || str_contains($normalized, 'malformed utf8')
            || str_contains($normalized, 'invalid utf-8')
            || str_contains($normalized, 'invalid utf8')
            || str_contains($normalized, 'possibly incorrectly encoded');
    }

    private function sanitizeValue(mixed $value): mixed
    {
        if (is_array($value)) {
            $out = [];

            foreach ($value as $key => $item) {
                $out[$key] = $this->sanitizeValue($item);
            }

            return $out;
        }

        if (!is_string($value)) {
            return $value;
        }

        return $this->sanitizeString($value);
    }

    private function sanitizeString(string $value): string
    {
        if (preg_match('//u', $value) !== 1) {
            if (function_exists('mb_convert_encoding')) {
                $value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
            }

            if (preg_match('//u', $value) !== 1 && function_exists('iconv')) {
                $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);

                if (is_string($converted)) {
                    $value = $converted;
                }
            }
        }

        if (preg_match('//u', $value) !== 1) {
            return '';
        }

        if (class_exists('Normalizer')) {
            $normalized = \Normalizer::normalize($value, \Normalizer::FORM_C);

            if (is_string($normalized)) {
                $value = $normalized;
            }
        }

        // Keep tab/newline/carriage-return, strip other control characters that can
        // make downstream JSON handling and logs harder to diagnose.
        return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value);
    }
}
