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

namespace Simplyin\Payment\Model\Auth;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\HTTP\Client\Curl;
use Magento\Store\Model\ScopeInterface;
use Psr\Log\LoggerInterface;
use Simplyin\Payment\Model\Config\EnvironmentConfig;

/**
 * One-time, zero-touch RSA key registration. Clean RSA-only integration: the merchant
 * configures an API key in admin; that key authenticates only this registration step, after which
 * every runtime request is RSA-signed and the API key is never sent again.
 *
 * Flow: request registration (API-key auth) -> generate RSA-2048 locally, sign the returned
 * challenge -> confirm registration -> persist the private key and credential id
 * ({@see RsaSigner::store()}) once the backend confirms.
 *
 * Uses a plain (non-signing) HTTP client for this handshake, deliberately not the request-signing
 * client: signing this registration call would be circular, since the key being registered is the
 * one the signing client would need.
 */
class RsaRegistrar
{
    private const START_ENDPOINT    = 'plugin-sync/startPluginPublicKeyRegistration';
    private const COMPLETE_ENDPOINT = 'plugin-sync/completePluginPublicKeyRegistration';

    public const XML_PATH_API_KEY = 'payment/simplyin_payment/api_key';

    /**
     * Attempt registration at most once per PHP process; the cron backstop retries across requests.
     *
     * @var bool
     */
    private bool $attempted = false;

    /**
     * HTTP status code of the most recent start/complete call, captured for auth-rejection detection.
     *
     * @var int
     */
    private int $lastStatusCode = 0;

    /**
     * Whether the most recent registration attempt failed specifically because the backend
     * rejected the API key (401/403), as opposed to a network/5xx/malformed-response failure.
     *
     * @var bool
     */
    private bool $lastFailureWasAuthRejection = false;

    /**
     * Whether the most recent registration attempt failed because the backend already has a
     * public key registered for this API key (a stale local RSA state, not an invalid key).
     *
     * @var bool
     */
    private bool $lastFailureWasAlreadyRegistered = false;

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

    /**
     * @var Curl
     */
    private Curl $curl;

    /**
     * @var ScopeConfigInterface
     */
    private ScopeConfigInterface $scopeConfig;

    /**
     * @var EncryptorInterface
     */
    private EncryptorInterface $encryptor;

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

    /**
     * @var EnvironmentConfig
     */
    private EnvironmentConfig $environmentConfig;

    /**
     * @param RsaSigner $signer
     * @param Curl $curl
     * @param ScopeConfigInterface $scopeConfig
     * @param EncryptorInterface $encryptor
     * @param LoggerInterface $logger
     * @param EnvironmentConfig $environmentConfig
     */
    public function __construct(
        RsaSigner $signer,
        Curl $curl,
        ScopeConfigInterface $scopeConfig,
        EncryptorInterface $encryptor,
        LoggerInterface $logger,
        EnvironmentConfig $environmentConfig
    ) {
        $this->signer = $signer;
        $this->curl = $curl;
        $this->scopeConfig = $scopeConfig;
        $this->encryptor = $encryptor;
        $this->logger = $logger;
        $this->environmentConfig = $environmentConfig;
    }

    /**
     * Idempotent, best-effort: ensure a usable RSA key is registered.
     *
     * No-op (and cheap) once active. Never throws — a failed attempt is retried by the cron
     * backstop / next process.
     *
     * @return bool True when RSA is active (already, or just registered).
     */
    public function ensureRegistered(): bool
    {
        $apiKey = $this->getApiKey();
        if ($apiKey === '') {
            return false;
        }
        // Active AND registered against the current API key → nothing to do.
        if ($this->signer->isActive() && $this->signer->matchesApiKey($apiKey)) {
            return true;
        }
        if ($this->attempted) {
            return false;
        }
        $this->attempted = true;
        // Register-then-swap: the working RSA material is not cleared up front. A fresh key pair is
        // generated and persisted only after the backend confirms the registration, so a bad or
        // mistyped API key leaves the still-working old credential in place instead of bricking RSA.
        // A genuinely revoked/rotated key is handled separately, clearing local RSA so the next
        // attempt can re-register.
        return $this->doRegister();
    }

    /**
     * Force a fresh registration attempt after local RSA was cleared (rotate/revoke).
     *
     * Resets the once-per-process guard so the just-cleared key can be re-registered with the
     * current API key.
     *
     * @return bool
     */
    public function reregister(): bool
    {
        $this->attempted = false;
        return $this->ensureRegistered();
    }

    /**
     * Whether a merchant API key is configured, i.e. RSA registration is applicable at all.
     *
     * Lets the admin config observer decide whether to surface RSA success/failure feedback after a
     * save (no point reporting a "failure" when no key was entered).
     *
     * @return bool
     */
    public function hasApiKey(): bool
    {
        return $this->getApiKey() !== '';
    }

