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

use Db;

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

/**
 * Idempotency guard for inbound payment webhooks (anti-replay). Each processed webhook is keyed by
 * its `event_id` (or a deterministic fallback), persisted in a dedicated table with a UNIQUE index
 * so a redelivered event is recognized and skipped even across processes / concurrent requests.
 *
 * Magento's receiver has no event-dedup; the Woo receiver keeps the last N ids in order-meta. This
 * store generalizes that to a durable, unbounded-safe table (pruned by age).
 */
final class WebhookDeduplicator
{
    private const TABLE = 'simplyin_webhook_event';

    /** Retention for processed-event rows; older rows are pruned. */
    private const RETENTION_DAYS = 30;

    public static function installSql(): bool
    {
        $table = _DB_PREFIX_ . self::TABLE;
        $sql = 'CREATE TABLE IF NOT EXISTS `' . $table . '` ('
            . '`id_webhook_event` INT(11) NOT NULL AUTO_INCREMENT,'
            . '`event_id` VARCHAR(191) NOT NULL,'
            . '`date_add` DATETIME NOT NULL,'
            . 'PRIMARY KEY (`id_webhook_event`),'
            . 'UNIQUE KEY `event_id` (`event_id`),'
            . 'KEY `date_add` (`date_add`)'
            . ') CHARACTER SET utf8 COLLATE utf8_general_ci;';

        return (bool) Db::getInstance()->execute($sql);
    }

    public static function uninstallSql(): bool
    {
        return (bool) Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . self::TABLE . '`');
    }

    /**
     * Record an event id and report whether it is the first time we have seen it. Concurrency-safe:
     * relies on the UNIQUE index + INSERT IGNORE, so a duplicate returns false without a race.
     *
     * @return bool true when the event is new (caller should process it); false when a duplicate
     */
    public function isNew(string $eventId): bool
    {
        if ($eventId === '') {
            // No usable id — do not dedup (better to process than to silently drop).
            return true;
        }

        $db = Db::getInstance();
        $inserted = $db->execute(
            'INSERT IGNORE INTO `' . _DB_PREFIX_ . self::TABLE . '` (`event_id`, `date_add`) '
            . "VALUES ('" . pSQL($this->storageKey($eventId)) . "', NOW())",
        );

        // A duplicate is a successful INSERT IGNORE with 0 affected rows. A hard DB failure (missing
        // table / permission / transient) makes execute() return false — fail OPEN there (process)
        // rather than silently dropping the webhook as if it were a duplicate.
        if ($inserted === false) {
            return true;
        }

        return $db->Affected_Rows() > 0;
    }

    /**
     * Release a previously-claimed event id so a redelivery can be processed again. Used when the
     * claim was taken but processing turned out to be a no-op (e.g. the order did not exist yet), so
     * the event must not stay permanently suppressed.
     */
    public function forget(string $eventId): void
    {
        if ($eventId === '') {
            return;
        }
        Db::getInstance()->execute(
            'DELETE FROM `' . _DB_PREFIX_ . self::TABLE . "` WHERE `event_id` = '" . pSQL($this->storageKey($eventId)) . "'",
        );
    }

    /**
     * Bound the stored key to the column width: ids within VARCHAR(191) are stored verbatim; longer
     * ids collapse to a SHA-256 hex digest so they can't be truncated (which would cause false
     * duplicates) and so isNew()/forget() always agree on the same key.
     */
    private function storageKey(string $eventId): string
    {
        return strlen($eventId) > 191 ? hash('sha256', $eventId) : $eventId;
    }

    public function prune(): void
    {
        Db::getInstance()->execute(
            'DELETE FROM `' . _DB_PREFIX_ . self::TABLE . '` '
            . 'WHERE `date_add` < DATE_SUB(NOW(), INTERVAL ' . self::RETENTION_DAYS . ' DAY)',
        );
    }
}
