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

namespace Simplyin\Payment\Model\Auth;

use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Config\Storage\WriterInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Store\Model\ScopeInterface;
use Psr\Log\LoggerInterface;

/**
 * RSA request signer (plugin side of SimplyIN's RSA request authentication, SIN-1381/SIN-1393).
 *
 * This is a clean, RSA-only integration: the Magento plugin has never shipped to a merchant, so
 * there is NO legacy migration and NO per-request API key. The API key is used ONLY for the
 * one-time key-registration handshake (see {@see RsaRegistrar}); every authenticated runtime
 * request is signed here with the shop's RSA private key over a fixed canonical string, and the
 * backend verifies with the registered public key plus anti-replay (timestamp + nonce).
 *
 * Owns the key lifecycle (stored in core_config_data, private key encrypted at rest via Magento's
 * EncryptorInterface) and the byte-exact signing contract. Wiring into the HTTP client lives in
 * {@see \Simplyin\Payment\Http\SigningCurlClient}.
 */
class RsaSigner
{
    /** Runtime request auth headers (all four sent together or none). */
    public const HEADER_CREDENTIAL_ID = 'X-Auth-Merchant-Credential-Id';
    public const HEADER_SIGNATURE     = 'X-Auth-Merchant-Signature';
    public const HEADER_TIMESTAMP     = 'X-Auth-Merchant-Timestamp';
    public const HEADER_NONCE         = 'X-Auth-Merchant-Nonce';

    /**
     * SHA-256 of an empty body (GET/HEAD or no body). Must match the backend constant
     * EMPTY_REQUEST_BODY_SHA256_HEX exactly.
     */
    public const EMPTY_BODY_SHA256_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';

    /** Config paths for the key material and pairing identifier. */
    public const XML_PATH_PRIVATE_KEY   = 'payment/simplyin_payment/rsa_private_key';
    public const XML_PATH_PUBLIC_KEY    = 'payment/simplyin_payment/rsa_public_key';
    public const XML_PATH_CREDENTIAL_ID = 'payment/simplyin_payment/rsa_credential_id';

    /** SHA-256 of the API key the current RSA key was registered against (rotation detection). */
    public const XML_PATH_RSA_API_KEY_FP = 'payment/simplyin_payment/rsa_api_key_fp';

    /** Consecutive RSA-401 counter for the rotate/revoke resilience guard. */
    public const XML_PATH_RSA_401_COUNT = 'payment/simplyin_payment/rsa_401_count';

    /** Clear local RSA + re-register after this many consecutive RSA 401s (transient-tolerant). */
    public const RSA_401_CLEAR_THRESHOLD = 3;

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

    /**
     * @var WriterInterface
     */
    private WriterInterface $configWriter;

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

    /**
     * @var TypeListInterface
     */
    private TypeListInterface $cacheTypeList;

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

    /**
     * In-memory cache so a request that just registered can read its own freshly stored key
     * without waiting for the config cache to be re-read.
     *
     * @var array<string,string>
     */
    private array $memoryCache = [];

    /**
     * @param ScopeConfigInterface $scopeConfig
     * @param WriterInterface $configWriter
     * @param EncryptorInterface $encryptor
     * @param TypeListInterface $cacheTypeList
     * @param LoggerInterface $logger
     */
    public function __construct(
        ScopeConfigInterface $scopeConfig,
        WriterInterface $configWriter,
        EncryptorInterface $encryptor,
        TypeListInterface $cacheTypeList,
        LoggerInterface $logger
    ) {
        $this->scopeConfig = $scopeConfig;
        $this->configWriter = $configWriter;
        $this->encryptor = $encryptor;
        $this->cacheTypeList = $cacheTypeList;
        $this->logger = $logger;
    }

    /**
     * Whether RSA-signed requests should be used for authenticated traffic.
     *
     * True when a registered key pair AND a credential id are present, and OpenSSL is available.
     *
     * @return bool
     */
    public function isActive(): bool
    {
        return $this->isRegistered() && function_exists('openssl_sign');
    }

    /**
     * Whether a private key and credential id are both stored.
     *
     * @return bool
     */
    public function isRegistered(): bool
    {
        return $this->getPrivateKeyPem() !== '' && $this->getCredentialId() !== '';
    }

