<?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 Configuration;
use Order;
use PrestaShopLogger;
use SimplyIn\Payment\OrderStateUpdaterInterface;
use SimplyInPayments;
use Validate;

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

final class WebhookProcessor
{
    public function __construct(
        private readonly OrderStateUpdaterInterface $orderStateUpdater,
        private readonly WebhookContextScoperInterface $contextScoper,
    ) {
    }

    /**
     * Apply the notification's status to every order matching the reference.
     *
     * The ORDER's state is per-order and always applied. The stored ATTEMPT's status is a different
     * question: an order can carry several attempts, and the writers that resolve to "the newest
     * attempt for this cart" would put an answer about an earlier attempt onto a later, possibly
     * still live, one. So when the notification lets us identify which attempt it concerns, the
     * status is written to exactly that attempt and the newest-row writer is skipped.
     *
     * @param string $attemptPaymentId a payment id the notification offered for this payment, or ''
     *                                 - never trusted as given; it counts only if it matches an
     *                                 attempt this plugin itself recorded
     * @param string $rejectReason     an already-sanitized {@see
     *                                 \SimplyIn\Payment\PaymentProviderErrorReason} member, or ''
     *                                 when the notification carried none (or it did not survive
     *                                 sanitization) - never stored in that case
     *
     * @return bool true when at least one order was found and handled; false when no order matched
     *              (e.g. the notification raced ahead of order creation) so the caller can allow a
     *              retry
     */
    public function process(string $orderReference, string $status, string $attemptPaymentId = '', string $rejectReason = ''): bool
    {
        $handled = false;
        $orders = Order::getByReference($orderReference);
        foreach ($orders as $order) {
            if (!($order instanceof Order) || !Validate::isLoadedObject($order)) {
                continue;
            }
            $handled = true;

            $this->contextScoper->scope($order);

            [$state, $callback, $attemptStatus, $rejectReasonToApply] = $this->resolve(
                strtoupper($status),
                $order,
                $orderReference,
                $rejectReason,
            );
            if ($state === null) {
                continue;
            }

            // Applying the status to the identified attempt first; on success the per-cart writer is
            // dropped so it cannot overwrite a different attempt's row with this answer.
            //
            // The identifier is validated by lookup rather than trusted: it counts only when it
            // matches the payment id of an attempt this plugin recorded. The field it arrives under
            // is not something this code can verify from its own side, so instead of assuming a
            // name, it asks whether the value names something we know.
            if ($attemptPaymentId !== '' && SimplyInPayments::applyStatusByUuid($attemptPaymentId, $attemptStatus, $rejectReasonToApply)) {
                $callback = '';
            } elseif ($rejectReasonToApply !== '') {
                // The attempt could not be identified by id, so this falls back to the per-cart
                // writer below - which the generic dispatch never threads a reason through. Applied
                // directly here instead, then dropped from the dispatch exactly as the identified
                // branch above does, so the write never happens twice.
                SimplyInPayments::markAsRejected($order->id_cart, $rejectReasonToApply);
                $callback = '';
            }

            $this->orderStateUpdater->update($order, $state, $callback);
        }

        return $handled;
    }

    /**
     * What a notification status means: the order state to move to, the per-cart status writer to
     * fall back on, the status to store against the attempt, and the reject reason that status
     * accepts (never anything a caller passed in for a status that has no business carrying one -
     * resolving that here, not by the caller matching against $callback, is what keeps the
     * attempt-scoped path and the per-cart path from ever recording different things).
     *
     * Returns [null, '', '', ''] when the status is not one we act on.
     *
     * @param string $rejectReason an already-sanitized {@see
     *                             \SimplyIn\Payment\PaymentProviderErrorReason} member offered by
     *                             the notification, or '' - only ever returned back for a status
     *                             that can carry one
     *
     * @return array{null|int, string, string, string}
     */
    private function resolve(string $status, Order $order, string $orderReference, string $rejectReason): array
    {
        switch ($status) {
            case 'PAID':
                return [(int) Configuration::get('PS_OS_PAYMENT'), 'markAsPaid', 'PAID', ''];

            case 'REJECTED':
            case 'FAILED':
            case 'FAILD': // defensive: historic misspelling, kept in sync with PaymentStatus::TERMINAL_STATUSES
                return [(int) Configuration::get('SIMPLYIN_PAYMENT_STATUS_CANCELED'), 'markAsRejected', 'REJECTED', $rejectReason];

            case 'CANCELLED':
            case 'CANCELED': // SIN.CORE's own spelling of this status; kept alongside the historic
                // CANCELLED in sync with PaymentStatus::TERMINAL_STATUSES. Normalised to
                // CANCELLED on write - the plugin's own write path never used the single-L
                // spelling, so existing stored attempts stay in one vocabulary.
                return [(int) Configuration::get('SIMPLYIN_PAYMENT_STATUS_CANCELED'), 'markAsCancelled', 'CANCELLED', ''];

            case 'REFUNDED':
                return [(int) Configuration::get('PS_OS_REFUND'), 'markAsRefunded', 'REFUNDED', ''];

            default:
                PrestaShopLogger::addLog(
                    sprintf('SimplyIn webhook: unknown status "%s" for order %s', $status, $orderReference),
                    2,
                    null,
                    'SimplyIn',
                    (int) $order->id,
                    true,
                );

                return [null, '', '', ''];
        }
    }
}
