<?php

/**
 * Copyright 2024-2025 Simply.IN Sp. z o.o.
 *
 * NOTICE OF LICENSE
 *
 * Licensed under the EUPL-1.2 or later.
 * You may not use this work except in compliance with the Licence.
 *
 * Copy of the Licence is available at:
 * https://joinup.ec.europa.eu/software/page/eupl
 * It is bundled with this package in the file LICENSE.txt
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the Licence is distributed on an as is basis,
 * without warranties or conditions of any kind, either express or implied.
 * Check the Licence for the specific language governing permissions
 * and limitations under the License.
 *
 * @author   Simply.IN Sp. z o.o.
 * @copyright 2024-2025 Simply.IN Sp. z o.o.
 * @license   https://joinup.ec.europa.eu/software/page/eupl
 */

declare(strict_types=1);

namespace SimplyIn\Api;

use Configuration;
use InvalidArgumentException;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use SimplyIn\Auth\RsaRegistrar;
use SimplyIn\Auth\RsaSigner;
use SimplyIn\BackOffice\ConfigurationKey;

if (!defined('_PS_VERSION_')) {
    exit;
}

class SimplyInApiClient
{
    private const SUPPORTED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];

    public function __construct(
        private readonly ClientInterface $httpClient,
        private readonly RequestFactoryInterface $requestFactory,
        private readonly StreamFactoryInterface $streamFactory,
        private readonly string $baseUrl,
        private readonly string $merchantApiKey,
        private readonly string $origin,
        private readonly ?ApiRequestContext $defaultContext = null,
        private readonly ?RsaSigner $rsaSigner = null,
        private readonly ?RsaRegistrar $rsaRegistrar = null,
    ) {
    }

    /**
     * Drop-in replacement for legacy SimplyInApi::apiCall().
     * Returns the decoded JSON body (or null/false on failure) to preserve BC.
     *
     * @param array<string, mixed>|false $body
     *
     * @return null|array<string, mixed>|false
     */
    /**
     * Whether a decoded response is a backend failure rather than usable data.
     *
     * Two distinct failure modes have to be caught, and only one of them is `false`:
     *  - the request never completed (transport, RSA signing, non-JSON body) → apiCall() = false;
     *  - the request completed but the backend refused → a normal array carrying an error `code`.
     * Error codes are SCREAMING_SNAKE (UNAUTHORIZED, NOT_FOUND, CONFLICT, …). Note that on the
     * checkout endpoints a SUCCESS response also has a `code` key — it holds the masked phone
     * digits (e.g. "****"), which is why the value's shape, not its presence, is the test.
     */
    public static function isFailure(null|array|false $decoded): bool
    {
        if (!is_array($decoded)) {
            return true;
        }
        $code = $decoded['code'] ?? null;

        return is_string($code) && preg_match('/^[A-Z][A-Z_]+$/', $code) === 1;
    }

    public function apiCall(
        array|false $body,
        string $method,
        string $endpoint,
        ?string $authToken = null,
    ): null|array|false {
        try {
            $response = $this->request(
                $method,
                $endpoint,
                $body === false ? [] : $body,
                authToken: $authToken,
                context: $this->defaultContext,
            );
        } catch (ApiException $e) {
            return false;
        }
        $decoded = $response->body;
        if (!is_array($decoded) && $decoded !== null) {

            return false;
        }

        return $decoded;
    }

    /**
     * @param array<string, mixed> $body request body (merged with context metadata when $context is provided)
     * @param array<string, int|string> $params query-string parameters
     */
    public function request(
        string $method,
        string $endpoint,
        array $body = [],
        array $params = [],
        ?string $authToken = null,
        ?ApiRequestContext $context = null,
        bool $skipMerchantApiKey = false,
    ): ApiResponse {
        $method = strtoupper($method);
        if (!in_array($method, self::SUPPORTED_METHODS, true)) {
            throw new InvalidArgumentException('Unsupported HTTP method: ' . $method);
        }
        if (!$skipMerchantApiKey && $this->merchantApiKey === '') {
            throw ApiException::missingApiKey();
        }

        if ($context !== null && $body !== []) {
            $body = array_merge($body, $context->toArray());
        }

        // The merchant API key is authenticated exclusively via the X-Auth-Merchant-Api-Key
        // header (see below) or the RSA-signed request headers — the backend never reads it
        // from the JSON body, so it is never added there.
        $signer = $this->rsaSigner;
        $rsaActive = !$skipMerchantApiKey && $signer !== null && $signer->isActive();

        $url = $this->buildUrl($endpoint, $params, $authToken);

        $payload = null;
        if ($body !== [] && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
            try {
                $payload = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
            } catch (JsonException $e) {
                throw new ApiException('Failed to encode Simply.IN request body: ' . $e->getMessage(), $endpoint, $method, null, null, $e);
            }
        }

        if ($rsaActive) {
            $response = $this->sendRsaSigned($signer, $method, $url, $payload, $endpoint);
            // Rotate/revoke resilience: a cleared credential (412, or a 401 streak) triggers a
            // re-register + one retry. There is no silent downgrade to the API key.
            if ($signer->noteRsaResponse($response->getStatusCode()) && $this->rsaRegistrar !== null) {
                if ($this->rsaRegistrar->reregister() && $signer->isActive()) {
                    $response = $this->sendRsaSigned($signer, $method, $url, $payload, $endpoint);
                } elseif ($this->rsaRegistrar->lastAttemptWasAuthRejected()) {
                    // The credential was confirmed cleared AND the immediate self-heal
                    // re-registration was itself rejected as an auth failure (not a transient
                    // technical one) — the merchant API key configured for this environment no
                    // longer works. Stop presenting the shop as connected so the storefront gates
                    // (front hooks) stop rendering the widget on stale trust, instead of only
                    // discovering this the next time a shopper's request happens to fail.
                    Configuration::updateValue(ConfigurationKey::CONNECTED->value, '0');
                }
            }
        } elseif (!$skipMerchantApiKey && $signer !== null) {
            // Runtime (RSA-capable) client whose RSA is not active yet. We do not downgrade to the
            // merchant API key at runtime. Try a throttled inline (re)registration; if it activates
            // signing, sign — otherwise fail closed with a clear error rather than a silent 500.
            // The API key stays confined to the handshake.
            if ($this->rsaRegistrar !== null && $this->rsaRegistrar->reregisterThrottled() && $signer->isActive()) {
                $response = $this->sendRsaSigned($signer, $method, $url, $payload, $endpoint);
            } else {
                if (!$signer->isSigningAvailable() && class_exists('PrestaShopLogger')) {
                    // Make the permanent fail-closed reason explicit (PHP without OpenSSL can never
                    // sign) instead of an opaque "RSA inactive".
                    \PrestaShopLogger::addLog(
                        sprintf('SimplyIN RSA signing unavailable (OpenSSL missing) — failing closed on %s %s', $method, $endpoint),
                        3,
                        null,
                        'SimplyIn',
                        null,
                        false,
                    );
                }
                throw ApiException::rsaInactive($method, $endpoint);
            }
        } else {
            // Handshake / key-validation client (no RSA signer configured) or an explicit skip:
            // authenticate the one-time API-key call. Runtime traffic never reaches this branch.
            $request = $this->buildBaseRequest($method, $url, $payload);
            if (!$skipMerchantApiKey) {
                $request = $request->withHeader('X-Auth-Merchant-Api-Key', $this->merchantApiKey);
            }
            $response = $this->transport($request, $method, $endpoint);
        }

        $rawBody = (string) $response->getBody();
        $decoded = $rawBody === '' ? null : json_decode($rawBody, true);
        if ($rawBody !== '' && $decoded === null && json_last_error() !== JSON_ERROR_NONE) {
            throw ApiException::invalidJson($method, $endpoint, $rawBody);
        }

        return new ApiResponse(
            statusCode: $response->getStatusCode(),
            body: $decoded,
            headers: $response->getHeaders(),
            rawBody: $rawBody,
        );
    }

    /**
     * Build an RSA-signed request and dispatch it. The API-key header is not sent. Fails closed if
     * signing is impossible — no downgrade.
     */
    private function sendRsaSigned(RsaSigner $signer, string $method, string $url, ?string $payload, string $endpoint): ResponseInterface
    {
        $request = $this->buildBaseRequest($method, $url, $payload);
        $rsaHeaders = $signer->buildHeaders($method, $this->pathWithQuery($url), $payload ?? '');
        if ($rsaHeaders === []) {
            throw ApiException::signingFailed($method, $endpoint);
        }
        foreach ($rsaHeaders as $name => $value) {
            $request = $request->withHeader($name, $value);
        }

        return $this->transport($request, $method, $endpoint);
    }

    private function buildBaseRequest(string $method, string $url, ?string $payload): RequestInterface
    {
        $request = $this->requestFactory->createRequest($method, $url)
            ->withHeader('Content-Type', 'application/json')
            ->withHeader('Origin', $this->origin);

        if ($payload !== null) {
            $request = $request->withBody($this->streamFactory->createStream($payload));
        }

        return $request;
    }

    private function transport(RequestInterface $request, string $method, string $endpoint): ResponseInterface
    {
        try {
            return $this->httpClient->sendRequest($request);
        } catch (ClientExceptionInterface $e) {
            throw ApiException::transportFailure($method, $endpoint, $e);
        }
    }

    /**
     * Path + query exactly as transmitted (includes the /api prefix carried by the base URL).
     * This is the exact string the backend re-derives and verifies the signature against —
     * keep it byte-for-byte identical to what's actually sent on the wire.
     */
    private function pathWithQuery(string $url): string
    {
        $path = (string) parse_url($url, PHP_URL_PATH);
        $query = parse_url($url, PHP_URL_QUERY);

        return $query === null || $query === '' ? $path : $path . '?' . $query;
    }

    /**
     * @param array<string, int|string> $params
     */
    private function buildUrl(string $endpoint, array $params, ?string $authToken): string
    {
        $url = rtrim($this->baseUrl, '/') . '/' . ltrim($endpoint, '/');

        $query = [];
        if ($authToken !== null && $authToken !== '') {
            $query['api_token'] = $authToken;
        }
        if ($params !== []) {
            $query = array_merge($query, $params);
        }
        if ($query !== []) {
            $separator = str_contains($url, '?') ? '&' : '?';
            $url .= $separator . http_build_query($query);
        }

        return $url;
    }
}
