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

use SimplyIn\Payment\OrderPaymentRetry;
use SimplyIn\Payment\RetryOrderResolver;
use SimplyIn\Session\PrestashopSessionAdapter;
use SimplyIn\Session\SimplyInSessionInterface;

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

/**
 * Starts one further payment against an order that already exists.
 *
 * The order is never touched: no cart is restored, nothing is cancelled and nothing is re-created,
 * so the order number, the addresses and the total are the ones the shopper already has. A payment
 * that succeeds here is indistinguishable from one that succeeded the first time, because it is the
 * same call to the same payment service against the same order, settled by the same notification
 * and the same order-state transition.
 *
 * ## Why the decision is taken again here
 *
 * The page that offered the retry decided from the state at render time. Between then and this
 * request, a notification can have landed, the order can have been paid, cancelled or expired, and
 * the shopper can have left the page open for an hour. So the verdict is re-taken here from freshly
 * read state and NOTHING in the request is trusted to carry it — that re-read is the guard, not the
 * button the shopper pressed.
 *
 * The refusal is total: no payment is created, the shopper is returned to the order, and nothing is
 * changed. Every early return in here has that shape, which is what makes the surface fail-safe —
 * the worst outcome of anything going wrong is the shopper being exactly where they started.
 *
 * ## Residual risk, stated rather than papered over
 *
 * The attempt is recorded before the payment is requested, so a second request arriving after that
 * point sees an open attempt and is refused. What remains is the gap between one request reading the
 * state and the other opening its attempt - microseconds, not the length of a payment call. It is
 * not a lock, and it is not presented as one; the attempt ceiling is what bounds the consequence.
  */
class SimplyinPaymentRetryModuleFrontController extends ModuleFrontController
{
    public ?SimplyInSessionInterface $simplyin_session = null;

    public function postProcess()
    {
        $this->simplyin_session = new PrestashopSessionAdapter($this->context->cookie, $this->context->cart);

        $order = $this->resolveOrder();
        if ($order === null) {
            // Nothing identifiable to act on: send the visitor to their order list rather than
            // rendering anything of ours.
            Tools::redirect('index.php?controller=history');

            return;
        }

        if (!$this->module->active || !Configuration::get('SIMPLYIN_PAYMENTS')) {
            $this->backToOrder($order, false);
        }

        // The same verdict the surface used, taken again from current state.
        if (!OrderPaymentRetry::isAllowed($order)) {
            $this->backToOrder($order, false);
        }

        $paymentType = trim((string) ($this->simplyin_session->get('simplyin_payment_type') ?: ''));
        $paymentMethodId = trim((string) ($this->simplyin_session->get('simplyin_payment_id') ?: ''));
        if ($paymentType === '' || $paymentMethodId === '') {
            // No method chosen, so there is nothing to pay with. Reported to the shopper, because
            // unlike the refusals above this one is theirs to fix.
            $this->backToOrder($order, true);
        }

        // The paywall renders inside this form, so the radio the shopper had selected is posted
        // alongside the token - while the session copy is written by a separate AJAX call that may
        // not have landed, may have failed, or may have been overtaken by another tab. The two can
        // therefore disagree, and the session can be holding an earlier selection.
        //
        // Refused rather than resolved in favour of either side. The session does not just name the
        // method, it holds the single-use credentials the attempt will spend - a BLIK code, a
        // one-time card token - and those belong to the selection that put them there. Paying on
        // the posted id would spend one method's credentials under another method's name; paying on
        // the session id would charge a method the shopper had visibly moved away from. Neither is
        // an acceptable outcome on a money path, and the shopper can fix this by pressing the
        // button again once the page has settled, so it is reported to them.
        //
        // Fails safe if the values are ever too long to compare: the session copy is length-capped
        // on the way in, so a truncated copy reads as a disagreement and refuses.
        $postedMethodId = trim((string) Tools::getValue('simplyin-payment-option', ''));
        if ($postedMethodId !== '' && $postedMethodId !== $paymentMethodId) {
            $this->backToOrder($order, true);
        }

        $cart = new Cart((int) $order->id_cart);
        $customer = new Customer((int) $order->id_customer);
        if (!Validate::isLoadedObject($cart) || !Validate::isLoadedObject($customer)) {
            $this->backToOrder($order, false);
        }

        $result = $this->createPayment($order, $cart, $customer);

        if ($result->externalRedirectUrl !== null) {
            // Authentication on the issuer's own page. The shopper is expected to be away from here
            // until they finish, and their attempt is already recorded as open, so no further
            // payment can be offered while they are gone.
            header('Location: ' . $result->externalRedirectUrl);
            session_write_close();
            exit;
        }

        $this->backToOrder($order, false);
    }