    /**
     * Generate a fresh 2048-bit RSA key pair WITHOUT persisting it.
     *
     * Registration persists the pair only after the backend confirms, so a failed completion never
     * clobbers a working key.
     *
     * @return array{private_pem: string, public_pem: string}|array{} Empty array on failure.
     */
    public function generateKeyPair(): array
    {
        if (!function_exists('openssl_pkey_new') || !function_exists('openssl_pkey_export')) {
            $this->logger->warning('SimplyIN RSA: OpenSSL unavailable, cannot generate key pair');
            return [];
        }

        $resource = openssl_pkey_new([
            'private_key_bits' => 2048,
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
        ]);
        if ($resource === false) {
            $this->logger->warning('SimplyIN RSA: openssl_pkey_new failed');
            return [];
        }

        $privatePem = '';
        if (openssl_pkey_export($resource, $privatePem) === false) {
            $this->logger->warning('SimplyIN RSA: openssl_pkey_export failed');
            return [];
        }

        $details = openssl_pkey_get_details($resource);
        if ($details === false || empty($details['key'])) {
            $this->logger->warning('SimplyIN RSA: openssl_pkey_get_details failed');
            return [];
        }

        return [
            'private_pem' => $privatePem,
            'public_pem' => (string)$details['key'],
        ];
    }

    /**
     * Sign a base64 registration challenge with a SPECIFIC private key, to prove possession of the
     * freshly generated key before it is persisted. Signs the DECODED challenge bytes (not the
     * base64 string) — the most common integration mistake.
     *
     * @param string $challengeBase64
     * @param string $privatePem
     * @return string Base64 RSA-SHA256 signature, or '' on failure.
     */
    public function signChallengeBase64WithKey(string $challengeBase64, string $privatePem): string
    {
        // phpcs:ignore Magento2.Functions.DiscouragedFunction -- decoding a backend-issued base64 challenge, not user input
        $challengeBytes = base64_decode($challengeBase64, true);
        if ($challengeBytes === false) {
            $this->logger->warning('SimplyIN RSA: invalid base64 challenge');
            return '';
        }

        return $this->signBytesWith($challengeBytes, $privatePem);
    }

    /**
     * Build the four runtime auth headers for a signed request.
     *
     * @param string $method HTTP method.
     * @param string $pathWithQuery Request path + query, EXACTLY as transmitted (must include /api).
     * @param string $rawBody Raw request body bytes ('' for no body).
     * @return array<string,string> Headers, or [] when signing was not possible.
     */
    public function buildHeaders(string $method, string $pathWithQuery, string $rawBody): array
    {
        $credentialId = $this->getCredentialId();
        if ($credentialId === '') {
            return [];
        }

        $timestampSec = (string)time();
        $nonce = $this->generateNonce();
        if ($nonce === '') {
            return [];
        }

        $canonical = $this->buildCanonicalString(
            $timestampSec,
            $method,
            $pathWithQuery,
            $this->bodySha256Hex($rawBody),
            $nonce
        );

        $signature = $this->signBytesWith($canonical, $this->getPrivateKeyPem());
        if ($signature === '') {
            return [];
        }

        return [
            self::HEADER_CREDENTIAL_ID => $credentialId,
            self::HEADER_SIGNATURE => $signature,
            self::HEADER_TIMESTAMP => $timestampSec,
            self::HEADER_NONCE => $nonce,
        ];
    }

    /**
     * Canonical string to sign.
     *
     * Fixed five-line format, LF separated — must match the backend
     * buildPluginCanonicalStringToSign byte for byte.
     *
     * @param string $timestampSec
     * @param string $method
     * @param string $pathWithQuery
     * @param string $bodySha256Hex
     * @param string $nonce
     * @return string
     */
    public function buildCanonicalString(
        string $timestampSec,
        string $method,
        string $pathWithQuery,
        string $bodySha256Hex,
        string $nonce
    ): string {
        return implode("\n", [
            $timestampSec,
            strtoupper($method),
            $pathWithQuery,
            $bodySha256Hex,
            $nonce,
        ]);
    }

