<?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 GuzzleHttp\Client as GuzzleClient;
use PrestaShopLogger;
use Throwable;

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

/**
 * One-time, zero-touch RSA key registration. 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: request registration (API-key auth) -> {credentialId, challengeBase64}
 *   -> generate RSA-2048, sign the decoded challenge -> confirm registration -> {ok}
 *   -> persist private key + credential id ({@see RsaSigner::store()}).
 *
 * Uses its own Guzzle client (not the request-signing API client) so the handshake goes out over the
 * API key and there is no re-entrancy with the request signer.
 *
 * The handshake + persist itself is additionally guarded by {@see RsaRegistrationLock}, a
 * cross-process mutex: several independent call sites (back-office header hook, cron, explicit
 * Save action, proactive runtime throttle) can otherwise race each other from separate PHP-FPM
 * workers, each seeing "not registered yet" and starting the same handshake concurrently.
 */
final class RsaRegistrar
{
    private const START_ENDPOINT = 'plugin-sync/startPluginPublicKeyRegistration';
    private const COMPLETE_ENDPOINT = 'plugin-sync/completePluginPublicKeyRegistration';

    /**
     * Backoff before the single already-registered retry in {@see register()}: a registration
     * attempt landing right after a revoke+regenerate in the merchant panel can transiently observe
     * the previous credential for this API key. Long enough to clear that window; this delay plus a
     * second full handshake round-trip both count against whatever request triggered it.
     */
    private const ALREADY_REGISTERED_RETRY_DELAY_MICROS = 1_500_000;

    /**
     * Timestamp of the last registration attempt (epoch seconds). Shared between the throttled
     * back-office/cron backstop ({@see Simplyin::attemptRsaRegistration}) and the proactive runtime
     * throttle ({@see reregisterThrottled}) so neither hammers the backend during an outage.
     */
    public const LAST_REGISTER_KEY = 'SIMPLYIN_RSA_LAST_REGISTER';

    /** Attempt registration at most once per PHP process; the cron/admin backstop retries later. */
    private bool $attempted = false;

    /** HTTP status code of the most recent handshake response, for {@see lastAttemptWasAuthRejected()}. */
    private ?int $lastStatusCode = null;

    /**
     * Whether the most recent failed handshake attempt failed because the backend already has an
     * RSA public key registered for this API key (from a previous session), for
     * {@see wasAlreadyRegistered()}.
     */
    private bool $lastFailureWasAlreadyRegistered = false;

    private readonly GuzzleClient $client;

    private readonly RsaRegistrationLockInterface $lock;

    /** @var callable(int): void */
    private $sleeper;

    /**
     * @param (callable(int): void)|null $sleeper microseconds -> void, invoked before the
     *                                             already-registered retry; defaults to a real
     *                                             {@see usleep}, overridable in tests so the suite
     *                                             does not actually pause
     */
    public function __construct(
        private readonly RsaSigner $signer,
        private readonly string $baseUrl,
        private readonly string $apiKey,
        private readonly string $origin,
        ?GuzzleClient $client = null,
        ?RsaRegistrationLockInterface $lock = null,
        ?callable $sleeper = null,
    ) {
        $this->client = $client ?? new GuzzleClient([
            'timeout' => 30.0,
            'connect_timeout' => 10.0,
            'verify' => true,
            'http_errors' => false,
        ]);
        $this->lock = $lock ?? new RsaRegistrationLock();
        $this->sleeper = $sleeper ?? static function (int $microseconds): void {
            usleep($microseconds);
        };
    }

    /**
     * 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 / admin backstop / next process.
     *
     * @param bool $allowBlockingRetry whether an "already exists" on the first attempt may block this
     *                                 call for {@see self::ALREADY_REGISTERED_RETRY_DELAY_MICROS} to
     *                                 retry once (see {@see register()}). True only for the explicit,
     *                                 one-shot admin/cron attempt ({@see \Simplyin::attemptRsaRegistration},
     *                                 the connect/Save action) where a shopper is never waiting on this
     *                                 call; {@see reregister()} always passes false, since it also runs
     *                                 inline on the request-signing path ({@see \SimplyIn\Api\SimplyInApiClient}),
     *                                 which a live checkout request can be blocked on.
     * @return bool true when RSA is active (already, or just registered)
     */
    public function ensureRegistered(bool $allowBlockingRetry = true): bool
    {
        if ($this->apiKey === '') {
            return false;
        }
        // Active AND registered against the current API key → nothing to do.
        if ($this->signer->isActive() && $this->signer->matchesApiKey($this->apiKey)) {
            return true;
        }
        if ($this->attempted) {
            return false;
        }
        $this->attempted = true;
        $this->lastStatusCode = null;
        // A lock-contention bailout in doRegister() returns before register() runs (and so before
        // register()'s own reset of this flag) — reset it here too, otherwise a contended attempt on
        // this instance could inherit a stale `true` left over from an earlier, genuine rejection.
        $this->lastFailureWasAlreadyRegistered = false;

        // Register-then-swap: generate a fresh key pair and only overwrite the stored credential
        // once the backend confirms ({@see RsaSigner::store}). The existing working RSA key is kept
        // until the replacement is confirmed, so checkout keeps working during the registration
        // window; a stale key that now 401s is handled by RsaSigner::noteRsaResponse.
        return $this->doRegister($allowBlockingRetry);
    }

