<?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 Cart;
use Configuration;
use Currency;
use Order;
use SimplyIn\Checkout\PaymentMethodDataBuilder;
use SimplyIn\Session\SimplyInSessionInterface;
use SimplyInOrder;

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

final class PaymentCreationService
{
    public function __construct(
        private readonly SimplyInSessionInterface $session,
        private readonly PaymentApiInterface $api,
        private readonly PaymentRecorderInterface $recorder,
        private readonly OrderStateUpdaterInterface $orderStateUpdater,
        private readonly PaymentMethodDataBuilder $paymentMethodDataBuilder,
    ) {
    }

    public function createPayment(
        Cart $cart,
        Order $order,
        string $customerFirstName,
        string $customerLastName,
        string $guestEmail,
        string $authenticatedEmail,
        bool $isGuest,
        bool $accountCreated,
        string $returnUrl,
        string $webhookUrl,
    ): PaymentCreationResult {
        // Same rule the pay-again paywall answers with: the session's token when this request has
        // one, else the token the order was placed with. Reading the session alone was why the
        // paywall could offer a saved card that the payment then went out unauthorized to use -
        // the backend cannot act on an instrument it is not told whose it is, so no authentication
        // step (3-D Secure) was ever started and the answer came back as one we could not read.
        $authToken = AuthorizationResolver::resolve($this->session, (int) $order->id);
        $paymentMethodId = $this->session->get('simplyin_payment_id');
        $paymentType = (string) ($this->session->get('simplyin_payment_type') ?: '');

        $paymentId = $this->recorder->newPaymentId($order);
        // The authenticated address only when we actually have one. It is read from the session,
        // which on the pay-again surface holds neither the e-mail nor the token - so keying this
        // on the RESOLVED token alone would send an empty address for exactly the request this
        // resolution exists to authorize. The shopper's own order address is the right fallback.
        $email = ($authToken !== '' && $authenticatedEmail !== '') ? $authenticatedEmail : $guestEmail;
        // Handed the resolved token rather than left to re-read the session: one resolution per
        // request, so the card payload cannot disagree with what the request is authorized as.
        $paymentMethodData = $this->paymentMethodDataBuilder->build($paymentType, $accountCreated, $authToken);

        // The backend's order _id is the canonical matching key for post-create ops. Persisted at
        // order creation in simplyin_order.simplyin_id (same source the status channel uses,
        // OrderStatusSync). Legacy orders have no stored _id -> omit orderId and let the backend
        // fall back to shopOrderNumber. Never send an empty orderId.
        $simplyInOrder = SimplyInOrder::getByReference((string) $order->reference);
        $simplyinOrderId = (string) ($simplyInOrder->simplyin_id ?? '');

        $body = [
            'paymentId' => $paymentId,
            'paymentMethodId' => $paymentMethodId,
            'title' => (string) $order->reference,
            'amount' => (float) $order->getOrdersTotalPaid(),
            'currency' => Currency::getIsoCodeById((int) $order->id_currency),
            'customerPaymentData' => [
                'email' => $email,
                'firstName' => $customerFirstName,
                'lastName' => $customerLastName,
                'phone' => '',
            ],
            'paymentMethodData' => $paymentMethodData,
            'returnUrl' => $returnUrl,
            'webhookUrl' => $webhookUrl,
        ];

        if ($simplyinOrderId !== '') {
            $body['orderId'] = $simplyinOrderId;
        }

        // The attempt is opened BEFORE the request goes out, with its outcome not yet known.
        //
        // From the moment the request leaves, a payment may exist, so the record of it has to exist
        // first. Recording afterwards leaves a window in which a payment is live and nothing knows -
        // and because the guard that decides whether this order may be paid again reads exactly
        // these rows, an unrecorded payment is one it cannot see. Anything that goes wrong from here
        // on therefore leaves an attempt whose outcome is unknown, which keeps the order closed to
        // further payments until something settles it. That is the safe direction.
        //
        // It also narrows the window in which two simultaneous requests could both decide the order
        // is payable: the first one's open attempt is visible to the second's check from this point,
        // rather than only after the payment service has answered.
        $this->recorder->record(
            $cart,
            $paymentType,
            PaymentStatus::AWAITING_CONFIRMATION,
            $paymentId,
            (string) ($this->session->get('simplyin_payment_provider') ?: ''),
            (string) ($this->session->get('simplyin_card_provider') ?: ''),
            (string) ($this->session->get('simplyin_payment_image') ?: ''),
            (string) ($this->session->get('simplyin_payment_logo') ?: ''),
        );

        $response = $this->api->createPayment($body, $authToken);

        $status = $this->resolveStatus($response);
        $rejectReason = $this->resolveRejectReason($response);

        // The outcome lands on the attempt that was opened above, by its own payment id - never on
        // "the newest attempt for this order", which is a different row once an order carries more
        // than one.
        if (!$this->recorder->settle($paymentId, $status, $rejectReason)) {
            \PrestaShopLogger::addLog(
                'SimplyIn: could not record the outcome of a payment attempt; it stays open until something settles it',
                3,
                null,
                'Order',
                (int) $order->id,
            );
        }

        // Only an authoritative refusal cancels the order. An unknown outcome must not: cancelling
        // there would assert a decline on no evidence (see PaymentStatus::AWAITING_CONFIRMATION).
        // The order stays in the pending state the checkout put it in, and the webhook - or the next
        // confirmation-page poll, which queries the payment service directly - settles it.
        if ($status === PaymentStatus::FAILED) {
            $this->orderStateUpdater->update(
                $order,
                (int) Configuration::get('SIMPLYIN_PAYMENT_STATUS_CANCELED'),
                'markAsRejected',
            );
        } elseif ($status === PaymentStatus::AWAITING_CONFIRMATION) {
            // The order will now sit pending until something settles it, so the merchant needs to
            // see why. The payment type goes into the message because it is what tells the reader
            // whether an answer was due immediately.
            //
            // The type arrives from the shopper's own request and is only length-capped on the way
            // in, so it is not fit to print as it stands: control characters in it would let a
            // forged entry be planted in the log this message is meant to explain. LogSafePaymentType
            // keeps the printable ASCII and drops the rest - shared with every other call site that
            // logs a payment type, so the sanitisation rule lives in one place.
            $loggedPaymentType = LogSafePaymentType::sanitize($paymentType);

            \PrestaShopLogger::addLog(
                sprintf(
                    'SimplyIn: payment outcome unknown for a "%s" attempt (no answer, or none we could read) - order left pending for confirmation',
                    $loggedPaymentType,
                ),
                2,
                null,
                'Order',
                (int) $order->id,
            );
        }

        // The customer-scoped part of the checkout session is cleared on EVERY outcome, exactly as
        // before. That is what drops the single-use credentials an attempt consumed - a BLIK code, a
        // one-time card token - and the method selection with them. Keeping them would let a
        // resubmitted form spend an expired credential on a doomed attempt, and every attempt an
        // order is allowed is one a shopper does not get back.
        $this->session->clearCheckout();

        // The full wipe, which is the only thing that discards the Simply.IN authorization itself,
        // is skipped for a definitively refused payment. That authorization is what a further
        // payment on this order starts from, and it only ever ran for a guest - so this is the one
        // behavioural change here, and it is the change the retry surface needs.
        if ($isGuest && !PaymentStatus::isFailure($status)) {
            $this->session->clear();
        }

        $externalRedirect = null;
        if (($paymentType === 'card' || $paymentType === 'add-card')
            && is_object($response)
            && isset($response->redirectUrl)
            && is_string($response->redirectUrl)
        ) {
            if ($this->isSafeRedirectUrl($response->redirectUrl)) {
                $externalRedirect = $response->redirectUrl;
            } else {
                // Rejected URL degrades to the regular order-confirmation redirect: the order
                // stays pending instead of the browser being sent to an unvetted destination.
                \PrestaShopLogger::addLog(
                    'Simplyin: rejected external redirect URL from payment API (not an absolute https URL)',
                    3,
                    null,
                    'Order',
                    (int) $order->id,
                );
            }
        }

        return new PaymentCreationResult($status, $externalRedirect);
    }

