<?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);

use SimplyIn\Auth\SecretCipher;
use SimplyIn\Auth\WebhookSignatureVerifier;
use SimplyIn\Payment\OrderStateUpdater;
use SimplyIn\Payment\PaymentProviderErrorReason;
use SimplyIn\Webhook\PrestaShopWebhookContextScoper;
use SimplyIn\Webhook\WebhookDeduplicator;
use SimplyIn\Webhook\WebhookEventKey;
use SimplyIn\Webhook\WebhookLogger;
use SimplyIn\Webhook\WebhookProcessor;
use SimplyIn\Webhook\WebhookSecretStore;

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

class SimplyinWebhookModuleFrontController extends ModuleFrontController
{
    /** Application-level payload cap; real webhook bodies are a few hundred bytes. */
    private const MAX_BODY_BYTES = 65536;

    public function __construct()
    {
        parent::__construct();

        $logger = new WebhookLogger();
        $senderIp = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';

        // Read one byte past the cap so an oversized body is detectable without buffering it whole
        // (Content-Length alone is not reliable, e.g. chunked transfer encoding).
        $payload = (string) file_get_contents('php://input', false, null, 0, self::MAX_BODY_BYTES + 1);
        if (strlen($payload) > self::MAX_BODY_BYTES) {
            $logger->warning(sprintf('Webhook body over %d bytes from IP "%s"; rejected', self::MAX_BODY_BYTES, $senderIp));
            http_response_code(413);
            exit('-1');
        }

        if (!$this->authenticate($logger, $senderIp, $payload)) {
            exit;
        }

        $decoded = json_decode($payload, true);
        if (!is_array($decoded) || empty($decoded['title']) || empty($decoded['status'])) {
            $logger->invalidPayload($senderIp, $payload);
            http_response_code(400);
            exit('-1');
        }

        if (!Configuration::get('SIMPLYIN_PAYMENTS')) {
            exit('-2');
        }

        // Idempotency / anti-replay: claim the event before processing (the UNIQUE INSERT is atomic,
        // so concurrent redeliveries cannot double-apply). A duplicate is acked without re-applying.
        $eventId = WebhookEventKey::derive($decoded);
        $deduplicator = new WebhookDeduplicator();
        if (!$deduplicator->isNew($eventId)) {
            $logger->duplicate($eventId);
            exit('OK');
        }

        $attemptPaymentId = WebhookEventKey::attemptPaymentId($decoded);
        if ($attemptPaymentId === '') {
            // Without a per-attempt identifier the status can only be applied to the newest attempt
            // recorded for the order, which is the wrong one whenever an order carries several.
            // Logged rather than silently tolerated: this is the condition under which the
            // attempt-scoped path is not in effect, and it is otherwise invisible.
            $logger->warning('Webhook carried no recognisable payment id; attempt status falls back to the newest attempt for the order');
        }

        $rejectReason = PaymentProviderErrorReason::sanitize($decoded['rejectReason'] ?? null);

        try {
            $handled = (new WebhookProcessor(new OrderStateUpdater(), new PrestaShopWebhookContextScoper()))->process(
                (string) $decoded['title'],
                (string) $decoded['status'],
                $attemptPaymentId,
                $rejectReason,
            );
        } catch (Throwable $e) {
            // Processing failed after the claim was taken: release it and let the error propagate
            // (5xx) so the sender's redelivery can re-process instead of hitting a stale claim.
            $deduplicator->forget($eventId);

            throw $e;
        }

        // The webhook can arrive before the order exists; if nothing was applied, release the claim
        // so a later redelivery is processed instead of being permanently suppressed.
        if (!$handled) {
            $deduplicator->forget($eventId);
        }

        exit('OK');
    }

    /**
     * Authenticate the inbound webhook via the per-shop HMAC secret (mismatch, or no secret
     * synchronized yet at all → fail closed). The admin-side retry prompt on a failed sync
     * is the safety net for the "not synced yet" window — there is no IP allowlist fallback.
     */
    private function authenticate(WebhookLogger $logger, string $senderIp, string $payload): bool
    {
        $secretStore = new WebhookSecretStore(new SecretCipher(_COOKIE_KEY_));
        $secret = $secretStore->get();

        if ($secret !== '') {
            // Read the signed headers with only surrounding-whitespace trimming — no content
            // sanitization, which would mutate the signed bytes and break verification.
            $signature = isset($_SERVER['HTTP_X_SIMPLYIN_WEBHOOK_SIGNATURE'])
                ? trim((string) $_SERVER['HTTP_X_SIMPLYIN_WEBHOOK_SIGNATURE'])
                : '';
            $timestamp = isset($_SERVER['HTTP_X_SIMPLYIN_WEBHOOK_TIMESTAMP'])
                ? trim((string) $_SERVER['HTTP_X_SIMPLYIN_WEBHOOK_TIMESTAMP'])
                : '';

            if (!(new WebhookSignatureVerifier())->verify($payload, $timestamp, $signature, $secret)) {
                $logger->signatureRejected($senderIp);
                http_response_code(401);

                return false;
            }

            return true;
        }

        // A secret is configured but failed to decrypt: fail closed.
        if ($secretStore->hasStoredValue()) {
            $logger->warning(sprintf('HMAC secret present but undecryptable; failing closed for IP "%s"', $senderIp));
            http_response_code(401);

            return false;
        }

        // No HMAC secret has been synchronized yet: fail closed rather than falling back to an IP
        // allowlist. Reject with 403 (a real auth failure, not a 2xx ack) so the delivery is
        // recorded as failed on the sender side instead of being silently accepted.
        $logger->rejected($senderIp, $payload);
        http_response_code(403);

        return false;
    }
}