    /**
     * 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.
     * Runs inline on the request-signing path, possibly inside a live checkout request — never
     * allowed to block on the already-registered retry, unlike the explicit admin/cron attempt.
     */
    public function reregister(): bool
    {
        $this->attempted = false;

        return $this->ensureRegistered(false);
    }

    /**
     * Proactive, throttled re-registration for the runtime request path. When RSA is inactive at
     * the start of an authenticated request we try to bring it up — but at most once per throttle
     * window (persisted in {@see self::LAST_REGISTER_KEY}, shared with the BO/cron backstop), so a
     * backend registration outage cannot turn every checkout call into a fresh handshake attempt.
     * Within the window it returns false immediately. The reactive self-heal (412 / 401-streak, via
     * {@see reregister}) does not go through here — that path retries immediately.
     *
     * @return bool true when RSA is active after this call
     */
    public function reregisterThrottled(int $throttleSeconds = 300): bool
    {
        $last = (int) Configuration::get(self::LAST_REGISTER_KEY);
        if ($last > 0 && (time() - $last) < $throttleSeconds) {
            return false;
        }
        Configuration::updateValue(self::LAST_REGISTER_KEY, (string) time());

        return $this->reregister();
    }

    /**
     * Whether the most recent failed handshake attempt was rejected by the backend as an
     * authentication failure (401/403 — the API key is invalid), as opposed to a network/technical
     * problem (timeout, 5xx, ...). Meaningless after a successful {@see ensureRegistered()} call.
     */
    public function lastAttemptWasAuthRejected(): bool
    {
        return $this->lastStatusCode === 401 || $this->lastStatusCode === 403;
    }

    /**
     * Whether the most recent failed handshake attempt failed because the backend reported that
     * this API key already has an RSA public key registered from a previous session — the key
     * itself is valid, there is simply nothing to (re-)register without an explicit revoke.
     * Meaningless after a successful {@see ensureRegistered()} call.
     */
    public function wasAlreadyRegistered(): bool
    {
        return $this->lastFailureWasAlreadyRegistered;
    }

    /**
     * Serializes the actual handshake across processes via {@see RsaRegistrationLock}. A contended
     * lock is not a failure worth reporting as "already registered" or "technical failure" to the
     * caller's outcome-classification logic — it means another process is (or just was) handling
     * this, so we bail out as a plain, silent no-op and let that process's result stand.
     *
     * The lock itself can never throw out of this method (see {@see ensureRegistered()}'s "never
     * throws" contract): acquiring it is covered by the same catch as the handshake, and releasing
     * it is wrapped separately inside the `finally` so a release-time failure cannot mask whatever
     * the `try` block already resolved (or escape as an uncaught exception on its own).
     *
     * Re-checking signer state right after acquiring the lock only catches a concurrent winner when
     * that state is visible to THIS process — cheap and correct within one process/request, but
     * PrestaShop's Configuration facade caches values per-process, so a losing worker's cache can
     * stay stale relative to a DB write another worker just made. In that case this still proceeds
     * to its own (now serialized, never concurrent) backend call, which the backend will correctly
     * reject as already registered — a true statement at that point, not a race artifact, just not
     * the silent success the caller would ideally see.
     */
    private function doRegister(bool $allowBlockingRetry): bool
    {
        $lockAcquired = false;
        try {
            $lockAcquired = $this->lock->acquire();
            if (!$lockAcquired) {
                $this->log('SimplyIN RSA: registration already in progress in another process, skipping');

                return false;
            }

            // Re-check under the lock: another process may have completed registration while this
            // one was waiting to acquire it, in which case there is nothing left to do.
            if ($this->signer->isActive() && $this->signer->matchesApiKey($this->apiKey)) {
                return true;
            }

            return $this->register($allowBlockingRetry);
        } catch (Throwable $e) {
            $this->log('SimplyIN RSA: registration failed — ' . $e->getMessage());

            return false;
        } finally {
            if ($lockAcquired) {
                try {
                    $this->lock->release();
                } catch (Throwable $e) {
                    $this->log('SimplyIN RSA: failed to release the registration lock — ' . $e->getMessage());
                }
            }
        }
    }

