<?php
/**
 * Copyright © Simplyin. All rights reserved.
 */
declare(strict_types=1);

namespace Simplyin\Payment\Http;

use Magento\Framework\HTTP\Client\Curl;
use Psr\Log\LoggerInterface;
use Simplyin\Payment\Model\Auth\RsaRegistrar;
use Simplyin\Payment\Model\Auth\RsaSigner;

/**
 * HTTP client wrapper (composition over Magento's Curl) that transparently RSA-signs every
 * authenticated outbound request to SIN.CORE.
 *
 * Injected into {@see \Simplyin\Payment\Helper\ApiClient} in place of the raw Curl, so all ~24 call
 * sites are signed without touching each one. Classification rule: a request is "authenticated"
 * exactly when the caller sets the `X-Auth-Merchant-Api-Key` header. For such a request this wrapper
 * — clean RSA-only, no per-request API key, no dual-auth fallback — DROPS the API key header and
 * adds the four X-Auth-Merchant-* RSA headers, computed over the exact outgoing method, path?query
 * (including the /api prefix, as the server sees it) and body. Requests without that header (e.g.
 * the app-level-encrypted order-status channel) pass through untouched.
 *
 * The one-time key registration is triggered lazily here (RsaRegistrar, which uses its own Curl) so
 * the very first authenticated call self-registers and then signs.
 */
class SigningCurlClient
{
    /**
     * @var Curl
     */
    private Curl $curl;

    /**
     * @var RsaSigner
     */
    private RsaSigner $signer;

    /**
     * @var RsaRegistrar
     */
    private RsaRegistrar $registrar;

    /**
     * @var LoggerInterface
     */
    private LoggerInterface $logger;

    /**
     * Headers staged by the caller before get()/post().
     *
     * @var array<string,string>
     */
    private array $pendingHeaders = [];

    /**
     * HTTP method override set via setOption(CURLOPT_CUSTOMREQUEST, ...) (e.g. PATCH).
     *
     * @var string|null
     */
    private ?string $customMethod = null;

    /**
     * Body staged via setOption(CURLOPT_POSTFIELDS, ...) for custom-method requests.
     *
     * @var string|null
     */
    private ?string $pendingBody = null;

    /**
     * Whether the most recent dispatch was actually RSA-signed (authenticated).
     *
     * @var bool
     */
    private bool $lastRequestSigned = false;

    /**
     * @param Curl $curl
     * @param RsaSigner $signer
     * @param RsaRegistrar $registrar
     * @param LoggerInterface $logger
     */
    public function __construct(
        Curl $curl,
        RsaSigner $signer,
        RsaRegistrar $registrar,
        LoggerInterface $logger
    ) {
        $this->curl = $curl;
        $this->signer = $signer;
        $this->registrar = $registrar;
        $this->logger = $logger;
    }

    /**
     * Stage headers; applied (with RSA swap) at dispatch time.
     *
     * @param array $headers
     * @return void
     */
    public function setHeaders(array $headers): void
    {
        $this->pendingHeaders = $headers;
    }

    /**
     * Pass through cURL options, tracking the ones that define a custom-method request.
     *
     * This lets the canonical string be signed over the real method + body.
     *
     * @param int $option
     * @param mixed $value
     * @return void
     */
    public function setOption($option, $value): void
    {
        if ($option === CURLOPT_CUSTOMREQUEST) {
            $this->customMethod = strtoupper((string)$value);
        } elseif ($option === CURLOPT_POSTFIELDS) {
            $this->pendingBody = (string)$value;
        }
        $this->curl->setOption($option, $value);
    }

    /**
     * Perform a GET (or staged custom-method) request, RSA-signed when authenticated.
     *
     * @param string $uri
     * @return void
     */
    public function get($uri): void
    {
        $method = $this->customMethod ?? 'GET';
        // A GET may actually be a custom-method (e.g. PATCH) request whose body was staged via
        // CURLOPT_POSTFIELDS; sign over that body then.
        $body = ($this->customMethod !== null && $this->pendingBody !== null) ? $this->pendingBody : '';
        $this->dispatch($method, $uri, $body, function () use ($uri) {
            $this->curl->get($uri);
        });
    }

    /**
     * Perform a POST request, RSA-signed when authenticated.
     *
     * @param string $uri
     * @param array|string $params
     * @return void
     */
    public function post($uri, $params): void
    {
        $method = $this->customMethod ?? 'POST';
        $body = is_string($params) ? $params : (string)json_encode($params);
        $this->dispatch($method, $uri, $body, function () use ($uri, $params) {
            $this->curl->post($uri, $params);
        });
    }

    /**
     * Get the last response body.
     *
     * @return string
     */
    public function getBody(): string
    {
        return (string)$this->curl->getBody();
    }

    /**
     * Get the last response HTTP status code.
     *
     * @return int
     */
    public function getStatus(): int
    {
        return (int)$this->curl->getStatus();
    }

