<?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 (SIN-1654). Clean RSA-only integration: the merchant
 * configures an API key in admin; that key authenticates ONLY this two-call handshake, after which
 * every runtime request is RSA-signed and the API key is never sent again.
 *
 * Flow: startPluginPublicKeyRegistration (API-key auth) -> {credentialId, challengeBase64}
 *   -> generate RSA-2048, sign the decoded challenge -> completePluginPublicKeyRegistration -> {ok}
 *   -> persist private key + credential id ({@see RsaSigner::store()}).
 *
 * Uses its OWN plain Curl (not the signing HTTP client) so the handshake goes out over the API key
 * and there is no re-entrancy with the request signer.
 */
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;

    /**
     * @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: do NOT clear the working RSA material up front. register() generates a
        // fresh pair and persists it (RsaSigner::store() overwrites private key + credentialId +
        // fingerprint in one shot) ONLY after the backend confirms (ok:true). So rotating to a bad or
        // mistyped API key leaves the still-working old credential in place instead of bricking RSA;
        // the swap happens atomically on success. A genuinely revoked/rotated key is handled by the
        // response guard (RsaSigner::noteRsaResponse — 412, or the 401 threshold), which clears local
        // RSA and lets the next attempt 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() !== '';
    }

    /**
     * Perform the two-step public-key registration handshake with SIN.CORE.
     *
     * @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
    {
        $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);
        $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
     * publicKeyPem + signature.
     *
     * @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
    {
        $complete = $this->postWithApiKey(
            $base . '/' . self::COMPLETE_ENDPOINT,
            json_encode(['publicKeyPem' => $publicPem, 'signatureBase64' => $signatureBase64]),
            $apiKey
        );
        $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;
    }

    /**
     * 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);
        $decoded = json_decode((string)$this->curl->getBody(), true);

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

    /**
     * Normalize a plugin-sync response: REST answers flat ({credentialId,...} / {ok:true}); the same
     * routes via the OpenAPI wrapper nest the payload under result.data. Return the inner data when
     * present, otherwise the response itself (1:1 with the WooCommerce reference).
     *
     * @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 (base URL without index.php / trailing slash) — the backend validates the
     * registration request's Origin against the merchant's registered domain. Mirrors
     * ApiClient::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);
    }
}