    private function register(bool $allowBlockingRetry): bool
    {
        $this->lastFailureWasAlreadyRegistered = false;

        $base = rtrim($this->baseUrl, '/');
        if ($base === '') {
            $this->log('SimplyIN RSA: no API base URL configured, cannot register');

            return false;
        }

        $started = $this->startRegistration($base);
        if ($allowBlockingRetry && $started === [] && $this->lastFailureWasAlreadyRegistered) {
            $this->log('SimplyIN RSA: already-registered on first attempt, retrying once after backoff');
            ($this->sleeper)(self::ALREADY_REGISTERED_RETRY_DELAY_MICROS);
            $this->lastFailureWasAlreadyRegistered = false;
            $started = $this->startRegistration($base);
        }
        if ($started === []) {
            return false;
        }

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

        if (!$this->completeRegistration($base, $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). If persistence fails, treat
        // registration as failed rather than half-committed.
        if (!$this->signer->store(
            $pair['private_pem'],
            $pair['public_pem'],
            $started['credentialId'],
            hash('sha256', $this->apiKey),
        )) {
            return false;
        }
        $this->log('SimplyIN RSA: registration completed, request signing active');

        return true;
    }

    /**
     * START handshake — obtain the credential id + challenge (API-key authenticated).
     *
     * @return array{credentialId: string, challenge: string}|array{} empty on failure
     *
     * @phpstan-impure sets $this->lastFailureWasAlreadyRegistered as a side effect on failure
     */
    private function startRegistration(string $base): array
    {
        $start = $this->postWithApiKey($base . '/' . self::START_ENDPOINT, '');
        $startData = $this->unwrap($start);
        $credentialId = isset($startData['credentialId']) ? trim((string) $startData['credentialId']) : '';
        $challenge = isset($startData['challengeBase64']) ? trim((string) $startData['challengeBase64']) : '';
        if ($credentialId === '' || $challenge === '') {
            // A 401/403 is always an auth rejection, regardless of message wording — never let a
            // coincidental "already exists" substring in a genuine auth-rejection body downgrade it
            // to the (reassuring) already-registered classification.
            $message = isset($startData['message']) ? (string) $startData['message'] : '';
            if ($this->lastStatusCode !== 401
                && $this->lastStatusCode !== 403
                && $message !== ''
                && stripos($message, 'already exists') !== false
            ) {
                $this->lastFailureWasAlreadyRegistered = true;
            }
            $this->log('SimplyIN RSA: start did not return credentialId/challenge');

            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 + signature.
     *
     * The backend expects the key as base64-encoded DER (SPKI), not PEM, so the PEM produced by
     * {@see RsaSigner::generateKeyPair()} is stripped down to its base64 payload here.
     */
    private function completeRegistration(string $base, string $publicPem, string $signatureBase64): bool
    {
        $publicKeyDerBase64 = preg_replace('/-----[^-]+-----|\s+/', '', $publicPem);
        if ($publicKeyDerBase64 === null || $publicKeyDerBase64 === '') {
            $this->log('SimplyIN RSA: could not derive base64 DER from the generated public key');

            return false;
        }
        $body = json_encode(['publicKey' => $publicKeyDerBase64, 'signature' => $signatureBase64]);
        if ($body === false) {
            $this->log('SimplyIN RSA: could not encode the complete-registration request body');

            return false;
        }
        $complete = $this->postWithApiKey($base . '/' . self::COMPLETE_ENDPOINT, $body);
        $confirm = $this->unwrap($complete);
        if (($confirm['ok'] ?? null) !== true) {
            $this->log('SimplyIN RSA: backend did not confirm registration');

            return false;
        }

        return true;
    }

    /**
     * POST with the merchant API key header; returns the decoded JSON body (array) or [].
     *
     * @return array<string, mixed>
     */
    private function postWithApiKey(string $url, string $body): array
    {
        $response = $this->client->request('POST', $url, [
            'headers' => [
                'Content-Type' => 'application/json',
                'Origin' => $this->origin,
                'X-Auth-Merchant-Api-Key' => $this->apiKey,
            ],
            'body' => $body,
        ]);
        $this->lastStatusCode = $response->getStatusCode();
        $decoded = json_decode((string) $response->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.
     *
     * @param array<string, mixed> $response
     *
     * @return array<string, mixed>
     */
    private function unwrap(array $response): array
    {
        if (isset($response['result']['data']) && is_array($response['result']['data'])) {
            return $response['result']['data'];
        }

        return $response;
    }

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