<?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\Webhook\PrestaShopWebhookContextScoper;
use SimplyIn\Webhook\WebhookDeduplicator;
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 = $this->eventId($decoded);
        $deduplicator = new WebhookDeduplicator();
        if (!$deduplicator->isNew($eventId)) {
            $logger->duplicate($eventId);
            exit('OK');
        }

        try {
            $handled = (new WebhookProcessor(new OrderStateUpdater(), new PrestaShopWebhookContextScoper()))->process(
                (string) $decoded['title'],
                (string) $decoded['status'],
            );
        } 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;
    }

    /**
     * Stable idempotency key: the backend event id when present, else a deterministic digest of the
     * order reference + status (matches the Woo receiver's fallback).
     *
     * @param array<string, mixed> $decoded
     */
    private function eventId(array $decoded): string
    {
        if (isset($decoded['event_id']) && is_scalar($decoded['event_id'])) {
            return (string) $decoded['event_id'];
        }
        if (isset($decoded['eventId']) && is_scalar($decoded['eventId'])) {
            return (string) $decoded['eventId'];
        }

        return hash('sha256', (string) $decoded['title'] . '|' . (string) $decoded['status']);
    }
}
