<?php
/**
 * Cart Manager — the main cart engine.
 *
 * Coordinates CartSession, CartRepository, CartTotals,
 * and the ProductRepository to provide a clean public API
 * for all cart operations.
 *
 * Handles add-to-cart and cart-content recalculation
 * pattern but in a clean service class.
 *
 * Key design decisions:
 *   - Cart identified by session key (cookie), not PHP session.
 *   - Persisted in tc_carts + tc_cart_items immediately on every change.
 *   - Product data hydrated at read time (not stored redundantly).
 *   - Quantity validation happens here before writing to the DB.
 *   - Hook points before and after every mutation for addon extensibility.
 *
 * @package TitanCart\Modules\Cart
 */

namespace TitanCart\Modules\Cart;

use TitanCart\Modules\Products\ProductRepository;
use TitanCart\Services\SettingsService;
use TitanCart\Core\LogService;

defined( 'ABSPATH' ) || exit;

class CartManager {

    private CartRepository  $repo;
    private CartTotals      $totals_calc;
    private ProductRepository $products;

    public function __construct() {
        $this->repo        = new CartRepository();
        $this->totals_calc = new CartTotals();
        $this->products    = new ProductRepository();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // READ
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return the fully hydrated cart for the current visitor.
     *
     * Includes:
     *   - Cart row (currency, coupon_code, notes)
     *   - Line items with product data attached
     *   - Computed totals
     *
     * @param  string|null $session_key  Defaults to current session.
     * @param  string      $lang
     * @return array<string,mixed>
     */
    public function get_cart( ?string $session_key = null, string $lang = 'en', ?string $context_email = null ): array {
        $key         = $session_key ?? CartSession::get();
        $customer_id = $this->current_customer_id();
        $cart        = $this->repo->find_or_create( $key, $customer_id );
        $raw_items   = $this->repo->get_items( $cart['id'] );

        // Hydrate each item with current product data.
        $hydrated = $this->hydrate_items( $raw_items, $lang );

        // ── Run auto cart promotions (non-coupon) ─────────────────────────────
        $meta            = is_string( $cart['meta'] ?? null ) ? json_decode( $cart['meta'], true ) : ( $cart['meta'] ?? [] );
        $coupon_discount = (float) ( $meta['coupon_discount'] ?? 0 );
        $promo_discount  = 0.0;
        $promo_free_ship = false;
        $promo_applied   = [];

        // ── RMA exchange credit (if this cart is completing a return exchange) ──
        $exchange_credit    = (float) ( $meta['exchange_credit']    ?? 0 );
        $exchange_return_id = (int)   ( $meta['exchange_return_id'] ?? 0 );

        if ( ! empty( $hydrated ) && class_exists( \TitanCart\Modules\Promotions\PromotionEngine::class ) ) {
            // Build a lightweight cart array the engine can evaluate against.
            $quick_subtotal = 0.0;
            $quick_qty      = 0;
            foreach ( $hydrated as $hi ) {
                $quick_subtotal += (float) $hi['unit_price'] * (int) $hi['quantity'];
                $quick_qty      += (int) $hi['quantity'];
            }

            $engine_cart = [
                'id'             => (int) $cart['id'],
                'customer_id'    => $cart['customer_id'],
                'customer_email' => $context_email !== null ? $context_email : '',
                'items'          => $hydrated,
                'totals' => [
                    'subtotal'   => $quick_subtotal,
                    'item_count' => $quick_qty,
                ],
            ];

            $engine = new \TitanCart\Modules\Promotions\PromotionEngine();
            $promo_result = $engine->apply_auto_promotions(
                [ 'discount' => 0.0, 'free_shipping' => false, 'applied_ids' => [] ],
                $engine_cart
            );

            $promo_discount  = (float) $promo_result['discount'];
            $promo_free_ship = (bool)  $promo_result['free_shipping'];
            $promo_applied   = $promo_result['applied_ids'] ?? [];
        }

        // Calculate totals (coupon discount + auto-promo discount + exchange credit combined).
        // CartTotals clamps total discount at subtotal so it can never go negative.
        $totals = $this->totals_calc->calculate( $cart, $hydrated, [
            'coupon_discount' => $coupon_discount + $promo_discount + $exchange_credit,
            'shipping_cost'   => 0.0, // Set during checkout.
            'tax_total'       => 0.0, // Set during checkout.
        ] );

        // Expose promotion breakdown in totals for display.
        $totals['promo_discount']   = round( $promo_discount, 2 );
        $totals['promo_applied_ids'] = $promo_applied;
        $totals['promo_free_shipping'] = $promo_free_ship;

        // Expose exchange credit in totals + on the cart for display.
        $totals['exchange_credit']    = round( $exchange_credit, 2 );
        $totals['exchange_return_id'] = $exchange_return_id;

        return [
            'id'                 => (int) $cart['id'],
            'session_key'        => $cart['session_key'],
            'customer_id'        => $cart['customer_id'],
            'currency'           => $cart['currency'],
            'coupon_code'        => $cart['coupon_code'],
            'notes'              => $cart['notes'],
            'totals'             => $totals,
            'items'              => $totals['line_items'],
            'item_count'         => $totals['item_count'],
            'exchange_credit'    => round( $exchange_credit, 2 ),
            'exchange_return_id' => $exchange_return_id,
            'updated_at'         => $cart['updated_at'],
        ];
    }

    /**
     * Return item count only (cheap — no hydration needed).
     *
     * @param  string|null $session_key
     * @return int
     */
    public function get_item_count( ?string $session_key = null ): int {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->get_by_session( $key );

        if ( ! $cart ) {
            return 0;
        }

        return $this->repo->count_items( (int) $cart['id'] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // MUTATIONS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Add a product to the cart, or increment quantity if it already exists.
     *
     * @param  int         $product_id
     * @param  int         $quantity
     * @param  array       $options      { option_id => variant_id, ... }
     * @param  int|null    $variation_id
     * @param  string|null $session_key
     * @return array{ success: bool, message: string, cart: array }
     */
    public function add_item(
        int     $product_id,
        int     $quantity      = 1,
        array   $options       = [],
        ?int    $variation_id  = null,
        ?string $session_key   = null,
        array   $meta          = []
    ): array {
        $key      = $session_key ?? CartSession::get();
        $cart     = $this->repo->find_or_create( $key, $this->current_customer_id() );
        $cart_id  = (int) $cart['id'];

        // ── Guest shopping policy ────────────────────────────────────────────
        if ( ! is_user_logged_in() ) {
            $guest_policy = SettingsService::get( 'checkout', 'allow_shopping_unlogged', 'allow' );
            if ( $guest_policy === 'deny' || $guest_policy === 'hide_price' ) {
                return $this->error( __( 'Please log in or create an account to add items to your cart.', 'titancart-lite' ) );
            }
        }


        // ── Validate product ──────────────────────────────────────────────────
        $product = $this->products->get( $product_id );

        if ( ! $product ) {
            return $this->error( __( 'Product not found.', 'titancart-lite' ) );
        }

        if ( $product['status'] !== 'active' ) {
            return $this->error( __( 'This product is not available.', 'titancart-lite' ) );
        }

        // ── Variation-group member guard (KI-21) ─────────────────────────────
        // Each variation (Color/Size) is its own product, linked into a group
        // via tc_product_group_products. A member must only be added from its
        // own PDP — where the customer has effectively chosen that Color/Size —
        // never via a bare card "Add to cart" that would silently add whichever
        // member happens to be listed. The PDP threads the loaded member's
        // combination as $meta['variation_combo'] (read from
        // #tc-variations[data-current] by assets/js/cart.js); a card add carries
        // no such signal. Reject any member add whose incoming combo does not
        // match the member's stored combination. Cards / quick-view render
        // "Choose Options" -> PDP instead of an add button, so a customer never
        // reaches here through the UI — this is the defense-in-depth backstop
        // for direct REST hits, stale/cached cards, and any non-PDP surface.
        //
        // The legacy single-product variation path (a real $variation_id) is
        // exempt: that id IS the deliberate selection. Only the separate-product
        // group model (bare $product_id member, no $variation_id) is gated here.
        if ( empty( $variation_id ) ) {
            $tc_vg = titancart_variation_group_for( $product_id );
            if ( $tc_vg !== null ) {
                $stored   = $tc_vg['combination'];
                $incoming = ( isset( $meta['variation_combo'] ) && is_array( $meta['variation_combo'] ) )
                    ? $meta['variation_combo']
                    : [];

                $norm = [];
                foreach ( $incoming as $fid => $vid ) {
                    $norm[ (int) $fid ] = (int) $vid;
                }

                ksort( $stored );
                ksort( $norm );

                // A misconfigured member with an empty stored combination has no
                // selectable axes to confirm, so it can never be added
                // unambiguously — reject (S273 heals empty combinations).
                if ( empty( $stored ) || $norm !== $stored ) {
                    return $this->error( __( 'Please choose the available options on the product page before adding this item to your cart.', 'titancart-lite' ) );
                }
            }
        }

        // ── Shared-variation guard (S283) ────────────────────────────────────
        // A shared-variation ("variable") product carries selectable axes
        // (Color/Size, feature purpose 'variation_shared') on its OWN PDP, with
        // the chosen variant for each axis threaded to the cart as
        // $meta['shared_variants'] (feature_id => variant_id by cart.js). Like a
        // group member, it must not be added straight from a card — require a
        // valid variant for EVERY shared axis. Listing surfaces render
        // "Choose Options" -> PDP; this is the authoritative backstop for direct
        // REST hits / stale cards / any non-PDP path. Grouped members use
        // 'variation_split' features (handled above), so their shared map is
        // empty and this guard is a no-op for them — no double-guard. (Required
        // product OPTIONS are separately enforced by check_required_options().)
        if ( function_exists( 'titancart_product_shared_variation_map' ) ) {
            $tc_shared_map = titancart_product_shared_variation_map( $product_id );
            if ( ! empty( $tc_shared_map ) ) {
                $picked = [];
                if ( isset( $meta['shared_variants'] ) && is_array( $meta['shared_variants'] ) ) {
                    foreach ( $meta['shared_variants'] as $k => $v ) {
                        $picked[ (int) $k ] = (int) $v;
                    }
                }
                foreach ( $tc_shared_map as $fid => $allowed ) {
                    $sel = $picked[ (int) $fid ] ?? 0;
                    if ( $sel <= 0 || ! in_array( $sel, $allowed, true ) ) {
                        return $this->error( __( 'Please choose the available options on the product page before adding this item to your cart.', 'titancart-lite' ) );
                    }
                }
            }
        }


        // ── Zero price policy ────────────────────────────────────────────────
        // Per-product `zero_price_action` is an explicit override:
        //   'do_not_allow' (the default value of the column AND the React form's
        //                   "Global (Do not allow customers)" option)
        //                  → fall back to the global checkout setting.
        //   'allow'        → allow $0 add-to-cart for this product specifically.
        //   'ask'          → fall through (cart-line price input UI not yet built).
        // Vocabulary normalization: the global SettingsController schema uses
        // 'deny' / 'allow' / 'ask'; the per-product column ENUM uses
        // 'do_not_allow' / 'allow' / 'ask'. Both 'deny' and 'do_not_allow' mean
        // "block", so we normalize after the fallback decision. Wired S122.
        //
        // Extension bypass (S168.1): some products are legitimately $0 at the
        // product row because the actual price lives on a child entity that's
        // not visible to CartManager directly. The canonical case is a
        // subscription product priced via tc_subscription_plans — the
        // Subscriptions extension hooks `titancart_cart_zero_price_bypass` to return
        // true when $meta['subscription_plan_id'] resolves to a plan with a
        // non-zero price. Without this filter the guard would fire before any
        // downstream pricing hook can substitute the plan price, and the add
        // would fail with "Products with a price of $0 cannot be added to the
        // cart" even though the cart line WILL ultimately have a non-zero
        // price. The filter is a no-op in default core (returns false) so
        // non-extension behavior is unchanged.
        $price = (float) ( $product['price'] ?? 0 );
        if ( $price <= 0 ) {
            $product_zpa = (string) ( $product['zero_price_action'] ?? '' );
            $action      = ( $product_zpa === '' || $product_zpa === 'do_not_allow' )
                ? (string) SettingsService::get( 'checkout', 'zero_price_action', 'deny' )
                : $product_zpa;

            /**
             * Filter — let extensions bypass the $0-price guard when the
             * cart line's eventual price will come from a child entity
             * (subscription plan, configurator-driven price, etc.).
             *
             * @param bool   $bypass  Default false. Return true to skip the guard.
             * @param array  $product Product row from tc_products.
             * @param array  $meta    Cart item meta (subscription_plan_id,
             *                        shared_variants, ...) — same array
             *                        passed to add_item().
             * @since S168.1
             */
            $bypass_zero_guard = (bool) apply_filters(
                'titancart_cart_zero_price_bypass',
                false,
                $product,
                $meta
            );

            if ( ! $bypass_zero_guard && ( $action === 'do_not_allow' || $action === 'deny' ) ) {
                return $this->error( __( 'Products with a price of $0 cannot be added to the cart.', 'titancart-lite' ) );
            }
            // 'allow' falls through; 'ask' falls through too until the cart-line
            // price-input UI is built (deferred to a future session).
        }

        // Use variation if provided.
        $target_id = $variation_id ?? $product_id;
        if ( $variation_id ) {
            $product = $this->products->get( $variation_id ) ?? $product;
        }

        // ── Downloadable Products: master-toggle gate (S138 Phase 5) ─────────
        // When `catalog_enable_downloads` is OFF, no downloadable product can
        // be added to the cart. The PDP also swaps its cart row for a notice
        // (templates/product/single.php), so a customer should never reach
        // here through the normal UI — this is the defense-in-depth backstop
        // for direct REST hits and any other entry point that bypasses the
        // PDP. Existing keys + file management remain intact while the toggle
        // is off, so flipping back is non-destructive.
        if ( ! empty( $product['is_downloadable'] )
             && ! SettingsService::bool( 'general', 'catalog_enable_downloads', true ) ) {
            return $this->error( __( 'Downloadable products are temporarily unavailable. Please contact the store for assistance.', 'titancart-lite' ) );
        }

        // ── Stock check ───────────────────────────────────────────────────────
        // Digital products (is_downloadable=1) have infinite supply by definition —
        // they're file downloads or licenses generated per-sale, never depleted.
        // Bypass the stock check regardless of tracking flags.
        // Per-product `out_of_stock_actions = 'buy'` enables buy-in-advance: bypass
        // the stock gate even when inventory is zero/negative. 'subscribe' keeps the
        // gate; the PDP waitlist form (templates/product/single.php:833-865) handles
        // signup independently. 'none' is the default — preserve the original block.
        // Wired S122.
        $global_tracking  = SettingsService::bool( 'general', 'catalog_track_inventory', true );
        $allow_negative   = SettingsService::bool( 'general', 'catalog_allow_negative_inventory', false );
        $is_digital       = ! empty( $product['is_downloadable'] );
        $track_this       = $global_tracking && $product['track_inventory'] && ! $is_digital;
        $buy_in_advance   = ( ( $product['out_of_stock_actions'] ?? 'none' ) === 'buy' );
        $backorders_ok    = $product['allow_backorders'] || $allow_negative || $buy_in_advance;

        if ( $track_this && ! $backorders_ok ) {
            $available = (int) $product['stock_quantity'];

            // How much is already in cart for this product?
            $in_cart = $this->qty_in_cart( $cart_id, $target_id );

            if ( ( $in_cart + $quantity ) > $available ) {
                if ( $available <= 0 ) {
                    return $this->error( __( 'This product is out of stock.', 'titancart-lite' ) );
                }

                $quantity = $available - $in_cart;

                if ( $quantity <= 0 ) {
                    return $this->error(
                        sprintf(
                            /* translators: %d: max available */
                            __( 'You already have the maximum available quantity (%d) in your cart.', 'titancart-lite' ),
                            $available
                        )
                    );
                }
            }
        }

        // ── Quantity step & min/max clamping ──────────────────────────────────
        // Per-product values take priority; fall back to global settings.
        $g_min   = SettingsService::int( 'checkout', 'min_qty_per_product', 0 );
        $g_max   = SettingsService::int( 'checkout', 'max_qty_per_product', 0 );
        $g_step  = SettingsService::int( 'checkout', 'qty_step', 0 );
        $min_qty  = max( 1, (int) $product['min_quantity'] ?: $g_min );
        $max_qty  = ( (int) $product['max_quantity'] ?: $g_max ) ?: PHP_INT_MAX;
        $step     = max( 1, (int) $product['quantity_step'] ?: $g_step );
        $quantity = max( $min_qty, $quantity );
        $quantity = min( $max_qty, $quantity );

        // Snap to nearest valid step above min_qty.
        if ( $step > 1 ) {
            $quantity = (int) ( ceil( ( $quantity - $min_qty ) / $step ) * $step ) + $min_qty;
        }

        // ── Resolve price (with usergroup tiers) ──────────────────────────────
        $usergroup_ids = $this->current_usergroup_ids();
        // Effective unit price: lowest of {base/list-price, Bulk-Buys tier at this
        // quantity, active catalog promo} so the cart matches the shelf price shown
        // on cards/PDP (roadmap #4 / KI-08). Cart-level promotions (coupons / auto
        // cart promos) still discount further at checkout on top of this price.
        // class_exists: Lite strips the Promotions module (R1 §🅰) — the cart
        // then charges the base shelf price (no tiers / catalog promos exist).
        if ( class_exists( \TitanCart\Modules\Promotions\CatalogPromotionService::class ) ) {
            $unit_price = (float) \TitanCart\Modules\Promotions\CatalogPromotionService::instance()
                              ->effective_price( $target_id, $quantity, $usergroup_ids )['price'];
        } else {
            $unit_price = (float) ( $product['price'] ?? 0 );
        }

        // ── Required-option enforcement ───────────────────────────────────────
        // Reject the add if any required option is missing or empty. Type F
        // (File) is exempt — file-upload pipeline is a separate feature and
        // products with a required type=F option must remain addable.
        $required_error = $this->check_required_options( $product_id, $options );
        if ( $required_error !== null ) {
            return $this->error( $required_error );
        }

        // ── Option modifiers ──────────────────────────────────────────────────
        $validated_options = $this->validate_options( $product_id, $options );

        // ── Hook: before add ──────────────────────────────────────────────────
        // Listeners receive (by ref): product_id, quantity, unit_price,
        // validated_options. They also receive (by val): cart. As of S168.1 they
        // additionally receive (by ref) $meta — the cart-item meta array
        // assembled by CartController (subscription_plan_id, shared_variants,
        // pp_tier_sites injected by extensions, etc.). $meta is needed by
        // listeners that want to substitute $unit_price based on a child entity
        // (e.g. the Subscriptions extension reads $meta['subscription_plan_id']
        // to swap the product's $0 base price with the chosen plan's price).
        // Backward-compatible: existing listeners that registered with 5 args
        // (`add_action(..., $cb, 5, 5)`) still get the first 5 unchanged and
        // simply ignore the 6th.
        do_action_ref_array(
            'titancart_add_to_cart_pre',
            [ &$product_id, &$quantity, &$unit_price, &$validated_options, $cart, &$meta ]
        );

        // ── Find existing matching item ───────────────────────────────────────
        // Hash must match MySQL's MD5(COALESCE(options, '')) — when no options,
        // the DB stores NULL so MySQL hashes ''. PHP must do the same.
        $options_json = ! empty( $validated_options ) ? wp_json_encode( $validated_options ) : '';
        $options_hash = md5( $options_json );
        $meta_json    = ! empty( $meta ) ? wp_json_encode( $meta ) : '';
        $meta_hash    = md5( $meta_json );
        $existing     = $this->repo->find_item( $cart_id, $product_id, $variation_id ?? 0, $options_hash, $meta_hash );

        // S175 — Track effective delta so we can distinguish a normal add
        // from a max-qty clamp (duplicate of an already-at-max product) and
        // surface a clearer toast message ("Already in your cart" vs
        // "Product added to cart"). Used by the marketplace single-license
        // flow but generic to any TC install with max_quantity enforcement.
        $delta = $quantity;

        if ( $existing ) {
            $old_qty = (int) $existing['quantity'];
            $new_qty = min( $max_qty, $old_qty + $quantity );
            $delta   = $new_qty - $old_qty;
            $this->repo->update_item( (int) $existing['id'], [
                'quantity'   => $new_qty,
                'unit_price' => $unit_price,
            ] );
        } else {
            $this->repo->add_item( $cart_id, [
                'product_id'   => $product_id,
                'variation_id' => $variation_id,
                'quantity'     => $quantity,
                'unit_price'   => $unit_price,
                'options'      => $validated_options,
                'meta'         => $meta,
            ] );
        }

        do_action( 'titancart_item_added_to_cart', $product_id, $quantity, $cart_id );

        // Choose toast message based on whether the add actually moved
        // quantity. $delta === 0 means the request was clamped to zero
        // because the line was already at max_quantity (e.g. a marketplace
        // license at max=1 with the user clicking Add To Cart a second time).
        if ( $delta === 0 ) {
            $message = __( 'This item is already in your cart.', 'titancart-lite' );
        } elseif ( $delta < $quantity ) {
            $message = __( 'Cart updated to the maximum allowed quantity.', 'titancart-lite' );
        } else {
            $message = __( 'Product added to cart.', 'titancart-lite' );
        }

        return [
            'success'      => true,
            'message'      => $message,
            'cart'         => $this->get_cart( $key ),
            // S175 — surfaced so the client can branch UX (toast variant,
            // redirect-to-cart, suppress button-state animation, etc.).
            // 'added' is the qty actually added after clamping; 'requested'
            // is what the caller asked for.
            'added'        => $delta,
            'requested'    => $quantity,
            'was_clamped'  => $delta < $quantity,
        ];
    }

    /**
     * Add a "Buy Together" bundle to the cart in one atomic operation.
     *
     * Loads the bundle (must be status='active' inside its date window),
     * iterates over its show_on_page=1 items whose product is still active,
     * and adds each to the cart with the bundle's per-item discount applied
     * to the resolved unit price. Cart items are tagged with
     * meta = { bundle_id, bundle_name } so future flows (group-remove,
     * order display, analytics) can reconstruct bundle linkage.
     *
     * The discount is applied via the existing titancart_add_to_cart_pre
     * action (same plumbing JFY deals use). For each call we register a
     * one-shot listener that overrides $unit_price for the matching
     * (product_id, item-id) pair, calls add_item, then unhooks. This
     * avoids changing the add_item signature and stays consistent with
     * the established pattern.
     *
     * S126 — wires Bundles B12 cart side.
     *
     * @param  int         $bundle_id
     * @param  string|null $session_key
     * @return array{ success: bool, message: string, cart: array, added_count?: int, errors?: array }
     */
    public function add_bundle( int $bundle_id, ?string $session_key = null ): array {
        global $wpdb;

        if ( $bundle_id <= 0 ) {
            return $this->error( __( 'Invalid bundle.', 'titancart-lite' ) );
        }

        // ── KI-35 (3b) — bundles is a Pro toggle-gated extension ─────────────
        // The storefront RENDER path (StorefrontModule) already fail-closes on
        // is_active('bundles'), but THIS cart-add path is a public endpoint
        // (POST /cart/bundles/{id}, public by design — '__return_true') reachable
        // directly by id. Without this gate a store that downgraded Pro -> Lite,
        // or simply turned the extension off, with leftover active product_bundles
        // rows could still SELL a bundle by POSTing its id even though nothing
        // renders. Fail CLOSED: bundles must be affirmatively active to proceed
        // (an absent ExtensionsController resolves to false = blocked, not open).
        $bundles_active = class_exists( '\TitanCart\REST\Extensions\ExtensionsController' )
            && \TitanCart\REST\Extensions\ExtensionsController::is_active( 'bundles' );
        if ( ! $bundles_active ) {
            return $this->error( __( 'This bundle is no longer available.', 'titancart-lite' ) );
        }

        $key = $session_key ?? CartSession::get();
        $p   = $wpdb->prefix . 'tc_';


        // ── Guest shopping policy ───────────────────────────────────────────
        if ( ! is_user_logged_in() ) {
            $guest_policy = SettingsService::get( 'checkout', 'allow_shopping_unlogged', 'allow' );
            if ( $guest_policy === 'deny' || $guest_policy === 'hide_price' ) {
                return $this->error( __( 'Please log in or create an account to add items to your cart.', 'titancart-lite' ) );
            }
        }

        // ── Load + validate bundle ──────────────────────────────────────────
        $bundle = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $p = $wpdb->prefix . TITANCART_TABLE_PREFIX (constants, never user input); values bound via placeholders.
            "SELECT b.id, b.status, b.date_from, b.date_to, bt.name
               FROM {$p}product_bundles b
          LEFT JOIN {$p}product_bundle_translations bt
                 ON bt.bundle_id = b.id AND bt.language_code = %s
              WHERE b.id = %d
              LIMIT 1",
            'en', $bundle_id
        ), ARRAY_A );

        if ( ! $bundle ) {
            return $this->error( __( 'Bundle not found.', 'titancart-lite' ) );
        }
        if ( ( $bundle['status'] ?? '' ) !== 'active' ) {
            return $this->error( __( 'This bundle is no longer available.', 'titancart-lite' ) );
        }

        // Date window check — both bounds optional.
        $now = current_time( 'mysql' );
        if ( ! empty( $bundle['date_from'] ) && $bundle['date_from'] > $now ) {
            return $this->error( __( 'This bundle is not yet available.', 'titancart-lite' ) );
        }
        if ( ! empty( $bundle['date_to'] ) && $bundle['date_to'] < $now ) {
            return $this->error( __( 'This bundle has expired.', 'titancart-lite' ) );
        }

        // ── Load bundle items ───────────────────────────────────────────────
        // Inner-join on products so deleted/disabled products silently drop
        // out. Filter to show_on_page=1 to mirror the storefront widget —
        // what the customer sees is what we add.
        $items = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $p = $wpdb->prefix . TITANCART_TABLE_PREFIX (constants, never user input); values bound via placeholders.
            "SELECT bi.id            AS item_id,
                    bi.product_id,
                    bi.quantity,
                    bi.discount_type,
                    bi.discount_value,
                    bi.position,
                    p.price          AS product_price,
                    p.status         AS product_status
               FROM {$p}product_bundle_items bi
         INNER JOIN {$p}products p
                 ON p.id = bi.product_id
                AND p.status = 'active'
              WHERE bi.bundle_id = %d
                AND bi.show_on_page = 1
           ORDER BY bi.position ASC",
            $bundle_id
        ), ARRAY_A ) ?: [];

