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

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

/**
 * 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 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.
 */
final class RsaRegistrar
{
    private const START_ENDPOINT = 'plugin-sync/startPluginPublicKeyRegistration';
    private const COMPLETE_ENDPOINT = 'plugin-sync/completePluginPublicKeyRegistration';

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

    private readonly GuzzleClient $client;

    public function __construct(
        private readonly RsaSigner $signer,
        private readonly string $baseUrl,
        private readonly string $apiKey,
        private readonly string $origin,
        ?GuzzleClient $client = null,
    ) {
        $this->client = $client ?? new GuzzleClient([
            'timeout' => 30.0,
            'connect_timeout' => 10.0,
            'verify' => true,
            'http_errors' => false,
        ]);
    }

    /**
     * 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.
     *
     * @return bool true when RSA is active (already, or just registered)
     */
    public function ensureRegistered(): 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;
        // Rotation: local RSA belongs to a different (revoked/rotated) API key → drop it before
        // re-registering with the current one, so we never sign with the stale credential again.
        if ($this->signer->isActive()) {
            $this->signer->clear();
        }

        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.
     */
    public function reregister(): bool
    {
        $this->attempted = false;

        return $this->ensureRegistered();
    }

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

            return false;
        }
    }

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

            return 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 (key
        // could not be encrypted at rest) 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
     */
    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 === '') {
            $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 publicKeyPem + signature.
     */
    private function completeRegistration(string $base, string $publicPem, string $signatureBase64): bool
    {
        $body = json_encode(['publicKeyPem' => $publicPem, 'signatureBase64' => $signatureBase64]);
        $complete = $this->postWithApiKey($base . '/' . self::COMPLETE_ENDPOINT, $body === false ? '' : $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,
        ]);
        $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);
        }
    }
}
