<?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 Order;
use PrestaShopLogger;
use SimplyIn\Webhook\WebhookContextScoperInterface;
use SimplyInPayments;
use Validate;

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

/**
 * The reconciliation pass an earlier change left open: an attempt recorded as
 * {@see PaymentStatus::AWAITING_CONFIRMATION} - or any other status that has not reached a terminal
 * one, including a locally-stored `PENDING` a payment retry can be left stranded in when the
 * shopper is redirected away before it settles - is honestly unresolved, but nothing was left to
 * ever come back to it if the webhook that would settle it never arrives. Run from the module's
 * existing cron hook ({@see \Simplyin::hookActionModuleRegisterCronJobs()}, every 15 minutes) -
 * there is already a cron mechanism in this plugin, so this is one more task on it, not a second
 * scheduling mechanism.
 *
 * ## What this pass is allowed to decide, and what it is not
 *
 * It may ask an authority (SIN.CORE, through {@see PaymentStatusReconciler}) and record what that
 * authority answers - nothing more. It never concludes an outcome from elapsed time: a card
 * attempt whose shopper is still on their issuer's authentication page is exactly as silent as one
 * that will never be heard from again, and only SIN.CORE can tell the two apart. So every
 * candidate row is asked about, every run, regardless of age.
 *
 * What elapsed time DOES decide - through {@see PaymentReconciliationPolicy} - is whether an
 * attempt SIN.CORE still has no answer for is stale enough that a merchant should be told. That
 * decision changes nothing: no status, no order state. It only writes a log entry, which is the
 * merchant-visible flag this order was left without. An order that has crossed that point stays
 * exactly where it was already correctly left - pending, closed to a further payment attempt by
 * {@see PaymentRetryPolicy} for as long as this one is unresolved - because turning an unknown
 * outcome into a terminal one without evidence is precisely the double-charge risk that status was
 * introduced to avoid.
 *
 * ## Shop context
 *
 * A cron hit has no natural relationship to any one of the shop's orders, unlike the
 * confirmation-page poller (a live request already scoped to the shopper's own shop) - the same
 * gap {@see \SimplyIn\Webhook\WebhookContextScoperInterface} exists to close for the webhook leg.
 * This pass scopes Context to each row's own order before touching it, for the same reason.
 *
 * ## Idempotence
 *
 * Running this twice, or running it again on the same order next tick, is indistinguishable from
 * running it once. The candidate query only ever selects rows whose status is not yet terminal
 * ({@see \SimplyInPayments::getStaleOpenAttempts()}), so an attempt this pass (or the webhook, or
 * the confirmation-page poller) already settled is never selected again - terminal states are never
 * revisited. The merchant-visible log uses
 * `allowDuplicate=false`, PrestaShopLogger's own repeat-collapsing (an exact match on the message
 * text, with no time window) - which is why {@see self::surface()} logs the grace-period threshold
 * rather than the attempt's actual, ever-growing age: the threshold is the same on every tick, so
 * only the first run past it ever writes anything, and every later tick finds that same message
 * already on record.
 */
final class PaymentReconciliationSweep
{
    /**
     * Bounds the SELECT this pass runs to a moving window instead of the whole table. An attempt
     * older than this has already cleared every grace period {@see PaymentReconciliationPolicy}
     * defines many times over, so excluding it costs nothing but a query row and an API call this
     * pass would certainly have spent on an answer already surfaced (or already given) on an
     * earlier run. Not a correctness requirement - a bound on how much work one cron tick does, so
     * a shop with years of history never turns this into a slow query on a cron.
     */
    private const WINDOW_DAYS = 7;

    /**
     * Caps how many attempts one cron tick asks the authority about. The candidate query is
     * ordered oldest first, so a shop with more stale attempts than this limit still drains them,
     * oldest first, one batch per tick - nothing is skipped forever, only spread across runs.
     */
    private const BATCH_LIMIT = 50;