        if ( empty( $items ) ) {
            return $this->error( __( 'This bundle has no available products.', 'titancart-lite' ) );
        }

        $bundle_name = (string) ( $bundle['name'] ?? '' ) ?: ( __( 'Bundle', 'titancart-lite' ) . ' #' . $bundle_id );

        // ── Loop through items, register one-shot price-override listener
        //    per add, call add_item, accumulate results. ──────────────────────
        $added         = 0;
        $errors        = [];
        $current_pid   = 0;
        $current_price = 0.0;

        // The closure captures by reference so we can swap target product/price
        // per iteration without re-registering the hook every loop. Single
        // registration, value-swap each iteration.
        $listener = function ( &$product_id, &$quantity, &$unit_price, &$options, $cart )
            use ( &$current_pid, &$current_price ) {
            if ( $current_pid > 0 && (int) $product_id === $current_pid ) {
                $unit_price = $current_price;
            }
        };
        \add_action( 'titancart_add_to_cart_pre', $listener, 5, 5 );

        try {
            foreach ( $items as $it ) {
                $pid    = (int) $it['product_id'];
                $qty    = max( 1, (int) $it['quantity'] );
                $price  = (float) $it['product_price'];
                $dtype  = (string) ( $it['discount_type'] ?? 'none' );
                $dval   = (float) $it['discount_value'];

                if ( $dtype === 'percent' && $dval > 0 ) {
                    $resolved = round( $price * ( 1 - $dval / 100 ), 2 );
                } elseif ( $dtype === 'fixed' && $dval > 0 ) {
                    $resolved = round( max( 0, $price - $dval ), 2 );
                } else {
                    $resolved = round( $price, 2 );
                }

                $current_pid   = $pid;
                $current_price = $resolved;

                $meta = [
                    'bundle_id'      => $bundle_id,
                    'bundle_name'    => $bundle_name,
                    'bundle_item_id' => (int) $it['item_id'],
                ];

                $result = $this->add_item( $pid, $qty, [], null, $key, $meta );

                if ( ! empty( $result['success'] ) ) {
                    $added++;
                } else {
                    $errors[] = [
                        'product_id' => $pid,
                        'message'    => (string) ( $result['message'] ?? __( 'Could not add item.', 'titancart-lite' ) ),
                    ];
                }
            }
        } finally {
            \remove_action( 'titancart_add_to_cart_pre', $listener, 5 );
            $current_pid   = 0;
            $current_price = 0.0;
        }