    /**
     * The order named by the request, or null when it cannot be established beyond doubt.
     *
     * Ownership is proved by a token bound to the order's own secret key, which only code that
     * already loaded that order can produce — and the shop only lets that happen after its own
     * ownership check, on the logged-in route and the guest-tracking route alike. That is what lets
     * this endpoint serve a guest, who has no session for it to check.
     */
    private function resolveOrder(): ?Order
    {
        // POST-only and non-expiry are enforced inside RetryOrderResolver::resolve() itself now -
        // shared with the order-confirmation poll's decision recheck, so both callers get the same
        // guard from one place rather than each carrying its own copy.
        if (!Tools::getValue('id_order') || !Tools::isSubmit('retry_token')) {
            return null;
        }

        return RetryOrderResolver::resolve(
            $this->module,
            (int) Tools::getValue('id_order'),
            (string) Tools::getValue('retry_token'),
        );
    }

    private function createPayment(Order $order, Cart $cart, Customer $customer): \SimplyIn\Payment\PaymentCreationResult
    {
        $service = new \SimplyIn\Payment\PaymentCreationService(
            $this->simplyin_session,
            new \SimplyIn\Payment\LegacyPaymentApi($this->module->apiClient),
            new \SimplyIn\Payment\LegacyPaymentRecorder(),
            new \SimplyIn\Payment\OrderStateUpdater(),
            new \SimplyIn\Checkout\PaymentMethodDataBuilder($this->simplyin_session),
        );

        return $service->createPayment(
            $cart,
            $order,
            (string) $customer->firstname,
            (string) $customer->lastname,
            (string) $customer->email,
            (string) $this->module->getCustomerEmail(),
            (bool) $customer->is_guest,
            // No account is created on this path: the shopper already has an order, and the
            // account-creation branch belongs to the checkout that produced it.
            false,
            $this->orderUrl($order, false),
            $this->context->link->getModuleLink($this->module->name, 'webhook', [], true),
        );
    }

    /**
     * Declared as never-returning because Tools::redirect() exits — which is what lets every
     * refusal above read as a single line with no trailing return, and keeps static analysis from
     * treating the code after one as reachable.
     *
     * @param bool $reportFailure whether to tell the shopper the attempt could not be started —
     *                            true only for refusals they can act on themselves
     */
    private function backToOrder(Order $order, bool $reportFailure): never
    {
        Tools::redirect($this->orderUrl($order, $reportFailure));
        exit;
    }

    /**
     * The order-confirmation page, guest or logged-in alike — the same page the shopper landed on
     * right after checkout, reached through the same guest-safe proof PrestaShop core's own
     * OrderConfirmationController checks: `id_cart`/`id_module`/`id_order`/`key` (the order's OWN
     * secure_key, which is what core compares against — not the customer's, which can drift from it
     * after the order is placed).
     *
     * Previously this branched: a logged-in owner went to `order-detail`, a guest to
     * `guest-tracking`, dropping the failure flag entirely on that second branch. Both were the
     * wrong target regardless of identity — neither page runs the live payment-status poll
     * ({@see \SimplyIn\Payment\OrderConfirmationRenderer}, mounted only on `hookDisplayOrderConfirmation`),
     * so a retry attempt returned there was never settled: it is what stranded the retry PENDING
     * forever. Order-confirmation is the one page that mounts the poll for anyone, so it is the one
     * target that actually closes the loop for both audiences.
     */
    private function orderUrl(Order $order, bool $reportFailure): string
    {
        $params = [
            'id_cart' => (int) $order->id_cart,
            'id_module' => (int) $this->module->id,
            'id_order' => (int) $order->id,
            'key' => (string) $order->secure_key,
        ];
        if ($reportFailure) {
            $params['simplyin_retry_error'] = 1;
        }

        return $this->context->link->getPageLink('order-confirmation', true, null, $params);
    }
}
