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

use Configuration;
use PrestaShopLogger;
use Throwable;

if (!defined('_PS_VERSION_')) {
    exit;
}

/**
 * RSA request signer (plugin side of Simply.IN's RSA request authentication).
 *
 * The merchant API key authenticates only the one-time key-registration handshake (see
 * {@see RsaRegistrar}); once a key pair is registered, every authenticated runtime request is
 * signed here with the shop's RSA private key and the API key header is dropped. There is no
 * per-request API-key fallback — if signing is active but fails, the caller fails closed rather
 * than downgrading silently.
 *
 * Owns the key lifecycle (stored via PrestaShop Configuration, private key encrypted at rest via
 * {@see SecretCipher}) and the signing contract, which must match the backend exactly.
 */
final 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's equivalent constant
     * exactly.
     */
    public const EMPTY_BODY_SHA256_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';

    /** Configuration keys for the key material and pairing identifier. */
    public const CONFIG_PRIVATE_KEY = 'SIMPLYIN_RSA_PRIVATE_KEY';
    public const CONFIG_PUBLIC_KEY = 'SIMPLYIN_RSA_PUBLIC_KEY';
    public const CONFIG_CREDENTIAL_ID = 'SIMPLYIN_RSA_CREDENTIAL_ID';

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

    /** Consecutive RSA-401 counter for the rotate/revoke resilience guard. */
    public const CONFIG_401_COUNT = 'SIMPLYIN_RSA_401_COUNT';

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

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

    /**
     * @param string $slotSuffix appended to every Configuration key this signer reads or writes,
     *                           so each environment keeps its own registration instead of the two
     *                           overwriting one shared slot. Production uses '' (the historical
     *                           unsuffixed names), so an existing registration keeps working
     *                           without a migration. See {@see \SimplyIn\BackOffice\Environment::rsaSlotSuffix()}.
     */
    public function __construct(
        private readonly SecretCipher $cipher,
        private readonly string $slotSuffix = '',
    ) {
    }

    /**
     * The Configuration key this signer actually reads/writes for a given logical slot. The
     * in-memory cache stays keyed by the logical (unsuffixed) name — one signer instance only
     * ever serves one environment, so there is nothing to disambiguate there.
     */
    private function slot(string $key): string
    {
        return $key . $this->slotSuffix;
    }

    /**
     * Whether RSA-signed requests should be used for authenticated traffic. True when a registered
     * key pair AND a credential id are present, and OpenSSL signing is available.
     *
     * @phpstan-impure reads persisted key material, which rotate/revoke handling mutates mid-request
     */
    public function isActive(): bool
    {
        return $this->isRegistered() && function_exists('openssl_sign');
    }

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

    /**
     * Whether RSA signing is even possible in this PHP build (OpenSSL present). When false, RSA can
     * never activate regardless of registration state — used to log an explicit, diagnosable reason
     * instead of an opaque "RSA inactive".
     */
    public function isSigningAvailable(): bool
    {
        return function_exists('openssl_sign');
    }

    /**
     * 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 = self::generateNonce();
        if ($nonce === '') {
            return [];
        }

        $canonical = self::buildCanonicalString(
            $timestampSec,
            $method,
            $pathWithQuery,
            self::bodySha256Hex($rawBody),
            $nonce,
        );

        $signature = self::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's
     * construction byte for byte.
     */
    public static 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.
     */
    public static function bodySha256Hex(string $rawBody): string
    {
        if ($rawBody === '') {
            return self::EMPTY_BODY_SHA256_HEX;
        }

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

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

            return '';
        }
    }

    /**
     * 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 static function generateKeyPair(): array
    {
        if (!function_exists('openssl_pkey_new') || !function_exists('openssl_pkey_export')) {
            self::log('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) {
            self::log('SimplyIN RSA: openssl_pkey_new failed');

            return [];
        }

        $privatePem = '';
        if (openssl_pkey_export($resource, $privatePem) === false) {
            self::log('SimplyIN RSA: openssl_pkey_export failed');

            return [];
        }

        $details = openssl_pkey_get_details($resource);
        if ($details === false || empty($details['key'])) {
            self::log('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.
     *
     * @return string base64 RSA-SHA256 signature, or '' on failure
     */
    public static function signChallengeBase64WithKey(string $challengeBase64, string $privatePem): string
    {
        $challengeBytes = base64_decode($challengeBase64, true);
        if ($challengeBytes === false) {
            self::log('SimplyIN RSA: invalid base64 challenge');

            return '';
        }

        return self::signBytesWith($challengeBytes, $privatePem);
    }

    /**
     * RSA-SHA256 (RSASSA-PKCS#1 v1.5) sign arbitrary bytes with a given private key.
     *
     * @return string base64 signature, or '' on failure
     */
    public static function signBytesWith(string $message, string $privatePem): string
    {
        if ($privatePem === '') {
            self::log('SimplyIN RSA: no private key to sign with');

            return '';
        }
        if (!function_exists('openssl_sign')) {
            self::log('SimplyIN RSA: OpenSSL unavailable, cannot sign');

            return '';
        }

        $signature = '';
        if (openssl_sign($message, $signature, $privatePem, OPENSSL_ALGO_SHA256) === false) {
            self::log('SimplyIN RSA: openssl_sign failed');

            return '';
        }

        return base64_encode($signature);
    }

    /**
     * Persist a key pair as the active RSA material. Private key encrypted at rest. Call only after
     * the backend has accepted the public key.
     *
     * @param string $apiKeyFingerprint SHA-256 of the API key this key was registered against
     *
     * @return bool false when the private key could not be encrypted (nothing is persisted, so the
     *              caller must treat registration as failed rather than commit unusable material)
     */
    public function store(string $privatePem, string $publicPem, string $credentialId, string $apiKeyFingerprint = ''): bool
    {
        $encryptedPrivate = $this->cipher->encrypt($privatePem);
        if ($encryptedPrivate === '') {
            self::log('SimplyIN RSA: private key encryption failed; not persisting registration');

            return false;
        }

        Configuration::updateValue($this->slot(self::CONFIG_PRIVATE_KEY), $encryptedPrivate);
        Configuration::updateValue($this->slot(self::CONFIG_PUBLIC_KEY), $publicPem);
        Configuration::updateValue($this->slot(self::CONFIG_CREDENTIAL_ID), $credentialId);
        Configuration::updateValue($this->slot(self::CONFIG_API_KEY_FP), $apiKeyFingerprint);
        Configuration::deleteByName($this->slot(self::CONFIG_401_COUNT));

        $this->memoryCache[self::CONFIG_PRIVATE_KEY] = $privatePem;
        $this->memoryCache[self::CONFIG_PUBLIC_KEY] = $publicPem;
        $this->memoryCache[self::CONFIG_CREDENTIAL_ID] = $credentialId;
        $this->memoryCache[self::CONFIG_API_KEY_FP] = $apiKeyFingerprint;

        return true;
    }

    /**
     * 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.
     */
    public function matchesApiKey(string $apiKey): bool
    {
        if ($apiKey === '') {
            return false;
        }
        $stored = $this->readConfig(self::CONFIG_API_KEY_FP);

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

    /**
     * Get the decrypted RSA private key PEM, or '' when none is stored / decryption fails.
     */
    public function getPrivateKeyPem(): string
    {
        if (isset($this->memoryCache[self::CONFIG_PRIVATE_KEY])) {
            return $this->memoryCache[self::CONFIG_PRIVATE_KEY];
        }
        $stored = $this->readConfig(self::CONFIG_PRIVATE_KEY);
        if ($stored === '') {
            return '';
        }

        // Cache the decrypted PEM for the rest of the request — signing happens per request and
        // isActive()/buildHeaders() would otherwise re-run openssl_decrypt on every call.
        $decrypted = $this->cipher->decrypt($stored);
        if ($decrypted !== '') {
            $this->memoryCache[self::CONFIG_PRIVATE_KEY] = $decrypted;
        }

        return $decrypted;
    }

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

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

    /**
     * Drop all local RSA material (rotate / revoke). After this isActive() is false, so the caller
     * can re-register with the current API key.
     */
    public function clear(): void
    {
        foreach (self::slotKeys() as $key) {
            Configuration::deleteByName($this->slot($key));
            unset($this->memoryCache[$key]);
        }
    }

    /**
     * The logical slots this class owns, in one place so callers never restate the key names.
     *
     * @return list<string>
     */
    public static function slotKeys(): array
    {
        return [
            self::CONFIG_PRIVATE_KEY,
            self::CONFIG_PUBLIC_KEY,
            self::CONFIG_CREDENTIAL_ID,
            self::CONFIG_API_KEY_FP,
            self::CONFIG_401_COUNT,
        ];
    }

    /**
     * Move an existing registration between environment slot namespaces, for the one-off upgrade
     * that discovers which environment a pre-existing (unsuffixed) registration actually belongs
     * to. A plain move, not a re-registration: the credential is still the one the backend holds,
     * so re-registering instead would be refused as already registered.
     */
    public static function moveSlots(string $fromSuffix, string $toSuffix): void
    {
        if ($fromSuffix === $toSuffix) {
            return;
        }
        foreach (self::slotKeys() as $key) {
            $value = Configuration::get($key . $fromSuffix);
            if ($value === false || $value === '') {
                continue;
            }
            Configuration::updateValue($key . $toSuffix, (string) $value);
            Configuration::deleteByName($key . $fromSuffix);
        }
    }

    /**
     * React to the HTTP status of an RSA-signed response and decide whether local RSA was dropped
     * (so the caller should re-register + retry):
     * - 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.
     *
     * @return bool true when local RSA was cleared and the caller should re-register + retry
     */
    public function noteRsaResponse(int $status): bool
    {
        if ($status === 412) {
            self::log('SimplyIN RSA: 412 — key not registered on backend (revoke/rotate); clearing local RSA');
            $this->clear();

            return true;
        }
        if ($status === 401) {
            $count = (int) $this->readConfig(self::CONFIG_401_COUNT) + 1;
            if ($count >= self::RSA_401_CLEAR_THRESHOLD) {
                self::log('SimplyIN RSA: ' . $count . ' consecutive 401s; clearing local RSA');
                $this->clear();

                return true;
            }
            Configuration::updateValue($this->slot(self::CONFIG_401_COUNT), (string) $count);

            return false;
        }
        if ($this->readConfig(self::CONFIG_401_COUNT) !== '') {
            Configuration::deleteByName($this->slot(self::CONFIG_401_COUNT));
        }

        return false;
    }

    private function readConfig(string $key): string
    {
        if (isset($this->memoryCache[$key])) {
            return $this->memoryCache[$key];
        }
        $value = Configuration::get($this->slot($key));

        return $value === false ? '' : (string) $value;
    }

    private static function log(string $message): void
    {
        if (class_exists('PrestaShopLogger')) {
            PrestaShopLogger::addLog($message, 2, null, 'SimplyIn', null, true);
        }
    }
}