    private function isSafeRedirectUrl(string $url): bool
    {
        // Control characters and whitespace survive parse_url() but have no place in a redirect
        // target (header-injection vector) — reject outright.
        if (preg_match('/[\x00-\x20\x7f]/', $url) === 1) {
            return false;
        }

        $parts = parse_url($url);

        return is_array($parts)
            && ($parts['scheme'] ?? '') === 'https'
            && !empty($parts['host'])
            && !isset($parts['user'])
            && !isset($parts['pass']);
    }

    private function resolveStatus(?object $response): string
    {
        // Every transport-class failure arrives here as null: a timed-out or dropped request, an
        // empty body, a body that is not readable as a payment response. None of them is evidence
        // of a decline, so the outcome is recorded as unknown rather than failed.
        if ($response === null) {
            return PaymentStatus::AWAITING_CONFIRMATION;
        }

        // FORBIDDEN is different in kind from silence: the request was received, understood and
        // refused because of our own credentials, before a payment could be started. On the
        // contract we rely on, the outcome is then known and final - the one case where failing the
        // order terminally is the honest answer.
        //
        // It is only that case while the answer carries no sign that a payment exists anyway. A
        // terminal verdict is the dangerous direction to be wrong in: it is what would later let a
        // shopper be offered another payment for an order that already has a live one. So a refusal
        // arriving alongside a status or a redirect target is not evidence of nothing, and is left
        // to the branches below rather than read as a decline.
        if (isset($response->code)
            && $response->code === 'FORBIDDEN'
            && !isset($response->status)
            && !isset($response->redirectUrl)
        ) {
            return PaymentStatus::FAILED;
        }

        if (isset($response->status) && is_string($response->status)) {
            return $response->status;
        }

        // An answer we cannot interpret is worth no more than no answer at all.
        return PaymentStatus::AWAITING_CONFIRMATION;
    }

    /**
     * The decline reason riding alongside the response, when SIN.CORE reported one - sanitized
     * through the same allowlist every other capture point of this value uses, so an unrecognized
     * or malformed reason is never stored, only ever a known {@see PaymentProviderErrorReason}
     * member.
     */
    private function resolveRejectReason(?object $response): string
    {
        if ($response === null || !isset($response->rejectReason)) {
            return '';
        }

        return PaymentProviderErrorReason::sanitize($response->rejectReason);
    }
}