    /**
     * Whether the most recent registration attempt failed as an explicit auth rejection.
     *
     * True when the backend answered START/COMPLETE with 401/403 (the API key itself is invalid),
     * as opposed to a transient/other failure (network, 5xx, malformed response).
     *
     * @return bool
     */
    public function wasAuthRejected(): bool
    {
        return $this->lastFailureWasAuthRejection;
    }

    /**
     * Whether the most recent registration attempt failed as an "already registered" rejection.
     *
     * True when START answered with a message containing "already exists" — the API key itself
     * is valid, but a previous registration (e.g. from before the shop lost its local RSA state)
     * is still on file. Distinct from an auth rejection: retrying with the same key will never
     * resolve this, only revoking the stale registration will.
     *
     * @return bool
     */
    public function wasAlreadyRegistered(): bool
    {
        return $this->lastFailureWasAlreadyRegistered;
    }

    /**
     * Perform the two-step public-key registration handshake with the backend.
     *
     * @return bool
     */
    private function doRegister(): bool
    {

        try {
            return $this->register();
        } catch (\Throwable $e) {
            $this->logger->error('SimplyIN RSA: registration failed — ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Run the two-call registration handshake.
     *
     * @return bool
     */
    private function register(): bool
    {
        $this->lastFailureWasAuthRejection = false;
        $this->lastFailureWasAlreadyRegistered = false;

        $apiKey = $this->getApiKey();
        if ($apiKey === '') {
            $this->logger->warning('SimplyIN RSA: no API key configured, cannot register');
            return false;
        }
        $base = rtrim($this->environmentConfig->getApiBaseUrl(), '/');
        if ($base === '') {
            $this->logger->warning('SimplyIN RSA: no API base URL configured, cannot register');
            return false;
        }

        $started = $this->startRegistration($base, $apiKey);
        if (empty($started)) {
            return false;
        }

        // Generate a fresh key pair and prove possession by signing the decoded challenge.
        $pair = $this->signer->generateKeyPair();
        if (empty($pair)) {
            return false;
        }
        $signatureBase64 = $this->signer->signChallengeBase64WithKey($started['challenge'], $pair['private_pem']);
        if ($signatureBase64 === '') {
            return false;
        }

        if (!$this->completeRegistration($base, $apiKey, $pair['public_pem'], $signatureBase64)) {
            return false;
        }

        // Backend accepted — commit the pair + credential id + the API-key fingerprint (so a later
        // key rotation is detected and triggers a fresh registration).
        $this->signer->store($pair['private_pem'], $pair['public_pem'], $started['credentialId'], hash('sha256', $apiKey));
        $this->logger->info('SimplyIN RSA: registration completed, request signing active');

        return true;
    }

    /**
     * START handshake — obtain the credential id + challenge (API-key authenticated).
     *
     * @param string $base
     * @param string $apiKey
     * @return array Empty on failure, else ['credentialId' => string, 'challenge' => string].
     */
    private function startRegistration(string $base, string $apiKey): array
    {
        $start = $this->postWithApiKey($base . '/' . self::START_ENDPOINT, '', $apiKey);
        if ($this->isAuthRejectionStatus()) {
            $this->logger->warning('SimplyIN RSA: start rejected — API key invalid', [
                'statusCode' => $this->lastStatusCode
            ]);
            return [];
        }
        if ($this->isAlreadyRegisteredMessage($start)) {
            $this->lastFailureWasAlreadyRegistered = true;
            $this->logger->warning('SimplyIN RSA: start rejected — key already registered for this API key', [
                'statusCode' => $this->lastStatusCode
            ]);
            return [];
        }
        $startData = $this->unwrap($start);
        $credentialId = isset($startData['credentialId']) ? trim((string)$startData['credentialId']) : '';
        $challenge = isset($startData['challengeBase64']) ? trim((string)$startData['challengeBase64']) : '';
        if ($credentialId === '' || $challenge === '') {
            $this->logger->warning('SimplyIN RSA: start did not return credentialId/challenge', ['response' => $start]);
            return [];
        }

        return ['credentialId' => $credentialId, 'challenge' => $challenge];
    }

    /**
     * COMPLETE handshake — register the public key (API-key authenticated).
     *
     * The backend binds the key to the API key (credentialId = apiKey record id), so we send only
     * publicKey (base64 SPKI DER) + signature (base64, unchanged from $signatureBase64) — the DER
     * encoding and these field names (rather than the older publicKeyPem/signatureBase64) avoid a
     * prod network filtering rule that rejected the old shape; the backend accepts both.
     *
     * @param string $base
     * @param string $apiKey
     * @param string $publicPem
     * @param string $signatureBase64
     * @return bool
     */
    private function completeRegistration(string $base, string $apiKey, string $publicPem, string $signatureBase64): bool
    {
        $publicKeyDerBase64 = $this->pemToDerBase64($publicPem);
        if ($publicKeyDerBase64 === '') {
            $this->logger->warning('SimplyIN RSA: could not convert generated public key to base64 DER');
            return false;
        }

        $complete = $this->postWithApiKey(
            $base . '/' . self::COMPLETE_ENDPOINT,
            json_encode(['publicKey' => $publicKeyDerBase64, 'signature' => $signatureBase64]),
            $apiKey
        );
        if ($this->isAuthRejectionStatus()) {
            $this->logger->warning('SimplyIN RSA: complete rejected — API key invalid', [
                'statusCode' => $this->lastStatusCode
            ]);
            return false;
        }
        $confirm = $this->unwrap($complete);
        if (($confirm['ok'] ?? null) !== true) {
            $this->logger->warning('SimplyIN RSA: backend did not confirm registration', ['response' => $complete]);
            return false;
        }

        return true;
    }

    /**
     * Strip the PEM armor (header/footer lines + surrounding whitespace) from an SPKI public key,
     * leaving the base64-encoded DER payload the backend expects. Pure string handling — deliberately
     * not routed through openssl, since the value is already the freshly generated key material.
     *
     * @param string $publicPem
     * @return string Base64 DER, or '' when the input is empty, contains no non-armor/non-whitespace
     *     content, or the strip itself failed.
     */
    private function pemToDerBase64(string $publicPem): string
    {
        if ($publicPem === '') {
            return '';
        }

        $der = preg_replace('/-----[^-]+-----|\s+/', '', $publicPem);

        return $der ?? '';
    }

    /**
     * POST with the merchant API key header; returns the decoded JSON body (array) or [].
     *
     * @param string $url
     * @param string $body
     * @param string $apiKey
     * @return array
     */
    private function postWithApiKey(string $url, string $body, string $apiKey): array
    {
        $this->curl->setHeaders([
            'Content-Type' => 'application/json',
            'Origin' => $this->getOrigin(),
            'X-Auth-Merchant-Api-Key' => $apiKey,
        ]);
        $this->curl->post($url, $body);
        $this->lastStatusCode = (int)$this->curl->getStatus();
        $decoded = json_decode((string)$this->curl->getBody(), true);

        return is_array($decoded) ? $decoded : [];
    }

    /**
     * Whether the last postWithApiKey() call got an explicit auth-rejection status (401/403).
     * Side-effecting: latches lastFailureWasAuthRejection so wasAuthRejected() can report it after
     * doRegister()'s catch-all returns to the caller.
     *
     * @return bool
     */
    private function isAuthRejectionStatus(): bool
    {
        if (in_array($this->lastStatusCode, [401, 403], true)) {
            $this->lastFailureWasAuthRejection = true;
            return true;
        }

        return false;
    }

    /**
     * Whether a START response's message indicates the backend already has a public key
     * registered for this API key. Checked before unwrap() (which only extracts a SUCCESS
     * payload from result.data) since an error message could sit flat, under result, or
     * nested the same way a success payload is per unwrap()'s documented convention.
     *
     * @param array $response
     * @return bool
     */
    private function isAlreadyRegisteredMessage(array $response): bool
    {
        $message = (string)($response['message'] ?? $response['error'] ?? '');
        if ($message === '' && isset($response['result']) && is_array($response['result'])) {
            $result = $response['result'];
            $message = (string)($result['message'] ?? $result['error'] ?? '');
            if ($message === '' && isset($result['data']) && is_array($result['data'])) {
                $message = (string)($result['data']['message'] ?? $result['data']['error'] ?? '');
            }
        }

        return $message !== '' && stripos($message, 'already exists') !== false;
    }

    /**
     * Normalize a registration response: some routes answer flat ({credentialId,...} / {ok:true}),
     * others nest the payload under result.data. Return the inner data when present, otherwise the
     * response itself.
     *
     * @param array $response
     * @return array
     */
    private function unwrap(array $response): array
    {
        if (isset($response['result']['data']) && is_array($response['result']['data'])) {
            return $response['result']['data'];
        }

        return $response;
    }

    /**
     * Shop origin, sent with the registration request.
     *
     * Base URL without index.php / trailing slash. Mirrors ApiTransport::getOrigin().
     *
     * @return string
     */
    private function getOrigin(): string
    {
        $baseUrl = (string)$this->scopeConfig->getValue('web/secure/base_url', ScopeInterface::SCOPE_STORE);
        $baseUrl = (string)preg_replace('#/index\.php/?#', '/', $baseUrl);

        return rtrim($baseUrl, '/');
    }

    /**
     * Get the decrypted merchant API key used only for the registration handshake.
     *
     * @return string
     */
    private function getApiKey(): string
    {
        $encrypted = $this->scopeConfig->getValue(self::XML_PATH_API_KEY, ScopeInterface::SCOPE_STORE);
        if (empty($encrypted)) {
            return '';
        }

        return (string)$this->encryptor->decrypt($encrypted);
    }
}