    /**
     * Whether the most recent dispatch was actually RSA-signed (authenticated).
     *
     * False for pass-through (non-authenticated) requests and for authenticated requests that could
     * not be signed because RSA is not registered yet or signing was unavailable. Callers that must
     * only trust merchant-scoped responses (e.g. available payment methods) use this to fail closed:
     * an unauthenticated request to a scoped endpoint gets an un-scoped, generic response that must
     * not be shown as if it were the merchant's configuration. Reflects the last dispatch only, so
     * read it immediately after the guarded get()/post(), before any further request runs.
     *
     * @return bool
     */
    public function wasLastRequestSigned(): bool
    {
        return $this->lastRequestSigned;
    }

    /**
     * Apply headers (RSA-swapped for authenticated requests) and run the delegate transport call.
     *
     * @param string $method
     * @param string $uri
     * @param string $body
     * @param callable $send
     * @return void
     */
    private function dispatch(string $method, string $uri, string $body, callable $send): void
    {
        $headers = $this->pendingHeaders;

        // "Authenticated" == the caller staged the merchant API key. Those requests go out RSA-signed
        // and WITHOUT the API key (clean RSA-only). Everything else passes through unchanged.
        if (!$this->hasApiKeyHeader($headers)) {
            // Not an authenticated request (e.g. the app-level-encrypted order-status channel): it
            // carries no merchant scope, so signing does not apply — never trusted as "signed".
            $this->lastRequestSigned = false;
            $this->curl->setHeaders($headers);
            $send();
            $this->resetState();
            return;
        }

        $baseHeaders = $this->stripApiKeyHeader($headers);
        $this->registrar->ensureRegistered();
        $signed = $this->applySignedHeaders($baseHeaders, $method, $uri, $body);
        $send();

        // Rotate/revoke resilience: if the signed call was rejected because the credential was
        // revoked/rotated on the backend (412, or repeated 401), clear local RSA, re-register with
        // the current API key and retry the same request once.
        if ($signed && $this->signer->noteRsaResponse((int)$this->curl->getStatus())) {
            $this->registrar->reregister();
            $signed = $this->applySignedHeaders($baseHeaders, $method, $uri, $body);
            if ($signed) {
                $send();
            }
        }

        $this->lastRequestSigned = $signed;
        $this->resetState();
    }

    /**
     * Apply headers for an authenticated request: strip the API key, add the four RSA headers.
     *
     * Adds the RSA headers when signing is active. Returns true when the request was actually
     * RSA-signed.
     *
     * @param array $baseHeaders
     * @param string $method
     * @param string $uri
     * @param string $body
     * @return bool
     */
    private function applySignedHeaders(array $baseHeaders, string $method, string $uri, string $body): bool
    {
        $headers = $baseHeaders;
        $signed = false;
        if ($this->signer->isActive()) {
            $rsa = $this->signer->buildHeaders($method, $this->pathWithQuery($uri), $body);
            if (!empty($rsa)) {
                $headers = array_merge($headers, $rsa);
                $signed = true;
            } else {
                // Active but signing failed (transient nonce/openssl issue): fail closed rather than
                // silently downgrade to the API key.
                $this->logger->warning('SimplyIN RSA: signing unavailable for ' . $method . ' ' . $uri);
            }
        } else {
            // Not registered yet. Clean RSA-only: no API-key fallback. The cron / next attempt registers.
            $this->logger->warning('SimplyIN RSA: not registered; request will be unauthenticated: ' . $uri);
        }
        $this->curl->setHeaders($headers);

        return $signed;
    }

    /**
     * Reset per-call state so it never leaks into the next request on a reused instance.
     *
     * @return void
     */
    private function resetState(): void
    {
        $this->pendingHeaders = [];
        $this->customMethod = null;
        $this->pendingBody = null;
    }

    /**
     * Case-insensitive check for the merchant API key header.
     *
     * @param array $headers
     * @return bool
     */
    private function hasApiKeyHeader(array $headers): bool
    {
        foreach (array_keys($headers) as $name) {
            if (strcasecmp((string)$name, 'X-Auth-Merchant-Api-Key') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Remove the merchant API key header (any case) from the header set.
     *
     * @param array $headers
     * @return array
     */
    private function stripApiKeyHeader(array $headers): array
    {
        foreach (array_keys($headers) as $name) {
            if (strcasecmp((string)$name, 'X-Auth-Merchant-Api-Key') === 0) {
                unset($headers[$name]);
            }
        }

        return $headers;
    }

    /**
     * Extract the path (+query) exactly as transmitted.
     *
     * Includes the /api prefix carried by the configured base URL — this is what the backend
     * canonicalizes and verifies against.
     *
     * @param string $uri
     * @return string
     */
    private function pathWithQuery(string $uri): string
    {
        // phpcs:ignore Magento2.Functions.DiscouragedFunction -- parsing our own outbound request URI for canonical signing
        $parts = parse_url($uri);
        $path = $parts['path'] ?? '';
        $query = $parts['query'] ?? '';

        return $query !== '' ? $path . '?' . $query : $path;
    }
}
