<?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 payment-status vocabulary of a stored attempt, and the single answer to "is this attempt
 * over?".
 *
 * Statuses reach a stored attempt from two places: the payment service reports its own status and
 * we pass it through untouched, or we mint one locally because the service told us nothing usable.
 * Only the locally minted ones are named here - passed-through values are not ours to enumerate.
 */
final class PaymentStatus
{
    /**
     * Locally minted: the payment request produced no usable answer, so the outcome is unknown.
     *
     * A request that timed out, a dropped connection, an empty or unreadable body. The request may
     * well have been accepted and the payment may already be succeeding, so this is neither a
     * decline nor a success - it is an open outcome that only the payment service can settle,
     * through the webhook or the status query the confirmation-page poller already performs.
     *
     * Deliberately absent from TERMINAL_STATUSES: consumers that ask isTerminal() keep waiting,
     * which is the intended behaviour and not an omission.
     *
     * It is worth being precise about what this value does and does not say, because "no answer
     * yet" has two very different meanings.
     *
     * An attempt is pending BY DESIGN while the shopper is away completing it - on an issuer's
     * authentication page, or on a provider's own payment page. That wait is normal for as long as
     * the shopper is there, however long that is, and normally such an attempt carries the payment
     * service's own status rather than this one: sending the shopper away needs a redirect target
     * read from the response, so an attempt whose answer we could not read has usually sent nobody
     * anywhere. The exception is a response carrying a usable redirect target but no readable
     * status - then the shopper does go, and the outcome is still unknown.
     *
     * So this value claims only what it can defend: we have no outcome from the payment service.
     * It does not claim the shopper stayed. What the payment type of the attempt adds is whether an
     * answer was due immediately - BLIK acceptance and a frictionless card charge are confirmed
     * while the shopper waits on our page, so their silence is a technical anomaly rather than a
     * shopper still deciding. Each stored attempt records its own type next to its own status, so
     * the two can be read together per attempt: one attempt's type says nothing about another's.
     *
     * What the type never says is that starting another payment is safe. An attempt whose outcome
     * is unknown may still become paid, and a payment on a different method is a second payment all
     * the same - so this status keeps the order in play regardless of what any later attempt would
     * use. Nothing upstream refuses a second payment on one order; this distinction is where that
     * is caught or not caught at all.
     */
    public const AWAITING_CONFIRMATION = 'AWAITING_CONFIRMATION';

    /**
     * Locally minted: the request to start a payment was received, understood and refused, so on
     * the contract we rely on no payment was started and no outcome will arrive later. Terminal -
     * this is the one refusal the plugin decides on its own, and the order is safe to cancel on it.
     *
     * The webhook leg is not restricted to the statuses we know, so this value (and its historic
     * misspelling) can also arrive from there - which is why the webhook keeps handling both.
     */
    public const FAILED = 'FAILED';

    /**
     * Statuses that end an attempt: nothing is waited for, polled or reconciled any more.
     *
     * FAILD is a historic misspelling of FAILED that older stored attempts still carry; it stays
     * so those rows keep resolving instead of polling forever.
     *
     * CANCELLED and CANCELED both appear here for a reason close to FAILD's, with one difference:
     * FAILD is a value nobody writes any more, while CANCELLED is this plugin's own current write
     * (see markAsCancelled) - only the READ side is widened here. SIN.CORE's own contract spells
     * the cancelled outcome CANCELED (single L), the opposite of what this plugin writes and has
     * historically matched. Rather than bet on one spelling, both are terminal on read - so a
     * stored attempt is never treated as still open just because it disagrees with us on spelling.
     */
    private const TERMINAL_STATUSES = ['PAID', 'REJECTED', self::FAILED, 'FAILD', 'CANCELLED', 'CANCELED', 'REFUNDED'];

    /**
     * The same list {@see isTerminal()} reads, exposed for the one caller that must exclude it in
     * SQL rather than test it in PHP - {@see \SimplyInPayments::getStaleOpenAttempts()}'s candidate
     * query needs the exclusion applied BEFORE its batch LIMIT, so pagination is never crowded out
     * by rows that were never candidates. Sourced from this single constant either way, so the query
     * and {@see isTerminal()} can never drift onto two different ideas of "terminal".
     *
     * @return list<string>
     */
    public static function terminalStatuses(): array
    {
        return self::TERMINAL_STATUSES;
    }

    /**
     * The terminal statuses that mean money moved. REFUNDED belongs here with PAID: a refund is
     * reached by being paid first, so an order carrying it has already taken a payment.
     */
    private const SETTLED_STATUSES = ['PAID', 'REFUNDED'];

    /**
     * Any status not listed as terminal - AWAITING_CONFIRMATION and every pending status reported
     * by the payment service alike - is still open and must keep being waited on.
     */
    public static function isTerminal(string $status): bool
    {
        return in_array($status, self::TERMINAL_STATUSES, true);
    }

    /**
     * Whether this attempt took the shopper's money. Deliberately a membership test rather than
     * "terminal and not a failure": it must answer false for an OPEN status, which is neither
     * settled nor failed, so that a caller can ask the two questions in either order and get
     * truthful answers to both.
     */
    public static function isSettled(string $status): bool
    {
        return in_array($status, self::SETTLED_STATUSES, true);
    }

    /**
     * Whether this attempt is over AND took nothing: the definitive non-success. This is the one
     * state a further payment on the order may follow, so it is stated once here rather than
     * re-derived by each caller from a list of status names.
     */
    public static function isFailure(string $status): bool
    {
        return self::isTerminal($status) && !self::isSettled($status);
    }
}