        if ( $added === 0 ) {
            return [
                'success' => false,
                'message' => __( 'Could not add bundle to cart.', 'titancart-lite' ),
                'cart'    => $this->get_cart( $key ),
                'errors'  => $errors,
            ];
        }

        \do_action( 'titancart_bundle_added_to_cart', $bundle_id, $added, count( $items ) );

        $message = ( $added === count( $items ) )
            ? __( 'Bundle added to cart.', 'titancart-lite' )
            : sprintf(
                /* translators: 1: products added, 2: total bundle items */
                __( 'Bundle partially added to cart (%1$d of %2$d items).', 'titancart-lite' ),
                $added, count( $items )
            );

        return [
            'success'     => true,
            'message'     => $message,
            'cart'        => $this->get_cart( $key ),
            'added_count' => $added,
            'errors'      => $errors,
        ];
    }

    /**
     * Add a gift card item to the cart.
     *
     * Gift cards bypass product validation (product_id = 0).
     * The amount, recipient, and message are stored in meta.
     *
     * @param  float       $amount          Gift card value.
     * @param  string      $recipient_name
     * @param  string      $recipient_email
     * @param  string      $sender_name
     * @param  string      $message
     * @param  string|null $session_key
     * @return array{ success: bool, message: string, cart: array }
     */
    public function add_gift_card_item(
        float   $amount,
        string  $recipient_name  = '',
        string  $recipient_email = '',
        string  $sender_name     = '',
        string  $message         = '',
        ?string $session_key     = null
    ): array {
        if ( $amount <= 0 ) {
            return $this->error( __( 'Gift card amount must be greater than zero.', 'titancart-lite' ) );
        }
        if ( empty( $recipient_email ) || ! is_email( $recipient_email ) ) {
            return $this->error( __( 'A valid recipient email address is required.', 'titancart-lite' ) );
        }

        $key     = $session_key ?? CartSession::get();
        $cart    = $this->repo->find_or_create( $key, $this->current_customer_id() );
        $cart_id = (int) $cart['id'];

        $meta = [
            'item_type'       => 'gift_card',
            'recipient_name'  => sanitize_text_field( $recipient_name ),
            'recipient_email' => sanitize_email( $recipient_email ),
            'sender_name'     => sanitize_text_field( $sender_name ),
            'message'         => sanitize_textarea_field( $message ),
        ];

        $this->repo->add_item( $cart_id, [
            'product_id' => 0,
            'quantity'   => 1,
            'unit_price' => round( $amount, 4 ),
            'meta'       => $meta,
        ] );

        return [
            'success' => true,
            'message' => __( 'Gift card added to cart.', 'titancart-lite' ),
            'cart'    => $this->get_cart( $key ),
        ];
    }

    /**
     * Update the quantity of a specific cart item.
     *
     * Passing quantity = 0 removes the item.
     *
     * @param  int         $item_id
     * @param  int         $quantity
     * @param  string|null $session_key
     * @return array{ success: bool, message: string, cart: array }
     */
    public function update_item_quantity(
        int     $item_id,
        int     $quantity,
        ?string $session_key = null
    ): array {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->get_by_session( $key );

        if ( ! $cart ) {
            return $this->error( __( 'Cart not found.', 'titancart-lite' ) );
        }

        if ( $quantity <= 0 ) {
            return $this->remove_item( $item_id, $key );
        }

        // Verify item belongs to this cart.
        $item = $this->get_item_from_cart( $item_id, (int) $cart['id'] );
        if ( ! $item ) {
            return $this->error( __( 'Item not found in cart.', 'titancart-lite' ) );
        }

        // Stock check. Digital products (is_downloadable=1) skip the stock clamp.
        // Per-product `out_of_stock_actions = 'buy'` (buy-in-advance) also bypasses
        // the clamp — kept symmetrical with add_to_cart() above. Wired S122.
        $product = $this->products->get( (int) $item['product_id'] );
        $g_track  = SettingsService::bool( 'general', 'catalog_track_inventory', true );
        $g_neg    = SettingsService::bool( 'general', 'catalog_allow_negative_inventory', false );
        $digital  = $product && ! empty( $product['is_downloadable'] );
        $buy_adv  = $product && ( ( $product['out_of_stock_actions'] ?? 'none' ) === 'buy' );
        if ( $product && ! $digital && $g_track && $product['track_inventory']
             && ! ( $product['allow_backorders'] || $g_neg || $buy_adv ) ) {
            $quantity = min( $quantity, (int) $product['stock_quantity'] );
        }

        // Clamp to product limits (per-product takes priority, fall back to global settings).
        if ( $product ) {
            $g_min   = SettingsService::int( 'checkout', 'min_qty_per_product', 0 );
            $g_max   = SettingsService::int( 'checkout', 'max_qty_per_product', 0 );
            $g_step  = SettingsService::int( 'checkout', 'qty_step', 0 );
            $min_qty  = max( 1, (int) $product['min_quantity'] ?: $g_min );
            $max_qty  = ( (int) $product['max_quantity'] ?: $g_max ) ?: PHP_INT_MAX;
            $step     = max( 1, (int) $product['quantity_step'] ?: $g_step );
            $quantity = max( $min_qty, min( $max_qty, $quantity ) );
            if ( $step > 1 ) {
                $quantity = (int) ( ceil( ( $quantity - $min_qty ) / $step ) * $step ) + $min_qty;
            }
        }

        $this->repo->update_item( $item_id, [ 'quantity' => $quantity ] );

        do_action( 'titancart_cart_item_updated', $item_id, $quantity, (int) $cart['id'] );

        return [
            'success' => true,
            'message' => __( 'Cart updated.', 'titancart-lite' ),
            'cart'    => $this->get_cart( $key ),
        ];
    }

    /**
     * Remove a specific item from the cart.
     *
     * @param  int         $item_id
     * @param  string|null $session_key
     * @return array{ success: bool, message: string, cart: array }
     */
    public function remove_item( int $item_id, ?string $session_key = null ): array {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->get_by_session( $key );

        if ( ! $cart ) {
            return $this->error( __( 'Cart not found.', 'titancart-lite' ) );
        }

        // Verify item belongs to this cart.
        if ( ! $this->get_item_from_cart( $item_id, (int) $cart['id'] ) ) {
            return $this->error( __( 'Item not found in cart.', 'titancart-lite' ) );
        }

        $this->repo->remove_item( $item_id );
        do_action( 'titancart_cart_item_removed', $item_id, (int) $cart['id'] );

        return [
            'success' => true,
            'message' => __( 'Item removed from cart.', 'titancart-lite' ),
            'cart'    => $this->get_cart( $key ),
        ];
    }

    /**
     * Remove all items from the current cart.
     *
     * @param  string|null $session_key
     * @return array
     */
    public function clear_cart( ?string $session_key = null ): array {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->get_by_session( $key );

        if ( $cart ) {
            $this->repo->clear_items( (int) $cart['id'] );
            $this->repo->update_cart( (int) $cart['id'], [
                'coupon_code' => null,
                'meta'        => null,
            ] );
            do_action( 'titancart_cart_cleared', (int) $cart['id'] );
        }

        return [
            'success' => true,
            'message' => __( 'Cart cleared.', 'titancart-lite' ),
            'cart'    => $this->get_cart( $key ),
        ];
    }

    /**
     * Apply a coupon code to the cart.
     * Validation and discount calculation delegated to Promotions module (Module 9).
     * Here we just store the code and apply a simple discount if known.
     *
     * @param  string      $coupon_code
     * @param  string|null $session_key
     * @return array{ success: bool, message: string, cart: array }
     */
    public function apply_coupon( string $coupon_code, ?string $session_key = null ): array {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->find_or_create( $key, $this->current_customer_id() );

        // Single coupon policy — reject if cart already has a different coupon.
        if (
            ! empty( $cart['coupon_code'] ) &&
            strtoupper( trim( $cart['coupon_code'] ) ) !== strtoupper( trim( $coupon_code ) ) &&
            SettingsService::bool( 'general', 'promo_single_coupon_only', true )
        ) {
            return $this->error( __( 'Only one coupon code can be used per order. Remove the current coupon first.', 'titancart-lite' ) );
        }

        // Build a hydrated cart with totals so the PromotionEngine can evaluate conditions.
        $raw_items = $this->repo->get_items( $cart['id'] );
        $hydrated  = $this->hydrate_items( $raw_items, 'en' );

        $quick_subtotal = 0.0;
        $quick_qty      = 0;
        foreach ( $hydrated as $hi ) {
            $quick_subtotal += (float) $hi['unit_price'] * (int) $hi['quantity'];
            $quick_qty      += (int) $hi['quantity'];
        }

        $engine_cart = [
            'id'          => (int) $cart['id'],
            'customer_id' => $cart['customer_id'],
            'items'       => $hydrated,
            'totals'      => [
                'subtotal'   => $quick_subtotal,
                'item_count' => $quick_qty,
            ],
        ];

        // Validate via filter — Promotions module (Module 9) hooks here.
        $result = apply_filters( 'titancart_validate_coupon', [
            'valid'    => false,
            'discount' => 0.0,
            'message'  => __( 'Coupon validation not available yet.', 'titancart-lite' ),
        ], $coupon_code, $engine_cart );

        if ( ! $result['valid'] ) {
            return $this->error( $result['message'] );
        }

        $meta = json_decode( $cart['meta'] ?? '{}', true ) ?: [];
        $meta['coupon_discount'] = (float) $result['discount'];

        $this->repo->update_cart( (int) $cart['id'], [
            'coupon_code' => sanitize_text_field( $coupon_code ),
            'meta'        => $meta,
        ] );

        do_action( 'titancart_coupon_applied', $coupon_code, $result['discount'], (int) $cart['id'] );

        return [
            'success' => true,
            'message' => $result['message'] ?? __( 'Coupon applied.', 'titancart-lite' ),
            'cart'    => $this->get_cart( $key ),
        ];
    }

    /**
     * Remove the applied coupon from the cart.
     *
     * @param  string|null $session_key
     * @return array
     */
    public function remove_coupon( ?string $session_key = null ): array {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->get_by_session( $key );

        if ( $cart ) {
            $meta = json_decode( $cart['meta'] ?? '{}', true ) ?: [];
            unset( $meta['coupon_discount'] );

            $this->repo->update_cart( (int) $cart['id'], [
                'coupon_code' => null,
                'meta'        => $meta,
            ] );
        }

        return [
            'success' => true,
            'message' => __( 'Coupon removed.', 'titancart-lite' ),
            'cart'    => $this->get_cart( $key ),
        ];
    }

    /**
     * Update cart notes.
     *
     * @param  string      $notes
     * @param  string|null $session_key
     * @return array
     */
    public function update_notes( string $notes, ?string $session_key = null ): array {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->find_or_create( $key, $this->current_customer_id() );

        $this->repo->update_cart( (int) $cart['id'], [
            'notes' => sanitize_textarea_field( $notes ),
        ] );

        return [
            'success' => true,
            'cart'    => $this->get_cart( $key ),
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // LOGIN MERGE
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Merge the guest session cart into the customer's saved cart on login.
     *
     * Strategy:
     *   1. If a saved customer cart exists, merge guest items INTO it
     *      (guest quantity wins if both have same product).
     *   2. Re-assign the merged cart to the current session key.
     *   3. Delete the old guest cart.
     *
     * @param int $wp_user_id
     */
    public function merge_on_login( int $wp_user_id ): void {
        $session_key = CartSession::get();
        $guest_cart  = $this->repo->get_by_session( $session_key );

        // Resolve TitanCart customer ID from WP user ID.
        $customer_id = $this->customer_id_from_wp_user( $wp_user_id );
        if ( ! $customer_id ) {
            // Tag guest cart with customer ID and return.
            if ( $guest_cart ) {
                $this->repo->update_cart( (int) $guest_cart['id'], [] );
                global $wpdb;
                $wpdb->update(
                    $wpdb->prefix . TITANCART_TABLE_PREFIX . 'carts',
                    [ 'customer_id' => $customer_id ],
                    [ 'id' => $guest_cart['id'] ]
                );
            }
            return;
        }

        $saved_cart = $this->repo->get_by_customer( $customer_id );

        if ( ! $saved_cart ) {
            // No saved cart — just tag the guest cart.
            if ( $guest_cart ) {
                global $wpdb;
                $wpdb->update(
                    $wpdb->prefix . TITANCART_TABLE_PREFIX . 'carts',
                    [ 'customer_id' => $customer_id ],
                    [ 'id' => $guest_cart['id'] ]
                );
            }
            return;
        }

        if ( ! $guest_cart || (int) $guest_cart['id'] === (int) $saved_cart['id'] ) {
            // Same cart or no guest cart — just reassign session key.
            $this->repo->reassign_session( (int) $saved_cart['id'], $session_key );
            return;
        }

        // Merge guest items into saved cart.
        $guest_items = $this->repo->get_items( (int) $guest_cart['id'] );

        foreach ( $guest_items as $item ) {
            $options_hash = md5( $item['options'] ?? '' );
            $existing     = $this->repo->find_item(
                (int) $saved_cart['id'],
                (int) $item['product_id'],
                (int) ( $item['variation_id'] ?? 0 ),
                $options_hash
            );

            if ( $existing ) {
                // Guest cart quantity wins.
                $this->repo->update_item( (int) $existing['id'], [
                    'quantity'   => (int) $item['quantity'],
                    'unit_price' => $item['unit_price'],
                ] );
            } else {
                $this->repo->add_item( (int) $saved_cart['id'], [
                    'product_id'   => $item['product_id'],
                    'variation_id' => $item['variation_id'],
                    'quantity'     => $item['quantity'],
                    'unit_price'   => $item['unit_price'],
                    'options'      => $item['options'],
                ] );
            }
        }

        // Move coupon from guest cart if saved cart has none.
        if ( ! empty( $guest_cart['coupon_code'] ) && empty( $saved_cart['coupon_code'] ) ) {
            $this->repo->update_cart( (int) $saved_cart['id'], [
                'coupon_code' => $guest_cart['coupon_code'],
                'meta'        => json_decode( $guest_cart['meta'] ?? '{}', true ),
            ] );
        }

        // Delete the guest cart FIRST so its session_key is released, THEN reassign
        // the saved cart to that key. The old order (reassign then delete) tripped
        // uq_session_key: the guest row still held $session_key when the saved row
        // was updated to it → "Duplicate entry '…' for key 'uq_session_key'".
        // Everything read from the guest cart (items above, coupon/meta from the
        // in-memory $guest_cart) has already happened, so deleting it here is safe.
        $this->repo->delete_cart( (int) $guest_cart['id'] );
        $this->repo->reassign_session( (int) $saved_cart['id'], $session_key );

        do_action( 'titancart_cart_merged', (int) $saved_cart['id'], $customer_id );
    }

    /**
     * Destroy the cart entirely after a successful order is placed.
     * Called by the Checkout module after fn_place_order equivalent.
     *
     * @param string|null $session_key
     */
    public function destroy_after_order( ?string $session_key = null ): void {
        $key  = $session_key ?? CartSession::get();
        $cart = $this->repo->get_by_session( $key );

        if ( $cart ) {
            $this->repo->delete_cart( (int) $cart['id'] );
        }

        CartSession::regenerate();
    }

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

    /**
     * Attach product display data to raw cart item rows.
     *
     * @param  array  $raw_items  DB rows from tc_cart_items.
     * @param  string $lang
     * @return array  Same items with name, sku, image, stock_status appended.
     */
    private function hydrate_items( array $raw_items, string $lang ): array {
        if ( empty( $raw_items ) ) {
            return [];
        }

        // Collect all unique product IDs to fetch in one query (skip 0 = gift cards).
        $product_ids = array_unique( array_filter( array_column( $raw_items, 'product_id' ), function( $id ) { return (int) $id > 0; } ) );
        $product_map = [];

        foreach ( $product_ids as $pid ) {
            $product = $this->products->get( (int) $pid, $lang );
            if ( $product ) {
                $product_map[ $pid ] = $product;
            }
        }

        $hydrated = [];

        foreach ( $raw_items as $item ) {
            $pid     = (int) $item['product_id'];

            // ── Gift card items (product_id = 0) — hydrate from meta ──
            $item_meta = $item['meta'] ?? null;
            if ( is_string( $item_meta ) ) {
                $item_meta = json_decode( $item_meta, true );
            }
            if ( $pid === 0 && is_array( $item_meta ) && ( $item_meta['item_type'] ?? '' ) === 'gift_card' ) {
                $gc_amount = number_format( (float) $item['unit_price'], 2 );
                /* translators: %s: gift card amount with currency symbol (e.g. "$50.00"). */
                $item['name']          = sprintf( __( 'Gift Card — %s', 'titancart-lite' ), '$' . $gc_amount );
                $item['sku']           = 'GIFT-CARD';
                $item['slug']          = '';
                $item['stock_status']  = 'in_stock';
                $item['min_quantity']  = 1;
                $item['max_quantity']  = 1;
                $item['quantity_step'] = 1;
                // Gift cards are non-physical — zero shipping weight (KI-93).
                $item['weight']        = 0.0;
                $item['image']         = null;
                $hydrated[] = $item;
                continue;
            }

            $product = $product_map[ $pid ] ?? null;

            if ( ! $product ) {
                // Product deleted — skip this item.
                // Optionally auto-remove: $this->repo->remove_item( $item['id'] );
                continue;
            }

            $main_image = ! empty( $product['images'][0] ) ? $product['images'][0] : null;

            // ── Override with variant image if shared variant has a linked image ──
            if ( $main_image && is_array( $item_meta ) && ! empty( $item_meta['shared_variants'] ) && ! empty( $product['features'] ) ) {
                foreach ( $item_meta['shared_variants'] as $_sv_fid => $_sv_vid ) {
                    // Find the feature value row that matches this variant selection.
                    foreach ( $product['features'] as $_fv ) {
                        if ( (int) ( $_fv['feature_id'] ?? 0 ) === (int) $_sv_fid
                          && (int) ( $_fv['variant_id'] ?? 0 ) === (int) $_sv_vid
                          && ! empty( $_fv['image_id'] )
                        ) {
                            // Find matching product image by image_id.
                            $img_id = (int) $_fv['image_id'];
                            foreach ( $product['images'] as $_pi ) {
                                if ( (int) ( $_pi['image_id'] ?? $_pi['id'] ?? 0 ) === $img_id ) {
                                    $main_image = $_pi;
                                    break 3; // Found a variant image — use it and stop.
                                }
                            }
                        }
                    }
                }
            }

            $item['name']         = $product['name']         ?? '';
            $item['sku']          = $product['sku']          ?? '';
            $item['slug']         = $product['slug']         ?? '';
            $item['stock_status']  = $product['stock_status']  ?? 'in_stock';
            $item['min_quantity']  = $product['min_quantity']  ?? 1;
            $item['max_quantity']  = $product['max_quantity']  ?? null;
            $item['quantity_step'] = $product['quantity_step'] ?? 1;
            // ── Shipping weight (KI-93) ──────────────────────────────────────
            // NORMALISED TO POUNDS at hydration so every downstream consumer
            // shares one unit. Products store a native value + their own
            // `weight_unit` (kg/g/lb/oz), which are not mutually comparable —
            // summing them raw would produce a meaningless total.
            //
            // ⚠️ Pounds is the canonical unit deliberately: the admin already
            // labels weight windows and by_weight bands in `lbs`, so every
            // min/max_weight and rate band a merchant has ALREADY entered keeps
            // its meaning — no migration, no silent reinterpretation of live
            // shipping config. All three carrier APIs also want pounds.
            //
            // Before this existed the key was simply absent, so
            // ShippingManager::build_cart_totals() read `?? 0` and total_weight
            // was ALWAYS 0.0 — which made max_weight dead, min_weight inverted
            // (any method with a minimum never appeared), and every by_weight
            // band resolve the zero tier.
            $item['weight'] = self::weight_to_lb(
                $product['weight']      ?? 0,
                (string) ( $product['weight_unit'] ?? '' )
            );
            // Pick cart thumbnail based on image shape setting (tall → medium/proportional, square → thumbnail/crop).
            $thumb_key = \TitanCart\Modules\Products\ImageService::cart_thumb_size() === 'medium' ? 'thumb' : 'thumbnail';
            $item['image']        = $main_image ? [
                'url'   => $main_image['url']       ?? '',
                'thumb' => $main_image[ $thumb_key ] ?? $main_image['thumb'] ?? '',
                'alt'   => $main_image['alt_text']  ?? $product['name'],
            ] : null;

            // ── Resolve variant labels (e.g. Size: Medium) ───────────────────
            // Two distinct variation models feed the SAME display contract —
            // an array of { option_name, variant_name } on $item['options'],
            // which every downstream surface already renders (cart page,
            // cart/item.php, checkout summary, order-confirmation + admin
            // emails, account dashboard, order detail):
            //
            //   shared-variation ("variable") products — ONE product carrying
            //   several selectable axes and a single SKU. The variant chosen
            //   per axis is threaded to the cart at add time by cart.js as
            //   $meta['shared_variants'].
            //
            //   variation_split group members (S420) — one product PER variant,
            //   each with its OWN SKU (how the Printful importer models POD
            //   size/colour). Nothing needs threading at add time: the member's
            //   identity IS its combination, pinned on its
            //   tc_product_group_products row and read back through the
            //   memoized titancart_variation_group_for(). add_item() already validates
            //   an incoming combo against that very row (the KI-21 guard), but
            //   purely as a gate — it never persisted or rendered the combo, so
            //   these lines displayed no size/colour at all before S420. This
            //   is the missing half, not a fix to a broken one.
            //
            // Both models reduce to feature_id => variant_id, so they share the
            // single lookup + append path below. The map is keyed by
            // feature_id, so in the (by-design impossible — see the S283 guard
            // note in add_item()) event that one product carried both, the axes
            // merge rather than rendering twice.
            $tc_variant_axes = [];
            if ( is_array( $item_meta ) && ! empty( $item_meta['shared_variants'] ) ) {
                foreach ( $item_meta['shared_variants'] as $_ax_fid => $_ax_vid ) {
                    $tc_variant_axes[ (int) $_ax_fid ] = (int) $_ax_vid;
                }
            }
            if ( $pid > 0 && function_exists( 'titancart_variation_group_for' ) ) {
                $_tc_vg = titancart_variation_group_for( $pid );
                if ( $_tc_vg !== null && ! empty( $_tc_vg['combination'] ) ) {
                    foreach ( $_tc_vg['combination'] as $_ax_fid => $_ax_vid ) {
                        $tc_variant_axes[ (int) $_ax_fid ] = (int) $_ax_vid;
                    }
                }
            }

            if ( ! empty( $tc_variant_axes ) ) {
                $db = \titancart_db();
                $t_ft  = $db->table( 'product_feature_translations' );
                $t_fvt = $db->table( 'product_feature_variant_translations' );
                // CartRepository stores options as a JSON string in the `options`
                // column; raw rows from get_items() therefore arrive here as a
                // string, NOT an array. Decode first, otherwise the array merge
                // below silently drops every real add-on selection (Gift wrapping,
                // engraving text, checkbox add-ons) for any item that ALSO has
                // shared_variants set — leaving only the synthesized size/color
                // labels and zeroing out modifier_total in CartTotals downstream.
                // S137 — Sean reported "gift wrapping not showing in cart, price
                // not updating with the fee" for size+color products with add-ons.
                $raw_existing = $item['options'] ?? null;
                if ( is_string( $raw_existing ) && $raw_existing !== '' ) {
                    $decoded       = json_decode( $raw_existing, true );
                    $extra_options = is_array( $decoded ) ? $decoded : [];
                } elseif ( is_array( $raw_existing ) ) {
                    $extra_options = $raw_existing;
                } else {
                    $extra_options = [];
                }
                foreach ( $tc_variant_axes as $fid => $vid ) {
                    $fname = $db->getField(
                        "SELECT name FROM {$t_ft} WHERE feature_id = ?i AND language_code = ?s",
                        (int) $fid, $lang
                    );
                    $vname = $db->getField(
                        "SELECT name FROM {$t_fvt} WHERE variant_id = ?i AND language_code = ?s",
                        (int) $vid, $lang
                    );
                    if ( $fname && $vname ) {
                        $extra_options[] = [
                            'option_name'  => $fname,
                            'variant_name' => $vname,
                        ];
                    }
                }
                $item['options'] = $extra_options;
            }

            $hydrated[] = $item;
        }

        /**
         * Filter — let extensions enrich hydrated cart line items in batch.
         *
         * Called after all items have their core fields populated (name, sku,
         * image, options, etc.) but before the array is returned for
         * downstream totals + rendering. Extensions can append display-only
         * affordances (e.g. plan name on a subscription line, license tier
         * label on a software line) without changing stored unit_price or
         * cart_item rows — purely a presentation enrichment layer.
         *
         * Subscriptions extension uses this to append "— Plan Name" to the
         * line item's display name when subscription_plan_id is in the
         * line's meta (set at add time via titancart_cart_item_extra_data).
         *
         * Batch mutation is preferred so extensions can issue a single
         * round-trip lookup for all subscription_plan_ids in the cart
         * rather than N queries. Returning a non-array silently restores
         * the unfiltered hydrated array.
         *
         * @param array  $hydrated Hydrated cart line items.
         * @param string $lang     Language code used for translations.
         * @since S168.1
         */
        $filtered = apply_filters( 'titancart_cart_items_hydrated', $hydrated, $lang );
        if ( is_array( $filtered ) ) {
            $hydrated = $filtered;
        }

        return $hydrated;
    }

    /**
     * Validate and normalise option selections against the product's option set.
     *
     * Returns an array of normalised option entries with the shape:
     *   {
     *     option_id     : int,
     *     option_name   : string,
     *     variant_id    : int,        // 0 for free-text inputs
     *     variant_name  : string,     // typed text for I/T types
     *     modifier      : float,      // 0.0 for free-text inputs
     *     modifier_type : 'A' | 'P',
     *   }
     *
     * Iteration order is the option's DB position, and checkbox variant_ids are
     * sorted ascending. The resulting array is therefore deterministic for any
     * given selection, so the MD5(JSON) options_hash used by CartRepository to
     * dedupe identical line items keeps working as expected.
     *
     * Handles all option types:
     *   S (Select)    — single variant_id (int)
     *   R (Radio)     — single variant_id (int)
     *   C (Checkbox)  — array of variant_ids (one entry per checked box)
     *   I (Input)     — free-text string, stored as variant_name
     *   T (Textarea)  — free-text string, stored as variant_name
     *   F (File)      — not yet supported; entry skipped (separate feature)
     *
     * S137 — extends S/R-only handling to all input types so engraving text,
     * gift-message textareas, and multi-select checkbox add-ons all reach the
     * cart and persist through to the order.
     *
     * @param  int   $product_id
     * @param  array $options  Raw user input keyed by option_id. Values may be
     *                         int, int[], or string depending on option type.
     * @return array
     */
    private function validate_options( int $product_id, array $options ): array {
        if ( empty( $options ) ) {
            return [];
        }

        // S141 — Sequential Options awareness:
        //   1. Topo-sort the product options so iteration matches the PDP's
        //      visual flow (parents before dependents) — the resulting
        //      validated entries get stored in topo order, which CartTotals
        //      relies on for compound-pricing running-total math.
        //   2. Compute the locked set (options whose dep rules aren't satisfied
        //      by the customer's submission) and silently filter their
        //      submissions out — defense in depth alongside the JS lock UI.
        //   3. Stamp `compound_pricing` (0|1) onto every validated entry so
        //      CartTotals reads it from the entry without a DB roundtrip.
        $product_options = $this->products->get_options( $product_id );
        $product_options = $this->products->topo_sort_options_for_storefront( $product_options );
        $locked          = $this->locked_options( $product_options, $options );
        $valid           = [];

        foreach ( $product_options as $opt ) {
            $opt_id   = (int) $opt['id'];
            $opt_type = (string) ( $opt['type'] ?? 'S' );

            if ( ! isset( $options[ $opt_id ] ) ) {
                continue;
            }
            if ( ( $opt['status'] ?? 'active' ) !== 'active' ) {
                continue;
            }
            // S141 — drop submissions for options whose dep rules aren't
            // satisfied. The JS module disables their inputs on the PDP, but
            // a hand-crafted POST or stale form could still send them.
            if ( isset( $locked[ $opt_id ] ) ) {
                continue;
            }

            $compound = (int) ( $opt['compound_pricing'] ?? 0 ) ? 1 : 0;

            $raw = $options[ $opt_id ];

            // ── Free-text inputs (Input / Textarea) ──────────────────────────
            if ( $opt_type === 'I' || $opt_type === 'T' ) {
                $text = is_string( $raw ) ? trim( $raw ) : '';
                if ( $text === '' ) {
                    continue;
                }
                // Cap at 1000 chars to prevent DOS via huge text. Aligns with
                // typical engraving / gift-message field expectations.
                $text = mb_substr( $text, 0, 1000 );

                $valid[] = [
                    'option_id'        => $opt_id,
                    'option_name'      => $opt['name'] ?? '',
                    'variant_id'       => 0,
                    'variant_name'     => sanitize_textarea_field( $text ),
                    'modifier'         => 0.0,
                    'modifier_type'    => 'A',
                    'compound_pricing' => $compound,
                ];
                continue;
            }

            // ── File upload (deferred — separate feature) ────────────────────
            if ( $opt_type === 'F' ) {
                // File-type options need a multipart upload + storage pipeline
                // and admin-side download links. Tracked as a follow-up; skip
                // here so products with type=F options remain addable.
                continue;
            }

            // ── Variant-based types (Select / Radio / Checkbox) ──────────────
            $variant_ids = is_array( $raw ) ? $raw : [ $raw ];
            $variant_ids = array_filter( array_map( 'intval', $variant_ids ), function ( $v ) {
                return $v > 0;
            } );

            if ( empty( $variant_ids ) ) {
                continue;
            }

            // Select / Radio collapse to the first selection — cart.js shouldn't
            // submit more, but defend in depth.
            if ( $opt_type === 'S' || $opt_type === 'R' ) {
                $variant_ids = [ reset( $variant_ids ) ];
            } else {
                // Checkbox: sort ascending so the JSON hash is deterministic
                // regardless of checkbox click order.
                sort( $variant_ids );
            }

            $opt_variants = $opt['variants'] ?? [];

            foreach ( $variant_ids as $vid ) {
                foreach ( $opt_variants as $variant ) {
                    if ( (int) $variant['id'] !== (int) $vid ) {
                        continue;
                    }
                    if ( ( $variant['status'] ?? '' ) !== 'active' ) {
                        break;
                    }
                    $valid[] = [
                        'option_id'        => $opt_id,
                        'option_name'      => $opt['name'] ?? '',
                        'variant_id'       => (int) $vid,
                        'variant_name'     => $variant['name'] ?? '',
                        'modifier'         => (float) $variant['modifier'],
                        'modifier_type'    => $variant['modifier_type'] ?? 'A',
                        'compound_pricing' => $compound,
                    ];
                    break;
                }
            }
        }

        return $valid;
    }

    /**
     * Compute which options are "locked" given the customer's raw submission.
     *
     * S141 — companion to Sequential Options. An option is locked when its
     * dependency rules aren't satisfied. Rules use OR-within-parent (any
     * matching variant on a parent unlocks that parent group) and
     * AND-across-parents (every distinct parent group must be satisfied).
     *
     * Iterates `$product_options` in topo order (caller's responsibility) so
     * by the time we evaluate a child, its parents have already been
     * evaluated. When a parent is itself locked, its raw submission is
     * stripped from the working copy so downstream children see it as empty
     * — prevents a hand-crafted POST from chain-unlocking through a locked
     * parent by submitting both option's values directly.
     *
     * @param  array $product_options Topo-sorted product options with
     *                                'dependencies' hydrated by get_options.
     * @param  array $raw_options     Raw user input keyed by option_id.
     * @return array<int,true>        Set of locked option_ids (key = id).
     */
    private function locked_options( array $product_options, array $raw_options ): array {
        $locked        = [];
        $effective_raw = $raw_options;

        foreach ( $product_options as $opt ) {
            $opt_id = (int) $opt['id'];
            $deps   = $opt['dependencies'] ?? [];
            if ( empty( $deps ) ) {
                continue; // Root option — never locked by dep evaluation.
            }

            // Group deps by parent_option_id (OR-within-parent semantics).
            $groups = [];
            foreach ( $deps as $d ) {
                $pid = (int) ( $d['parent_option_id'] ?? 0 );
                if ( $pid <= 0 ) {
                    continue;
                }
                if ( ! isset( $groups[ $pid ] ) ) {
                    $groups[ $pid ] = [];
                }
                $groups[ $pid ][] = (int) ( $d['parent_variant_id'] ?? 0 );
            }

            // AND across parent groups — every group must hit.
            $unlocked = true;
            foreach ( $groups as $pid => $allowed_vids ) {
                $parent_raw    = $effective_raw[ $pid ] ?? null;
                $selected_vids = $this->extract_variant_ids( $parent_raw );
                $hit           = false;
                foreach ( $selected_vids as $sv ) {
                    if ( in_array( $sv, $allowed_vids, true ) ) {
                        $hit = true;
                        break;
                    }
                }
                if ( ! $hit ) {
                    $unlocked = false;
                    break;
                }
            }

            if ( ! $unlocked ) {
                $locked[ $opt_id ] = true;
                // Strip from effective_raw so downstream children that depend
                // on THIS option don't see its raw submission as a valid
                // parent answer. Without this, a hand-crafted POST could
                // chain-unlock through a locked parent.
                unset( $effective_raw[ $opt_id ] );
            }
        }

        return $locked;
    }

    /**
     * Extract a list of integer variant_ids from a raw option submission.
     *
     * Handles all three variant-bearing input shapes:
     *   - int        — Select / Radio (single value)
     *   - int[]      — Checkbox (multiple values)
     *   - null/other — empty submission, returns []
     *
     * Free-text submissions (string for Input / Textarea types) return []
     * because they don't carry variant_ids — sequential rules can only depend
     * on S/R/C-type parents, which is enforced at admin-save time.
     *
     * @param  mixed $raw
     * @return int[]
     */
    private function extract_variant_ids( $raw ): array {
        if ( $raw === null ) {
            return [];
        }
        if ( is_array( $raw ) ) {
            return array_values( array_filter(
                array_map( 'intval', $raw ),
                function ( $v ) { return $v > 0; }
            ) );
        }
        // Free-text strings collapse to (int) 0 here, which gets filtered out.
        $v = (int) $raw;
        return $v > 0 ? [ $v ] : [];
    }

    /**
     * Verify that every required option on the product has a non-empty value
     * in the incoming raw selection map.
     *
     * Returns null when all required options are satisfied, or a translated
     * customer-facing error message naming the first missing option.
     *
     * Type F (File) is exempt because the file-upload pipeline is not yet
     * wired — see validate_options() for the deferral note.
     *
     * S137 — paired with validate_options() to make required selections an
     * actual blocker instead of a silently-dropped value.
     *
     * @param  int   $product_id
     * @param  array $options    Raw user input keyed by option_id.
     * @return string|null
     */
    private function check_required_options( int $product_id, array $options ): ?string {
        $product_options = $this->products->get_options( $product_id );

        // S141 — apply the same locked-set computation validate_options uses,
        // so a customer who never saw a locked option (its dependency rules
        // weren't satisfied) isn't blocked from checkout for failing to fill
        // it in. Topo-sort first because locked_options requires parents to
        // be evaluated before their dependents.
        $product_options = $this->products->topo_sort_options_for_storefront( $product_options );
        $locked          = $this->locked_options( $product_options, $options );

        foreach ( $product_options as $opt ) {
            if ( empty( $opt['required'] ) ) {
                continue;
            }
            if ( ( $opt['status'] ?? 'active' ) !== 'active' ) {
                continue;
            }
            if ( ( $opt['type'] ?? 'S' ) === 'F' ) {
                continue; // File uploads not yet supported — don't block.
            }
            // S141 — locked options aren't visible/interactable on the PDP.
            // Treating them as "required" against a customer who couldn't see
            // them would create an undismissable error from their POV.
            if ( isset( $locked[ (int) $opt['id'] ] ) ) {
                continue;
            }

            $opt_id = (int) $opt['id'];
            $value  = $options[ $opt_id ] ?? null;

            $missing = false;
            if ( $value === null ) {
                $missing = true;
            } elseif ( is_array( $value ) ) {
                $filtered = array_filter( $value, function ( $v ) {
                    if ( is_string( $v ) ) {
                        return trim( $v ) !== '';
                    }
                    return ! empty( $v );
                } );
                if ( empty( $filtered ) ) {
                    $missing = true;
                }
            } elseif ( is_string( $value ) ) {
                if ( trim( $value ) === '' ) {
                    $missing = true;
                }
            } elseif ( ! $value ) {
                $missing = true;
            }

            if ( $missing ) {
                $name = $opt['name'] ?? '';
                if ( $name === '' ) {
                    return __( 'Please complete all required product options before adding to cart.', 'titancart-lite' );
                }
                return sprintf(
                    /* translators: %s: option name (e.g. "Gift wrapping") */
                    __( 'Please select a value for "%s".', 'titancart-lite' ),
                    $name
                );
            }
        }

        return null;
    }

    /**
     * Get an item row only if it belongs to the specified cart.
     *
     * @param  int $item_id
     * @param  int $cart_id
     * @return array|null
     */
    private function get_item_from_cart( int $item_id, int $cart_id ): ?array {
        $db    = titancart_db();
        $table = $db->table( 'cart_items' );
        return $db->getRow(
            "SELECT * FROM {$table} WHERE id = ?i AND cart_id = ?i LIMIT 1",
            $item_id,
            $cart_id
        ) ?: null;
    }

    /**
     * Get the total quantity of a specific product already in a cart.
     *
     * @param  int $cart_id
     * @param  int $product_id
     * @return int
     */
    private function qty_in_cart( int $cart_id, int $product_id ): int {
        $db    = titancart_db();
        $table = $db->table( 'cart_items' );
        return (int) $db->getField(
            "SELECT COALESCE(SUM(quantity), 0) FROM {$table}
             WHERE cart_id = ?i AND product_id = ?i",
            $cart_id,
            $product_id
        );
    }

    /**
     * Get the TitanCart customer_id for the currently logged-in WP user.
     *
     * @return int|null
     */
    private function current_customer_id(): ?int {
        if ( ! is_user_logged_in() ) {
            return null;
        }

        $wp_user_id = get_current_user_id();
        return $this->customer_id_from_wp_user( $wp_user_id );
    }

    /**
     * Resolve TitanCart customer ID from WP user ID.
     *
     * @param  int $wp_user_id
     * @return int|null
     */
    private function customer_id_from_wp_user( int $wp_user_id ): ?int {
        $db    = titancart_db();
        $table = $db->table( 'customers' );
        $id    = $db->getField(
            "SELECT id FROM {$table} WHERE wp_user_id = ?i LIMIT 1",
            $wp_user_id
        );
        return $id ? (int) $id : null;
    }

    /**
     * Get current user's usergroup IDs for price tier resolution.
     *
     * @return int[]
     */
    private function current_usergroup_ids(): array {
        if ( ! is_user_logged_in() ) {
            return [0]; // Guest.
        }

        $customer_id = $this->current_customer_id();
        if ( ! $customer_id ) {
            return [0];
        }

        $db    = titancart_db();
        $table = $db->table( 'customer_usergroups' );
        $ids   = $db->getColumn(
            "SELECT group_id FROM {$table} WHERE customer_id = ?i",
            $customer_id
        );

        return array_merge( [0], array_map( 'intval', $ids ) );
    }

    /**
     * Return a standardised error response.
     *
     * @param  string $message
     * @return array
     */
    private function error( string $message ): array {
        LogService::warning( 'checkout', 'cart_errors', 'Cart error: ' . $message );
        return [
            'success' => false,
            'message' => $message,
            'cart'    => null,
        ];
    }

    /**
     * Convert a product's native weight into POUNDS — the canonical internal unit. [KI-93]
     *
     * Products store a raw value plus their own `weight_unit`, so two lines in the
     * same cart can be recorded in different units. Summing them raw is meaningless,
     * which is why normalisation happens once, here, at hydration — every downstream
     * consumer (weight windows, by_weight bands, carrier packaging) then shares one
     * unit and none of them needs to know about `weight_unit` at all.
     *
     * Pounds is canonical because the admin already labels weight windows and
     * by_weight conditions in `lbs`, so thresholds merchants have ALREADY saved keep
     * their exact meaning — this fix cannot silently reinterpret live shipping config.
     * All three carrier APIs (USPS/UPS/FedEx) also ultimately want pounds.
     *
     * An unknown or empty unit is treated as pounds, matching those admin labels and
     * the historical assumption; a negative or non-numeric value floors at 0.0 so a
     * bad row can never subtract from a cart's total weight.
     *
     * Pure arithmetic — no WordPress calls, no DB (Rule 4: portable).
     *
     * @param  mixed  $value  Raw weight as stored (may be a padded DECIMAL string).
     * @param  string $unit   'lb' | 'lbs' | 'oz' | 'kg' | 'g' (case-insensitive).
     * @return float          Weight in pounds, never negative.
     */
    private static function weight_to_lb( $value, string $unit ): float {
        $v = is_numeric( $value ) ? (float) $value : 0.0;

        if ( $v <= 0 ) {
            return 0.0;
        }

        switch ( strtolower( trim( $unit ) ) ) {
            case 'oz':
                return $v / 16;
            case 'kg':
                return $v * 2.2046226218;
            case 'g':
                return $v / 453.59237;
            case 'lb':
            case 'lbs':
            default:
                return $v;
        }
    }
}
