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

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

/**
 * The single answer to "may this order be paid again?".
 *
 * ## Why this decision carries the whole safety burden
 *
 * A shop page is the only place that can tell a shopper legitimately paying again after a decline
 * from a shopper about to be charged twice: the difference lives in the order's own state, which is
 * exactly what this side can read and no one else can. So this class is written as the place where
 * that judgement is made, and it does not assume anything downstream will make it again.
 *
 * The consequence is absolute. Treat this as the only line of defence, because that is how it is
 * built: a retry is offered ONLY when the previous attempts are provably over. Where an
 * outcome is unknown, or still on its way, the answer is no - not "no for now, revisit with a
 * timeout", simply no. A retry that is refused when it could have been allowed costs one
 * conversion; a retry allowed when it should not have been takes a second real payment from a
 * shopper. Those are not comparable, and no configuration option or later relaxation makes them
 * comparable.
 *
 * ## Why the rule reads EVERY attempt, not the latest one
 *
 * Stored attempts are appended one row per attempt, and an answer is applied to the exact attempt it
 * names whenever the notification carrying it can be tied back to one. When it cannot, the fallback
 * writes to the newest attempt for the order - so an answer about an earlier attempt can still land
 * on a later one.
 *
 * Requiring EVERY recorded attempt to be over - rather than only the newest - is what keeps a
 * further payment off an order that still has one running, independently of which row an answer was
 * written to. It is not a claim that mis-attribution cannot happen: it is the reason a
 * mis-attributed status cannot by itself open a retry while an attempt is genuinely live, because
 * the order's own paid state is checked separately from the rows, and the attempt ceiling bounds the
 * total number of payments an order can carry however the rows read.
 *
 * One case is worth naming because it is the one this rule alone does not close: an attempt refused
 * in the immediate response, followed later by a separate notification about that same refusal. If
 * the notification cannot be tied to its attempt, it is written to the newest row - which by then
 * may be a live retry - and that row then reads as failed. Whether the payment service sends such a
 * notification at all is a question about the service, not about this code, and it is not answered
 * here. What is done here is to make the mis-attribution avoidable whenever the answer identifies
 * its attempt, and observable when it does not.
 *
 * ## Why the payment type is not an input here
 *
 * How long silence from a payment service is normal depends entirely on the instrument: a card
 * charge without an authentication step and an accepted BLIK confirmation are answered while the
 * shopper waits, whereas a card sent through issuer authentication, or any method that hands the
 * shopper to the provider's own page, is silent for exactly as long as the shopper is there. The
 * useful question is therefore never "how long has it been" but "is the shopper expected to be
 * away completing this" - and an attempt that is not over cannot be distinguished from one whose
 * shopper is still working through it. Both answers are the same here: no retry. The type would
 * only matter to a rule that treats some silences as declines, and this class does not have one.
 *
 * Nor does the instrument the shopper would pay WITH matter. A shopper whose card failed may
 * legitimately reach for BLIK or another card, and that is a first-class path - but two live
 * payments on one order are two payments whatever rails they run on, so switching method is never
 * a reason to allow a retry that this rule would otherwise refuse.
 */
final class PaymentRetryPolicy
{
    /**
     * Attempts allowed on one order in total, the first one included - so a shopper is offered a
     * retry after the first failure and after the second, and is pointed elsewhere after the
     * third. Repeated attempts on one order are themselves a fraud signal, and re-offering the
     * same instrument indefinitely helps nobody whose instrument simply cannot succeed.
     */
    public const MAX_ATTEMPTS = 3;

    /** No payment of ours was ever recorded for this order: there is nothing to offer, and nothing to say. */
    public const NOTHING_TO_OFFER = 'nothing_to_offer';

    /** At least one attempt is not over. The shopper may be completing it right now. */
    public const PAYMENT_IN_PROGRESS = 'payment_in_progress';

    /** The order is paid (or was paid and refunded). Paying again would be the double charge. */
    public const ALREADY_SETTLED = 'already_settled';

    /** The shop closed the order - a cleanup routine, or the merchant. Not ours to re-open. */
    public const ORDER_CLOSED = 'order_closed';

    /** Every failure so far was definitive and the ceiling is reached: escalate, do not re-offer. */
    public const ATTEMPTS_EXHAUSTED = 'attempts_exhausted';

    /** Every attempt is over, every one of them failed definitively, and the order is still payable. */
    public const RETRY_ALLOWED = 'retry_allowed';

    /**
     * Pure decision, so the rule lives in one testable place and ports to the sibling plugins
     * unchanged - the caller supplies the readings, this class never looks anything up.
     *
     * @param array<int, string> $attemptStatuses the stored status of EVERY payment attempt
     *                                           recorded for this order, in any order. An empty
     *                                           array must also be what the caller passes when the
     *                                           statuses could not be read at all: unreadable
     *                                           state offers no retry, which is the safe direction
     * @param bool               $orderIsPaid    whether the ORDER reached a paid state, read from
     *                                           the shop rather than from an attempt row, so a
     *                                           mis-attributed attempt status cannot hide it
     * @param bool               $orderIsClosed  whether the shop cancelled, refunded or errored
     *                                           the order out
     *
     * @return string one of the decision constants above
     */
    public static function decide(array $attemptStatuses, bool $orderIsPaid, bool $orderIsClosed): string
    {
        if ($attemptStatuses === []) {
            return self::NOTHING_TO_OFFER;
        }

        // Checked before openness, and independently of it: an attempt that took the money is the
        // decisive fact about this order however many others are still in flight.
        foreach ($attemptStatuses as $status) {
            if (PaymentStatus::isSettled($status)) {
                return self::ALREADY_SETTLED;
            }
        }

        // The guard. Every status that is not explicitly over - a pending one reported by the
        // payment service, an unknown outcome of ours, and any value neither side has ever named,
        // since unrecognized statuses are treated as open - stops the retry here.
        foreach ($attemptStatuses as $status) {
            if (!PaymentStatus::isTerminal($status)) {
                return self::PAYMENT_IN_PROGRESS;
            }
        }

        if ($orderIsPaid) {
            return self::ALREADY_SETTLED;
        }

        if ($orderIsClosed) {
            return self::ORDER_CLOSED;
        }

        if (count($attemptStatuses) >= self::MAX_ATTEMPTS) {
            return self::ATTEMPTS_EXHAUSTED;
        }

        return self::RETRY_ALLOWED;
    }
}