    /**
     * SHA-256 hex of the raw body bytes; the empty-body constant for no body.
     *
     * @param string $rawBody
     * @return string
     */
    public function bodySha256Hex(string $rawBody): string
    {
        if ($rawBody === '') {
            return self::EMPTY_BODY_SHA256_HEX;
        }

        return hash('sha256', $rawBody);
    }

    /**
     * Unique per-request nonce (32 hex chars — within the backend-allowed charset).
     *
     * Returns '' when secure randomness is unavailable so callers abort rather than send a
     * half-signed request.
     *
     * @return string
     */
    public function generateNonce(): string
    {
        try {
            return bin2hex(random_bytes(16));
        } catch (\Throwable $e) {
            $this->logger->warning('SimplyIN RSA: random_bytes failed — ' . $e->getMessage());
            return '';
        }
    }

    /**
     * Persist a key pair as the active RSA material.
     *
     * Private key encrypted at rest, credential id alongside. Call only after the backend has
     * accepted the public key.
     *
     * @param string $privatePem
     * @param string $publicPem
     * @param string $credentialId
     * @param string $apiKeyFingerprint SHA-256 of the API key this key was registered against.
     * @return void
     */
    public function store(string $privatePem, string $publicPem, string $credentialId, string $apiKeyFingerprint = ''): void
    {
        $this->configWriter->save(
            self::XML_PATH_PRIVATE_KEY,
            $this->encryptor->encrypt($privatePem),
            ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
            0
        );
        $this->configWriter->save(
            self::XML_PATH_PUBLIC_KEY,
            $publicPem,
            ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
            0
        );
        $this->configWriter->save(
            self::XML_PATH_CREDENTIAL_ID,
            $credentialId,
            ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
            0
        );
        $this->configWriter->save(
            self::XML_PATH_RSA_API_KEY_FP,
            $apiKeyFingerprint,
            ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
            0
        );

        // A successful (re-)registration installs a fresh credential, so clear any stale
        // consecutive-401 streak carried over from a previous credential. Without this, register-
        // then-swap (which no longer clear()s first) would leave the old counter in place and a
        // single transient 401 on the new key could cross the threshold and tear it straight back
        // down. clear() already resets this path; store() must too.
        if ((string) $this->scopeConfig->getValue(self::XML_PATH_RSA_401_COUNT, ScopeInterface::SCOPE_STORE) !== '') {
            $this->configWriter->delete(self::XML_PATH_RSA_401_COUNT, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, 0);
        }

        // Same-request reads use the in-memory copy; other processes need the config cache cleared
        // so ScopeConfigInterface reflects the freshly stored values instead of the stale cache.
        $this->memoryCache[self::XML_PATH_PRIVATE_KEY] = $privatePem;
        $this->memoryCache[self::XML_PATH_PUBLIC_KEY] = $publicPem;
        $this->memoryCache[self::XML_PATH_CREDENTIAL_ID] = $credentialId;
        $this->memoryCache[self::XML_PATH_RSA_API_KEY_FP] = $apiKeyFingerprint;
        $this->cacheTypeList->cleanType('config');
    }

    /**
     * Whether the stored RSA key was registered against the given API key (rotation detection).
     *
     * A mismatch means the merchant changed/rotated the API key and the local RSA key is stale.
     *
     * @param string $apiKey
     * @return bool
     */
    public function matchesApiKey(string $apiKey): bool
    {
        if ($apiKey === '') {
            return false;
        }
        $stored = isset($this->memoryCache[self::XML_PATH_RSA_API_KEY_FP])
            ? $this->memoryCache[self::XML_PATH_RSA_API_KEY_FP]
            : (string)$this->scopeConfig->getValue(self::XML_PATH_RSA_API_KEY_FP, ScopeInterface::SCOPE_STORE);

        return $stored !== '' && hash_equals($stored, hash('sha256', $apiKey));
    }

    /**
     * Get the decrypted RSA private key PEM.
     *
     * @return string
     */
    public function getPrivateKeyPem(): string
    {
        if (isset($this->memoryCache[self::XML_PATH_PRIVATE_KEY])) {
            return $this->memoryCache[self::XML_PATH_PRIVATE_KEY];
        }
        $stored = (string)$this->scopeConfig->getValue(self::XML_PATH_PRIVATE_KEY, ScopeInterface::SCOPE_STORE);

        return $stored === '' ? '' : (string)$this->encryptor->decrypt($stored);
    }

