<?php
/**
 * Checkout Manager — the fn_place_order equivalent.
 *
 * Orchestrates the full checkout pipeline:
 *
 *   1. Load + validate current cart
 *   2. Validate address data
 *   3. Resolve shipping cost   (Module 7 hooks in here)
 *   4. Resolve tax             (Module 8 hooks in here)
 *   5. Apply coupon / promotions (Module 9 hooks in here)
 *   6. Final totals calculation
 *   7. Guard checks (payment method exists, stock still available)
 *   8. Create order record + items (OrderRepository)
 *   9. Decrement stock (ProductRepository::update_stock)
 *  10. Optionally save address to customer profile
 *  11. Destroy cart (CartManager::destroy_after_order)
 *  12. Fire hooks / dispatch email action
 *  13. Return order number for redirect
 *
 * @package TitanCart\Modules\Checkout
 */

namespace TitanCart\Modules\Checkout;

use TitanCart\Modules\Cart\CartManager;
use TitanCart\Modules\Cart\CartTotals;
use TitanCart\Modules\Cart\CartSession;
use TitanCart\Modules\Downloadables\DownloadableManager;
use TitanCart\Modules\Payments\AbstractGateway;
use TitanCart\Modules\Payments\GatewayRegistry;
use TitanCart\Modules\Payments\PaymentMethodRepository;
use TitanCart\Modules\Products\ProductRepository;
use TitanCart\Services\SettingsService;

defined( 'ABSPATH' ) || exit;

class CheckoutManager {

    private CartManager       $cart_manager;
    private OrderRepository   $order_repo;
    private AddressValidator  $address_validator;
    private ProductRepository $products;
    private CartTotals        $totals_calc;

