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

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
     */
    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());
        }

        if (!$skipMerchantApiKey && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
            $body['merchantApiKey'] = $this->merchantApiKey;
        }

        // Clean RSA-only: once a key pair is registered, an authenticated request is RSA-signed and
        // the merchant API key is NEVER sent again — neither as a header NOR inside the body (the
        // backend resolves the merchant from the RSA credential). Strip it before encoding/signing.
        $signer = $this->rsaSigner;
        $rsaActive = !$skipMerchantApiKey && $signer !== null && $signer->isActive();
        if ($rsaActive) {
            unset($body['merchantApiKey'], $body['apiKey']);
        }

        $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
                && $this->rsaRegistrar->reregister()
                && $signer->isActive()) {
                $response = $this->sendRsaSigned($signer, $method, $url, $payload, $endpoint);
            }
        } else {
            $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 four X-Auth-Merchant-* headers are computed
     * over the exact method, path?query (with the /api prefix, byte-for-byte as transmitted) and
     * body; 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 what the backend canonicalizes and verifies the signature against.
     */
    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;
    }
}
