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

use Configuration;
use Order;
use SimplyInPayments;

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

/**
 * Binds {@see PaymentRetryPolicy} to one PrestaShop order: reads the state the rule needs and
 * reports its verdict.
 *
 * Everything platform-specific lives here so the rule itself stays free of it. Both the page that
 * shows the offer and the endpoint that acts on it go through this same call, so the endpoint cannot
 * be reached with a verdict the page happened to render earlier - it re-reads, every time.
 *
 * The reads are ordered cheapest-first, which matters because the shop fires the hook that leads
 * here on EVERY order page, for every order it has - the overwhelming majority of which never went
 * through this module. A foreign order is answered from data already in memory, and an order with no
 * recorded attempt is answered after one indexed read, without touching the order's history.
 */
final class OrderPaymentRetry
{
    /**
     * @return string one of the {@see PaymentRetryPolicy} decision constants
     */
    public static function decisionFor(Order $order): string
    {
        // Free: PrestaShop records the module that created the order, and an attempt of ours only
        // ever exists for an order our own checkout created. Answering here spares every foreign
        // order the queries below.
        //
        // Wrong in the safe direction if that assumption ever fails: a retry would not be offered
        // where it could have been, which costs a conversion rather than allowing a second payment.
        if ((string) $order->module !== 'simplyin') {
            return PaymentRetryPolicy::NOTHING_TO_OFFER;
        }

        $statuses = SimplyInPayments::getAttemptStatuses((int) $order->id_cart);

        // One indexed read has already settled it. Asking the order whether it was paid means
        // joining its whole status history, so it is not asked until the answer can depend on it -
        // the policy returns the same verdict for an empty list whatever the order looks like.
        if ($statuses === []) {
            return PaymentRetryPolicy::NOTHING_TO_OFFER;
        }

        return PaymentRetryPolicy::decide($statuses, self::isPaid($order), self::isClosed($order));
    }

    public static function isAllowed(Order $order): bool
    {
        return self::decisionFor($order) === PaymentRetryPolicy::RETRY_ALLOWED;
    }

    /**
     * Read from the order's own history rather than from a stored attempt, so a status written
     * against the wrong attempt row cannot hide a payment that actually landed.
     */
    private static function isPaid(Order $order): bool
    {
        // Two signals, because neither alone covers the ground. hasBeenPaid() asks whether the order
        // ever reached the shop's "payment accepted" state, which misses the other states the shop
        // treats as paid; total_paid_real is money actually recorded against the order, whatever
        // state it sits in. Either one is enough to refuse - this is the check that has to hold when
        // the attempt rows are wrong, so it is deliberately the broader notion of "paid".
        return (bool) $order->hasBeenPaid() || (float) $order->total_paid_real > 0.0;
    }

    /**
     * The shop's own end states - a merchant cancelling, a cleanup routine expiring an unpaid
     * order, a payment error recorded by another module. The module's own "payment canceled" state
     * is deliberately NOT among them: that is the normal resting state of an order whose Simply.IN
     * payment was refused, which is precisely the order a retry exists for.
     *
     * Free of queries: the state ids come from the configuration PrestaShop already holds in memory
     * for the request, and the order's current state is a plain property.
     */
    private static function isClosed(Order $order): bool
    {
        $closingStates = [];
        foreach (['PS_OS_CANCELED', 'PS_OS_REFUND', 'PS_OS_ERROR'] as $key) {
            $id = (int) Configuration::get($key);
            if ($id > 0) {
                $closingStates[] = $id;
            }
        }

        return in_array((int) $order->getCurrentState(), $closingStates, true);
    }
}