    public function __construct(
        private readonly PaymentStatusReconcilerInterface $reconciler,
        private readonly WebhookContextScoperInterface $contextScoper,
    ) {
    }

    public function run(): void
    {
        $rows = SimplyInPayments::getStaleOpenAttempts(self::WINDOW_DAYS, self::BATCH_LIMIT);
        foreach ($rows as $row) {
            $this->processRow($row);
        }
    }

    /**
     * @param array<string, mixed> $row
     */
    private function processRow(array $row): void
    {
        $uuid = (string) ($row['uuid'] ?? '');
        $idCart = (int) ($row['cart_id'] ?? 0);
        $paymentType = (string) ($row['type'] ?? '');
        $dateAdd = (string) ($row['date_add'] ?? '');

        if ($uuid === '' || $idCart === 0 || $dateAdd === '') {
            return;
        }

        // This runs from a bare cron hit ({@see \Simplyin::execCron()}), not a live shopper
        // request, and can process attempts from any of the shop's carts one after another - the
        // same reason WebhookProcessor scopes Context to each order before touching it. Without
        // this, a multi-shop install would write every row's order-state change (and any
        // notification email PrestaShop core sends for it) under whichever single shop the cron
        // request happened to hit, not the shop the order actually belongs to.
        $order = Order::getByCartId($idCart);
        if ($order instanceof Order && Validate::isLoadedObject($order)) {
            $this->contextScoper->scope($order);
        }

        if ($this->reconciler->reconcile($uuid, $idCart) !== null) {
            // Settled by the authority: PAID or REJECTED is now on record, and the retry surface
            // will read it the next time the order is looked at. Nothing left to flag.
            return;
        }

        // A date_add this class cannot parse gives no reliable age - not evidence the attempt is
        // fresh, and not evidence it is stale either, so it is left for a later tick rather than
        // guessed at from a broken timestamp.
        $dateAddTimestamp = strtotime($dateAdd);
        if ($dateAddTimestamp === false) {
            return;
        }

        if (!PaymentReconciliationPolicy::isOverdue($paymentType, time() - $dateAddTimestamp)) {
            return;
        }

        $this->surface($idCart, $paymentType);
    }

    private function surface(int $idCart, string $paymentType): void
    {
        // Re-read rather than reusing the Order looked up in processRow(): every other read in
        // this class re-reads rather than trusting an earlier lookup (see
        // {@see PaymentStatusReconciler::reconcile()}, which does the same for the same reason),
        // so a row this method is handed is never acted on with stale data from one step earlier.
        $order = Order::getByCartId($idCart);
        if (!Validate::isLoadedObject($order)) {
            // No order to point a merchant at, and no order page for this to appear on - the
            // checkout never reached order creation, so there is nothing merchant-visible to flag.
            return;
        }

        // allowDuplicate=false makes PrestaShopLogger skip the insert when a row with the EXACT
        // SAME message/severity/object already exists (PrestaShopLogger::isPresent(), no time
        // window) - which only actually collapses repeats if the message stays byte-identical
        // across ticks. The grace-period THRESHOLD is used here rather than the attempt's actual,
        // ever-growing elapsed age for exactly that reason: the threshold is constant for a given
        // order and payment type, so every run after the first finds the same message already
        // logged and writes nothing further, where the real elapsed age would make every single
        // tick's message unique and defeat the dedup entirely.
        PrestaShopLogger::addLog(
            sprintf(
                'SimplyIn: a "%s" payment attempt on order #%d has had no settled outcome for over'
                . ' %d minutes and SIN.CORE still has no answer for it - this needs a manual look.'
                . ' The order stays closed to a further payment attempt until it is resolved.',
                LogSafePaymentType::sanitize($paymentType),
                (int) $order->id,
                intdiv(PaymentReconciliationPolicy::graceSeconds($paymentType), 60),
            ),
            2,
            null,
            'Order',
            (int) $order->id,
            false,
        );
    }
}