    /**
     * Get the stored RSA public key PEM.
     *
     * @return string
     */
    public function getPublicKeyPem(): string
    {
        if (isset($this->memoryCache[self::XML_PATH_PUBLIC_KEY])) {
            return $this->memoryCache[self::XML_PATH_PUBLIC_KEY];
        }

        return (string)$this->scopeConfig->getValue(self::XML_PATH_PUBLIC_KEY, ScopeInterface::SCOPE_STORE);
    }

    /**
     * Get the stored credential id (apiKey record id the public key is bound to).
     *
     * @return string
     */
    public function getCredentialId(): string
    {
        if (isset($this->memoryCache[self::XML_PATH_CREDENTIAL_ID])) {
            return $this->memoryCache[self::XML_PATH_CREDENTIAL_ID];
        }

        return (string)$this->scopeConfig->getValue(self::XML_PATH_CREDENTIAL_ID, ScopeInterface::SCOPE_STORE);
    }

    /**
     * Drop all local RSA material (rotate / revoke).
     *
     * After this isActive() is false, so the caller can re-register with the current API key.
     *
     * @return void
     */
    public function clear(): void
    {
        foreach ([self::XML_PATH_PRIVATE_KEY, self::XML_PATH_PUBLIC_KEY, self::XML_PATH_CREDENTIAL_ID, self::XML_PATH_RSA_API_KEY_FP, self::XML_PATH_RSA_401_COUNT] as $path) {
            $this->configWriter->delete($path, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, 0);
            unset($this->memoryCache[$path]);
        }
        $this->cacheTypeList->cleanType('config');
    }

    /**
     * React to the HTTP status of an RSA-signed response and decide whether local RSA was dropped
     * (so the caller should re-register + retry). Keyed off the status CODE:
     * - 412 = credential exists but no RSA key on the backend (post revoke/rotate) → clear now.
     * - 401 = transient RSA rejection → clear only after RSA_401_CLEAR_THRESHOLD consecutive 401s.
     * - anything else (incl. 2xx) resets the streak.
     *
     * @param int $status
     * @return bool True when local RSA was cleared and the caller should re-register + retry.
     */
    public function noteRsaResponse(int $status): bool
    {
        if ($status === 412) {
            $this->logger->warning('SimplyIN RSA: 412 — key not registered on backend (revoke/rotate); clearing local RSA');
            $this->clear();
            return true;
        }
        if ($status === 401) {
            $count = (int)$this->scopeConfig->getValue(self::XML_PATH_RSA_401_COUNT, ScopeInterface::SCOPE_STORE) + 1;
            if ($count >= self::RSA_401_CLEAR_THRESHOLD) {
                $this->logger->warning('SimplyIN RSA: ' . $count . ' consecutive 401s; clearing local RSA');
                $this->clear();
                return true;
            }
            $this->configWriter->save(self::XML_PATH_RSA_401_COUNT, (string)$count, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, 0);
            $this->cacheTypeList->cleanType('config');
            return false;
        }
        if ((string)$this->scopeConfig->getValue(self::XML_PATH_RSA_401_COUNT, ScopeInterface::SCOPE_STORE) !== '') {
            $this->configWriter->delete(self::XML_PATH_RSA_401_COUNT, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, 0);
            $this->cacheTypeList->cleanType('config');
        }
        return false;
    }

    /**
     * RSA-SHA256 sign arbitrary bytes with a given private key.
     *
     * @param string $message
     * @param string $privatePem
     * @return string Base64 signature, or '' on failure.
     */
    private function signBytesWith(string $message, string $privatePem): string
    {
        if ($privatePem === '') {
            $this->logger->warning('SimplyIN RSA: no private key to sign with');
            return '';
        }
        if (!function_exists('openssl_sign')) {
            $this->logger->warning('SimplyIN RSA: OpenSSL unavailable, cannot sign');
            return '';
        }

        $signature = '';
        if (openssl_sign($message, $signature, $privatePem, OPENSSL_ALGO_SHA256) === false) {
            $this->logger->warning('SimplyIN RSA: openssl_sign failed');
            return '';
        }

        return base64_encode($signature);
    }
}