    public function __construct() {
        $this->cart_manager      = new CartManager();
        $this->order_repo        = new OrderRepository();
        $this->address_validator = new AddressValidator();
        $this->products          = new ProductRepository();
        $this->totals_calc       = new CartTotals();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // GET checkout data (populate form)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return everything the checkout page template needs:
     *   - Cart summary
     *   - Saved customer addresses (for prefill)
     *   - Available payment methods
     *   - Available shipping methods (stubs — Module 7 fills these)
     *
     * @param  string $lang
     * @return array<string,mixed>
     */
    public function get_checkout_data( string $lang = 'en' ): array {
        $cart             = $this->cart_manager->get_cart( null, $lang );
        $saved_address    = $this->get_saved_address();
        $payment_methods  = $this->get_active_payment_methods();

        // KI-79 (S394): on a subscription cart, hide gateways that cannot vault
        // an off-session-chargeable token — offering COD here only to refuse it
        // after the customer has filled the whole form is a bad experience.
        //
        // ⚠️ THIS IS THE COURTESY LAYER, NOT THE FIX. The load-bearing gate is
        // server-side in place_order() and runs regardless of what this picker
        // shows — exactly as the S138 shipping note below says of its own
        // picker. KI-35 3b is the precedent: a UI-only hide leaves the public
        // POST selling. Never delete the place_order() gate because this filter
        // exists.
        if ( (bool) apply_filters( 'titancart_cart_requires_payment_token', false, $cart ) ) {
            $payment_methods = array_values( array_filter(
                $payment_methods,
                function ( array $method ): bool {
                    $gateway = GatewayRegistry::instance()->make( (string) ( $method['gateway'] ?? '' ) );
                    return $gateway instanceof AbstractGateway && $gateway->supports_subscriptions();
                }
            ) );
        }

        // S138 Phase 5 (mixed-content reread): digital-only carts skip the
        // shipping pipeline entirely. The picker is forced empty so the JS
        // doesn't render method radios; the template uses the explicit
        // `needs_shipping` flag to hide the address section + method step
        // and to renumber the visible steps. Server-side enforcement of
        // shipping_cost = 0 lives in place_order regardless of UI state.
        $needs_shipping   = $this->cart_needs_shipping( $cart );
        $shipping_methods = $needs_shipping
            ? apply_filters( 'titancart_checkout_shipping_methods', [], $cart )
            : [];

        return [
            'cart'             => $cart,
            'saved_address'    => $saved_address,
            'payment_methods'  => $payment_methods,
            'shipping_methods' => $shipping_methods,
            'needs_shipping'   => $needs_shipping,
            'guest_checkout'   => $this->guest_checkout_allowed(),
            'is_logged_in'     => is_user_logged_in(),
            'settings'         => $this->get_checkout_settings(),
        ];
    }

    /**
     * Does the current cart need shipping?
     *
     * Returns true if ANY cart item references a product where
     * `requires_shipping = 1`. This is the canonical runtime gate
     * for the entire shipping pipeline — picker UI, address section,
     * method validation, cost calculation. Plugin.php's S129 Migration
     * 26 docblock anticipated this gate landing here:
     *
     *   "The future mixed-content case (downloadable + physical bundle)
     *    will gate on the existing `requires_shipping` column, NOT on
     *    a new `edp_shipping` column."
     *
     * Three product configurations to keep in mind:
     *
     *   1. Pure-digital download — is_downloadable=1, requires_shipping=0
     *      → cart doesn't need shipping; checkout hides the picker;
     *        shipping_cost forced to 0 server-side regardless of any
     *        client-submitted method id.
     *
     *   2. Vinyl + digital BUNDLE (single product) — is_downloadable=1,
     *      requires_shipping=1 → cart needs shipping; vinyl ships, key
     *        also issued for the digital file. Both flags coexist
     *        because the product genuinely is both.
     *
     *   3. Mixed cart (one vinyl + one separate digital download) — at
     *      least one item has requires_shipping=1 → cart needs shipping;
     *      vinyl ships at its quoted rate, digital item adds nothing to
     *      shipping cost (shipping rules typically don't multiply by
     *      digital items, but rule definition is shipping module's job).
     *
     * Note: the requires_shipping column defaults to 1 in the schema, so
     * admins building a pure-digital product MUST manually uncheck the
     * "Requires Shipping" toggle in the product editor for the gate to
     * recognize the product as digital-only. Future UX improvement: when
     * is_downloadable is checked in the admin UI, suggest auto-unchecking
     * requires_shipping with a tooltip explaining the bundle override.
     *
     * Wired S138 Phase 5.
     *
     * @param  array $cart Hydrated cart from CartManager::get_cart().
     * @return bool
     */
    private function cart_needs_shipping( array $cart ): bool {
        $items = $cart['items'] ?? [];
        if ( empty( $items ) ) {
            // Empty cart — irrelevant; place_order rejects empty carts at
            // step 1 anyway. Default true preserves existing-checkout
            // expectations for the rare case this is queried with empty.
            return true;
        }
        foreach ( $items as $item ) {
            $product = $this->products->get( (int) $item['product_id'] );
            if ( $product && ! empty( $product['requires_shipping'] ) ) {
                return true;
            }
        }
        return false;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // VALIDATE (called before place_order to give real-time field errors)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Validate address fields only. Used by the React/JS form for
     * inline validation before the final submit.
     *
     * @param  array $data  Raw form input.
     * @return array{ valid: bool, errors: array }
     */
    public function validate_address( array $data ): array {
        $ship_different = ! empty( $data['ship_to_different_address'] );
        return $this->address_validator->validate( $data, $ship_different );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PLACE ORDER — the core fn_place_order equivalent
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Execute the full checkout pipeline and place the order.
     *
     * @param  array  $data  Validated form data from the REST request.
     * @param  string $lang
     * @return array{
     *   success: bool,
     *   message: string,
     *   order_number: string|null,
     *   order_id: int|null,
     *   redirect_url: string|null,
     *   errors: array
     * }
     */
    public function place_order( array $data, string $lang = 'en' ): array {
        // ── 1. Load cart ──────────────────────────────────────────────────────
        $cart = $this->cart_manager->get_cart( null, $lang );

        if ( empty( $cart['items'] ) ) {
            return $this->fail( __( 'Your cart is empty.', 'titancart-lite' ) );
        }

        // ── 1a. Guest checkout server-side enforcement ───────────────────────
        // The UI hides the guest form when guest checkout is disabled, but a
        // crafted POST could bypass that. Reject server-side when the setting
        // is on AND the requester is not authenticated.
        if ( ! is_user_logged_in() && ! $this->guest_checkout_allowed() ) {
            return $this->fail( __( 'You must be logged in to place an order.', 'titancart-lite' ) );
        }

        // ── 1b. Minimum order amount ─────────────────────────────────────────
        $min_amount = (float) $this->checkout_setting( 'min_order_amount', '0' );
        if ( $min_amount > 0 ) {
            $subtotal = (float) ( $cart['totals']['subtotal'] ?? 0 );
            if ( $subtotal < $min_amount ) {
                return $this->fail( sprintf(
                    /* translators: 1: minimum order amount. 2: current cart subtotal. */
                    __( 'The minimum order amount is %1$s. Your current cart total is %2$s.', 'titancart-lite' ),
                    number_format( $min_amount, 2 ),
                    number_format( $subtotal, 2 )
                ) );
            }
        }

        // ── 1c. Downloadable Products: master-toggle gate (S138 Phase 5) ─────
        // Mirrors CartManager::add_to_cart's gate at checkout time. The cart
        // could have been built before the admin flipped `catalog_enable_downloads`
        // off — block place-order rather than silently completing a purchase
        // for a feature that's currently disabled. PDP swaps the cart row for
        // an "unavailable" notice when this is off, so the only way to reach
        // here with a downloadable item is a stale browser session that was
        // populated before the toggle flipped.
        if ( ! SettingsService::bool( 'general', 'catalog_enable_downloads', true ) ) {
            foreach ( $cart['items'] as $item ) {
                $product = $this->products->get( (int) $item['product_id'] );
                if ( $product && ! empty( $product['is_downloadable'] ) ) {
                    return $this->fail( __( 'Downloadable products are temporarily unavailable. Please remove them from your cart to continue.', 'titancart-lite' ) );
                }
            }
        }

        // ── 1d. Age Verification: server-side gate at checkout (defense in depth) ─
        // The add-to-cart guard is the primary block; this catches age-restricted
        // items that entered the cart before the extension was enabled, or via a
        // stale session. Reads the verified-age cookie server-side; no-ops when
        // the extension is inactive or no restricted item is present.
        $age_block = \TitanCart\Extensions\AgeVerificationExtension::guard_items( $cart['items'] );
        if ( $age_block !== null ) {
            return $this->fail( $age_block );
        }

        // ── 2. Validate addresses ─────────────────────────────────────────────
        $ship_different = ! empty( $data['ship_to_different_address'] );
        $validation     = $this->address_validator->validate( $data, $ship_different );

        if ( ! $validation['valid'] ) {
            return array_merge(
                $this->fail( __( 'Please fix the errors below.', 'titancart-lite' ) ),
                [ 'errors' => $validation['errors'] ]
            );
        }

        $addresses = $this->address_validator->extract( $data, $ship_different );

        // ── 2a. Re-evaluate email-targeted promotions with the billing email ──
        // The cart loaded at step 1 ran before the shopper's email was known, so
        // any promotion carrying a `customer_email` condition could not be
        // verified for a guest (a logged-in customer's account email already
        // resolved). Now that the billing email is in hand, reload the cart with
        // it so those promotions apply -- or correctly stay off -- before the
        // order totals are fixed below. Items/subtotal are unchanged; only the
        // promotion discount can move.
        $promo_context_email = (string) ( $addresses['billing']['email'] ?? '' );
        if ( $promo_context_email !== '' ) {
            $cart = $this->cart_manager->get_cart( null, $lang, $promo_context_email );
        }

        // ── 2b. Extract payment_data from request body ────────────────────────
        // Gateways like Authorize.net (Accept.js) and Square (Web Payments SDK)
        // tokenize card details client-side, then POST the resulting nonce in
        // a `payment_data` object on the place-order request. We whitelist the
        // known keys and sanitize as plain text before handing off to the gateway.
        // Nonces are one-time-use and NOT persisted — they only flow through
        // $order_data into `titancart_order_placed` → PaymentsModule → gateway.
        $incoming_pd = is_array( $data['payment_data'] ?? null ) ? $data['payment_data'] : [];
        $payment_data = [];
        foreach ( [
            // Authorize.net Accept.js
            'opaque_data_descriptor',
            'opaque_data_value',
            // Square Web Payments SDK
            'source_id',
        ] as $pd_key ) {
            if ( isset( $incoming_pd[ $pd_key ] ) && is_scalar( $incoming_pd[ $pd_key ] ) ) {
                $val = trim( (string) $incoming_pd[ $pd_key ] );
                if ( $val !== '' ) {
                    $payment_data[ $pd_key ] = sanitize_text_field( $val );
                }
            }
        }

        // ── 3. Payment method ─────────────────────────────────────────────────
        $payment_method_id = (int) ( $data['payment_method_id'] ?? 0 );

        if ( ! $this->payment_method_exists( $payment_method_id ) ) {
            return $this->fail( __( 'Please select a payment method.', 'titancart-lite' ) );
        }

        // ── 3b. KI-79 (S394) — subscription carts require a vault-capable gateway ──
        //
        // A tokenless gateway (COD / cheque / bank transfer) on a subscription
        // line silently strands the entitlement: no vaulted token =>
        // RenewalService::process_single_renewal() gets `requires_manual` =>
        // create_renewal_order() never runs => the `subscription_renewal` item
        // meta is never written => PP_Licensing::extend_license_for_renewal()
        // is UNREACHABLE. The sub rolls forward and reads healthy forever while
        // the licence expires on its original date. Refuse the combination at
        // the boundary instead of minting a subscription we cannot renew.
        //
        // ★ THIS IS THE LOAD-BEARING LAYER, AND IT IS SERVER-SIDE ON PURPOSE.
        // KI-35 3b is the exact precedent: render was fail-closed but the public
        // POST was not, so a downgraded store could still SELL. Hiding offline
        // gateways in the checkout UI is a courtesy, not a fix — this POST is
        // what a crafted request hits.
        //
        // ★ GRANDFATHERING IS STRUCTURAL, NOT CODED. Renewals never reach here:
        // RenewalService::create_renewal_order() builds its order directly via
        // titancart_db() and there is no place_order() call anywhere in the
        // Subscriptions extension. So this gate cannot break an existing
        // offline sub's renewal — it only refuses NEW carts, which is exactly
        // the required scope.
        if ( (bool) apply_filters( 'titancart_cart_requires_payment_token', false, $cart ) ) {
            $gateway = $this->resolve_gateway_for_method( $payment_method_id );

            // ⚠️ NULL GATEWAY MUST REJECT — DO NOT "SKIP WHEN UNKNOWN".
            // payment_method_exists() returns true for id=0 WITHOUT any DB
            // lookup ("Allow 0 = no payment method (e.g. $0 orders or COD)",
            // L1177-1179), so id=0 resolves to NO gateway at all. A gate written
            // as `if ($gateway && ! $gateway->supports_subscriptions())` would
            // therefore fail OPEN on precisely the value whose own comment names
            // COD — rebuilding KI-35 3b inside the fix meant to prevent it. An
            // unresolvable gateway is an ungateable one; treat it as incapable.
            if ( ! $gateway instanceof AbstractGateway || ! $gateway->supports_subscriptions() ) {
                return $this->fail( __(
                    'The selected payment method cannot be used for a subscription. Please choose a card or PayPal payment method.',
                    'titancart-lite'
                ) );
            }
        }

        // ── 4. Shipping method ────────────────────────────────────────────────
        // S138 Phase 5 (mixed-content reread): digital-only carts skip the
        // shipping pipeline entirely. A crafted POST could submit a
        // shipping_method_id even when the UI hid the picker (or for a
        // stale form populated before the cart became all-digital), but
        // we IGNORE it server-side when no cart item has requires_shipping=1.
        // Defense in depth — the actual bug Sean caught was a downloadable
        // product still being charged shipping; this is the canonical fix.
        $cart_needs_shipping = $this->cart_needs_shipping( $cart );
        $shipping_snapshot   = null;
        if ( $cart_needs_shipping ) {
            $shipping_method_id = (int) ( $data['shipping_method_id'] ?? 0 );

            // Realtime carrier services (USPS/UPS/FedEx, KI-32) share ONE base
            // method_id but expose one picker row PER service, disambiguated by
            // service_code. Core (zone) methods carry no service_code. The storefront
            // picker echoes back the chosen service_code (empty for core methods) so we
            // can (a) validate the exact (method_id, service_code) pair the shopper saw
            // and (b) charge that SPECIFIC service, not merely the cheapest on the
            // method. sanitize_text_field is safe: a carrier service_code is a short
            // token (e.g. "PRIORITY", "03") and never contains markup.
            $service_code = sanitize_text_field( (string) ( $data['shipping_service_code'] ?? '' ) );

            // A physical cart MUST ship via a real, currently-available method. We never
            // silently fall back to $0 when shipping isn't configured — an unconfigured
            // store should surface "no shipping available", not give away free shipping.
            // Extensions (e.g. live Printful rates) contribute their methods to this list.
            $available_methods = apply_filters(
                'titancart_checkout_shipping_methods',
                [],
                $cart,
                $addresses['shipping']
            );
            $available_methods = is_array( $available_methods ) ? $available_methods : [];

            if ( empty( $available_methods ) ) {
                return $this->fail( __( 'No shipping options are available for your address. Please contact the store to arrange delivery.', 'titancart-lite' ) );
            }

            // Match on the (method_id, service_code) PAIR — not method_id alone —
            // because a realtime method offers several rows under one id. An empty
            // requested service_code matches a core row (which has none); a supplied
            // code must match a specific carrier service row. The matched row is kept
            // as the shipping snapshot persisted onto the order (durable record of the
            // exact service + charged rate, since live carrier rates change over time).
            foreach ( $available_methods as $m ) {
                if ( (int) ( $m['method_id'] ?? 0 ) !== $shipping_method_id ) {
                    continue;
                }
                if ( (string) ( $m['service_code'] ?? '' ) !== $service_code ) {
                    continue;
                }
                $shipping_snapshot = $m;
                break;
            }

            if ( null === $shipping_snapshot ) {
                return $this->fail( __( 'Please select an available shipping method.', 'titancart-lite' ) );
            }

            $shipping_cost = apply_filters(
                'titancart_checkout_shipping_cost',
                0.0,
                $shipping_method_id,
                $cart,
                $addresses['shipping'],
                $service_code
            );
        } else {
            $shipping_method_id = 0;
            $shipping_cost      = 0.0;
        }

        // ── 5. Tax calculation ────────────────────────────────────────────────
        $tax_total = apply_filters(
            'titancart_checkout_tax_total',
            0.0,
            $cart,
            $addresses['shipping'],
            $addresses['billing']
        );

        // ── 6. Payment surcharge ──────────────────────────────────────────────
        $payment_surcharge = apply_filters(
            'titancart_payment_surcharge',
            0.0,
            $payment_method_id,
            $cart
        );

        // ── 7. Final totals ───────────────────────────────────────────────────
        // The cart from get_cart() already has auto-promotion discounts
        // calculated (PromotionEngine::apply_auto_promotions).
        // Use that combined discount instead of only the raw coupon_discount.
        $cart_totals     = $cart['totals'] ?? [];
        $coupon_discount = (float) ( $cart_totals['coupon_discount'] ?? 0 );

        $raw_cart_data = titancart_db()->getRow(
            "SELECT * FROM {$this->cart_manager_repo_table()} WHERE session_key = ?s LIMIT 1",
            CartSession::get()
        );

        $totals = $this->totals_calc->calculate(
            $raw_cart_data ?: [ 'currency' => $cart['currency'] ],
            $cart['items'],
            [
                'coupon_discount'   => $coupon_discount,
                'shipping_cost'     => $shipping_cost,
                'tax_total'         => $tax_total,
                'payment_surcharge' => $payment_surcharge,
                'currency'          => $cart['currency'],
            ]
        );

        // Carry promo metadata into the order for audit trail.
        $totals['promo_applied_ids'] = $cart_totals['promo_applied_ids'] ?? [];

        // ── 7b. Tip / Donation (S328) ─────────────────────────────────────────
        // Server-authoritative tip, computed from the FINAL pre-tip total (after
        // the gift-card / reward-points deductions already stamped onto $totals
        // by the titancart_cart_totals by-ref hooks above). Spliced in here
        // rather than fed through a SECOND totals_calc->calculate() pass, so
        // those discount hooks can never double-apply. 0.0 (column stays inert)
        // unless the paid Tip/Donation extension supplied a tip via the
        // titancart_tip_config filter inside resolve_tip().
        $tip_total = $this->resolve_tip( $data, (float) $totals['total'] );
        $totals['tip_total'] = round( $tip_total, 2 );
        if ( $tip_total > 0 ) {
            $totals['total'] = round( (float) $totals['total'] + $tip_total, 2 );
        }

        // ── 7c. Zero-total gate on the "no payment method" path (S420) ────────
        // payment_method_id === 0 is the sentinel for "this order needs no
        // gateway", and step 14's commit gate honours it by setting
        // $payment_committed = true outright — no transient consulted, no
        // gateway called. That is correct ONLY for a genuinely zero-total
        // order: a 100% coupon, a free digital product, a basket fully covered
        // by a gift card or reward points.
        //
        // Nothing upstream ever asserted the total actually WAS zero.
        // payment_method_exists() returns true for 0 without touching the DB,
        // and $data['payment_method_id'] arrives verbatim from the request body
        // (CheckoutController::place_order -> get_json_params). So a store with
        // no active payment methods checked out for free — and so did any
        // request that simply posted 0, on ANY store, however many live
        // gateways it had configured. The empty-methods case was the visible
        // symptom; the forgeable field was the actual hole.
        //
        // This is the authoritative gate, and it sits here deliberately: the
        // final total is not settled until step 7b splices the tip in directly
        // above. Gating any earlier would let a $0 subtotal carrying real
        // shipping, tax, surcharge or tip through as "free".
        //
        // NOT a COD carve-out. COD, cheque and bank transfer are ordinary rows
        // in tc_payment_methods with real ids, resolved through GatewayRegistry
        // ('cod' / 'check' / 'bank_transfer'), so they never arrive here as 0.
        // The "e.g. $0 orders or COD" note on payment_method_exists() describes
        // a model that does not exist and is stale — corrected there.
        if ( $payment_method_id === 0 && (float) $totals['total'] > 0 ) {
            // Two genuinely different situations, so two different messages:
            // with methods configured the shopper simply skipped the step, but
            // with none configured there is nothing to select and "please
            // select a payment method" is a dead end pointing at an empty list.
            // That case is a merchant misconfiguration, not shopper error.
            return $this->fail(
                empty( $this->get_active_payment_methods() )
                    ? __( 'This store is not able to accept payments right now. Please contact us to complete your order.', 'titancart-lite' )
                    : __( 'Please select a payment method.', 'titancart-lite' )
            );
        }

        // ── 8. Stock re-check before committing ───────────────────────────────
        // Mirrors CartManager's AND logic: tracking is active only when the global
        // setting AND the per-product flag are both on. Backorders pass when either
        // the per-product flag OR the global "allow negative" setting is on.
        // Digital products (is_downloadable=1) always bypass stock checks.
        $global_tracking = SettingsService::bool( 'general', 'catalog_track_inventory', true );
        $allow_negative  = SettingsService::bool( 'general', 'catalog_allow_negative_inventory', false );

        foreach ( $cart['items'] as $item ) {
            $product = $this->products->get( (int) $item['product_id'] );

            if (
                $product
                && $global_tracking
                && $product['track_inventory']
                && empty( $product['is_downloadable'] )
                && ! ( $product['allow_backorders'] || $allow_negative )
                && (int) $product['stock_quantity'] < (int) $item['quantity']
            ) {
                return $this->fail(
                    sprintf(
                        /* translators: %s: product name */
                        __( '"%s" does not have enough stock to fulfil your order.', 'titancart-lite' ),
                        $item['name']
                    )
                );
            }
        }

        // ── 9. Hook: pre_place_order ──────────────────────────────────────────
        $allow = true;
        do_action_ref_array(
            'titancart_pre_place_order',
            [ &$allow, $cart, $addresses, $totals ]
        );

        if ( ! $allow ) {
            return $this->fail( __( 'Your order could not be placed at this time.', 'titancart-lite' ) );
        }

        // ── 9b. Downloadable Products: agreement acceptance gate (S138 Phase 5) ─
        // For each downloadable item with a required agreement, verify that
        // an acceptance row exists for this customer/email + agreement
        // version. This is the binding gate — the PDP checkbox is a UX
        // commitment but only logged-in customers persist an acceptance
        // there (the controller rejects guest acceptance without an email
        // anchor — see DownloadableController::accept_agreement docblock).
        // For guests, the gate would otherwise reject every checkout, so we
        // auto-record acceptance using the billing email AFTER order
        // creation in step 11b — the JS already enforced the checkbox at
        // PDP time, and submitting checkout is the customer's binding act.
        //
        // Identity resolution: pass both customer_id (when logged-in) AND
        // billing email — has_acceptance() OR-matches, so either hit counts.
        // This handles the case where a logged-in user accepted under their
        // WP account email but is checking out with a different billing
        // address email.
        //
        // Pattern #71 (S137) note: $cart['items'] is hydrated by
        // CartManager::get_cart() so options are decoded; no JSON-column
        // reads happen directly in this gate.
        $billing_email = (string) ( $addresses['billing']['email'] ?? '' );
        $dl_mgr        = new DownloadableManager();

        // Non-creating customer_id lookup for logged-in users — DO NOT call
        // resolve_customer_id() here. That helper inserts a new guest
        // customer row for new-email guests as a side effect, and we don't
        // want orphan rows if the gate rejects below.
        $gate_customer_id = null;
        if ( is_user_logged_in() ) {
            $cid = titancart_db()->getField(
                "SELECT id FROM " . titancart_db()->table( 'customers' ) . " WHERE wp_user_id = ?i LIMIT 1",
                get_current_user_id()
            );
            $gate_customer_id = $cid ? (int) $cid : null;
        }

        // Build the list of (agreement, source product_id) pairs we need to
        // (a) verify acceptance for and (b) re-record post-order with the
        // order_id anchor for chargeback evidence (step 11b). Dedup by
        // agreement_id+version so the same agreement applied to multiple
        // cart items is only checked + recorded once.
        $required_agreements = [];
        foreach ( $cart['items'] as $item ) {
            $product = $this->products->get( (int) $item['product_id'] );
            if ( ! $product || empty( $product['is_downloadable'] ) ) {
                continue;
            }
            $agreement = $dl_mgr->get_applicable_agreement( (int) $product['id'], $lang );
            if ( ! $agreement || empty( $agreement['required'] ) ) {
                continue;
            }

            $key = (int) $agreement['id'] . ':' . (int) $agreement['version'];
            if ( ! isset( $required_agreements[ $key ] ) ) {
                $required_agreements[ $key ] = [
                    'agreement'  => $agreement,
                    'product_id' => (int) $product['id'],
                ];
            }
        }

        // Logged-in customers must have an acceptance row before we proceed.
        // Guests skip this check — they get auto-recorded in step 11b
        // because their PDP-time checkbox couldn't persist (no email anchor
        // available pre-checkout).
        if ( is_user_logged_in() ) {
            foreach ( $required_agreements as $entry ) {
                $agreement = $entry['agreement'];
                $accepted  = $dl_mgr->has_accepted(
                    (int) $agreement['id'],
                    (int) $agreement['version'],
                    $gate_customer_id,
                    $billing_email !== '' ? $billing_email : null
                );
                if ( ! $accepted ) {
                    return $this->fail( sprintf(
                        /* translators: %s: agreement title */
                        __( 'Please accept the "%s" license agreement before placing your order. Return to the product page to accept.', 'titancart-lite' ),
                        $agreement['title']
                    ) );
                }
            }
        }

        // ── 10. Create order record ───────────────────────────────────────────
        $order_number = $this->order_repo->generate_order_number();
        $order_key    = OrderRepository::generate_order_key();
        $customer_id  = $this->resolve_customer_id( $addresses['billing']['email'], $data );

        $order_data = [
            'customer_id'        => $customer_id,
            'order_number'       => $order_number,
            'order_key'          => $order_key,
            'status'             => 'pending',
            'lang_code'          => $lang,
            'ip_address'         => $this->get_ip(),
            'user_agent'         => isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 255 ) : '', // WP.org R1 🅱2: sanitize before cap.
            'billing'            => $addresses['billing'],
            'shipping'           => $addresses['shipping'],
            'currency'           => $totals['currency'],
            'subtotal'           => $totals['subtotal'],
            'discount'           => $totals['coupon_discount'],
            'shipping_cost'      => $totals['shipping_cost'],
            'tax_total'          => $totals['tax_total'],
            'payment_surcharge'  => $totals['payment_surcharge'],
            // Customer tip / donation (S328). Server-computed in resolve_tip();
            // 0 unless the paid Tip/Donation extension is active. Already folded
            // into 'total' above, so every gateway charges it automatically.
            'tip_total'          => $totals['tip_total'] ?? 0,
            'total'              => $totals['total'],
            'payment_method_id'  => $payment_method_id ?: null,
            'shipping_method_id' => $shipping_method_id ?: null,
            // Durable snapshot of the exact shipping service the shopper picked and
            // was charged for (KI-32). For core (zone) methods this records the base
            // method; for realtime carrier services it also pins the service_code,
            // composed name ("USPS Live Rates — Priority Mail"), carrier and rate, so
            // the order screen / fulfillment / label purchase can reference the precise
            // service even though live carrier rates change after checkout. NULL when
            // the cart doesn't ship. Stored in the tc_orders.shipping_info JSON column.
            'shipping_info'      => is_array( $shipping_snapshot ) ? [
                'method_id'    => (int) ( $shipping_snapshot['method_id'] ?? $shipping_method_id ),
                'service_code' => (string) ( $shipping_snapshot['service_code'] ?? '' ),
                'carrier'      => (string) ( $shipping_snapshot['carrier'] ?? '' ),
                'rate_type'    => (string) ( $shipping_snapshot['rate_type'] ?? 'zone' ),
                'name'         => (string) ( $shipping_snapshot['name'] ?? '' ),
                'cost'         => (float) ( $shipping_snapshot['cost'] ?? $shipping_cost ),
                'currency'     => (string) ( $shipping_snapshot['currency'] ?? ( $totals['currency'] ?? 'USD' ) ),
            ] : null,
            // Durable snapshot of the payment gateway the shopper actually used
            // (KI-97, S424). Deliberately the mirror of shipping_info above:
            // without it the order screen, the storefront order view and the CSV
            // export all resolve the gateway NAME by live join, so deleting a
            // gateway row blanked the payment method on historical orders.
            // NULL when payment_method_id is the 0 "no gateway needed" sentinel
            // (zero-total orders), which is a real state, not a missing value.
            'payment_info'       => $this->payment_snapshot(
                $payment_method_id,
                $lang,
                (string) ( $totals['currency'] ?? 'USD' )
            ),
            'coupon_code'        => $cart['coupon_code'] ?? null,
            'promotions_applied' => $totals['promo_applied_ids'] ?? [],
            // Gift card applied as payment (S255). apply_gift_card_discount()
            // stamped these onto $totals during the cart-totals pipeline; the
            // order `total` above already reflects the deduction. The balance is
            // drawn down at titancart_order_committed by reading these back off
            // the persisted order row.
            'gift_card_code'     => $totals['gift_card_code'] ?? null,
            'gift_card_discount' => $totals['gift_card_discount'] ?? 0,
            // Reward points redeemed as payment (S255, KI-04). apply_points_discount()
            // stamped these onto $totals during the cart-totals pipeline; the order
            // `total` above already reflects the deduction. Persisted here at order
            // creation (both commit paths) and read back at titancart_order_committed
            // to deduct the balance — never at titancart_order_placed (declined orders
            // must not burn points).
            'points_redeemed'    => $totals['redeem_points'] ?? 0,
            'points_discount'    => $totals['points_discount'] ?? 0,
            'notes'              => sanitize_textarea_field( $data['notes'] ?? '' ) ?: null,
            // Optional "How did you hear about us?" answer (S323, Essentials #8).
            // Resolves the "Other → please specify" free text into the same value.
            // Null when the feature is disabled or nothing was chosen.
            'referral_source'    => $this->resolve_referral_source( $data ),
            // Nonce/token data from client-side tokenization (Accept.js / Square SDK).
            // Read by PaymentsModule::process_payment_for_order() → gateway->process_payment().
            // Not persisted — OrderRepository::create_order() allowlist drops it before insert.
            'payment_data'       => $payment_data,
        ];

        $order_id = $this->order_repo->create_order( $order_data );

        if ( ! $order_id ) {
            return $this->fail( __( 'Failed to create the order. Please try again.', 'titancart-lite' ) );
        }

        // ── 11. Create order items ────────────────────────────────────────────
        $this->order_repo->create_order_items( $order_id, $totals['line_items'] );

        // ── 11b. Re-record agreement acceptances with order_id anchor (S138 Phase 5) ─
        // For every required agreement on this order, write a fresh acceptance
        // row anchored to the order_id (and customer_id + billing email).
        // Two reasons we record AGAIN here even when a PDP-time row already
        // exists for logged-in customers:
        //
        //   1. Chargeback evidence — a payment processor disputing a digital
        //      goods purchase wants the acceptance ROW to point at the
        //      specific order, not just the customer. order_id is the
        //      canonical anchor; the PDP-time row has order_id = NULL.
        //
        //   2. Guest binding — guests can't persist an acceptance at PDP
        //      time (the controller rejects acceptance without an email
        //      anchor). The JS already enforced the checkbox; submitting
        //      checkout is the binding act, and this is where it lands.
        //
        // Duplicate rows are inert by design (controller docblock) — the
        // table has lookup indexes but no UNIQUE constraint, so admins
        // editing the agreement mid-session get a re-acceptance row rather
        // than a silent UPDATE that would lose the original-text evidence.
        if ( ! empty( $required_agreements ) && $billing_email !== '' ) {
            $ip = $this->get_ip() ?: null;
            $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
                ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 255 ) // WP.org R1 🅱2: sanitize before cap.
                : null;

            // order_item_id intentionally NULL: $totals['line_items'] holds
            // pre-insert rows without IDs, and order_id is sufficient
            // chargeback anchor. Future enhancement could query back the
            // inserted rows and bind per-item, but the gain is marginal.
            foreach ( $required_agreements as $entry ) {
                $agreement = $entry['agreement'];
                $dl_mgr->record_acceptance(
                    (int) $agreement['id'],
                    (int) $agreement['version'],
                    (string) $agreement['content'],
                    $customer_id,
                    $billing_email,
                    $order_id,
                    null,
                    $ip,
                    $ua
                );
            }
        }

        // ── 12. Optionally save/update customer address ───────────────────────
        if ( $customer_id && ! empty( $data['save_address'] ) ) {
            $this->save_customer_address( $customer_id, $addresses );
        }

        // ── 12b. Link to RMA exchange return (if this cart is completing a return) ──
        // Reads exchange_return_id from cart meta and stamps the new order on the
        // return record so the RMA flow can track the replacement. Also auto-approves
        // the return since the customer has committed by placing the order.
        $this->link_exchange_return_if_any( $order_id, $raw_cart_data );

        // ── 13. Dispatch post-order actions ───────────────────────────────────
        // PaymentsModule (Module 6) hooks here to call gateway->process_payment().
        // Module 5 (Orders + Email) hooks here to send confirmation email.
        // PaymentsModule stashes its result into the `titancart_payment_result_{order_id}`
        // transient — we read it back below to decide whether to decrement stock
        // and destroy the cart.
        do_action( 'titancart_order_placed', $order_id, $order_data, $totals );

        // ── 14. Determine whether the order is "committed" ────────────────────
        // Stock decrement and cart destruction must only run when payment has
        // either succeeded synchronously (AN, Square, offline gateways) or
        // been committed-to via redirect (PayPal, Stripe Checkout Session
        // both return success=true with a redirect_url at this stage).
        //
        // The `requires_action` flag carves out one explicit exception: Stripe
        // Card Elements (inline PaymentIntents). Its `process_payment()` only
        // CREATES the PaymentIntent — the actual charge happens client-side via
        // `stripe.confirmCardPayment()`. Returning success=true here just means
        // "intent created"; the money has not moved. Committing now would leave
        // a customer with an empty cart and depleted stock if their card then
        // declines client-side. Instead, the cart commit is deferred to the
        // POST /checkout/finalize/{order_id} endpoint, which the JS calls once
        // Stripe confirms server-side that the PaymentIntent reached `succeeded`.
        //
        // Gating prevents the prior bug where a declined transaction left the
        // customer with an empty cart, short stock counts, and an orphan
        // pending order they could not retry without re-adding everything.
        if ( $payment_method_id === 0 ) {
            // Zero-total orders bypass the gateway entirely (e.g. 100% coupon).
            // No transient gets set in that path, so we treat them as committed.
            $payment_committed = true;
        } else {
            $payment_result    = get_transient( "titancart_payment_result_{$order_id}" );
            $payment_committed = is_array( $payment_result )
                && ! empty( $payment_result['success'] )
                && empty( $payment_result['payment_data']['requires_action'] );
        }

        if ( $payment_committed ) {
            // ── 14a. Decrement stock ──────────────────────────────────────────
            // Skip entirely when global tracking is off — matches step 8's gate
            // and prevents surprising stock movement on sites that have disabled
            // tracking. When global tracking is on, ProductRepository::update_stock()
            // itself filters per-product via `WHERE track_inventory = 1`.
            if ( $global_tracking ) {
                foreach ( $totals['line_items'] as $line ) {
                    $pid = (int) $line['product_id'];
                    if ( $pid === 0 ) {
                        continue; // Gift card items — no stock to decrement.
                    }
                    $this->products->update_stock( $pid, -( (int) $line['quantity'] ) );
                }
                // S266 KI-16: record that this order took stock at commit, so the
                // restore-only status listener (a status with stock_action='restore',
                // e.g. cancelled/refunded) knows there is stock to return and returns
                // it exactly once. Left 'none' when global tracking is off (no stock
                // was decremented → nothing to restore).
                $this->db()->update( 'orders', [ 'stock_state' => 'reduced' ], [ 'id' => $order_id ] );
            }

            // ── 14b. Destroy cart ─────────────────────────────────────────────
            $this->cart_manager->destroy_after_order();

            // -- 14c. Fire the canonical commit signal -------------------------
            // Consumers that must only act on a COMMITTED order (promotion /
            // coupon usage recording) listen here, NOT on titancart_order_placed
            // -- which fires before payment is known and would burn usage on a
            // declined transaction (KI-03). The titancart_order_committed_{id} transient
            // mirrors finalize_order()'s idempotency guard, so a stray finalize
            // call on an inline-committed order is a harmless early-return no-op.
            set_transient( "titancart_order_committed_{$order_id}", 1, HOUR_IN_SECONDS );
            do_action( 'titancart_order_committed', $order_id, $order_data, $totals );
        }

        // The order_key gates public viewing of the thank-you page and the
        // REST /orders/{number} endpoint. Without it, anyone could iterate
        // order numbers (TC-YYYYMMDD-NNNN is enumerable) and read the
        // billing address, email, phone, and items off any order.
        $redirect_url = apply_filters(
            'titancart_order_received_url',
            add_query_arg(
                [ 'key' => $order_key ],
                home_url( "/order-received/{$order_number}/" )
            ),
            $order_id,
            $order_number,
            $order_key
        );

        $response = [
            'success'      => true,
            'message'      => __( 'Your order has been placed!', 'titancart-lite' ),
            'order_id'     => $order_id,
            'order_number' => $order_number,
            'order_key'    => $order_key,
            'total'        => $totals['total'],
            'items'        => $totals['line_items'],
            'redirect_url' => $redirect_url,
            'payment_data' => [],
            'errors'       => [],
        ];

        /**
         * Filter: titancart_place_order_response
         *
         * Lets gateways merge their payment_data (Stripe client_secret, PayPal
         * approval URL, etc.) into the REST response so the frontend JS can
         * finalize the payment. PaymentsModule::inject_payment_data hooks here
         * and reads the transient set by process_payment_for_order().
         *
         * @param array $response  The response array about to be returned.
         * @param int   $order_id  The newly created order ID.
         */
        return apply_filters( 'titancart_place_order_response', $response, $order_id );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // FINALIZE — deferred-commit for asynchronous gateways (Stripe Card Elements)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Commit an order whose payment was deferred to a JS-driven confirmation step.
     *
     * Called by `POST /checkout/finalize/{order_id}` after the customer's browser
     * completes `stripe.confirmCardPayment()` (or any future async-confirmation
     * gateway's equivalent). The endpoint is necessary because Stripe Card Elements
     * defers the actual charge to client-side JS — `process_payment()` only creates
     * the PaymentIntent. By the time JS finishes the charge, `place_order()`'s
     * commit gate (step 14) has already returned, having NOT decremented stock or
     * destroyed the cart (because `payment_data['requires_action']` was set, which
     * the refined gate now treats as "not yet committed").
     *
     * SECURITY MODEL
     * --------------
     * This method NEVER trusts the caller. It re-queries the gateway's API via
     * `verify_async_payment()` and commits ONLY when the gateway itself confirms
     * the payment is settled server-side. Concretely for Stripe: it hits
     * `/payment_intents/{id}` and requires status `succeeded` or `processing`.
     *
     * Three layers of defense against abuse:
     *   1. Idempotency transient — second call is a no-op even if the first
     *      legitimately committed.
     *   2. Gateway API verification — a malicious caller with no payment cannot
     *      fake `succeeded`; only the gateway can.
     *   3. `cart_manager->destroy_after_order()` operates on the CALLER's session
     *      cookie, not the order's original session — so a third party calling
     *      finalize on someone else's order can only destroy their own (likely
     *      empty) cart. Worst case is the legitimate customer's cart is not
     *      auto-cleared after redirect, a minor UX issue, not a security one.
     *
     * IDEMPOTENCY
     * -----------
     * `titancart_order_committed_{order_id}` transient (1-hour TTL) is set on the first
     * successful commit. Subsequent calls return early as `already_finalized=true`
     * without redoing stock or cart work.
     *
     * @param  int $order_id
     * @return array{
     *   success: bool,
     *   message: string,
     *   already_finalized?: bool
     * }
     */
    public function finalize_order( int $order_id ): array {
        if ( $order_id <= 0 ) {
            return [ 'success' => false, 'message' => __( 'Invalid order ID.', 'titancart-lite' ) ];
        }

        // Idempotency check — already finalized? Return success without redoing work.
        if ( get_transient( "titancart_order_committed_{$order_id}" ) ) {
            return [
                'success'           => true,
                'message'           => __( 'Order already finalized.', 'titancart-lite' ),
                'already_finalized' => true,
            ];
        }

        $db = titancart_db();

        // Look up the order. If it doesn't exist, refuse.
        $order = $db->getRow(
            "SELECT * FROM {$db->table('orders')} WHERE id = ?i LIMIT 1",
            $order_id
        );

        if ( ! $order ) {
            return [ 'success' => false, 'message' => __( 'Order not found.', 'titancart-lite' ) ];
        }

        // Find the latest payment transaction to verify. A normal order has a
        // 'charge' row; a $0 vault-only order (preorder / delayed-start
        // subscription) has an 'authorize' row from its SetupIntent (the type
        // ENUM has no 'setup' member — #286). Match either, newest first;
        // excludes 'refund' rows. (S279, B2)
        $txn = $db->getRow(
            "SELECT * FROM {$db->table('payment_transactions')}
             WHERE order_id = ?i AND type IN ('charge','authorize')
             ORDER BY id DESC LIMIT 1",
            $order_id
        );

        if ( ! $txn ) {
            return [ 'success' => false, 'message' => __( 'No payment transaction found for this order.', 'titancart-lite' ) ];
        }

        $gateway_slug = $txn['gateway'] ?? '';
        $registry     = GatewayRegistry::instance();

        if ( ! $registry->has( $gateway_slug ) ) {
            return [ 'success' => false, 'message' => __( 'Payment gateway not registered.', 'titancart-lite' ) ];
        }

        $gateway = $registry->make( $gateway_slug );

        // Only AbstractGateway-based gateways expose `verify_async_payment()`.
        // Custom third-party gateways that don't extend AbstractGateway are
        // refused — they should commit at place_order time anyway.
        if ( ! $gateway instanceof AbstractGateway ) {
            return [ 'success' => false, 'message' => __( 'Gateway does not support async finalization.', 'titancart-lite' ) ];
        }

        // Load gateway settings.
        $payment_method_id = (int) ( $order['payment_method_id'] ?? 0 );
        if ( $payment_method_id <= 0 ) {
            return [ 'success' => false, 'message' => __( 'Order has no payment method.', 'titancart-lite' ) ];
        }

        $method_repo = new PaymentMethodRepository();
        $settings    = $method_repo->get_settings( $payment_method_id );

        // ★ THE SECURITY GATE ★
        // Re-query the gateway's API to confirm the payment actually settled.
        // For Stripe, this hits /payment_intents/{id} and requires status
        // === 'succeeded' or 'processing'. For sync gateways (default
        // AbstractGateway::verify_async_payment), this is always false.
        //
        // A $0 vault-only order (preorder / delayed-start subscription) confirms
        // via a SetupIntent (seti_...) instead of a charge — the card is saved
        // for a deferred first charge and no money moves now (S279, B2). Those
        // verify through the gateway's setup-intent gate, which returns the
        // verified SetupIntent object so we can dispatch the renewal-token
        // capture after committing.
        $transaction_id  = (string) $txn['transaction_id'];
        $is_setup_intent = ( strpos( $transaction_id, 'seti_' ) === 0 );
        $setup           = [ 'verified' => false, 'intent' => [] ];

        if ( $is_setup_intent ) {
            $setup    = $gateway->verify_setup_intent( $transaction_id, $settings );
            $verified = ! empty( $setup['verified'] );
        } else {
            $verified = $gateway->verify_async_payment( $transaction_id, $settings );
        }

        if ( ! $verified ) {
            return [ 'success' => false, 'message' => __( 'Payment has not been confirmed by the gateway.', 'titancart-lite' ) ];
        }

        // Mark committed BEFORE doing the work — prevents a TOCTOU race if two
        // calls arrive in close succession. 1-hour TTL is more than enough; the
        // order's commit state is terminal long before then.
        set_transient( "titancart_order_committed_{$order_id}", 1, HOUR_IN_SECONDS );

        // Decrement stock for each line item — same gating logic as place_order
        // step 14a. Skip when global tracking is off; ProductRepository's
        // update_stock() further filters per-product via `WHERE track_inventory = 1`.
        $global_tracking = SettingsService::bool( 'general', 'catalog_track_inventory', true );

        if ( $global_tracking ) {
            $items = $db->getArray(
                "SELECT product_id, quantity FROM {$db->table('order_items')} WHERE order_id = ?i",
                $order_id
            );

            foreach ( $items as $line ) {
                $pid = (int) $line['product_id'];
                if ( $pid === 0 ) {
                    continue; // Gift card items — no stock to decrement.
                }
                $this->products->update_stock( $pid, -( (int) $line['quantity'] ) );
            }
            // S266 KI-16: mark stock taken (same marker as place_order step 14a) so
            // the restore-only status listener can return it once for orders
            // committed via the deferred Stripe Card Elements / 3DS path.
            $db->update( 'orders', [ 'stock_state' => 'reduced' ], [ 'id' => $order_id ] );
        }

        // Destroy the cart. Operates on the CALLER's session, which for the
        // legitimate JS-driven finalize is the customer who placed the order.
        $this->cart_manager->destroy_after_order();

        // Fire the canonical commit signal for the deferred (async) commit path.
        // place_order() skipped its inline dispatch because payment was not yet
        // committed (Stripe Card Elements requires_action); THIS is the real
        // commit, so promotion/coupon usage records here, not at placement (KI-03).
        // finalize has no $totals, so reconstruct the handler args from the
        // persisted order row -- record_promotion_usage() falls back to
        // $order_data['promotions_applied'] when $totals['promo_applied_ids'] is
        // absent. The titancart_order_committed_{id} transient was already set above
        // (L796 idempotency guard), so this fires exactly once per order.
        $committed_order_data = [
            'promotions_applied' => $order['promotions_applied']
                ? ( json_decode( (string) $order['promotions_applied'], true ) ?: [] )
                : [],
            'coupon_code'        => $order['coupon_code']  ?? null,
            'customer_id'        => $order['customer_id']  ?? null,
        ];
        do_action( 'titancart_order_committed', $order_id, $committed_order_data, [] );

        // For a vault-only SetupIntent there is no charge webhook to advance the
        // order and capture the renewal token, so dispatch it here from the
        // verified SetupIntent object. The gateway handler marks the order
        // 'processing' (which activates the deferred subscription) and fires
        // titancart_stripe_setup_intent_succeeded for token capture. Idempotent
        // with the setup_intent.succeeded webhook (completed-status guard). (S279, B2)
        if ( $is_setup_intent && ! empty( $setup['intent'] ) ) {
            $gateway->handle_setup_intent_succeeded( $setup['intent'], $txn );
        }

        return [
            'success' => true,
            'message' => __( 'Order finalized.', 'titancart-lite' ),
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PRIVATE HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Resolve or create a TitanCart customer_id.
     *
     * For logged-in users: look up by WP user ID.
     * For guests: look up by email; create customer row if not found.
     *
     * @param  string $email
     * @param  array  $data  Full form data.
     * @return int|null
     */
    private function resolve_customer_id( string $email, array $data ): ?int {
        $db = titancart_db();

        if ( is_user_logged_in() ) {
            $wp_user_id = get_current_user_id();
            $id         = $db->getField(
                "SELECT id FROM {$db->table('customers')} WHERE wp_user_id = ?i LIMIT 1",
                $wp_user_id
            );

            return $id ? (int) $id : null;
        }

        // Guest — look up by email.
        if ( $email ) {
            $id = $db->getField(
                "SELECT id FROM {$db->table('customers')} WHERE email = ?s AND wp_user_id IS NULL LIMIT 1",
                $email
            );

            if ( $id ) {
                return (int) $id;
            }

            // Create a guest customer record.
            $billing = $data['billing'] ?? [];
            $new_id  = $db->insert( 'customers', [
                'email'      => $email,
                'first_name' => sanitize_text_field( $data['billing_first_name'] ?? '' ),
                'last_name'  => sanitize_text_field( $data['billing_last_name']  ?? '' ),
                'phone'      => sanitize_text_field( $data['billing_phone']      ?? '' ),
                'status'     => 'active',
                'wp_user_id' => null,
            ] );

            return $new_id ? (int) $new_id : null;
        }

        return null;
    }

    /**
     * Get the customer's saved default address for prefill.
     *
     * Returns a merged view of the customer's default billing address row
     * (`customer_addresses`) and their profile record (`customers`). The
     * address row takes precedence; profile fields (phone, email, first_name,
     * last_name) fill in gaps — this handles the common case where an admin
     * edits the customer profile (which writes to `customers`) but the user
     * has no saved address yet, or where the saved address row was created
     * before a given profile field was populated.
     *
     * @return array|null
     */
    private function get_saved_address(): ?array {
        $address          = null;
        $customer_profile = null;

        if ( is_user_logged_in() ) {
            $db         = titancart_db();
            $wp_user_id = get_current_user_id();

            $customer_profile = $db->getRow(
                "SELECT id, email, first_name, last_name, phone
                 FROM {$db->table('customers')}
                 WHERE wp_user_id = ?i LIMIT 1",
                $wp_user_id
            ) ?: null;

            if ( $customer_profile ) {
                $address = $db->getRow(
                    "SELECT * FROM {$db->table('customer_addresses')}
                     WHERE customer_id = ?i AND is_default = 1 LIMIT 1",
                    (int) $customer_profile['id']
                ) ?: null;
            }
        }

        // Merge profile fallbacks into the address result. Address values win
        // when non-empty; otherwise profile values fill in.
        if ( $customer_profile ) {
            if ( ! is_array( $address ) ) {
                $address = [];
            }
            foreach ( [ 'phone', 'email', 'first_name', 'last_name' ] as $field ) {
                if ( empty( $address[ $field ] ) && ! empty( $customer_profile[ $field ] ) ) {
                    $address[ $field ] = $customer_profile[ $field ];
                }
            }
        }

        // Fall back to Default Customer Location settings for guests or
        // logged-in users without a saved address or profile.
        if ( empty( $address ) ) {
            $address = $this->get_default_location();
        }

        return $address ?: null;
    }

    /**
     * Build a fallback address array from the Default Customer Location
     * settings (Checkout → Default Customer Location).
     *
     * @return array|null  Returns null if no defaults are configured.
     */
    private function get_default_location(): ?array {
        $country = $this->checkout_setting( 'default_country', '' );
        if ( empty( $country ) ) {
            return null;
        }

        return [
            'address_1'    => $this->checkout_setting( 'default_address', '' ),
            'city'         => $this->checkout_setting( 'default_city', '' ),
            'state'        => $this->checkout_setting( 'default_state', '' ),
            'postcode'     => $this->checkout_setting( 'default_zipcode', '' ),
            // Matches the template's `$saved['country']` key (and the column
            // name on customer_addresses) — not `country_code`.
            'country'      => $country,
            'phone'        => $this->checkout_setting( 'default_phone', '' ),
        ];
    }

    /**
     * Get active payment methods for display on the checkout page.
     *
     * @return array
     */
    private function get_active_payment_methods(): array {
        // Delegate to PaymentMethodRepository so column names and join
        // conditions stay consistent with Module 6 (Payments).
        return ( new \TitanCart\Modules\Payments\PaymentMethodRepository() )->get_active( 'en' );
    }

    /**
     * Build the durable payment-gateway snapshot stored in tc_orders.payment_info.
     *
     * KI-97 (S424). The mirror of the shipping_info snapshot: it records the
     * gateway slug and the RESOLVED display name at the moment of checkout, so
     * order history never depends on the tc_payment_methods row continuing to
     * exist. Returns NULL for the id=0 "no gateway needed" sentinel — a real
     * state (zero-total orders), not a missing value.
     *
     * @param  int    $method_id
     * @param  string $lang       Order language, for the translated label.
     * @param  string $currency
     * @return array|null
     */
    private function payment_snapshot( int $method_id, string $lang, string $currency ): ?array {
        if ( $method_id <= 0 ) {
            return null;
        }

        $db  = titancart_db();
        $row = $db->getRow(
            "SELECT pm.gateway,
                    COALESCE(NULLIF(pmt.name, ''), pm.gateway) AS name
               FROM {$db->table('payment_methods')} pm
          LEFT JOIN {$db->table('payment_method_translations')} pmt
                 ON pmt.method_id = pm.id AND pmt.language_code = ?s
              WHERE pm.id = ?i
              LIMIT 1",
            $lang,
            $method_id
        );

        if ( ! $row ) {
            return null;
        }

        return [
            'method_id' => $method_id,
            'gateway'   => (string) ( $row['gateway'] ?? '' ),
            'name'      => (string) ( $row['name'] ?? '' ),
            'currency'  => $currency,
        ];
    }

    /**
     * Check a payment method ID exists and is active.
     *
     * @param  int $id
     * @return bool
     */
    private function payment_method_exists( int $id ): bool {
        if ( $id === 0 ) {
            // 0 = the "no gateway needed" sentinel. Accepted HERE unconditionally
            // because this check runs at step 3, before the total is known —
            // the total-aware gate that decides whether 0 is legitimate lives at
            // step 7c, once tip/shipping/tax/surcharge are folded in (S420).
            // ⚠️ Do NOT treat a true return here as "this order may skip
            // payment"; it only means "0 is not a malformed id".
            // (Historical note: this previously read "e.g. $0 orders or COD".
            // COD is wrong — CodGateway has its own tc_payment_methods row and
            // a real id, like cheque and bank transfer. No gateway uses 0.)
            return true;
        }

        $db    = titancart_db();
        $count = (int) $db->getField(
            "SELECT COUNT(*) FROM {$db->table('payment_methods')}
             WHERE id = ?i AND status = 'active'",
            $id
        );

        return $count > 0;
    }

    /**
     * Resolve an active payment-method row to its gateway instance. KI-79 (S394).
     *
     * `tc_payment_methods.gateway` holds the gateway SLUG (VARCHAR(64)), which
     * GatewayRegistry maps to a class. Returns null when the id is 0, the row is
     * missing/inactive, or the slug is not registered.
     *
     * ⚠️ NULL IS A REAL ANSWER AND CALLERS MUST TREAT IT AS "INCAPABLE", NOT AS
     * "UNKNOWN, CARRY ON". id=0 is an accepted value at checkout
     * (payment_method_exists() short-circuits it to true), and a slug can be
     * unregistered if an extension that registered it is deactivated while a
     * saved method row still points at it. Both cases mean "we cannot ask this
     * gateway anything" — for a fail-closed gate that must read as a refusal.
     *
     * ⚠️ THIRD PARTIES: GatewayRegistry::make() returns ?PaymentGatewayInterface,
     * and `supports_subscriptions()` lives on AbstractGateway — so a third-party
     * gateway that implements the INTERFACE without extending the ABSTRACT
     * resolves to null here and is refused for subscription carts. That is
     * deliberate and fail-closed (we have no method to ask), not an oversight:
     * a gateway we cannot interrogate is one we must not auto-renew against.
     * The remedy for such an author is to extend AbstractGateway and override
     * the flag — i.e. to opt in explicitly, which is the whole design.
     *
     * @since S394 (KI-79)
     * @param  int $id  tc_payment_methods.id (0 = none).
     * @return AbstractGateway|null
     */
    private function resolve_gateway_for_method( int $id ): ?AbstractGateway {
        if ( $id === 0 ) {
            return null;
        }

        $db   = titancart_db();
        $slug = (string) $db->getField(
            "SELECT gateway FROM {$db->table('payment_methods')}
             WHERE id = ?i AND status = 'active'",
            $id
        );

        if ( $slug === '' ) {
            return null;
        }

        $gateway = GatewayRegistry::instance()->make( $slug );

        return $gateway instanceof AbstractGateway ? $gateway : null;
    }

    /**
     * Save / update the customer's default address.
     *
     * @param int   $customer_id
     * @param array $addresses  { billing: [...], shipping: [...] }
     */
    private function save_customer_address( int $customer_id, array $addresses ): void {
        $db  = titancart_db();
        $b   = $addresses['billing'];

        $existing = $db->getField(
            "SELECT id FROM {$db->table('customer_addresses')}
             WHERE customer_id = ?i AND is_default = 1 LIMIT 1",
            $customer_id
        );

        $row = [
            'customer_id' => $customer_id,
            'first_name'  => $b['first_name'],
            'last_name'   => $b['last_name'],
            'company'     => $b['company']   ?? '',
            'phone'       => $b['phone']     ?? '',
            'address_1'   => $b['address_1'],
            'address_2'   => $b['address_2'] ?? '',
            'city'        => $b['city'],
            'state'       => $b['state']     ?? '',
            'postcode'    => $b['postcode'],
            'country'     => $b['country'],
            'is_default'  => 1,
            'type'        => 'billing',
        ];

        if ( $existing ) {
            $db->update( 'customer_addresses', $row, [ 'id' => (int) $existing ] );
        } else {
            $db->insert( 'customer_addresses', $row );
        }
    }

    /**
     * Get the carts table name — avoids direct DB access.
     *
     * @return string
     */
    private function cart_manager_repo_table(): string {
        return titancart_db()->table( 'carts' );
    }

    /**
     * If this cart was set up as an RMA exchange (customer returning item for
     * a higher-priced replacement), stamp the new order on the return record
     * and auto-approve the return. Customer has committed by checking out.
     *
     * Safe to call unconditionally — returns early if no exchange metadata.
     *
     * @param int        $order_id
     * @param array|null $raw_cart  Raw cart row including `meta` JSON (from place_order).
     */
    private function link_exchange_return_if_any( int $order_id, ?array $raw_cart ): void {
        if ( ! $raw_cart || empty( $raw_cart['meta'] ) ) {
            return;
        }

        $meta = is_string( $raw_cart['meta'] ) ? json_decode( $raw_cart['meta'], true ) : $raw_cart['meta'];
        if ( ! is_array( $meta ) ) {
            return;
        }

        $return_id = (int) ( $meta['exchange_return_id'] ?? 0 );
        if ( $return_id <= 0 ) {
            return;
        }

        $db = titancart_db();

        // Verify the return exists + is in a state we can link to.
        $return_row = $db->getRow(
            "SELECT id, status, replacement_order_id FROM {$db->table('returns')} WHERE id = ?i LIMIT 1",
            $return_id
        );
        if ( ! $return_row ) {
            return;
        }

        // Don't clobber an already-linked return.
        if ( ! empty( $return_row['replacement_order_id'] ) ) {
            return;
        }

        // Stamp the replacement order ID on the return.
        $db->query(
            "UPDATE {$db->table('returns')} SET replacement_order_id = ?i, updated_at = ?s WHERE id = ?i",
            $order_id,
            titancart_now_utc(), // KI-24: UTC, not MySQL-tz NOW()
            $return_id
        );

        // If still in 'requested' status, advance to 'approved' — customer has paid.
        if ( $return_row['status'] === 'requested' ) {
            $db->query(
                "UPDATE {$db->table('returns')} SET status = 'approved', updated_at = ?s WHERE id = ?i",
                titancart_now_utc(), // KI-24: UTC, not MySQL-tz NOW()
                $return_id
            );

            // Log the auto-approval to return history.
            $db->query(
                "INSERT INTO {$db->table('return_history')} SET
                    return_id  = ?i,
                    old_status = ?s,
                    new_status = ?s,
                    changed_by = ?i,
                    note       = ?s",
                $return_id,
                'requested',
                'approved',
                get_current_user_id(),
                sprintf( 'Customer completed checkout for replacement order #%d. Return auto-approved — awaiting physical return.', $order_id )
            );
        } else {
            // Just log that the order is linked.
            $db->query(
                "INSERT INTO {$db->table('return_history')} SET
                    return_id  = ?i,
                    old_status = ?s,
                    new_status = ?s,
                    changed_by = ?i,
                    note       = ?s",
                $return_id,
                (string) $return_row['status'],
                (string) $return_row['status'],
                get_current_user_id(),
                sprintf( 'Customer completed checkout for replacement order #%d.', $order_id )
            );
        }
    }

    /**
     * Shorthand for titancart_db().
     */
    private function db(): \TitanCart\DB\Database {
        return titancart_db();
    }

    /**
     * Get visitor IP address.
     *
     * WP.org R1 🅱2: delegates to titancart_client_ip() (validated REMOTE_ADDR only).
     * Previously walked HTTP_CLIENT_IP / HTTP_X_FORWARDED_FOR first — those are
     * client-forgeable, so the stored order IP could be spoofed. Stores behind
     * a trusted proxy configure that at the server layer (which rewrites
     * REMOTE_ADDR), matching the posture documented in Core\Plugin.php.
     *
     * @return string
     */
    private function get_ip(): string {
        return titancart_client_ip();
    }

    /**
     * Whether guest checkout is allowed per settings.
     *
     * @return bool
     */
    private function guest_checkout_allowed(): bool {
        $disabled = $this->checkout_setting( 'disable_anonymous_checkout', '0' );
        $allowed  = $disabled !== '1';
        return (bool) apply_filters( 'titancart_guest_checkout_allowed', $allowed );
    }

    /**
     * Read a single checkout setting from tc_settings.
     */
    private function checkout_setting( string $key, string $default = '' ): string {
        $db  = titancart_db();
        $val = $db->getField(
            "SELECT value FROM {$db->table('settings')} WHERE section = ?s AND `key` = ?s LIMIT 1",
            'checkout', $key
        );
        return $val !== null ? $val : $default;
    }

    /**
     * Gather all checkout-relevant settings for the frontend.
     */
    private function get_checkout_settings(): array {
        return [
            'ship_to_billing_only'    => $this->checkout_setting( 'ship_to_billing_only', '0' ) === '1',
            'same_as_billing_default' => $this->checkout_setting( 'same_as_billing_default', '1' ) === '1',
            'min_order_amount'        => (float) $this->checkout_setting( 'min_order_amount', '0' ),
            'estimate_shipping'       => $this->checkout_setting( 'estimate_shipping_on_cart', '1' ) === '1',
            'offer_guest_account'     => $this->checkout_setting( 'offer_guest_account', '1' ) === '1',
            'estimate_tax_default'    => SettingsService::bool( 'appearance', 'estimate_taxes_default_addr', false ),
            'tax_calc_basis'          => SettingsService::get( 'checkout', 'tax_calculation_basis', 'subtotal' ),
            // Referral Source field (S323, Essentials #8). The storefront renders
            // the optional "How did you hear about us?" dropdown only when enabled.
            'referral_enabled'        => $this->checkout_setting( 'referral_enabled', '0' ) === '1',
            'referral_label'          => $this->checkout_setting( 'referral_label', 'How did you hear about us?' ),
            'referral_options'        => $this->checkout_setting( 'referral_options', self::default_referral_options() ),
        ];
    }

    /**
     * Default newline-separated referral-source options. Seeds the setting and
     * is the fallback when the merchant has cleared the field.
     */
    private static function default_referral_options(): string {
        return "Facebook\nInstagram\nGoogle\nFriend or family\nYouTube\nTikTok\nOther";
    }

    /**
     * Resolve the referral-source value to persist on the order (S323).
     *
     * Reads the checkout POST body. Returns null when the feature is disabled
     * or nothing was selected. When the customer picked "Other" and typed a
     * specifying value (referral_source_other), that text replaces "Other" so
     * the report captures the real source. Always sanitised + capped to the
     * column width (100). Always optional — never blocks the order.
     *
     * @param  array $data  Raw checkout POST body.
     * @return string|null
     */
    private function resolve_referral_source( array $data ): ?string {
        if ( $this->checkout_setting( 'referral_enabled', '0' ) !== '1' ) {
            return null;
        }

        $choice = sanitize_text_field( (string) ( $data['referral_source'] ?? '' ) );
        if ( $choice === '' ) {
            return null;
        }

        // "Other → please specify": fold the typed text into the stored value.
        if ( strcasecmp( $choice, 'Other' ) === 0 ) {
            $other = sanitize_text_field( (string) ( $data['referral_source_other'] ?? '' ) );
            if ( $other !== '' ) {
                $choice = $other;
            }
        }

        // Cap to the column width; mb_substr so multibyte input isn't truncated mid-char.
        if ( function_exists( 'mb_substr' ) ) {
            $choice = mb_substr( $choice, 0, 100 );
        } else {
            $choice = substr( $choice, 0, 100 );
        }

        return $choice !== '' ? $choice : null;
    }

    /**
     * Resolve a customer-chosen tip / donation into a server-authoritative amount.
     *
     * The tip MODEL + limits are owned by the paid Tip/Donation extension, which
     * supplies them via the `titancart_tip_config` filter. Core ships NO config,
     * so this returns 0.0 (the tip_total column stays inert) until that extension
     * is active. Tamper-proofing is the whole point of computing this server-side:
     *   - round_up is derived HERE from the live pre-tip total (integer-cents math,
     *     no float drift) — a posted dollar figure is never trusted for round-up;
     *   - a preset amount is accepted ONLY if it matches one the extension offered;
     *   - a custom amount is allowed only when the extension permits it;
     *   - every path is clamped to a non-negative ceiling so a tampered POST can
     *     never inflate the charge.
     *
     * @param  array $data           Raw checkout POST body (tip_mode / tip_round_up / tip_amount).
     * @param  float $pre_tip_total  The authoritative order total BEFORE the tip.
     * @return float                 The tip amount (>= 0), rounded to 2 dp. 0.0 when off.
     */
    private function resolve_tip( array $data, float $pre_tip_total ): float {
        $config = apply_filters( 'titancart_tip_config', null, $data, $pre_tip_total );
        if ( ! is_array( $config ) || empty( $config['enabled'] ) ) {
            return 0.0; // No Tip/Donation extension active, or feature disabled.
        }

        $mode = isset( $data['tip_mode'] ) ? sanitize_key( (string) $data['tip_mode'] ) : '';
        $tip  = 0.0;

        if ( $mode === 'round_up' && ! empty( $data['tip_round_up'] ) ) {
            // Round the live pre-tip total UP to the next whole currency unit.
            // Done in integer cents to avoid floating-point drift on the boundary.
            $cents = (int) round( $pre_tip_total * 100 );
            $up    = (int) ( ceil( $cents / 100 ) * 100 );
            $tip   = ( $up - $cents ) / 100;
        } elseif ( $mode === 'preset' ) {
            // Accept ONLY a value the extension actually offered (2-dp compare).
            $posted  = round( (float) ( $data['tip_amount'] ?? 0 ), 2 );
            $presets = array_map(
                static function ( $v ) { return round( (float) $v, 2 ); },
                (array) ( $config['presets'] ?? [] )
            );
            if ( in_array( $posted, $presets, true ) ) {
                $tip = $posted;
            }
        } elseif ( $mode === 'custom' && ! empty( $config['allow_custom'] ) ) {
            $tip = (float) ( $data['tip_amount'] ?? 0 );
        }

        // Final clamp. Ceiling = the extension's declared max, else a safe default
        // (the greater of $1000 or 5x the order) so nothing absurd can be charged.
        $ceiling = isset( $config['max'] )
            ? (float) $config['max']
            : max( 1000.0, $pre_tip_total * 5 );
        $tip = max( 0.0, min( $tip, $ceiling ) );

        return round( $tip, 2 );
    }

    /**
     * Standardised failure response.
     *
     * @param  string $message
     * @return array
     */
    private function fail( string $message ): array {
        return [
            'success'      => false,
            'message'      => $message,
            'order_number' => null,
            'order_id'     => null,
            'redirect_url' => null,
            'errors'       => [],
        ];
    }
}
