<?php
/**
 * TitanCart Storefront Module
 *
 * Boots all customer-facing functionality:
 *  - Registers rewrite rules and query vars via SeoService
 *  - Wires TemplateLoader to serve the correct template for each URL
 *  - Hooks frontend asset enqueuing
 *  - Registers [titancart_cart], [titancart_checkout], [titancart_account]
 *    shortcodes as fallback page methods
 *
 * URL structure:
 *   /shop/                          → templates/shop/archive.php
 *   /shop/{category-slug}/          → templates/category/single.php
 *   /shop/{parent}/{child}/         → templates/category/single.php  (nested category)
 *   /product/{slug}/                → templates/product/single.php
 *   /cart/                          → templates/cart/cart.php
 *   /checkout/                      → templates/checkout/checkout.php
 *   /my-account/                    → templates/account/dashboard.php
 *
 * @package TitanCart\Storefront
 */

namespace TitanCart\Storefront;

use TitanCart\Services\SeoService;
use TitanCart\Services\SettingsService;
use TitanCart\Services\CurrencyService;
use TitanCart\REST\StorefrontApiController;
use TitanCart\REST\Extensions\ExtensionsController;

defined( 'ABSPATH' ) || exit;

class StorefrontModule {

    public static function init(): void {
        // ── Rewrite rules & query vars ────────────────────────────────────────
        add_action( 'init',        [ SeoService::class, 'register_rewrite_rules' ] );
        add_filter( 'query_vars',  [ SeoService::class, 'register_query_vars'   ] );
        // SEO extension outputs (meta tags / canonical / Open Graph) — gated on
        // the `seo` extension toggle so switching it off actually disables them
        // (KI-35, S352 — "anything not gated to a switch should be"). The
        // is_active() check runs at wp_head (render) time via the closure, not at
        // registration, so a mid-session toggle takes effect on the next page
        // load and there is no early-boot DB timing concern. The pretty-URL
        // rewrites above are CORE (always-on) and are deliberately NOT gated.
        add_action( 'wp_head', static function () {
            if ( ExtensionsController::is_active( 'seo' ) ) { SeoService::output_canonical(); }
        } );
        add_action( 'wp_head', static function () {
            if ( ExtensionsController::is_active( 'seo' ) ) { SeoService::output_meta_tags(); }
        }, 5 );

        // Product Ribbons (core) — emit the ribbon CSS sitewide so it reaches
        // theme homepages (own card renderers / preset layouts), shop, and PDP.
        add_action( 'wp_enqueue_scripts', [ ProductRibbons::class, 'output_css'        ], 99 ); // WP.org R1 🅱1: was wp_head@99 echoing a raw style tag; now enqueues via wp_add_inline_style (same sitewide reach).

        // Also register /cart/, /checkout/, /my-account/ rewrite rules.
        add_action( 'init', [ self::class, 'register_extra_rewrite_rules' ] );
        add_filter( 'query_vars', [ self::class, 'register_extra_query_vars' ] );

        // Strip WP's built-in 's' query var on TitanCart shop pages so the main
        // WP_Query doesn't run a useless post-search when the storefront is
        // already handling ?s= itself for product filtering.
        add_filter( 'request', [ self::class, 'strip_wp_search_var' ] );

        // ── Register nav menu locations (Appearance → Menus) ─────────────────
        // Registered on 'init', NOT 'after_setup_theme' (S468): translating the
        // location labels before init trips WP 6.7's just-in-time textdomain
        // notice on every request (after_setup_theme fires pre-init). init is
        // early enough — locations registered here are available to both the
        // Menus admin screen and wp_nav_menu() at template time — and the
        // TITANCART_THEME_ACTIVE constant (defined when a TitanCart theme's
        // functions.php loads, which is before init) is already resolvable.
        add_action( 'init', function() {
            if ( defined( 'TITANCART_THEME_ACTIVE' ) ) {
                return; // Theme registers its own menus.
            }
            register_nav_menus( [
                'tc_topbar'       => __( 'TitanCart – Top Bar',      'titancart-lite' ),
                'tc_main_nav'     => __( 'TitanCart – Main Nav',     'titancart-lite' ),
                'tc_footer_col1'  => __( 'TitanCart – Footer Col 1', 'titancart-lite' ),
                'tc_footer_col2'  => __( 'TitanCart – Footer Col 2', 'titancart-lite' ),
            ] );
        } );

        // ── Public storefront REST API ───────────────────────────────────────
        add_action( 'rest_api_init', function() {
            ( new StorefrontApiController() )->register_routes();
        } );

        // ── Template interception ─────────────────────────────────────────────
        add_filter( 'template_include', [ self::class, 'maybe_load_template' ] );

        // ── Printable invoice (standalone document, bypasses the theme) ───────
        // S267 (#7/KI-15): renders /order-invoice/{number}/ as its own complete
        // HTML document on template_redirect (fires before template_include),
        // then exits — so the printout carries no theme/admin chrome. Auth is
        // enforced inside the handler (admin / logged-in owner / ?key=).
        add_action( 'template_redirect', [ self::class, 'maybe_render_invoice' ] );

        // ── Printable packing slip (warehouse-internal document) ──────────────
        // Mirrors the invoice document but admin-only (manage_options) and price
        // -free: a pick list for fulfilment. Route /order-packing-slip/{number}/.
        add_action( 'template_redirect', [ self::class, 'maybe_render_packing_slip' ] );

        // ── Frontend assets ───────────────────────────────────────────────────
        add_action( 'wp_enqueue_scripts', [ self::class, 'enqueue_assets' ] );

        // ── Shortcodes (fallback: pages with these shortcodes also work) ──────
        add_shortcode( 'titancart_cart',     [ self::class, 'shortcode_cart'     ] );
        add_shortcode( 'titancart_checkout', [ self::class, 'shortcode_checkout' ] );
        add_shortcode( 'titancart_account',  [ self::class, 'shortcode_account'  ] );
        add_shortcode( 'titancart_shop',     [ self::class, 'shortcode_shop'     ] );

        // ── Body classes ──────────────────────────────────────────────────────
        add_filter( 'body_class', [ self::class, 'body_classes' ] );

        // ── Page title ────────────────────────────────────────────────────────
        add_filter( 'document_title_parts', [ self::class, 'page_title' ] );

        // ── Site footer (injected on TitanCart pages only) ──
        // Header is injected via template-wrapper.php output buffering.
        // Runtime check inside the method skips rendering when the TitanCart theme is active.
        add_action( 'wp_footer', [ self::class, 'render_site_footer' ], 5 );

        // ── Hide WP admin bar for non-admin users on the storefront ──────────
        // Runtime check inside the method avoids double-filtering when theme handles it.
        add_filter( 'show_admin_bar', [ self::class, 'maybe_hide_admin_bar' ] );

        // ── Nav menu meta boxes (Appearance → Menus) ─────────────────────────
        // Adds "TitanCart Pages" and "TitanCart Categories" sections so users
        // can easily add shop links to any WordPress menu.
        add_action( 'admin_head-nav-menus.php', [ self::class, 'register_nav_menu_meta_boxes' ] );
    }

    // ── Extra rewrite rules for cart / checkout / account ─────────────────────

    public static function register_extra_rewrite_rules(): void {
        add_rewrite_rule( '^cart/?$',                    'index.php?tc_cart=1',          'top' );
        add_rewrite_rule( '^checkout/?$',                'index.php?tc_checkout=1',      'top' );
        add_rewrite_rule( '^order-received/([^/]+)/?$',  'index.php?tc_order_received=$matches[1]', 'top' );
        add_rewrite_rule( '^order-invoice/([^/]+)/?$',   'index.php?tc_order_invoice=$matches[1]',  'top' );
        add_rewrite_rule( '^order-packing-slip/([^/]+)/?$', 'index.php?tc_order_packing_slip=$matches[1]', 'top' );
        add_rewrite_rule( '^my-account/?$',              'index.php?tc_account=1',       'top' );
        add_rewrite_rule( '^my-account/([a-z\-]+)/?$',   'index.php?tc_account=1&tc_account_section=$matches[1]', 'top' );
        add_rewrite_rule( '^search/?$',                  'index.php?tc_search=1',        'top' );
    }

    public static function register_extra_query_vars( array $vars ): array {
        $vars[] = 'tc_cart';
        $vars[] = 'tc_checkout';
        $vars[] = 'tc_order_received';
        $vars[] = 'tc_order_invoice';
        $vars[] = 'tc_order_packing_slip';
        $vars[] = 'tc_account';
        $vars[] = 'tc_account_section';
        $vars[] = 'tc_search';
        return $vars;
    }

    /**
     * Build the public URL for an order's printable invoice.
     *
     * Always returns the pretty form (/order-invoice/{number}/). The handler
     * has a REQUEST_URI fallback, so this works even if rewrite rules haven't
     * been flushed yet after deploy.
     *
     * @param array $order       Order row (needs order_number; order_key for $with_key).
     * @param bool  $with_key    Append ?key={order_key} so the link works without login (emails).
     * @param bool  $auto_print  Append ?print=1 so the page auto-opens the print dialog (admin button).
     */
    public static function invoice_url( array $order, bool $with_key = false, bool $auto_print = false ): string {
        $number = (string) ( $order['order_number'] ?? '' );
        if ( $number === '' ) {
            return '';
        }
        $url  = home_url( '/order-invoice/' . rawurlencode( $number ) . '/' );
        $args = [];
        if ( $with_key && ! empty( $order['order_key'] ) ) {
            $args['key'] = $order['order_key'];
        }
        if ( $auto_print ) {
            $args['print'] = '1';
        }
        return $args ? add_query_arg( $args, $url ) : $url;
    }

    /**
     * Render the standalone printable invoice for /order-invoice/{number}/.
     *
     * Fires on template_redirect (before template selection). Resolves the
     * order by its (enumerable) number, then enforces the same three-path
     * access model used by the thank-you page and GET /orders/{number}:
     *   1. WP admin (manage_options)
     *   2. Authenticated WP user whose tc_customers.id matches the order
     *   3. ?key= matching the row's order_key (constant-time hash_equals)
     * On any failure we emit a generic 404 — never confirming existence.
     *
     * Includes templates/checkout/invoice.php directly (NOT via the theme
     * wrapper) and exits, so the printout is a clean branded document.
     */
    public static function maybe_render_invoice(): void {
        // 1. Resolve the order number — query var first, REQUEST_URI fallback.
        //    The fallback means the pretty URL works even before a permalink
        //    flush (template_redirect fires on the resulting 404 too).
        $number = (string) get_query_var( 'tc_order_invoice' );
        if ( $number === '' ) {
            $path = (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ), PHP_URL_PATH );
            if ( preg_match( '#/order-invoice/([A-Za-z0-9\-]+)#', $path, $m ) ) {
                $number = $m[1];
            }
        }
        if ( $number === '' ) {
            return; // Not an invoice request.
        }

        $number     = strtoupper( sanitize_text_field( $number ) );
        $order_repo = new \TitanCart\Modules\Checkout\OrderRepository();
        $order      = $order_repo->get_by_number( $number );

        // ── Access control (admin / owner / key) ──
        $authorized = false;
        if ( $order ) {
            if ( current_user_can( 'manage_options' ) ) {
                $authorized = true;
            } elseif ( is_user_logged_in()
                && class_exists( '\TitanCart\Modules\Accounts\CustomerRepository' ) ) {
                $repo = new \TitanCart\Modules\Accounts\CustomerRepository();
                $row  = $repo->find_by_wp_user( get_current_user_id() );
                if ( $row && (int) $row['id'] === (int) $order['customer_id'] ) {
                    $authorized = true;
                }
            }
            if ( ! $authorized ) {
                $supplied = isset( $_GET['key'] ) ? preg_replace( '/[^a-fA-F0-9]/', '', sanitize_text_field( wp_unslash( (string) $_GET['key'] ) ) ) : ''; // WP.org R2 item 5: unslash+sanitize at boundary, hex whitelist on top.
                $row_key  = (string) ( $order['order_key'] ?? '' );
                if ( $supplied !== '' && $row_key !== '' && hash_equals( $row_key, $supplied ) ) {
                    $authorized = true;
                }
            }
        }

        if ( ! $order || ! $authorized ) {
            // Generic 404 — indistinguishable from a missing order, no leak.
            status_header( 404 );
            nocache_headers();
            wp_die(
                esc_html__( 'We could not locate this invoice. The link may be incorrect or expired.', 'titancart-lite' ),
                esc_html__( 'Invoice not found', 'titancart-lite' ),
                [ 'response' => 404 ]
            );
        }

        // ── Hydrate ──
        $items = $order_repo->get_items( (int) $order['id'] );
        $lang  = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $db    = titancart_db();

        // Status label.
        $status_label = ucfirst( (string) ( $order['status'] ?? 'pending' ) );
        if ( ! empty( $order['status_id'] ) ) {
            $srow = $db->getRow(
                "SELECT COALESCE(ost.name, os.handle) AS status_name
                 FROM {$db->table('order_statuses')} os
                 LEFT JOIN {$db->table('order_status_translations')} ost
                   ON ost.status_id = os.id AND ost.language_code = ?s
                 WHERE os.id = ?i LIMIT 1",
                $lang,
                (int) $order['status_id']
            );
            if ( ! empty( $srow['status_name'] ) ) {
                $status_label = $srow['status_name'];
            }
        }

        // Payment method title (translation name → gateway fallback).
        $payment_method_title = '';
        if ( ! empty( $order['payment_method_id'] ) ) {
            $prow = $db->getRow(
                "SELECT pm.gateway, pmt.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,
                (int) $order['payment_method_id']
            );
            $payment_method_title = ! empty( $prow['name'] ) ? $prow['name'] : ucwords( (string) ( $prow['gateway'] ?? '' ) );
        }

        /* KI-97 (S424): the gateway row may have been deleted since the order was
         * placed — getRow() then returns null and the line above resolves to ''.
         * Fall back to the payment_info snapshot captured at checkout so a
         * customer's own order view never shows a blank payment method. Live
         * lookup still wins when the row exists, so renames behave as before. */
        if ( $payment_method_title === '' && ! empty( $order['payment_info'] ) ) {
            $psnap = is_array( $order['payment_info'] )
                ? $order['payment_info']
                : json_decode( (string) $order['payment_info'], true );
            if ( is_array( $psnap ) && ! empty( $psnap['name'] ) ) {
                $payment_method_title = (string) $psnap['name'];
            }
        }

        // Shipping method title.
        $shipping_method_title = '';
        if ( ! empty( $order['shipping_method_id'] ) ) {
            $hrow = $db->getRow(
                "SELECT sm.carrier, smt.name
                 FROM {$db->table('shipping_methods')} sm
                 LEFT JOIN {$db->table('shipping_method_translations')} smt
                   ON smt.method_id = sm.id AND smt.language_code = ?s
                 WHERE sm.id = ?i LIMIT 1",
                $lang,
                (int) $order['shipping_method_id']
            );
            $shipping_method_title = ! empty( $hrow['name'] ) ? $hrow['name'] : ucwords( (string) ( $hrow['carrier'] ?? '' ) );
        }

        /* KI-97 (S424): same fallback as payment above. shipping_info has been
         * written since KI-32 but, until S424, nothing ever READ it — so a deleted
         * shipping method blanked this line too. Orders placed before KI-32 have
         * no snapshot and legitimately fall through to ''. */
        if ( $shipping_method_title === '' && ! empty( $order['shipping_info'] ) ) {
            $ssnap = is_array( $order['shipping_info'] )
                ? $order['shipping_info']
                : json_decode( (string) $order['shipping_info'], true );
            if ( is_array( $ssnap ) && ! empty( $ssnap['name'] ) ) {
                $shipping_method_title = (string) $ssnap['name'];
            }
        }

        // Company / branding — mirrors Mailer so the invoice matches the emails.
        $get_co = static function ( $key, $default = '' ) {
            return \TitanCart\Services\SettingsService::get( 'company', $key, $default );
        };
        $company = [
            'name'    => (string) $get_co( 'company_name', get_bloginfo( 'name' ) ),
            // Display only an explicitly-configured order email — never fall back
            // to the WP admin email on a customer-facing document.
            'email'   => (string) $get_co( 'email_order_dept', '' ),
            'address' => trim(
                $get_co( 'company_address' ) . ', ' .
                $get_co( 'company_city' ) . ' ' .
                $get_co( 'company_state' ) . ' ' .
                $get_co( 'company_zip' ) . ', ' .
                $get_co( 'company_country' ),
                ', '
            ),
            'phone'   => (string) $get_co( 'company_phone' ),
            'phone_2' => (string) $get_co( 'company_phone_2' ),
            'website' => (string) $get_co( 'company_website' ),
            'year'    => (string) $get_co( 'company_year' ),
        ];

        $currency     = apply_filters( 'titancart_currency_symbol', '$' );
        $logo_url     = apply_filters( 'titancart_email_logo_url', '' );
        $accent_color = apply_filters( 'titancart_email_accent_color', '#1F6AE1' );
        $auto_print   = ! empty( $_GET['print'] );

        // Render the standalone document and stop. $order/$items/etc. live in
        // this scope, so the included template reads them directly.
        nocache_headers();
        include TITANCART_PATH . 'templates/checkout/invoice.php';
        exit;
    }

    /**
     * Build the admin-facing URL for an order's printable packing slip.
     * Always the pretty form; the handler has a REQUEST_URI fallback so it
     * works even before a permalink flush.
     *
     * @param array|string $order_or_number Order row (uses order_number) or a number.
     */
    public static function packing_slip_url( $order_or_number, bool $print = true ): string {
        $number = is_array( $order_or_number )
            ? (string) ( $order_or_number['order_number'] ?? '' )
            : (string) $order_or_number;
        if ( $number === '' ) {
            return '';
        }
        $url = home_url( '/order-packing-slip/' . rawurlencode( $number ) . '/' );
        return $print ? add_query_arg( 'print', '1', $url ) : $url;
    }

    /**
     * Render the standalone printable PACKING SLIP for
     * /order-packing-slip/{number}/.
     *
     * Warehouse-internal pick list: mirrors the invoice document's hydration and
     * standalone-render approach, but is ADMIN-ONLY (manage_options) — there is
     * no owner/?key= path, because a packing slip is never customer-facing — and
     * it carries no prices or payment details. Fires on template_redirect and
     * exits so the printout has no theme/admin chrome.
     */
    public static function maybe_render_packing_slip(): void {
        $number = (string) get_query_var( 'tc_order_packing_slip' );
        if ( $number === '' ) {
            $path = (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ), PHP_URL_PATH );
            if ( preg_match( '#/order-packing-slip/([A-Za-z0-9\-]+)#', $path, $m ) ) {
                $number = $m[1];
            }
        }
        if ( $number === '' ) {
            return; // Not a packing-slip request.
        }

        // Admin-only: a packing slip is warehouse-internal.
        if ( ! current_user_can( 'manage_options' ) ) {
            status_header( 404 );
            nocache_headers();
            wp_die(
                esc_html__( 'We could not locate this document.', 'titancart-lite' ),
                esc_html__( 'Not found', 'titancart-lite' ),
                [ 'response' => 404 ]
            );
        }

        $number     = strtoupper( sanitize_text_field( $number ) );
        $order_repo = new \TitanCart\Modules\Checkout\OrderRepository();
        $order      = $order_repo->get_by_number( $number );

        if ( ! $order ) {
            status_header( 404 );
            nocache_headers();
            wp_die(
                esc_html__( 'We could not locate this packing slip.', 'titancart-lite' ),
                esc_html__( 'Packing slip not found', 'titancart-lite' ),
                [ 'response' => 404 ]
            );
        }

        // ── Hydrate (items + status + shipping method + company branding) ──
        $items = $order_repo->get_items( (int) $order['id'] );
        $lang  = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $db    = titancart_db();

        $status_label = ucfirst( (string) ( $order['status'] ?? 'pending' ) );
        if ( ! empty( $order['status_id'] ) ) {
            $srow = $db->getRow(
                "SELECT COALESCE(ost.name, os.handle) AS status_name
                 FROM {$db->table('order_statuses')} os
                 LEFT JOIN {$db->table('order_status_translations')} ost
                   ON ost.status_id = os.id AND ost.language_code = ?s
                 WHERE os.id = ?i LIMIT 1",
                $lang,
                (int) $order['status_id']
            );
            if ( ! empty( $srow['status_name'] ) ) {
                $status_label = $srow['status_name'];
            }
        }

        $shipping_method_title = '';
        if ( ! empty( $order['shipping_method_id'] ) ) {
            $hrow = $db->getRow(
                "SELECT sm.carrier, smt.name
                 FROM {$db->table('shipping_methods')} sm
                 LEFT JOIN {$db->table('shipping_method_translations')} smt
                   ON smt.method_id = sm.id AND smt.language_code = ?s
                 WHERE sm.id = ?i LIMIT 1",
                $lang,
                (int) $order['shipping_method_id']
            );
            $shipping_method_title = ! empty( $hrow['name'] ) ? $hrow['name'] : ucwords( (string) ( $hrow['carrier'] ?? '' ) );
        }

        /* KI-97 (S424): snapshot fallback, as in the order-view block above. This
         * is a SECOND, independent resolution of the same label — both must carry
         * the fallback or the two surfaces disagree after a method is deleted. */
        if ( $shipping_method_title === '' && ! empty( $order['shipping_info'] ) ) {
            $ssnap = is_array( $order['shipping_info'] )
                ? $order['shipping_info']
                : json_decode( (string) $order['shipping_info'], true );
            if ( is_array( $ssnap ) && ! empty( $ssnap['name'] ) ) {
                $shipping_method_title = (string) $ssnap['name'];
            }
        }

        $get_co = static function ( $key, $default = '' ) {
            return \TitanCart\Services\SettingsService::get( 'company', $key, $default );
        };
        $company = [
            'name'    => (string) $get_co( 'company_name', get_bloginfo( 'name' ) ),
            'address' => trim(
                $get_co( 'company_address' ) . ', ' .
                $get_co( 'company_city' ) . ' ' .
                $get_co( 'company_state' ) . ' ' .
                $get_co( 'company_zip' ) . ', ' .
                $get_co( 'company_country' ),
                ', '
            ),
            'phone'   => (string) $get_co( 'company_phone' ),
            'website' => (string) $get_co( 'company_website' ),
        ];

        $logo_url     = apply_filters( 'titancart_email_logo_url', '' );
        $accent_color = apply_filters( 'titancart_email_accent_color', '#1F6AE1' );
        $auto_print   = ! empty( $_GET['print'] );

        nocache_headers();
        include TITANCART_PATH . 'templates/checkout/packing-slip.php';
        exit;
    }

    /**
     * Strip WordPress's built-in 's' query var on TitanCart shop/category pages.
     *
     * WordPress treats any URL with ?s= as a search query and runs the main
     * WP_Query against posts. On a TitanCart shop page we read $_GET['s']
     * ourselves and filter products via ProductRepository, so the WP search
     * is wasteful and pollutes is_search() / body classes / canonical URL.
     *
     * @param  array<string,mixed> $vars
     * @return array<string,mixed>
     */
    public static function strip_wp_search_var( array $vars ): array {
        if ( ! empty( $vars['tc_shop'] ) || ! empty( $vars['tc_category'] ) || ! empty( $vars['tc_tag'] ) || ! empty( $vars['tc_search'] ) ) {
            unset( $vars['s'] );
        }
        return $vars;
    }

    // ── Central template dispatcher ────────────────────────────────────────────

    /**
     * Intercept WordPress's template selection and serve a TitanCart template
     * when one of our custom query vars is set.
     *
     * We use output buffering via TemplateLoader rather than just returning a
     * file path, so we can pass data into template scope cleanly.
     *
     * @param  string $template  The template WordPress was about to load.
     * @return string            Our wrapper template path, or the original.
     */
    public static function maybe_load_template( string $template ): string {
        $map = [
            'tc_shop'     => 'shop/archive.php',
            'tc_category' => 'category/single.php',  // S163.5 — own template route; default core impl defers to shop/archive.php for backward compat
            'tc_tag'      => 'shop/archive.php',
            'tc_product'  => 'product/single.php',
            'tc_cart'           => 'cart/cart.php',
            'tc_checkout'       => 'checkout/checkout.php',
            'tc_order_received' => 'checkout/order-received.php',
            'tc_account'        => 'account/dashboard.php',
            'tc_search'         => 'search/results.php',
        ];

        foreach ( $map as $query_var => $template_file ) {
            if ( get_query_var( $query_var ) ) {
                $found = TemplateLoader::locate( $template_file );
                if ( $found ) {
                    // Store which template to load so our wrapper can find it.
                    self::$pending_template      = $found;
                    self::$pending_template_data = self::build_template_data( $query_var );

                    // Return a tiny wrapper that sets variables then includes the real file.
                    return TITANCART_PATH . 'src/Storefront/template-wrapper.php';
                }
            }
        }

        return $template;
    }

    /** @var string|null  Path of the TitanCart template to load. */
    private static ?string $pending_template = null;

    /** @var array  Data to extract into the template's scope. */
    private static array $pending_template_data = [];

    /**
     * Called by template-wrapper.php — returns the pending template path
     * and clears the static properties.
     */
    public static function consume_pending_template(): array {
        $data = [
            'file' => self::$pending_template,
            'vars' => self::$pending_template_data,
        ];
        self::$pending_template      = null;
        self::$pending_template_data = [];
        return $data;
    }

    // ── Data builders ──────────────────────────────────────────────────────────

    /**
     * [KI-53] Serve a stored 301/302 SEO redirect for the current request, then exit.
     *
     * Called at the exact points where TitanCart has just determined that a
     * /product/{slug}/ or /shop/{slug}/ URL resolves to no live object — the only
     * moment the "this slug is now a 404" fact exists.
     *
     * Why here and not on the old `template_redirect` guard in Plugin.php: those
     * URLs match a TitanCart rewrite, so WP_Query resolves them and is_404() is
     * FALSE at template_redirect — the guard could never fire for the only URLs the
     * redirect table ever holds (it is populated exclusively by product/category
     * slug changes). build_template_data() runs on template_include, before any
     * output, so serving the redirect here is safe (headers not yet sent) and adds
     * NO query to the happy path — it runs only when an object is already missing.
     *
     * The redirect table holds only old→new slug-change rows, so a soft-deleted
     * object's *current* slug has no matching from_url and correctly 404s; a renamed
     * old slug 301s to the new one (which is itself re-validated as live on the
     * redirected request).
     */
    private static function try_seo_redirect(): void {
        // Output already started (some plugin echoed early) — cannot redirect
        // without a "headers already sent" warning; fall through to the 404.
        if ( headers_sent() ) {
            return;
        }

        $request_uri = trim( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ), '/' );
        if ( $request_uri === '' ) {
            return;
        }

        // Normalize: leading slash, strip query string; try slash + no-slash forms.
        $path         = '/' . strtok( $request_uri, '?' );
        $path_slash   = rtrim( $path, '/' ) . '/';
        $path_noslash = rtrim( $path, '/' );

        try {
            $db    = titancart_db();
            $table = $db->table( 'seo_redirects' );

            $redirect = $db->getRow(
                "SELECT to_url, type FROM {$table}
                 WHERE status = 'active'
                   AND ( from_url = ?s OR from_url = ?s OR from_url = ?s )
                 LIMIT 1",
                $path, $path_slash, $path_noslash
            );

            if ( $redirect && ! empty( $redirect['to_url'] ) ) {
                $to   = home_url( $redirect['to_url'] );
                $code = (int) ( $redirect['type'] ?? 301 );
                if ( ! in_array( $code, [ 301, 302 ], true ) ) {
                    $code = 301;
                }

                // Increment the hit counter for the matched row(s).
                $db->query(
                    "UPDATE {$table} SET hits = hits + 1
                      WHERE from_url = ?s OR from_url = ?s OR from_url = ?s",
                    $path, $path_slash, $path_noslash
                );

                wp_safe_redirect( $to, $code );
                exit;
            }
        } catch ( \Throwable $e ) {
            // Table missing / DB not ready — fall through to the normal 404.
        }
    }

    /**
     * Build the data array to be extracted into a template's scope.
     * Fetches products/categories/etc from the DB as needed.
     */
    private static function build_template_data( string $query_var ): array {
        global $wpdb;
        $p    = $wpdb->prefix . TITANCART_TABLE_PREFIX;
        $lang = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';

        switch ( $query_var ) {

            // ── Shop index or category page ───────────────────────────────────
            // Both query vars share this data-builder. Template routing diverges:
            // tc_shop loads templates/shop/archive.php; tc_category loads
            // templates/category/single.php (S163.5 seam — core default defers
            // to shop/archive.php so behavior is unchanged unless an extension
            // overrides category/single.php specifically).
            case 'tc_shop':
            case 'tc_category':
                $page        = max( 1, absint( wp_unslash( $_GET['tc_page'] ?? 1 ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- idempotent GET view-state (pagination), see reviewer notes §6.
                $per_page    = SettingsService::int( 'appearance', 'products_per_page', 24 );
                $cat_slug    = get_query_var( 'tc_category' ) ?: '';
                $filter_mode = sanitize_key( $_GET['filter'] ?? '' );

                // Resolve category_id from slug if browsing a category.
                $category_id = 0;
                if ( $cat_slug ) {
                    // slug lives on the categories table, not translations.
                    $category_id = (int) $wpdb->get_var( $wpdb->prepare(
                        "SELECT c.id
                           FROM {$p}categories c
                           WHERE c.slug = %s AND c.status = 'active' AND c.deleted_at IS NULL
                           LIMIT 1",
                        $cat_slug
                    ) );
                }

                // [KI-53] A category slug was supplied but resolves to no live,
                // active category. If it was renamed, 301 to the new URL (exits on
                // match); otherwise fall through to the existing behavior (render the
                // shop index). tc_shop has an empty $cat_slug and is never affected.
                if ( $cat_slug !== '' && $category_id === 0 ) {
                    self::try_seo_redirect();
                }

                $list_params = [
                    'status'         => 'active',
                    'parent_id'      => 0,
                    'page'           => $page,
                    'items_per_page' => $per_page,
                    'sort_by'        => 'date',
                    'sort_order'     => 'desc',
                ];

                // ── User-applied filter GET params ────────────────────────────
                // Tracks resolved values so the template can highlight active
                // selections without re-parsing $_GET on every render.
                $active_filters = [
                    'search'         => '',
                    'price_range'    => '',
                    'sort'           => '',
                    'min_rating'     => '',
                    'updated_within' => '',
                    'vendor'         => '',
                ];

                // Search — accept both ?s= (form convention) and ?q= (legacy hero box).
                $search = sanitize_text_field( wp_unslash( (string) ( $_GET['s'] ?? $_GET['q'] ?? '' ) ) );
                if ( $search !== '' ) {
                    $list_params['search']    = $search;
                    $active_filters['search'] = $search;
                }

                // Price range — UI submits "0-50", "50-100", "100-999", or "100-" (open-ended high).
                $price_range = sanitize_text_field( wp_unslash( (string) ( $_GET['price_range'] ?? '' ) ) );
                if ( $price_range !== '' && preg_match( '/^(\d+)-(\d*)$/', $price_range, $m ) ) {
                    $pmin = (float) $m[1];
                    $pmax = $m[2] !== '' ? (float) $m[2] : null;
                    if ( $pmin > 0 ) {
                        $list_params['price_min'] = $pmin;
                    }
                    if ( $pmax !== null && $pmax > 0 ) {
                        $list_params['price_max'] = $pmax;
                    }
                    $active_filters['price_range'] = $price_range;
                }

                // Sort — UI submits "field-direction" (e.g. "price-asc", "rating-desc").
                $sort_param = sanitize_text_field( wp_unslash( (string) ( $_GET['sort'] ?? '' ) ) );
                if ( $sort_param !== '' && strpos( $sort_param, '-' ) !== false ) {
                    [ $sort_field, $sort_direction ] = explode( '-', $sort_param, 2 );
                    $allowed_fields = [ 'name', 'price', 'position', 'date', 'sales', 'rating', 'discount' ];
                    $allowed_dirs   = [ 'asc', 'desc' ];
                    if ( in_array( $sort_field, $allowed_fields, true ) && in_array( $sort_direction, $allowed_dirs, true ) ) {
                        $list_params['sort_by']    = $sort_field;
                        $list_params['sort_order'] = $sort_direction;
                        $active_filters['sort']    = $sort_param;
                    }
                }

                // Minimum rating — UI submits 1–5.
                $min_rating = (float) sanitize_text_field( wp_unslash( $_GET['min_rating'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- idempotent GET view-state (filter), see reviewer notes §6.
                if ( $min_rating > 0 && $min_rating <= 5 ) {
                    $list_params['min_rating']     = $min_rating;
                    $active_filters['min_rating']  = (string) $min_rating;
                }

                // Date Updated — UI submits "day", "week", "month", "year".
                $updated_within = sanitize_key( $_GET['updated_within'] ?? '' );
                $updated_map    = [ 'day' => 1, 'week' => 7, 'month' => 30, 'year' => 365 ];
                if ( isset( $updated_map[ $updated_within ] ) ) {
                    $list_params['updated_within_days'] = $updated_map[ $updated_within ];
                    $active_filters['updated_within']    = $updated_within;
                }

                // Vendor — UI submits slug. TC core stays vendor-agnostic per
                // Rule 4: sanitize and pass through as `vendor_slug` in
                // $list_params. The marketplace extension's listener at
                // `titancart_products_list_query` (S197 VendorProductListFilter)
                // resolves slug → vendor_id and injects the JOIN. Non-existent
                // or non-active vendor slugs fall through with no filter
                // applied — same semantics as a no-op for unknown values.
                //
                // @since S198 (Track B Module B8 close — shop archive ?vendor= facet).
                $vendor_slug = sanitize_title( wp_unslash( (string) ( $_GET['vendor'] ?? '' ) ) );
                if ( $vendor_slug !== '' ) {
                    $list_params['vendor_slug'] = $vendor_slug;
                    $active_filters['vendor']   = $vendor_slug;
                }

                // Category filtering — include subcategory products when setting is ON.
                if ( $category_id > 0 ) {
                    if ( SettingsService::bool( 'general', 'catalog_show_subcategory_products', true ) ) {
                        // Gather all descendant category IDs.
                        $all_cat_ids = [ $category_id ];
                        $queue       = [ $category_id ];
                        $max_depth   = 50; // safety limit
                        while ( ! empty( $queue ) && $max_depth-- > 0 ) {
                            $parent = array_shift( $queue );
                            $children = $wpdb->get_col( $wpdb->prepare(
                                "SELECT id FROM {$p}categories WHERE parent_id = %d AND status = 'active' AND deleted_at IS NULL",
                                $parent
                            ) );
                            foreach ( $children as $child_id ) {
                                $cid = (int) $child_id;
                                if ( ! in_array( $cid, $all_cat_ids, true ) ) {
                                    $all_cat_ids[] = $cid;
                                    $queue[]       = $cid;
                                }
                            }
                        }
                        $list_params['category_ids'] = $all_cat_ids;
                    } else {
                        $list_params['category_id'] = $category_id;
                    }
                }

                // Hide out-of-stock products when the setting says so.
                if ( ! SettingsService::bool( 'general', 'catalog_show_out_of_stock', true ) ) {
                    $list_params['stock_status'] = 'in_stock';
                }

                // ── Special filter modes (Just For You landing pages) ──
                if ( $filter_mode === 'hidden-treasures' ) {
                    $now  = titancart_now_utc(); // KI-24: windows stored UTC; compare UTC, not MySQL-tz NOW().
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            "SELECT pc.value
                             FROM {$p}promotion_conditions pc
                             INNER JOIN {$p}promotions pr ON pr.id = pc.promotion_id
                             WHERE pr.type = 'cart' AND pr.status = 'active'
                               AND pc.type = 'products'
                               AND (pr.valid_from IS NULL OR pr.valid_from <= %s)
                               AND (pr.valid_to   IS NULL OR pr.valid_to   >= %s)",
                            $now,
                            $now
                        )
                    );
                    $promo_ids = [];
                    foreach ( $rows as $row ) {
                        $val = json_decode( $row->value, true );
                        if ( ! empty( $val['ids'] ) && is_array( $val['ids'] ) ) {
                            $promo_ids = array_merge( $promo_ids, array_map( 'intval', $val['ids'] ) );
                        }
                    }
                    $promo_ids = array_unique( array_filter( $promo_ids ) );
                    if ( ! empty( $promo_ids ) ) {
                        $list_params['ids'] = $promo_ids;
                    } else {
                        $list_params['ids'] = [ 0 ]; // Force empty result.
                    }
                } elseif ( $filter_mode === 'biggest-deals' ) {
                    $list_params['on_sale']    = true;
                    $list_params['sort_by']    = 'discount';
                    $list_params['sort_order'] = 'desc';
                }

                // roadmap #15: collapse single-display groups to one card in the grid.
                $list_params['hide_single_group_members'] = true;

                $repo     = new \TitanCart\Modules\Products\ProductRepository();
                $result   = $repo->get_list( $list_params, $lang );
                $products = $result['products'];
                $total    = $result['params']['total_items'];

                // Batch-load features for list view (single query, avoid N+1).
                $pids = array_column( $products, 'id' );
                if ( ! empty( $pids ) ) {
                    // WP.org R2 §8: generated-placeholder prepare() — one %d per id,
                    // %s for the language code; nothing is string-interpolated.
                    // Roadmap #11: ORDER BY honours each attribute's sort_mode (FeatureValueOrder).
                    $pids_int = array_map( 'intval', $pids );
                    $pids_ph  = implode( ',', array_fill( 0, count( $pids_int ), '%d' ) );
                    $fvo_order = \TitanCart\Modules\Products\FeatureValueOrder::clause( 'f', 'fvar', 'vt' );
                    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $p = $wpdb->prefix . constant; $pids_ph is a generated %d list whose count matches the spread values.
                    $feat_rows = $wpdb->get_results( $wpdb->prepare(
                        "SELECT fv.product_id, ft.name AS feature_name,
                                COALESCE(vt.name, fv.value_str, fv.value_text) AS feature_value,
                                f.filter_type AS filter_type, fvar.color_hex AS color_hex
                         FROM {$p}product_feature_values fv
                         INNER JOIN {$p}product_features f ON f.id = fv.feature_id
                            AND f.filter_type <> 'none'
                            AND (
                                f.purpose IN ('filter','info')
                                OR ( f.purpose IN ('variation_shared','variation_split') AND f.filter_type = 'color' )
                            )
                         LEFT JOIN {$p}product_feature_translations ft ON ft.feature_id = fv.feature_id AND ft.language_code = %s
                         LEFT JOIN {$p}product_feature_variant_translations vt ON vt.variant_id = fv.variant_id AND vt.language_code = %s
                         LEFT JOIN {$p}product_feature_variants fvar ON fvar.id = fv.variant_id
                         WHERE fv.product_id IN ({$pids_ph})
                         ORDER BY fv.product_id ASC, f.position ASC, {$fvo_order}",
                        ...array_merge( [ $lang, $lang ], $pids_int )
                    ) );
                    // phpcs:enable
                    $feat_map = [];
                    foreach ( $feat_rows as $row ) {
                        $fn = $row->feature_name ?: '';
                        $fv = $row->feature_value ?: '';
                        if ( $fn && $fv ) {
                            $feat_map[ (int) $row->product_id ][] = [
                                'name'         => $fn,
                                'variant_name' => $fv,
                                'filter_type'  => $row->filter_type ?: 'checkbox',
                                'color_hex'    => $row->color_hex ?: '',
                            ];
                        }
                    }
                    foreach ( $products as &$_p ) {
                        $_p['features'] = array_slice( $feat_map[ (int) $_p['id'] ] ?? [], 0, 6 );
                    }
                    unset( $_p );
                }

                $category = null;
                if ( $category_id ) {
                    $category = $wpdb->get_row( $wpdb->prepare(
                        "SELECT c.*, t.name, t.description FROM {$p}categories c LEFT JOIN {$p}category_translations t ON t.category_id = c.id AND t.language_code = %s WHERE c.id = %d LIMIT 1",
                        $lang, $category_id
                    ) );
                }

                return [
                    'products'       => $products,
                    'category'       => $category,
                    'category_id'    => $category_id,
                    'filter_mode'    => $filter_mode,
                    'active_filters' => $active_filters,
                    'lang'           => $lang,
                    'params'         => [
                        'page'        => $page,
                        'per_page'    => $per_page,
                        'total_items' => $total,
                        'total_pages' => (int) ceil( $total / $per_page ),
                    ],
                ];

            // ── Tag archive ───────────────────────────────────────────────────
            // Re-uses templates/shop/archive.php with a $tag context object.
            // Resolves slug → tag → list of tagged product IDs, then defers to
            // ProductRepository::get_list() with the existing 'ids' filter so
            // sort / price-range / rating / on-sale / search filters compose
            // naturally on top of the tag scope. S123 — Tags B7 frontend gap.
            case 'tc_tag':
                $tag_slug = get_query_var( 'tc_tag' );
                $tag      = null;
                $tag_product_ids = [];

                if ( $tag_slug ) {
                    $tag_row = $wpdb->get_row( $wpdb->prepare(
                        "SELECT id, name, slug, product_count FROM {$p}tags WHERE slug = %s LIMIT 1",
                        sanitize_text_field( $tag_slug )
                    ) );
                    if ( $tag_row ) {
                        $tag = $tag_row;
                        // Pull all product IDs associated with this tag.
                        // Bounded by tag.product_count which is recounted on every
                        // sync, so this list cannot grow unboundedly.
                        $tag_product_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
                            "SELECT pt.product_id
                             FROM {$p}product_tags pt
                             INNER JOIN {$p}products p ON p.id = pt.product_id
                             WHERE pt.tag_id = %d
                               AND p.status = 'active'
                               AND p.deleted_at IS NULL",
                            (int) $tag_row->id
                        ) ) );
                    } else {
                        // Slug doesn't resolve — synthesize a stub object so the
                        // archive page can still show "Tag: {slug}" in the
                        // breadcrumb and an empty grid, rather than silently
                        // falling back to the Shop page. (S123)
                        $tag = (object) [
                            'id'            => 0,
                            'name'          => sanitize_text_field( $tag_slug ),
                            'slug'          => sanitize_text_field( $tag_slug ),
                            'product_count' => 0,
                        ];
                    }
                }

                $page     = max( 1, absint( wp_unslash( $_GET['tc_page'] ?? 1 ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- idempotent GET view-state (pagination), see reviewer notes §6.
                $per_page = SettingsService::int( 'appearance', 'products_per_page', 24 );

                // No matching tag, or tag has zero products → render an empty grid.
                // archive.php handles the "no results" state already.
                if ( ! $tag || empty( $tag_product_ids ) ) {
                    return [
                        'products'       => [],
                        'category'       => null,
                        'category_id'    => 0,
                        'tag'            => $tag, // stub object (id=0) for unresolved slugs — breadcrumb still shows slug
                        'filter_mode'    => '',
                        'active_filters' => [
                            'search' => '', 'price_range' => '', 'sort' => '',
                            'min_rating' => '', 'updated_within' => '', 'vendor' => '',
                        ],
                        'lang'           => $lang,
                        'params'         => [
                            'page' => 1, 'per_page' => $per_page,
                            'total_items' => 0, 'total_pages' => 1,
                        ],
                    ];
                }

                // ── Filter params (mirror tc_shop / tc_category) ─────────────
                $list_params = [
                    'status'         => 'active',
                    'parent_id'      => 0,
                    'ids'            => $tag_product_ids,
                    'page'           => $page,
                    'items_per_page' => $per_page,
                    'sort_by'        => 'date',
                    'sort_order'     => 'desc',
                ];

                $active_filters = [
                    'search'         => '',
                    'price_range'    => '',
                    'sort'           => '',
                    'min_rating'     => '',
                    'updated_within' => '',
                    'vendor'         => '',
                ];

                // Search
                $search = sanitize_text_field( wp_unslash( (string) ( $_GET['s'] ?? $_GET['q'] ?? '' ) ) );
                if ( $search !== '' ) {
                    $list_params['search']    = $search;
                    $active_filters['search'] = $search;
                }

                // Price range
                $price_range = sanitize_text_field( wp_unslash( (string) ( $_GET['price_range'] ?? '' ) ) );
                if ( $price_range !== '' && preg_match( '/^(\d+)-(\d*)$/', $price_range, $m ) ) {
                    $pmin = (float) $m[1];
                    $pmax = $m[2] !== '' ? (float) $m[2] : null;
                    if ( $pmin > 0 ) {
                        $list_params['price_min'] = $pmin;
                    }
                    if ( $pmax !== null && $pmax > 0 ) {
                        $list_params['price_max'] = $pmax;
                    }
                    $active_filters['price_range'] = $price_range;
                }

                // Sort
                $sort_param = sanitize_text_field( wp_unslash( (string) ( $_GET['sort'] ?? '' ) ) );
                if ( $sort_param !== '' && strpos( $sort_param, '-' ) !== false ) {
                    [ $sort_field, $sort_direction ] = explode( '-', $sort_param, 2 );
                    $allowed_fields = [ 'name', 'price', 'position', 'date', 'sales', 'rating', 'discount' ];
                    $allowed_dirs   = [ 'asc', 'desc' ];
                    if ( in_array( $sort_field, $allowed_fields, true ) && in_array( $sort_direction, $allowed_dirs, true ) ) {
                        $list_params['sort_by']    = $sort_field;
                        $list_params['sort_order'] = $sort_direction;
                        $active_filters['sort']    = $sort_param;
                    }
                }

                // Min rating
                $min_rating = (float) sanitize_text_field( wp_unslash( $_GET['min_rating'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- idempotent GET view-state (filter), see reviewer notes §6.
                if ( $min_rating > 0 && $min_rating <= 5 ) {
                    $list_params['min_rating']    = $min_rating;
                    $active_filters['min_rating'] = (string) $min_rating;
                }

                // Updated within
                $updated_within = sanitize_key( $_GET['updated_within'] ?? '' );
                $updated_map    = [ 'day' => 1, 'week' => 7, 'month' => 30, 'year' => 365 ];
                if ( isset( $updated_map[ $updated_within ] ) ) {
                    $list_params['updated_within_days'] = $updated_map[ $updated_within ];
                    $active_filters['updated_within']   = $updated_within;
                }

                // Vendor — UI submits slug. Same passthrough contract as the
                // tc_shop / tc_category case (L307+). Marketplace's listener
                // at `titancart_products_list_query` resolves slug → vendor_id
                // and injects the JOIN. Non-existent / non-active slugs are
                // no-ops. Lets `/tag/{slug}/?vendor=test-vendor` work for users
                // who construct the URL manually even though the tag archive
                // doesn't (yet) render a vendor facet UI.
                //
                // @since S198 (Track B Module B8 close — filter contract parity).
                $vendor_slug = sanitize_title( wp_unslash( (string) ( $_GET['vendor'] ?? '' ) ) );
                if ( $vendor_slug !== '' ) {
                    $list_params['vendor_slug'] = $vendor_slug;
                    $active_filters['vendor']   = $vendor_slug;
                }

                // Hide out-of-stock when setting says so.
                if ( ! SettingsService::bool( 'general', 'catalog_show_out_of_stock', true ) ) {
                    $list_params['stock_status'] = 'in_stock';
                }

                // roadmap #15: collapse single-display groups to one card in the grid.
                $list_params['hide_single_group_members'] = true;

                $repo     = new \TitanCart\Modules\Products\ProductRepository();
                $result   = $repo->get_list( $list_params, $lang );
                $products = $result['products'];
                $total    = $result['params']['total_items'];

                // Batch-load list-view features (mirrors tc_shop case to avoid N+1).
                $pids = array_column( $products, 'id' );
                if ( ! empty( $pids ) ) {
                    // WP.org R2 §8: generated-placeholder prepare() (twin of the shop-archive site above).
                    // Roadmap #11: ORDER BY honours each attribute's sort_mode (FeatureValueOrder).
                    $pids_int = array_map( 'intval', $pids );
                    $pids_ph  = implode( ',', array_fill( 0, count( $pids_int ), '%d' ) );
                    $fvo_order = \TitanCart\Modules\Products\FeatureValueOrder::clause( 'f', 'fvar', 'vt' );
                    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $p = $wpdb->prefix . constant; $pids_ph is a generated %d list whose count matches the spread values.
                    $feat_rows = $wpdb->get_results( $wpdb->prepare(
                        "SELECT fv.product_id, ft.name AS feature_name,
                                COALESCE(vt.name, fv.value_str, fv.value_text) AS feature_value,
                                f.filter_type AS filter_type, fvar.color_hex AS color_hex
                         FROM {$p}product_feature_values fv
                         INNER JOIN {$p}product_features f ON f.id = fv.feature_id
                            AND f.filter_type <> 'none'
                            AND (
                                f.purpose IN ('filter','info')
                                OR ( f.purpose IN ('variation_shared','variation_split') AND f.filter_type = 'color' )
                            )
                         LEFT JOIN {$p}product_feature_translations ft ON ft.feature_id = fv.feature_id AND ft.language_code = %s
                         LEFT JOIN {$p}product_feature_variant_translations vt ON vt.variant_id = fv.variant_id AND vt.language_code = %s
                         LEFT JOIN {$p}product_feature_variants fvar ON fvar.id = fv.variant_id
                         WHERE fv.product_id IN ({$pids_ph})
                         ORDER BY fv.product_id ASC, f.position ASC, {$fvo_order}",
                        ...array_merge( [ $lang, $lang ], $pids_int )
                    ) );
                    // phpcs:enable
                    $feat_map = [];
                    foreach ( $feat_rows as $row ) {
                        $fn = $row->feature_name ?: '';
                        $fv = $row->feature_value ?: '';
                        if ( $fn && $fv ) {
                            $feat_map[ (int) $row->product_id ][] = [
                                'name'         => $fn,
                                'variant_name' => $fv,
                                'filter_type'  => $row->filter_type ?: 'checkbox',
                                'color_hex'    => $row->color_hex ?: '',
                            ];
                        }
                    }
                    foreach ( $products as &$_p ) {
                        $_p['features'] = array_slice( $feat_map[ (int) $_p['id'] ] ?? [], 0, 6 );
                    }
                    unset( $_p );
                }

                return [
                    'products'       => $products,
                    'category'       => null,
                    'category_id'    => 0,
                    'tag'            => $tag,
                    'filter_mode'    => '',
                    'active_filters' => $active_filters,
                    'lang'           => $lang,
                    'params'         => [
                        'page'        => $page,
                        'per_page'    => $per_page,
                        'total_items' => $total,
                        'total_pages' => $total > 0 ? (int) ceil( $total / $per_page ) : 1,
                    ],
                ];

            // ── Single product ────────────────────────────────────────────────
            case 'tc_product':
                $slug    = get_query_var( 'tc_product' );
                $product = null;
                if ( $slug ) {
                    $repo = new \TitanCart\Modules\Products\ProductRepository();
                    global $wpdb;
                    $product_id = (int) $wpdb->get_var( $wpdb->prepare(
                        "SELECT p.id
                           FROM {$p}products p
                           WHERE p.slug = %s AND p.status = 'active' AND p.deleted_at IS NULL
                           LIMIT 1",
                        $slug
                    ) );
                    if ( $product_id ) {
                        $product = $repo->get( $product_id, $lang );

                        // S141 — topologically sort options so Sequential
                        // Options dependents always render after their parents
                        // on the PDP. Admin-configured position remains the
                        // stable tiebreaker for options with no dep
                        // relationship. Storefront-only — admin SPA + REST API
                        // still receive position-ordered options.
                        if ( ! empty( $product['options'] ) ) {
                            $product['options'] = $repo->topo_sort_options_for_storefront( $product['options'] );
                        }

                        // Reviews summary
                        $product['review_count'] = (int) $wpdb->get_var( $wpdb->prepare(
                            "SELECT COUNT(*) FROM {$p}reviews WHERE product_id = %d AND status = 'approved'",
                            $product_id
                        ) );
                        $product['review_avg'] = (float) $wpdb->get_var( $wpdb->prepare(
                            "SELECT AVG(rating) FROM {$p}reviews WHERE product_id = %d AND status = 'approved'",
                            $product_id
                        ) );
                        $raw_reviews = $wpdb->get_results( $wpdb->prepare(
                            "SELECT r.id, r.rating, r.title, r.body, r.customer_id, r.created_at
                             FROM {$p}reviews r
                             WHERE r.product_id = %d AND r.status = 'approved'
                             ORDER BY r.created_at DESC LIMIT 5",
                            $product_id
                        ), ARRAY_A ) ?: [];
                        // Enrich with customer display name
                        $product['reviews'] = array_map( function( $r ) {
                            $name = 'Anonymous';
                            if ( ! empty( $r['customer_id'] ) ) {
                                $user = get_userdata( (int) $r['customer_id'] );
                                if ( $user ) $name = $user->display_name ?: $user->user_login;
                            }
                            $r['name']    = $name;
                            $r['content'] = $r['body'] ?? '';
                            return $r;
                        }, $raw_reviews );
                        // Rating distribution
                        $product['review_dist'] = [];
                        for ( $star = 5; $star >= 1; $star-- ) {
                            $product['review_dist'][ $star ] = (int) $wpdb->get_var( $wpdb->prepare(
                                "SELECT COUNT(*) FROM {$p}reviews WHERE product_id = %d AND status = 'approved' AND rating = %d",
                                $product_id, $star
                            ) );
                        }

                        // Category breadcrumb — walk up parent chain from primary category
                        $cat_ids = $product['categories'] ?? [];
                        $product['category_breadcrumb'] = [];
                        if ( ! empty( $cat_ids ) ) {
                            $cat_id = is_array( $cat_ids[0] ) ? (int) $cat_ids[0]['category_id'] : (int) $cat_ids[0];
                            $chain = [];
                            $walk_id = $cat_id;
                            $max_depth = 10; // safety limit
                            while ( $walk_id > 0 && $max_depth-- > 0 ) {
                                $cat = $wpdb->get_row( $wpdb->prepare(
                                    "SELECT c.id, c.parent_id, c.slug, ct.name FROM {$p}categories c LEFT JOIN {$p}category_translations ct ON ct.category_id = c.id AND ct.language_code = %s WHERE c.id = %d AND c.deleted_at IS NULL LIMIT 1",
                                    $lang, $walk_id
                                ) );
                                if ( ! $cat || ! $cat->name ) break;
                                array_unshift( $chain, [
                                    'name' => $cat->name,
                                    'url'  => SeoService::category_url( $cat->slug ),
                                ] );
                                $walk_id = (int) $cat->parent_id;
                            }
                            $product['category_breadcrumb'] = $chain;
                        }

                        // ── Product Tabs (S125 — wires Custom Tabs B10) ─────
                        // Resolve the tab list from tc_product_tabs honoring:
                        //   - global tabs (product_id IS NULL) with their default status
                        //   - per-product overrides flipping status active↔disabled
                        //     and optionally replacing richtext content
                        //   - product-specific richtext tabs (product_id = this product)
                        //   - admin position-based ordering (mixing globals and per-product)
                        //   - extension/theme registration via filter
                        //
                        // Built-in tabs (description / attributes / reviews) do not
                        // carry their content here — the template renders them from
                        // existing data ($desc, $features, reviews payload). The
                        // sizing_chart tab carries no content either; chart data is
                        // loaded into $product['sizing_chart'] (preserved name) so
                        // the existing template extraction `$sizing = ...['sizing_chart']`
                        // still works.
                        //
                        // Disabled tabs (effective status) are dropped here so the
                        // template never sees them. Empty-content auto-hide for
                        // built-ins / sizing_chart is handled in the template since
                        // it has access to the data sources.
                        $product['tabs']         = [];
                        $product['sizing_chart'] = null;

                        $tab_globals = $wpdb->get_results( $wpdb->prepare(
                            "SELECT t.id, t.tab_type, t.tab_key, t.template_path, t.position, t.status, t.is_primary, tr.title, tr.content
                               FROM {$p}product_tabs t
                          LEFT JOIN {$p}product_tab_translations tr
                                 ON tr.tab_id = t.id AND tr.language_code = %s
                              WHERE t.product_id IS NULL
                           ORDER BY t.position ASC",
                            $lang
                        ), ARRAY_A ) ?: [];

                        $tab_override_rows = $wpdb->get_results( $wpdb->prepare(
                            "SELECT tab_id, status, content_override
                               FROM {$p}product_tab_overrides
                              WHERE product_id = %d",
                            $product_id
                        ), ARRAY_A ) ?: [];
                        $tab_overrides = [];
                        foreach ( $tab_override_rows as $_ov ) {
                            $tab_overrides[ (int) $_ov['tab_id'] ] = $_ov;
                        }

                        $tab_product_specific = $wpdb->get_results( $wpdb->prepare(
                            "SELECT t.id, t.tab_type, t.tab_key, t.template_path, t.position, t.status, t.is_primary, tr.title, tr.content
                               FROM {$p}product_tabs t
                          LEFT JOIN {$p}product_tab_translations tr
                                 ON tr.tab_id = t.id AND tr.language_code = %s
                              WHERE t.product_id = %d
                           ORDER BY t.position ASC",
                            $lang, $product_id
                        ), ARRAY_A ) ?: [];

                        $tabs_resolved = [];

                        foreach ( $tab_globals as $_g ) {
                            $_tid     = (int) $_g['id'];
                            $_has_ov  = isset( $tab_overrides[ $_tid ] );
                            $_status  = $_has_ov ? $tab_overrides[ $_tid ]['status'] : $_g['status'];
                            if ( $_status !== 'active' ) continue;

                            $_content = $_g['content'] ?? '';
                            if ( $_has_ov ) {
                                $_co = $tab_overrides[ $_tid ]['content_override'];
                                if ( $_co !== null && $_co !== '' ) {
                                    $_content = $_co;
                                }
                            }

                            $tabs_resolved[] = [
                                'id'            => $_tid,
                                'tab_type'      => $_g['tab_type'],
                                'tab_key'       => $_g['tab_key'],
                                'template_path' => $_g['template_path'],
                                'position'      => (int) $_g['position'],
                                'is_primary'    => (int) $_g['is_primary'],
                                'title'         => ! empty( $_g['title'] )
                                    ? $_g['title']
                                    : ucfirst( str_replace( '_', ' ', (string) $_g['tab_key'] ) ),
                                'content'       => $_content,
                                'scope'         => 'global',
                            ];
                        }

                        foreach ( $tab_product_specific as $_ps ) {
                            if ( ( $_ps['status'] ?? '' ) !== 'active' ) continue;
                            $_psk = $_ps['tab_key'] ?: ( 'tab_' . (int) $_ps['id'] );
                            $tabs_resolved[] = [
                                'id'            => (int) $_ps['id'],
                                'tab_type'      => $_ps['tab_type'],
                                'tab_key'       => $_psk,
                                'template_path' => $_ps['template_path'],
                                'position'      => (int) $_ps['position'],
                                'is_primary'    => 0,
                                'title'         => ! empty( $_ps['title'] )
                                    ? $_ps['title']
                                    : __( 'Untitled', 'titancart-lite' ),
                                'content'       => $_ps['content'] ?? '',
                                'scope'         => 'product',
                            ];
                        }

                        // Sort by position. PHP 8.0+ usort is stable.
                        usort( $tabs_resolved, function( $a, $b ) {
                            return ( (int) ( $a['position'] ?? 0 ) ) <=> ( (int) ( $b['position'] ?? 0 ) );
                        } );

                        // Load sizing_chart data only if a sizing_chart tab survived.
                        $_has_sizing_tab = false;
                        foreach ( $tabs_resolved as $_t ) {
                            if ( ( $_t['tab_type'] ?? '' ) === 'sizing_chart' || ( $_t['tab_key'] ?? '' ) === 'sizing_chart' ) {
                                $_has_sizing_tab = true;
                                break;
                            }
                        }
                        if ( $_has_sizing_tab ) {
                            $sz_row = $wpdb->get_row( $wpdb->prepare(
                                "SELECT chart_data, notes FROM {$p}product_sizing_charts WHERE product_id = %d LIMIT 1",
                                $product_id
                            ) );
                            if ( $sz_row ) {
                                $product['sizing_chart'] = [
                                    'data'  => json_decode( $sz_row->chart_data, true ),
                                    'notes' => $sz_row->notes,
                                ];
                            }
                        }

                        // Filter hook for themes/extensions to add or modify tabs.
                        // Receives the resolved (active-only, sorted) list and the
                        // product id. Listeners returning malformed entries will
                        // be silently dropped by the template's own validation.
                        $product['tabs'] = \apply_filters(
                            'titancart_product_tabs_storefront',
                            $tabs_resolved,
                            $product_id
                        );

                        // ── Product Tags ─────────────────────────────────────
                        // Load full tag rows (id/name/slug) so the PDP can render
                        // a clickable chip strip linking to /product-tag/{slug}/.
                        // S123 — wires Tags B7 frontend gap from the matrix.
                        $product['tags'] = $wpdb->get_results( $wpdb->prepare(
                            "SELECT t.id, t.name, t.slug
                             FROM {$p}tags t
                             INNER JOIN {$p}product_tags pt ON pt.tag_id = t.id
                             WHERE pt.product_id = %d
                             ORDER BY t.name ASC",
                            $product_id
                        ), ARRAY_A ) ?: [];

                        // ── Product Bundles (S126 — wires Bundles B12) ──────
                        // Resolve "Buy Together" bundles that contain this product.
                        // Honors:
                        //   - bundle status='active'
                        //   - bundle date window (date_from <= NOW <= date_to,
                        //     either bound NULL = open-ended)
                        //   - bundle item show_on_page=1 (display + cart-add filter)
                        //   - included product status='active' (deleted/disabled
                        //     products silently drop out)
                        //
                        // For each kept item we compute the per-item discounted
                        // price and line total using the same math as
                        // BundlesController::load_bundles_by_ids so admin and
                        // storefront stay numerically aligned. Bundle totals
                        // (full / cost / savings) reflect ONLY the displayed
                        // items so "Price for all" matches what gets added to
                        // cart when "Add all to cart" is clicked.
                        //
                        // The render is gated on the bundle having >=1 valid
                        // displayable item. A bundle whose only displayable
                        // items are dead products produces no widget — the
                        // template's `if ( ! empty( $bundles ) )` skip handles
                        // that case naturally.
                        //
                        // Filter hook `titancart_product_bundles_storefront`
                        // lets themes / extensions append, modify, or drop
                        // entries before render. Receives the resolved list
                        // and product id.
                        $product['bundles'] = [];

                        // Product Bundles is a toggle-gated extension (Pro) — only
                        // resolve + render bundles when the `bundles` extension is
                        // active (KI-35, S352: "anything not gated to a switch should
                        // be"). On Lite (where bundles is Pro) is_active() fail-closes,
                        // so leftover bundles never display or add to cart from here.
                        // An empty $bundle_id_rows short-circuits the whole resolution
                        // below, leaving $product['bundles'] = [] (normal "no bundles").
                        $now = titancart_now_utc(); // KI-24: bundle windows stored UTC; compare UTC, not MySQL-tz NOW().
                        $bundle_id_rows = ExtensionsController::is_active( 'bundles' )
                            ? $wpdb->get_results( $wpdb->prepare(
                            "SELECT DISTINCT b.id
                               FROM {$p}product_bundles b
                              INNER JOIN {$p}product_bundle_items bi ON bi.bundle_id = b.id
                              WHERE bi.product_id = %d
                                AND b.status = 'active'
                                AND ( b.date_from IS NULL OR b.date_from <= %s )
                                AND ( b.date_to   IS NULL OR b.date_to   >= %s )",
                            $product_id,
                            $now,
                            $now
                        ) )
                            : [];

                        if ( ! empty( $bundle_id_rows ) ) {
                            $bundle_ids = array_map( function( $r ) { return (int) $r->id; }, $bundle_id_rows );
                            // WP.org R2 §8: generated %d placeholders, ids bound via prepare().
                            $bid_in     = implode( ',', array_fill( 0, count( $bundle_ids ), '%d' ) );
                            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $p = prefix . constant; $bid_in / $pid_in are generated %d lists whose counts match the spread values.

                            // Bundle headers with translated name/description.
                            $bundle_rows = $wpdb->get_results( $wpdb->prepare(
                                "SELECT b.id, b.status, b.date_from, b.date_to, bt.name, bt.description
                                   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 IN ({$bid_in})
                               ORDER BY b.created_at DESC",
                                ...array_merge( [ $lang ], $bundle_ids )
                            ), ARRAY_A ) ?: [];

                            // Items for those bundles. Inner-join on products
                            // (drops dead products), filter to active only and
                            // show_on_page=1.
                            $bundle_item_rows = $wpdb->get_results( $wpdb->prepare(
                                "SELECT bi.id            AS item_id,
                                        bi.bundle_id,
                                        bi.product_id,
                                        bi.quantity,
                                        bi.discount_type,
                                        bi.discount_value,
                                        bi.position,
                                        p.price          AS product_price,
                                        p.list_price     AS product_list_price,
                                        p.slug           AS product_slug,
                                        p.sku            AS product_sku,
                                        pt.name          AS product_name
                                   FROM {$p}product_bundle_items bi
                              INNER JOIN {$p}products p
                                     ON p.id = bi.product_id
                                    AND p.status = 'active'
                              LEFT JOIN {$p}product_translations pt
                                     ON pt.product_id = bi.product_id
                                    AND pt.language_code = %s
                                  WHERE bi.bundle_id IN ({$bid_in})
                                    AND bi.show_on_page = 1
                               ORDER BY bi.bundle_id ASC, bi.position ASC",
                                ...array_merge( [ $lang ], $bundle_ids )
                            ), ARRAY_A ) ?: [];

                            // Resolve main image url per product_id (single
                            // pass — avoid N+1 queries inside the items loop).
                            $bundle_product_ids = array_map( function( $r ) { return (int) $r['product_id']; }, $bundle_item_rows );
                            $bundle_product_ids = array_values( array_unique( $bundle_product_ids ) );
                            $bundle_image_map   = []; // product_id => url

                            if ( ! empty( $bundle_product_ids ) ) {
                                // WP.org R2 §8: generated %d placeholders, ids bound via prepare().
                                $pid_in    = implode( ',', array_fill( 0, count( $bundle_product_ids ), '%d' ) );
                                $img_rows  = $wpdb->get_results( $wpdb->prepare(
                                    "SELECT pi.product_id, pi.wp_attachment_id, pi.is_main, pi.position
                                       FROM {$p}product_images pi
                                      WHERE pi.product_id IN ({$pid_in})
                                   ORDER BY pi.product_id ASC, pi.is_main DESC, pi.position ASC, pi.id ASC",
                                    ...$bundle_product_ids
                                ), ARRAY_A ) ?: [];
                                // phpcs:enable
                                foreach ( $img_rows as $_ir ) {
                                    $_pid = (int) $_ir['product_id'];
                                    if ( isset( $bundle_image_map[ $_pid ] ) ) continue; // first wins (main, then position)
                                    $_url = wp_get_attachment_image_url( (int) $_ir['wp_attachment_id'], 'medium' );
                                    if ( $_url ) {
                                        $bundle_image_map[ $_pid ] = $_url;
                                    }
                                }
                            }

                            // Group items by bundle_id with discount math.
                            $items_by_bundle = [];
                            foreach ( $bundle_item_rows as $_it ) {
                                $_bid     = (int) $_it['bundle_id'];
                                $_pid     = (int) $_it['product_id'];
                                $_qty     = max( 1, (int) $_it['quantity'] );
                                $_price   = (float) $_it['product_price'];
                                $_dtype   = $_it['discount_type'] ?? 'none';
                                $_dval    = (float) $_it['discount_value'];

                                if ( $_dtype === 'percent' && $_dval > 0 ) {
                                    $_discounted = round( $_price * ( 1 - $_dval / 100 ), 2 );
                                } elseif ( $_dtype === 'fixed' && $_dval > 0 ) {
                                    $_discounted = round( max( 0, $_price - $_dval ), 2 );
                                } else {
                                    $_discounted = round( $_price, 2 );
                                }

                                $_url = ! empty( $_it['product_slug'] )
                                    ? SeoService::product_url( $_it['product_slug'] )
                                    : home_url( '/?p=' . $_pid );

                                $items_by_bundle[ $_bid ][] = [
                                    'item_id'         => (int) $_it['item_id'],
                                    'product_id'      => $_pid,
                                    'product_name'    => (string) ( $_it['product_name'] ?? '' ),
                                    'product_sku'     => (string) ( $_it['product_sku'] ?? '' ),
                                    'product_url'     => $_url,
                                    'product_image'   => $bundle_image_map[ $_pid ] ?? '',
                                    'quantity'        => $_qty,
                                    'product_price'   => round( $_price, 2 ),
                                    'discount_type'   => $_dtype,
                                    'discount_value'  => $_dval,
                                    'discounted_price'=> $_discounted,
                                    'line_full'       => round( $_price * $_qty, 2 ),
                                    'line_total'      => round( $_discounted * $_qty, 2 ),
                                    'is_self'         => ( $_pid === $product_id ),
                                ];
                            }

                            // Assemble final list. Drop bundles whose only
                            // surviving displayable items are gone (e.g. all
                            // products deleted). Single-item bundles still
                            // render fine — the template handles the +-strip
                            // gracefully when count=1.
                            $bundles_resolved = [];
                            foreach ( $bundle_rows as $_b ) {
                                $_bid   = (int) $_b['id'];
                                $_items = $items_by_bundle[ $_bid ] ?? [];
                                if ( empty( $_items ) ) continue;

                                $_full = 0.0;
                                $_cost = 0.0;
                                foreach ( $_items as $_i ) {
                                    $_full += (float) $_i['line_full'];
                                    $_cost += (float) $_i['line_total'];
                                }

                                $bundles_resolved[] = [
                                    'id'            => $_bid,
                                    'name'          => (string) ( $_b['name'] ?? '' )
                                                          ?: __( 'Buy Together', 'titancart-lite' ),
                                    'description'   => (string) ( $_b['description'] ?? '' ),
                                    'items'         => $_items,
                                    'total_full'    => round( $_full, 2 ),
                                    'total_cost'    => round( $_cost, 2 ),
                                    'total_savings' => round( $_full - $_cost, 2 ),
                                    'item_count'    => count( $_items ),
                                ];
                            }

                            $product['bundles'] = \apply_filters(
                                'titancart_product_bundles_storefront',
                                $bundles_resolved,
                                $product_id
                            );
                        }

                        // ── Recommended products: featured first, then same-category fill ──
                        $product['related'] = [];
                        $max_related = 12; // enough for carousel

                        // First: featured products (exclude current, top-level only)
                        $featured_result = $repo->get_list( [
                            'status'         => 'active',
                            'featured'       => true,
                            'parent_id'      => 0,
                            'hide_single_group_members' => true, // roadmap #15
                            'items_per_page' => $max_related + 1,
                            'page'           => 1,
                            'sort_by'        => 'sales',
                            'sort_order'     => 'desc',
                        ], $lang );
                        $featured = array_values( array_filter(
                            $featured_result['products'],
                            function( $rp ) use ( $product_id ) { return (int) $rp['id'] !== $product_id; }
                        ) );
                        $product['related'] = array_slice( $featured, 0, $max_related );

                        // Fill remaining slots with same-category products
                        if ( count( $product['related'] ) < $max_related && ! empty( $cat_ids ) ) {
                            $r_cat = is_array( $cat_ids[0] ) ? (int) $cat_ids[0]['category_id'] : (int) $cat_ids[0];
                            $exclude_ids = array_map( 'intval', array_merge( [ $product_id ], array_column( $product['related'], 'id' ) ) );
                            $fill_result = $repo->get_list( [
                                'status'         => 'active',
                                'category_id'    => $r_cat,
                                'parent_id'      => 0,
                                'hide_single_group_members' => true, // roadmap #15
                                'items_per_page' => $max_related - count( $product['related'] ) + 1,
                                'page'           => 1,
                                'sort_by'        => 'sales',
                                'sort_order'     => 'desc',
                            ], $lang );
                            $fill = array_values( array_filter(
                                $fill_result['products'],
                                function( $rp ) use ( $exclude_ids ) { return ! in_array( (int) $rp['id'], $exclude_ids, true ); }
                            ) );
                            $product['related'] = array_merge(
                                $product['related'],
                                array_slice( $fill, 0, $max_related - count( $product['related'] ) )
                            );
                        }

                        // Final fallback: any active products if still under quota
                        if ( count( $product['related'] ) < $max_related ) {
                            $exclude_ids = array_map( 'intval', array_merge( [ $product_id ], array_column( $product['related'], 'id' ) ) );
                            $any_result = $repo->get_list( [
                                'status'         => 'active',
                                'parent_id'      => 0,
                                'hide_single_group_members' => true, // roadmap #15
                                'items_per_page' => $max_related - count( $product['related'] ) + count( $exclude_ids ),
                                'page'           => 1,
                                'sort_by'        => 'sales',
                                'sort_order'     => 'desc',
                            ], $lang );
                            $any = array_values( array_filter(
                                $any_result['products'],
                                function( $rp ) use ( $exclude_ids ) { return ! in_array( (int) $rp['id'], $exclude_ids, true ); }
                            ) );
                            $product['related'] = array_merge(
                                $product['related'],
                                array_slice( $any, 0, $max_related - count( $product['related'] ) )
                            );
                        }

                        // ── Product Group / Variations ────────────────────────
                        $product['variation_group'] = null;
                        $gp_row = $wpdb->get_row( $wpdb->prepare(
                            "SELECT group_id, combination FROM {$p}product_group_products WHERE product_id = %d LIMIT 1",
                            $product_id
                        ) );
                        if ( $gp_row ) {
                            $group_id   = (int) $gp_row->group_id;
                            $group      = $wpdb->get_row( $wpdb->prepare(
                                "SELECT id, group_code, feature_ids FROM {$p}product_groups WHERE id = %d", $group_id
                            ) );
                            $feature_ids = json_decode( $group->feature_ids ?? '[]', true ) ?: [];

                            // Build axes: for each feature, get name + all unique variants across group members
                            $axes = [];
                            foreach ( $feature_ids as $fid ) {
                                $feat_row = $wpdb->get_row( $wpdb->prepare(
                                    "SELECT type, filter_type FROM {$p}product_features WHERE id = %d AND status = 'active'", $fid
                                ) );
                                // Skip deleted / missing features.
                                if ( ! $feat_row ) continue;

                                $fname = $wpdb->get_var( $wpdb->prepare(
                                    "SELECT name FROM {$p}product_feature_translations WHERE feature_id = %d AND language_code = %s",
                                    $fid, $lang
                                ) );

                                $axes[] = [
                                    'feature_id'  => (int) $fid,
                                    'name'        => $fname ?: 'Attribute #' . $fid,
                                    'type'        => $feat_row->type ?? 'S',
                                    'filter_type' => $feat_row->filter_type ?? 'checkbox',
                                    'variants'    => [], // populated below
                                ];
                            }

                            // Load all group members with key data
                            $members_raw = $wpdb->get_results( $wpdb->prepare(
                                "SELECT gp.product_id, gp.combination, gp.position
                                 FROM {$p}product_group_products gp
                                 WHERE gp.group_id = %d
                                 ORDER BY gp.position ASC",
                                $group_id
                            ), ARRAY_A );

                            $members = [];
                            $variant_ids_seen = [];
                            foreach ( $members_raw as $m ) {
                                $mid   = (int) $m['product_id'];
                                $combo = json_decode( $m['combination'], true ) ?: [];

                                // Minimal product data for JS switching
                                $mp = $repo->get( $mid, $lang );
                                if ( ! $mp ) continue;

                                $main_img = null;
                                if ( ! empty( $mp['images'] ) ) {
                                    $main_img = $mp['images'][0]['url'] ?? null;
                                }

                                // Catalog-promo-aware member prices (roadmap #4 / KI-08).
                                // Each variation is its own product; decorate at this single
                                // build point so the variant matrix — and single.php's inline
                                // re-select branch — show the viewer's effective price, not the
                                // raw shelf price (otherwise re-clicking the active variant
                                // reverts the S269-decorated headline to the undiscounted price).
                                // On-screen surface -> viewer usergroups (matches the headline).
                                $m_raw_list = (float) ( $mp['list_price'] ?? 0 );
                                // Lite build strips the Promotions module (R1 §🅰,
                                // WPORG-R1-TRIALWARE-STRIP-SPEC §2): fall back to
                                // the base shelf price when the service is absent.
                                if ( class_exists( \TitanCart\Modules\Promotions\CatalogPromotionService::class ) ) {
                                    $m_eff = \TitanCart\Modules\Promotions\CatalogPromotionService::instance()
                                        ->effective_price_for_viewer( $mid, 1, [
                                            'price'      => (float) ( $mp['price'] ?? 0 ),
                                            'list_price' => $m_raw_list,
                                        ] );
                                } else {
                                    $m_eff = [
                                        'price'      => (float) ( $mp['price'] ?? 0 ),
                                        'on_sale'    => false,
                                        'compare_at' => $m_raw_list,
                                    ];
                                }

                                $members[] = [
                                    'product_id'   => $mid,
                                    'combination'  => $combo,
                                    'price'        => (float) $m_eff['price'],
                                    'list_price'   => $m_eff['on_sale'] ? (float) $m_eff['compare_at'] : $m_raw_list,
                                    'stock_status' => $mp['stock_status'] ?? 'in_stock',
                                    'sku'          => $mp['sku'] ?? '',
                                    'name'         => $mp['name'] ?? '',
                                    'slug'         => $mp['slug'] ?? '',
                                    'image'        => $main_img,
                                    'url'          => SeoService::product_url( $mp['slug'] ?? $mid ),
                                ];

                                // Collect unique variant IDs per axis
                                foreach ( $combo as $fid => $vid ) {
                                    $variant_ids_seen[ (int) $fid ][ (int) $vid ] = true;
                                }
                            }

                            // Resolve variant names + color_hex for each axis
                            foreach ( $axes as &$axis ) {
                                $fid = $axis['feature_id'];
                                $vids = array_keys( $variant_ids_seen[ $fid ] ?? [] );
                                if ( empty( $vids ) ) continue;

                                // WP.org R2 §8: generated %d placeholders + %s lang, all bound via prepare().
                                $vids_int = array_map( 'intval', $vids );
                                $vids_ph  = implode( ',', array_fill( 0, count( $vids_int ), '%d' ) );
                                $fvo_order = \TitanCart\Modules\Products\FeatureValueOrder::clause( 'fsm', 'v', 'vt' );
                                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $p = prefix . constant; $vids_ph is a generated %d list whose count matches the spread values; $fvo_order is a code-generated ORDER BY fragment built from validated internal aliases (no user input), see FeatureValueOrder::clause().
                                $vars = $wpdb->get_results( $wpdb->prepare(
                                    "SELECT v.id, v.color_hex, vt.name
                                     FROM {$p}product_feature_variants v
                                     INNER JOIN {$p}product_features fsm ON fsm.id = v.feature_id
                                     LEFT JOIN {$p}product_feature_variant_translations vt
                                        ON vt.variant_id = v.id AND vt.language_code = %s
                                     WHERE v.id IN ({$vids_ph})
                                     ORDER BY {$fvo_order}",
                                    ...array_merge( [ $lang ], $vids_int )
                                ), ARRAY_A );
                                // phpcs:enable
                                $axis['variants'] = array_map( function( $v ) {
                                    return [
                                        'id'        => (int) $v['id'],
                                        'name'      => $v['name'] ?: 'Variant #' . $v['id'],
                                        'color_hex' => $v['color_hex'] ?: null,
                                    ];
                                }, $vars );
                            }
                            unset( $axis );

                            // Current product's combination
                            $current_combo = json_decode( $gp_row->combination, true ) ?: [];

                            $product['variation_group'] = [
                                'group_id'      => $group_id,
                                'feature_ids'   => $feature_ids,
                                'axes'          => $axes,
                                'members'       => $members,
                                'current_combo' => $current_combo,
                            ];
                        }
                    }
                }
                if ( ! $product ) {
                    // [KI-53] The product slug resolves to no live product. If its slug
                    // was renamed, 301 to the new URL (exits on match); otherwise 404.
                    self::try_seo_redirect();
                    global $wp_query;
                    $wp_query->set_404();
                    status_header( 404 );
                }
                return [ 'product' => $product, 'lang' => $lang ];

            // ── Cart ──────────────────────────────────────────────────────────
            case 'tc_cart':
                $cart_manager = new \TitanCart\Modules\Cart\CartManager();
                $cart = $cart_manager->get_cart( null, $lang );
                return [ 'cart' => $cart, 'lang' => $lang ];

            // ── Checkout ──────────────────────────────────────────────────────
            case 'tc_checkout':
                $checkout_mgr = new \TitanCart\Modules\Checkout\CheckoutManager();
                return [ 'data' => $checkout_mgr->get_checkout_data( $lang ), 'lang' => $lang ];

            // ── Order Received (Thank You) ───────────────────────────────────
            case 'tc_order_received':
                $order_number = strtoupper( sanitize_text_field( get_query_var( 'tc_order_received' ) ) );
                $order_repo   = new \TitanCart\Modules\Checkout\OrderRepository();
                $order        = $order_repo->get_by_number( $order_number );
                $items        = $order ? $order_repo->get_items( (int) $order['id'] ) : [];

                // Resolve status_id → human-readable handle for the template.
                if ( $order && ! empty( $order['status_id'] ) ) {
                    $db    = titancart_db();
                    $srow  = $db->getRow(
                        "SELECT os.handle, COALESCE(ost.name, os.handle) AS status_name
                         FROM {$db->table('order_statuses')} os
                         LEFT JOIN {$db->table('order_status_translations')} ost
                           ON ost.status_id = os.id AND ost.language_code = ?s
                         WHERE os.id = ?i LIMIT 1",
                        $lang,
                        (int) $order['status_id']
                    );
                    $order['status']      = $srow['handle']      ?? 'pending';
                    $order['status_name'] = $srow['status_name'] ?? ucfirst( $order['status'] );
                }

                return [ 'order' => $order ?: [], 'items' => $items ];

            // ── Search Results ────────────────────────────────────────────────
            case 'tc_search':
                $query    = sanitize_text_field( wp_unslash( $_GET['q'] ?? $_GET['s'] ?? '' ) );
                $page     = max( 1, absint( wp_unslash( $_GET['tc_page'] ?? 1 ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- idempotent GET view-state (pagination), see reviewer notes §6.
                $per_page = SettingsService::int( 'appearance', 'products_per_page', 24 );

                $products = [];
                $params   = [ 'page' => $page, 'total_items' => 0, 'total_pages' => 1 ];

                if ( $query !== '' ) {
                    $repo   = new \TitanCart\Modules\Products\ProductRepository();
                    $search_params = [
                        'status'         => 'active',
                        'search'         => $query,
                        'page'           => $page,
                        'items_per_page' => $per_page,
                        'sort_by'        => 'relevance',
                        'sort_order'     => 'desc',
                    ];
                    if ( ! SettingsService::bool( 'general', 'catalog_show_out_of_stock', true ) ) {
                        $search_params['stock_status'] = 'in_stock';
                    }
                    $search_params['hide_single_group_members'] = true; // roadmap #15
                    $result = $repo->get_list( $search_params, $lang );
                    $products = $result['products'];
                    $params   = $result['params'];
                }

                return [
                    'query'    => $query,
                    'products' => $products,
                    'params'   => $params,
                    'pages'    => self::search_wp_pages( $query ),
                    'lang'     => $lang,
                ];

            // ── Account ───────────────────────────────────────────────────────
            case 'tc_account':
                $section = get_query_var( 'tc_account_section' ) ?: 'dashboard';
                $acct_mgr = new \TitanCart\Modules\Accounts\AccountManager();

                // Not logged in → return minimal data for login/register form.
                if ( ! is_user_logged_in() ) {
                    return [
                        'section'    => $section,
                        'logged_in'  => false,
                        'customer'   => null,
                        'addresses'  => [],
                        'orders'     => [],
                        'countries'  => self::get_countries_list(),
                    ];
                }

                $customer = $acct_mgr->get_or_create_current_customer();

                // Fetch recent orders.
                $recent_orders = [];
                if ( $customer ) {
                    $cid = (int) $customer['id'];
                    $orders_per_page = SettingsService::int( 'appearance', 'orders_per_page', 10 );
                    $raw_orders = $wpdb->get_results( $wpdb->prepare(
                        "SELECT o.id, o.order_number, o.status_id, o.total, o.created_at,
                                ost.name AS status_label, os.color AS status_color, os.handle AS status_handle
                         FROM {$p}orders o
                         LEFT JOIN {$p}order_statuses os ON os.id = o.status_id
                         LEFT JOIN {$p}order_status_translations ost ON ost.status_id = os.id AND ost.language_code = %s
                         WHERE o.customer_id = %d AND o.deleted_at IS NULL
                         ORDER BY o.created_at DESC
                         LIMIT %d",
                        $lang, $cid, $orders_per_page
                    ), ARRAY_A );
                    $recent_orders = $raw_orders ?: [];

                    // Attach first item name + product image to each order for display.
                    foreach ( $recent_orders as &$ro ) {
                        $first_item_row = $wpdb->get_row( $wpdb->prepare(
                            "SELECT oi.name AS product_name, oi.product_id
                             FROM {$p}order_items oi
                             WHERE oi.order_id = %d
                             LIMIT 1",
                            (int) $ro['id']
                        ), ARRAY_A );
                        // S93 canonical image shape — array {url, thumb, alt} or null.
                        // Matches cart.php / OrderManager::get_order items / ProductRepository::get_listing.
                        $product_image = null;
                        if ( $first_item_row && $first_item_row['product_id'] ) {
                            $img_row = $wpdb->get_row( $wpdb->prepare(
                                "SELECT wp_attachment_id, alt_text FROM {$p}product_images WHERE product_id = %d AND is_main = 1 LIMIT 1",
                                (int) $first_item_row['product_id']
                            ), ARRAY_A );
                            if ( $img_row && ! empty( $img_row['wp_attachment_id'] ) ) {
                                $att_id = (int) $img_row['wp_attachment_id'];
                                $thumb  = wp_get_attachment_image_url( $att_id, \TitanCart\Modules\Products\ImageService::cart_thumb_size() );
                                if ( $thumb ) {
                                    $url = wp_get_attachment_image_url( $att_id, 'medium_large' )
                                        ?: wp_get_attachment_image_url( $att_id, 'medium' )
                                        ?: $thumb;
                                    $alt = (string) ( $img_row['alt_text'] ?? '' );
                                    if ( $alt === '' ) {
                                        $alt = (string) ( $first_item_row['product_name'] ?? '' );
                                    }
                                    $product_image = [
                                        'url'   => $url,
                                        'thumb' => $thumb,
                                        'alt'   => $alt,
                                    ];
                                }
                            }
                        }
                        $ro['first_item'] = $first_item_row
                            ? [ 'product_name' => $first_item_row['product_name'], 'product_image' => $product_image ]
                            : null;
                        $ro['item_count'] = (int) $wpdb->get_var( $wpdb->prepare(
                            "SELECT COUNT(*) FROM {$p}order_items WHERE order_id = %d",
                            (int) $ro['id']
                        ) );
                    }
                    unset( $ro );
                }

                // Fetch addresses.
                $addresses = $customer ? $acct_mgr->get_addresses( (int) $customer['id'] ) : [];

                // Fetch wishlist items (only when on wishlist section, to avoid extra queries).
                $wishlist_data = [ 'items' => [], 'total' => 0, 'pages' => 1 ];
                if ( $section === 'wishlist' && $customer ) {
                    $wl_mgr = new \TitanCart\Modules\Wishlist\WishlistManager();
                    $wishlist_data = $wl_mgr->get_items( (int) $customer['id'], 1, 50 );
                }

                // Fetch download keys (only when on downloads section). Includes
                // inactive (revoked / expired) so customers can see what they
                // USED to be able to download — UX preference.
                $downloads = [];
                if ( $section === 'downloads' && $customer ) {
                    $dl_repo = new \TitanCart\Modules\Downloadables\DownloadableRepository();
                    $downloads = $dl_repo->get_keys_for_customer(
                        (int) $customer['id'],
                        (string) $customer['email'],
                        true // include_inactive — show full history
                    );
                }

                // Fetch reward points balance (lightweight, used for dashboard stat).
                $rewards_balance = 0;
                // class_exists: Lite strips the RewardPoints module (R1 §🅰) —
                // balance stays 0 and the dashboard stat row is omitted.
                if ( $customer && class_exists( \TitanCart\Modules\RewardPoints\RewardPointsManager::class ) && \TitanCart\REST\Extensions\ExtensionsController::is_active( 'rewards' ) ) {
                    $rewards_balance = ( new \TitanCart\Modules\RewardPoints\RewardPointsManager() )->get_balance( (int) $customer['id'] );
                }

                // If viewing a single order detail, fetch full order with items/notes.
                $order_detail = null;
                if ( $section === 'orders' && ! empty( $_GET['view'] ) && $customer ) {
                    $view_id = absint( $_GET['view'] );
                    if ( $view_id ) {
                        $om    = new \TitanCart\Modules\Orders\OrderManager();
                        $order = $om->get_order( $view_id );
                        // Verify this order belongs to the current customer.
                        if ( $order && (int) ( $order['customer_id'] ?? 0 ) === (int) $customer['id'] ) {
                            // S282: attach fulfillment/tracking for the customer's
                            // order-detail view. get_by_order() avoids the smt join,
                            // so it's unaffected by the column bug fixed this session.
                            $sm        = new \TitanCart\Modules\Shipping\ShipmentManager();
                            $shipments = $sm->get_by_order( $view_id );
                            foreach ( $shipments as &$_sh ) {
                                $_sh['tracking_url'] = $sm->get_tracking_url(
                                    (string) ( $_sh['carrier'] ?? '' ),
                                    (string) ( $_sh['tracking_number'] ?? '' )
                                );
                            }
                            unset( $_sh );
                            $order['shipments'] = $shipments;
                            $order_detail       = $order;
                        }
                    }
                }

                return [
                    'section'         => $section,
                    'logged_in'       => true,
                    'customer'        => $customer,
                    'orders'          => $recent_orders,
                    'order_detail'    => $order_detail,
                    'addresses'       => $addresses,
                    'wishlist'        => $wishlist_data,
                    'downloads'       => $downloads,
                    'rewards_balance' => $rewards_balance,
                    'countries'       => self::get_countries_list(),
                    'total_orders'    => count( $recent_orders ),
                    'total_spent'     => array_sum( array_column( $recent_orders, 'total' ) ),
                ];
        }

        return [];
    }

    /**
     * Get list of active countries for address forms.
     */
    private static function get_countries_list(): array {
        global $wpdb;
        $p = $wpdb->prefix . TITANCART_TABLE_PREFIX;
        $rows = $wpdb->get_results(
            "SELECT code, name FROM {$p}countries WHERE status = 'active' ORDER BY name ASC",
            ARRAY_A
        );
        return $rows ?: [];
    }

    // ══════════════════════════════════════════════════════════════════════════
    //  SITE HEADER + FOOTER  (injected on TitanCart pages)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * WP.org R2 item 6: nav-menu allowlist for wp_nav_menu() output — kses_post
     * would strip aria-current on the current item, so the three menu echo sites
     * pass this scoped list straight to wp_kses() (the sniff only recognises
     * core escaping functions at the echo, hence the list is exposed, not a
     * wrapper).
     */
    private static function nav_kses_allowed(): array {
        $common = [ 'id' => true, 'class' => true, 'role' => true, 'aria-current' => true, 'aria-haspopup' => true, 'aria-expanded' => true, 'aria-label' => true, 'data-*' => true ];
        return [
            'ul'   => $common,
            'li'   => $common,
            'a'    => $common + [ 'href' => true, 'target' => true, 'rel' => true, 'title' => true ],
            'span' => $common,
            'div'  => $common,
        ];
    }

    /**
     * Render the TitanCart site header/nav bar.
     * Hooked to wp_body_open — fires right after <body>.
     */
    public static function render_site_header(): void {
        // Theme has its own header.php — skip plugin's injected header.
        if ( defined( 'TITANCART_THEME_ACTIVE' ) ) {
            return;
        }

        if ( ! self::is_tc_page() ) {
            return;
        }

        $home    = esc_url( home_url( '/' ) );
        $shop    = esc_url( SeoService::get_shop_url() );
        $cart    = esc_url( home_url( '/cart/' ) );
        $account = esc_url( home_url( '/my-account/' ) );
        $search  = esc_url( home_url( '/?s=' ) );
        $name    = get_bloginfo( 'name' ) ?: 'TitanCart';

        // Get top-level categories for nav.
        global $wpdb;
        $p    = $wpdb->prefix . TITANCART_TABLE_PREFIX;
        $lang = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $cats = $wpdb->get_results( $wpdb->prepare(
            "SELECT c.id, c.slug, COALESCE(ct.name, '') AS name
             FROM {$p}categories c
             LEFT JOIN {$p}category_translations ct ON ct.category_id = c.id AND ct.language_code = %s
             WHERE c.parent_id = 0 AND c.status = 'active' AND c.deleted_at IS NULL
             ORDER BY c.position ASC, c.id ASC
             LIMIT 8",
            $lang
        ), ARRAY_A ) ?: [];

        $logged_in = is_user_logged_in();
        ?>
        <div class="tc-site-header" id="tc-site-header">
            <!-- Top strip -->
            <div class="tc-topbar">
                <div class="tc-topbar__inner">
                    <div class="tc-topbar__links">
                        <?php if ( has_nav_menu( 'tc_topbar' ) ) :
                            wp_nav_menu( [
                                'theme_location' => 'tc_topbar',
                                'container'      => false,
                                'items_wrap'     => '%3$s',
                                'depth'          => 1,
                                'fallback_cb'    => false,
                                'link_before'    => '',
                                'link_after'     => '',
                            ] );
                        else : ?>
                            <a href="<?php echo esc_url( $shop ); ?>">Shop</a>
                            <a href="<?php echo esc_url( $account ); ?>orders/">Track Order</a>
                            <a href="#">FAQ</a>
                            <a href="#">Contact</a>
                        <?php endif; ?>
                    </div>
                    <div class="tc-topbar__right">
                        <span class="tc-topbar__msg">Free shipping on orders over $50</span>
                    </div>
                </div>
            </div>

            <!-- Main nav -->
            <nav class="tc-nav" id="tc-nav">
                <div class="tc-nav__inner">
                    <!-- Logo -->
                    <a href="<?php echo esc_url( $home ); ?>" class="tc-nav__logo">
                        <img src="<?php echo esc_url( TITANCART_ASSETS_URL . 'images/logo.png' ); ?>" alt="<?php echo esc_attr( $name ); ?>" class="tc-nav__logo-img" width="50" height="50">
                        <span class="tc-nav__logo-text"><?php echo esc_html( $name ); ?></span>
                    </a>

                    <!-- Mobile hamburger -->
                    <button class="tc-nav__hamburger" id="tc-nav-hamburger" type="button" aria-label="Menu">
                        <i class="bi bi-list"></i>
                    </button>

                    <!-- Desktop links -->
                    <div class="tc-nav__menu" id="tc-nav-menu">
                        <?php
                        $main_nav_html = has_nav_menu( 'tc_main_nav' )
                            ? wp_nav_menu( [
                                'theme_location' => 'tc_main_nav',
                                'container'      => false,
                                'items_wrap'     => '%3$s',
                                'depth'          => 1,
                                'fallback_cb'    => false,
                                'echo'           => false,
                                'link_before'    => '',
                                'link_after'     => '',
                            ] )
                            : '';
                        if ( $main_nav_html ) :
                            echo wp_kses( $main_nav_html, self::nav_kses_allowed() );
                        else : ?>
                            <a href="<?php echo esc_url( $shop ); ?>" class="tc-nav__link">Shop All</a>
                            <?php foreach ( $cats as $cat ) :
                                $cat_url = esc_url( SeoService::category_url( $cat['slug'] ) );
                            ?>
                                <a href="<?php echo esc_url( $cat_url ); ?>" class="tc-nav__link"><?php echo esc_html( $cat['name'] ); ?></a>
                            <?php endforeach; ?>
                        <?php endif; ?>
                    </div>

                    <!-- Search bar -->
                    <form class="tc-nav__search" action="<?php echo esc_url( home_url( '/search/' ) ); ?>" method="get">
                        <i class="bi bi-search"></i>
                        <input type="text" name="q" placeholder="Search products..." class="tc-nav__search-input" autocomplete="off">
                    </form>

                    <!-- Icon buttons -->
                    <div class="tc-nav__icons">
                        <a href="<?php echo esc_url( $account ); ?>" class="tc-nav__icon-btn" title="Account">
                            <i class="bi bi-person"></i>
                        </a>
                        <?php if ( ExtensionsController::is_active( 'wishlist' ) ) : ?>
                        <a href="<?php echo esc_url( $account ); ?>wishlist/" class="tc-nav__icon-btn" title="Wishlist">
                            <i class="bi bi-heart"></i>
                            <span class="tc-nav__badge tc-wishlist-count" style="display:none">0</span>
                        </a>
                        <?php endif; ?>
                        <button type="button" class="tc-nav__icon-btn tc-cart-trigger" title="Cart">
                            <i class="bi bi-bag"></i>
                            <span class="tc-nav__badge tc-cart-count">0</span>
                        </button>
                    </div>
                </div>
            </nav>

            <!-- Mobile offcanvas menu -->
            <div class="tc-mobile-overlay" id="tc-mobile-overlay"></div>
            <div class="tc-mobile-menu" id="tc-mobile-menu">
                <div class="tc-mobile-menu__head">
                    <a href="<?php echo esc_url( $home ); ?>" class="tc-nav__logo">
                        <img src="<?php echo esc_url( TITANCART_ASSETS_URL . 'images/logo.png' ); ?>" alt="<?php echo esc_attr( $name ); ?>" class="tc-nav__logo-img" width="50" height="50">
                        <span class="tc-nav__logo-text"><?php echo esc_html( $name ); ?></span>
                    </a>
                    <button type="button" class="tc-mobile-menu__close" id="tc-mobile-close"><i class="bi bi-x-lg"></i></button>
                </div>
                <form class="tc-mobile-menu__search" action="<?php echo esc_url( home_url( '/search/' ) ); ?>" method="get">
                    <i class="bi bi-search"></i>
                    <input type="text" name="q" placeholder="Search products...">
                </form>
                <div class="tc-mobile-menu__links">
                    <?php if ( $main_nav_html ) :
                        echo wp_kses( $main_nav_html, self::nav_kses_allowed() );
                    else : ?>
                        <a href="<?php echo esc_url( $shop ); ?>">Shop All</a>
                        <?php foreach ( $cats as $cat ) :
                            $cat_url = esc_url( SeoService::category_url( $cat['slug'] ) );
                        ?>
                            <a href="<?php echo esc_url( $cat_url ); ?>"><?php echo esc_html( $cat['name'] ); ?></a>
                        <?php endforeach; ?>
                    <?php endif; ?>
                    <hr>
                    <a href="<?php echo esc_url( $account ); ?>"><i class="bi bi-person"></i> <?php echo esc_html( $logged_in ? 'My Account' : 'Login / Register' ); ?></a>
                    <?php if ( ExtensionsController::is_active( 'wishlist' ) ) : ?>
                    <a href="<?php echo esc_url( $account ); ?>wishlist/"><i class="bi bi-heart"></i> Wishlist</a>
                    <?php endif; ?>
                    <a href="<?php echo esc_url( $account ); ?>orders/"><i class="bi bi-bag-check"></i> Track Order</a>
                </div>
            </div>
        </div>
        <?php
        // WP.org R2: inline JS is built as a PHP nowdoc (no output buffering, no
        // markup literals) and attached via wp_add_inline_script (stub handle,
        // footer; handlers are event-based so relocation is inert).
        $tc_hdr_js = <<<'JS'
        (function(){
            var ham = document.getElementById('tc-nav-hamburger');
            var menu = document.getElementById('tc-mobile-menu');
            var overlay = document.getElementById('tc-mobile-overlay');
            var close = document.getElementById('tc-mobile-close');
            function open() { menu.classList.add('tc-open'); overlay.classList.add('tc-open'); document.body.style.overflow='hidden'; }
            function shut() { menu.classList.remove('tc-open'); overlay.classList.remove('tc-open'); document.body.style.overflow=''; }
            if(ham) ham.addEventListener('click', open);
            if(close) close.addEventListener('click', shut);
            if(overlay) overlay.addEventListener('click', shut);
        })();
JS;
        wp_register_script( 'titancart-header-inline', false, [], TITANCART_VERSION, true );
        wp_enqueue_script( 'titancart-header-inline' );
        wp_add_inline_script( 'titancart-header-inline', $tc_hdr_js );
    }

    /**
     * Render the TitanCart site footer.
     * Hooked to wp_footer (priority 5, before default scripts).
     *
     * S149 — Layout updated to match the new 5-column theme footer
     * (Brand+Social / Shop / Categories / Company / Support). Replaces the
     * older 4-column layout (Brand / Categories / Account / Newsletter)
     * which diverged from the theme footer once front-page.php was rebuilt
     * and the newsletter moved to its own homepage banner. The plugin and
     * theme footers are now visually identical when rendered side by side.
     *
     * Data sources differ between the two:
     *   - Theme footer (`titancart-theme/footer.php`) reads `get_theme_mod()`
     *     for socials, about text, copyright, and policy page IDs.
     *   - Plugin footer (this method) reads `SettingsService::get()` for
     *     socials and policy URLs, and uses the same hardcoded link defaults
     *     for Shop / Company / Support that the theme uses. Categories are
     *     pulled live from the DB (more accurate than the theme's hardcoded
     *     list, but renders the same shape so the columns line up).
     */
    public static function render_site_footer(): void {
        // Theme has its own footer.php — skip plugin's injected footer.
        if ( defined( 'TITANCART_THEME_ACTIVE' ) ) {
            return;
        }

        if ( ! self::is_tc_page() ) {
            return;
        }

        $home    = esc_url( home_url( '/' ) );
        $shop    = esc_url( SeoService::get_shop_url() );
        $account = esc_url( home_url( '/my-account/' ) );
        $name    = get_bloginfo( 'name' ) ?: 'TitanCart';
        $year    = gmdate( 'Y' );

        // Get top categories for footer (Categories column data).
        // Plugin pulls live from DB — better than theme's hardcoded fallback.
        global $wpdb;
        $p    = $wpdb->prefix . TITANCART_TABLE_PREFIX;
        $lang = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $cats = $wpdb->get_results( $wpdb->prepare(
            "SELECT c.slug, COALESCE(ct.name, '') AS name
             FROM {$p}categories c
             LEFT JOIN {$p}category_translations ct ON ct.category_id = c.id AND ct.language_code = %s
             WHERE c.parent_id = 0 AND c.status = 'active' AND c.deleted_at IS NULL
             ORDER BY c.position ASC
             LIMIT 4",
            $lang
        ), ARRAY_A ) ?: [];

        // Resolve policy URLs (used in both Support column and bottom bar).
        $resolve_url = static function( string $u, string $fallback ): string {
            $u = $u !== '' ? $u : $fallback;
            return preg_match( '#^https?://#i', $u )
                ? $u
                : home_url( '/' . ltrim( $u, '/' ) );
        };
        $tc_privacy_url = $resolve_url( SettingsService::get( 'gdpr',    'privacy_policy_page', '' ), '/privacy-policy' );
        $tc_terms_url   = $resolve_url( SettingsService::get( 'general', 'terms_page_url',      '' ), '/terms-of-service' );
        $tc_refund_url  = $resolve_url( SettingsService::get( 'general', 'refund_page_url',     '' ), '/refund-policy' );
        ?>
        <footer class="tc-site-footer tct-footer-5col" id="tc-site-footer">
            <div class="tc-footer__main">
                <div class="tc-footer__inner">

                    <!-- Col 1: Brand + Social -->
                    <div class="tc-footer__col tc-footer__brand">
                        <a href="<?php echo esc_url( $home ); ?>" class="tc-footer__logo">
                            <img src="<?php echo esc_url( TITANCART_ASSETS_URL . 'images/logo.png' ); ?>" alt="<?php echo esc_attr( $name ); ?>" class="tc-nav__logo-img" width="50" height="50">
                            <span class="tc-nav__logo-text"><?php echo esc_html( $name ); ?></span>
                        </a>
                        <p class="tc-footer__desc">Your one-stop shop for quality products at great prices.</p>
                        <?php
                        // Social links — render configured icons. Settings live
                        // in `general` section. Empty URLs are skipped entirely
                        // (theme footer keeps placeholders for the first 4; we
                        // skip them here because the plugin storefront has no
                        // Customizer to configure them — the SettingsService
                        // path is the only source).
                        $tc_socials = array_filter( [
                            [ 'icon' => 'facebook',  'label' => 'Facebook',  'url' => SettingsService::get( 'general', 'social_facebook_url',  '' ) ],
                            [ 'icon' => 'twitter-x', 'label' => 'X',         'url' => SettingsService::get( 'general', 'social_twitter_url',   '' ) ],
                            [ 'icon' => 'instagram', 'label' => 'Instagram', 'url' => SettingsService::get( 'general', 'social_instagram_url', '' ) ],
                            [ 'icon' => 'linkedin',  'label' => 'LinkedIn',  'url' => SettingsService::get( 'general', 'social_linkedin_url',  '' ) ],
                            [ 'icon' => 'youtube',   'label' => 'YouTube',   'url' => SettingsService::get( 'general', 'social_youtube_url',   '' ) ],
                        ], static function( $s ) { return $s['url'] !== ''; } );
                        if ( ! empty( $tc_socials ) ) : ?>
                        <div class="tc-footer__social">
                            <?php foreach ( $tc_socials as $s ) : ?>
                            <a href="<?php echo esc_url( $s['url'] ); ?>" target="_blank" rel="noopener noreferrer" aria-label="<?php echo esc_attr( $s['label'] ); ?>"><i class="bi bi-<?php echo esc_attr( $s['icon'] ); ?>"></i></a>
                            <?php endforeach; ?>
                        </div>
                        <?php endif; ?>
                    </div>

                    <!-- Col 2: Shop -->
                    <div class="tc-footer__col">
                        <h4 class="tc-footer__heading">Shop</h4>
                        <ul class="tc-footer__links">
                            <li><a href="<?php echo esc_url( $shop ); ?>">All Products</a></li>
                            <li><a href="<?php echo esc_url( $shop . '?filter=new' ); ?>">New Arrivals</a></li>
                            <li><a href="<?php echo esc_url( $shop . '?filter=bestsellers' ); ?>">Best Sellers</a></li>
                            <li><a href="<?php echo esc_url( $shop . '?filter=sale' ); ?>">On Sale</a></li>
                        </ul>
                    </div>

                    <!-- Col 3: Categories (live from DB; falls back to empty) -->
                    <div class="tc-footer__col">
                        <h4 class="tc-footer__heading">Categories</h4>
                        <?php
                        $f1_html = has_nav_menu( 'tc_footer_col1' )
                            ? wp_nav_menu( [
                                'theme_location' => 'tc_footer_col1',
                                'container'      => false,
                                'menu_class'     => 'tc-footer__links',
                                'depth'          => 1,
                                'fallback_cb'    => false,
                                'echo'           => false,
                            ] )
                            : '';
                        if ( $f1_html ) :
                            echo wp_kses( $f1_html, self::nav_kses_allowed() );
                        else : ?>
                            <ul class="tc-footer__links">
                                <?php foreach ( $cats as $cat ) : ?>
                                    <li><a href="<?php echo esc_url( SeoService::category_url( $cat['slug'] ) ); ?>"><?php echo esc_html( $cat['name'] ); ?></a></li>
                                <?php endforeach; ?>
                            </ul>
                        <?php endif; ?>
                    </div>

                    <!-- Col 4: Company -->
                    <div class="tc-footer__col">
                        <h4 class="tc-footer__heading">Company</h4>
                        <ul class="tc-footer__links">
                            <li><a href="<?php echo esc_url( home_url( '/about/' ) ); ?>">About Us</a></li>
                            <li><a href="<?php echo esc_url( home_url( '/blog/' ) ); ?>">Our Blog</a></li>
                            <li><a href="<?php echo esc_url( home_url( '/contact/' ) ); ?>">Contact Us</a></li>
                            <li><a href="<?php echo esc_url( home_url( '/careers/' ) ); ?>">Careers</a></li>
                        </ul>
                    </div>

                    <!-- Col 5: Support -->
                    <div class="tc-footer__col">
                        <h4 class="tc-footer__heading">Support</h4>
                        <ul class="tc-footer__links">
                            <li><a href="<?php echo esc_url( home_url( '/faq/' ) ); ?>">FAQ</a></li>
                            <li><a href="<?php echo esc_url( home_url( '/shipping-returns/' ) ); ?>">Shipping &amp; Returns</a></li>
                            <li><a href="<?php echo esc_url( $tc_privacy_url ); ?>">Privacy Policy</a></li>
                            <li><a href="<?php echo esc_url( $tc_terms_url ); ?>">Terms of Service</a></li>
                        </ul>
                    </div>
                </div>
            </div>

            <!-- Bottom bar -->
            <div class="tc-footer__bottom">
                <div class="tc-footer__bottom-inner">
                    <p>&copy; <?php echo esc_html( $year ); ?> <?php echo esc_html( $name ); ?>. All rights reserved.</p>
                    <div class="tc-footer__policy-links">
                        <a href="<?php echo esc_url( $tc_privacy_url ); ?>">Privacy Policy</a>
                        <a href="<?php echo esc_url( $tc_terms_url ); ?>">Terms of Service</a>
                        <a href="<?php echo esc_url( $tc_refund_url ); ?>">Refund Policy</a>
                    </div>
                    <div class="tc-footer__payments">
                        <svg viewBox="0 0 38 24" width="38" height="24" role="img" aria-label="Visa"><rect width="38" height="24" rx="3" fill="#1A1F71"/><text x="19" y="15.5" fill="#fff" font-size="10" font-weight="700" text-anchor="middle" font-family="Arial">VISA</text></svg>
                        <svg viewBox="0 0 38 24" width="38" height="24" role="img" aria-label="Mastercard"><rect width="38" height="24" rx="3" fill="#252525"/><circle cx="15" cy="12" r="7" fill="#EB001B"/><circle cx="23" cy="12" r="7" fill="#F79E1B"/><path d="M19 6.6a7 7 0 010 10.8 7 7 0 000-10.8z" fill="#FF5F00"/></svg>
                        <svg viewBox="0 0 38 24" width="38" height="24" role="img" aria-label="PayPal"><rect width="38" height="24" rx="3" fill="#253B80"/><text x="19" y="15.5" fill="#fff" font-size="8" font-weight="700" text-anchor="middle" font-family="Arial">PayPal</text></svg>
                        <svg viewBox="0 0 38 24" width="38" height="24" role="img" aria-label="Stripe"><rect width="38" height="24" rx="3" fill="#635BFF"/><text x="19" y="15.5" fill="#fff" font-size="8.5" font-weight="700" text-anchor="middle" font-family="Arial">Stripe</text></svg>
                    </div>
                </div>
            </div>
        </footer>
        <?php
    }



    public static function enqueue_assets(): void {
        $is_tc = self::is_tc_page();
        $theme = defined( 'TITANCART_THEME_ACTIVE' );

        // On non-TC pages, only load global assets when theme is active
        // (theme homepage has product cards with Add to Cart).
        if ( ! $is_tc && ! $theme ) {
            return;
        }

        // Bootstrap Icons (used for quantity +/-, cart, heart, etc.).
        // Bundled locally (assets/vendor/, S443/S444) — .org guidelines prohibit
        // loading assets from CDNs; font paths inside the CSS are relative.
        wp_enqueue_style( 'bootstrap-icons', TITANCART_URL . 'assets/vendor/bootstrap-icons/bootstrap-icons.min.css', [], '1.11.3' );

        // Main frontend stylesheet.
        $css_path = TITANCART_PATH . 'assets/css/titancart.css';
        $css_url  = TITANCART_URL  . 'assets/css/titancart.css';
        if ( file_exists( $css_path ) ) {
            wp_enqueue_style( 'titancart-frontend', $css_url, [ 'bootstrap-icons' ], filemtime( $css_path ) );

            // Dynamic product image shape based on Thumbnails setting.
            $shape = \TitanCart\Services\SettingsService::get( 'thumbnails', 'product_image_shape', 'tall' );
            $ratio = $shape === 'square' ? '1 / 1' : ( $shape === 'wide' ? '4 / 3' : '3 / 4' );
            wp_add_inline_style( 'titancart-frontend',
                ":root { --tc-img-ratio: {$ratio}; }\n" .
                ".product-img { aspect-ratio: var(--tc-img-ratio); object-fit: cover; }\n" .
                ".product-img-placeholder { aspect-ratio: var(--tc-img-ratio); }\n" .
                ".tc-cartpage__item-img { aspect-ratio: var(--tc-img-ratio); object-fit: cover; }\n" .
                ".tc-cartpage__item-img--placeholder { aspect-ratio: var(--tc-img-ratio); }\n" .
                ".tc-drawer-item__img { aspect-ratio: var(--tc-img-ratio); object-fit: cover; }\n" .
                ".tc-checkout__product-img { aspect-ratio: var(--tc-img-ratio); object-fit: cover; }\n" .
                ".tc-compare-table__img { aspect-ratio: var(--tc-img-ratio); object-fit: cover; }"
            );
        }

        // Site header/footer + nav stylesheet.
        $nav_css = TITANCART_PATH . 'assets/css/storefront-nav.css';
        if ( file_exists( $nav_css ) ) {
            wp_enqueue_style( 'titancart-nav', TITANCART_URL . 'assets/css/storefront-nav.css', [ 'titancart-frontend' ], filemtime( $nav_css ) );
        }

        // Cart JS (used on all pages for mini-cart, add-to-cart, cart drawer).
        $cart_js = TITANCART_PATH . 'assets/js/cart.js';
        if ( file_exists( $cart_js ) ) {
            wp_enqueue_script( 'titancart-cart', TITANCART_URL . 'assets/js/cart.js', [], filemtime( $cart_js ), true );
        }

        // Search suggest typeahead (all pages — lives in nav bar).
        $ss_js = TITANCART_PATH . 'assets/js/search-suggest.js';
        if ( file_exists( $ss_js ) ) {
            wp_enqueue_script( 'titancart-search-suggest', TITANCART_URL . 'assets/js/search-suggest.js', [ 'titancart-cart' ], filemtime( $ss_js ), true );
        }

        // Compare (all pages — compare bar floats at bottom).
        if ( SettingsService::bool( 'general', 'catalog_allow_compare', true ) ) {
            $cmp_js = TITANCART_PATH . 'assets/js/compare.js';
            if ( file_exists( $cmp_js ) ) {
                wp_enqueue_script( 'titancart-compare', TITANCART_URL . 'assets/js/compare.js', [ 'titancart-cart' ], filemtime( $cmp_js ), true );
            }
        }

        // Shop JS (category/shop/tag pages — offcanvas filter, AJAX product grid).
        if ( get_query_var( 'tc_shop' ) || get_query_var( 'tc_category' ) || get_query_var( 'tc_tag' ) ) {
            $shop_js = TITANCART_PATH . 'assets/js/shop.js';
            if ( file_exists( $shop_js ) ) {
                wp_enqueue_script( 'titancart-shop', TITANCART_URL . 'assets/js/shop.js', [ 'titancart-cart' ], TITANCART_VERSION, true );
            }
        }

        // Checkout JS (only on checkout page).
        if ( get_query_var( 'tc_checkout' ) ) {
            // Checkout-specific CSS with ID-scoped selectors to override theme.
            $ck_css = TITANCART_PATH . 'assets/css/checkout.css';
            if ( file_exists( $ck_css ) ) {
                wp_enqueue_style( 'titancart-checkout-css', TITANCART_URL . 'assets/css/checkout.css', [ 'titancart-frontend' ], filemtime( $ck_css ) );
            }

            $ck_js = TITANCART_PATH . 'assets/js/checkout.js';
            if ( file_exists( $ck_js ) ) {
                wp_enqueue_script( 'titancart-checkout', TITANCART_URL . 'assets/js/checkout.js', [ 'titancart-cart' ], TITANCART_VERSION, true );
                wp_localize_script( 'titancart-checkout', 'TitancartCheckout', [
                    'apiUrl'   => rest_url( 'titancart/v1' ),
                    'nonce'    => wp_create_nonce( 'wp_rest' ),
                    'currency' => CurrencyService::primary_symbol(),
                    'recaptcha' => is_user_logged_in()
                        ? null
                        : \TitanCart\Extensions\RecaptchaExtension::client_config( 'checkout' ),
                    'i18n'     => [
                        'placing'    => __( 'Placing order…', 'titancart-lite' ),
                        'placeOrder' => __( 'Place Order', 'titancart-lite' ),
                        'error'      => __( 'Something went wrong. Please try again.', 'titancart-lite' ),
                        'recaptcha'  => __( 'Please complete the reCAPTCHA.', 'titancart-lite' ),
                    ],
                ] );
            }
        }

        // Reviews JS (only on single product page).
        if ( get_query_var( 'tc_product' ) ) {
            // NOTE: reviews.js is enqueued (with the titancartReviews localize, including
            // the reCAPTCHA client config) by ReviewsModule::enqueue_scripts() on
            // every storefront page. Do NOT re-enqueue it here — a second copy
            // executes before the localize block and captures an empty cfg,
            // breaking reCAPTCHA token attach. (Same principle as the wishlist
            // no-duplicate note below.)

            // S141 — Sequential Options PDP gating module. Reads `data-tc-deps`
            // off [data-option-id] wrappers (S140 schema -> Phase 5 template
            // wiring) and grey-locks dependent options until parents are
            // satisfied. Self-contained IIFE; no dependencies. Server-side
            // enforcement (defense in depth) lands in Phase 6 via
            // CartManager::validate_options + check_required_options.
            $seq_js = TITANCART_PATH . 'assets/js/sequential-options.js';
            if ( file_exists( $seq_js ) ) {
                wp_enqueue_script( 'titancart-sequential-options', TITANCART_URL . 'assets/js/sequential-options.js', [], filemtime( $seq_js ), true );
            }
        }

        // Wishlist JS is enqueued by WishlistModule::enqueue_scripts() with
        // proper localization (titancartWishlist config). Do NOT duplicate here.

        // Account CSS + JS (only on my-account pages).
        if ( get_query_var( 'tc_account' ) ) {
            $acct_css = TITANCART_PATH . 'assets/css/account.css';
            if ( file_exists( $acct_css ) ) {
                wp_enqueue_style( 'titancart-account-css', TITANCART_URL . 'assets/css/account.css', [ 'titancart-frontend' ], filemtime( $acct_css ) );
            }
            $acct_js = TITANCART_PATH . 'assets/js/account.js';
            if ( file_exists( $acct_js ) ) {
                wp_enqueue_script( 'titancart-account', TITANCART_URL . 'assets/js/account.js', [ 'titancart-cart' ], filemtime( $acct_js ), true );
                wp_localize_script( 'titancart-account', 'TitancartAccount', [
                    'apiUrl'    => rest_url( 'titancart/v1' ),
                    'nonce'     => wp_create_nonce( 'wp_rest' ),
                    'loginUrl'  => wp_login_url( home_url( '/my-account/' ) ),
                    'loggedIn'  => is_user_logged_in(),
                    'i18n'      => [
                        'saving'  => __( 'Saving…', 'titancart-lite' ),
                        'saved'   => __( 'Changes saved!', 'titancart-lite' ),
                        'error'   => __( 'Something went wrong. Please try again.', 'titancart-lite' ),
                        'confirm_delete' => __( 'Are you sure you want to delete this address?', 'titancart-lite' ),
                    ],
                ] );
            }
        }

        // Address autocomplete (Google Places) — checkout + account pages only.
        // Pattern #72 closure: the `address_autocomplete` setting now has live
        // runtime consumers — this enqueue block + the JS module dispatch.
        // When the toggle is off OR the API key is blank, nothing loads and
        // the address forms behave as plain text inputs.
        if ( ( get_query_var( 'tc_checkout' ) || get_query_var( 'tc_account' ) )
            && SettingsService::bool( 'checkout', 'address_autocomplete', false ) ) {
            $aa_key = trim( SettingsService::get( 'checkout', 'address_autocomplete_api_key', '' ) );
            if ( $aa_key !== '' ) {
                $aa_js = TITANCART_PATH . 'assets/js/address-autocomplete.js';
                if ( file_exists( $aa_js ) ) {
                    // Enqueue our wrapper FIRST so window.tcAddressAutocompleteInit
                    // is defined by the time the Google SDK invokes it via its
                    // `?callback=` parameter. The Google script declares this
                    // wrapper as a dependency to guarantee print order.
                    wp_enqueue_script(
                        'titancart-address-autocomplete',
                        TITANCART_URL . 'assets/js/address-autocomplete.js',
                        [],
                        filemtime( $aa_js ),
                        true
                    );

                    // Google Maps SDK with Places library. `loading=async` +
                    // `callback=tcAddressAutocompleteInit` opt into the new
                    // async-loading API; the SDK calls our init when ready.
                    // No version querystring — Google manages SDK versioning.
                    $gm_url = 'https://maps.googleapis.com/maps/api/js'
                            . '?libraries=places'
                            . '&loading=async'
                            . '&callback=tcAddressAutocompleteInit'
                            . '&key=' . rawurlencode( $aa_key );

                    wp_enqueue_script(
                        'titancart-google-places',
                        $gm_url,
                        [ 'titancart-address-autocomplete' ],
                        null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- deliberate: Google manages Maps SDK versioning server-side; a version querystring would bust Google's own cache strategy (comment above).
                        true
                    );

                    // Best-effort `async` HTML attribute for WP 6.3+ — older
                    // versions silently ignore the strategy key. The SDK works
                    // either way; this is a small perf win on supported WPs.
                    if ( function_exists( 'wp_script_add_data' ) ) {
                        wp_script_add_data( 'titancart-google-places', 'strategy', 'async' );
                    }
                }
            }
        }

        // ── Subscribe popup (Phase 5 block B, S241) ──────────────────────
        // Site-wide newsletter signup modal. Gated on: newsletters extension
        // active + popup enabled + a real target list chosen. Suppressed on the
        // checkout page so it never interrupts a purchase in progress. The
        // markup is built client-side by subscribe-popup.js from this config.
        if (
            ! get_query_var( 'tc_checkout' )
            && \TitanCart\REST\Extensions\ExtensionsController::is_active( 'newsletters' )
            && SettingsService::bool( 'newsletters', 'popup_enabled', false )
        ) {
            $popup_list = SettingsService::int( 'newsletters', 'popup_list_id', 0 );
            $popup_js   = TITANCART_PATH . 'assets/js/subscribe-popup.js';
            if ( $popup_list > 0 && file_exists( $popup_js ) ) {
                $popup_css = TITANCART_PATH . 'assets/css/subscribe-popup.css';
                if ( file_exists( $popup_css ) ) {
                    wp_enqueue_style( 'titancart-subscribe-popup', TITANCART_URL . 'assets/css/subscribe-popup.css', [], filemtime( $popup_css ) );
                }
                wp_enqueue_script( 'titancart-subscribe-popup', TITANCART_URL . 'assets/js/subscribe-popup.js', [], filemtime( $popup_js ), true );
                wp_localize_script( 'titancart-subscribe-popup', 'titancartSubscribePopup', [
                    'apiUrl'        => rest_url( 'titancart/v1' ),
                    'listId'        => $popup_list,
                    'trigger'       => SettingsService::get( 'newsletters', 'popup_trigger', 'both' ),
                    'delay'         => SettingsService::int( 'newsletters', 'popup_delay', 15 ),
                    'frequencyDays' => SettingsService::int( 'newsletters', 'popup_frequency_days', 30 ),
                    'heading'       => SettingsService::get( 'newsletters', 'popup_heading', 'Subscribe & Save' ),
                    'text'          => SettingsService::get( 'newsletters', 'popup_text', 'Get exclusive offers and the latest news straight to your inbox.' ),
                    'button'        => SettingsService::get( 'newsletters', 'popup_button', 'Subscribe' ),
                    'placeholder'   => __( 'Enter your email', 'titancart-lite' ),
                ] );
            }
        }

        // Pass store config to JS.
        wp_localize_script( 'titancart-cart', 'titancartConfig', [
            'apiUrl'               => rest_url( 'titancart/v1' ),
            'nonce'                => wp_create_nonce( 'wp_rest' ),
            'currency'             => CurrencyService::primary_code(),
            'symbol'               => CurrencyService::primary_symbol(),
            'decimals'             => CurrencyService::primary_decimals(),
            'symbolAfter'          => CurrencyService::symbol_after(),
            'shopUrl'              => SeoService::get_shop_url(),
            'cartUrl'              => home_url( '/cart/' ),
            'checkoutUrl'          => home_url( '/checkout/' ),
            'accountUrl'           => home_url( '/my-account/' ),
            'enableQuickView'      => SettingsService::bool( 'appearance', 'enable_quick_view', true ),
            'searchMinLength'      => SettingsService::int( 'appearance', 'search_min_length', 1 ),
            'allowShoppingGuest'   => SettingsService::get( 'checkout', 'allow_shopping_unlogged', 'allow' ),
            'showStockCount'       => SettingsService::bool( 'appearance', 'show_number_available', false ),
            'showFilterCounts'     => SettingsService::bool( 'general', 'catalog_show_filter_counts', false ),
            'checkoutOnlyFromCart' => SettingsService::bool( 'checkout', 'checkout_only_from_cart', false ),
            'showTaxOnCatalog'    => SettingsService::bool( 'appearance', 'show_tax_on_catalog', false ),
            'allowCompare'        => SettingsService::bool( 'general', 'catalog_allow_compare', true ),
            'elementsPerPage'     => SettingsService::int( 'appearance', 'elements_per_page', 10 ),
            'weightSymbol'        => SettingsService::get( 'general', 'weight_symbol', 'lbs' ),
            'dimensionsUnit'      => SettingsService::get( 'general', 'dimensions_unit', 'in' ),
            'viewPerCategory'     => SettingsService::bool( 'appearance', 'view_selected_per_category', true ),
            'estimateShipping'    => SettingsService::bool( 'checkout', 'estimate_shipping_on_cart', true ),
            'showTaxOnCart'       => SettingsService::bool( 'appearance', 'show_tax_on_cart', false ),
            'showOptionModifiers' => SettingsService::bool( 'general', 'catalog_display_option_modifiers', true ),
            'productDetailTabs'   => SettingsService::bool( 'appearance', 'product_details_in_tabs', true ),
            'showTrackOrders'     => SettingsService::bool( 'appearance', 'show_track_orders', true ),
            'qtyValueChanger'     => SettingsService::bool( 'appearance', 'use_qty_value_changer', true ),
            'thumbnailGallery'    => SettingsService::bool( 'appearance', 'thumbnail_gallery', true ),
            'paginationType'      => SettingsService::get( 'appearance', 'pagination_type', 'numbers' ),
            'isLoggedIn'           => is_user_logged_in(),
            'extensions'           => [
                'wishlist'      => ExtensionsController::is_active( 'wishlist' ),
                'pricePerUnit'  => ExtensionsController::is_active( 'price-per-unit' ),
                'socialButtons' => ExtensionsController::is_active( 'social-buttons' ),
            ],
            // Price Per Unit global defaults (only relevant when extension active).
            'pricePerUnit' => ExtensionsController::is_active( 'price-per-unit' ) ? [
                'showOnCatalog'    => SettingsService::bool( 'price_per_unit', 'show_on_catalog', true ),
                'showOnPdp'        => SettingsService::bool( 'price_per_unit', 'show_on_pdp', true ),
                'defaultUnit'      => SettingsService::get( 'price_per_unit', 'default_unit_name', 'kg' ),
                'defaultUnits'     => SettingsService::float( 'price_per_unit', 'default_units_in_product', 1 ),
                'defaultPerX'      => SettingsService::float( 'price_per_unit', 'default_show_per_x', 1 ),
            ] : null,
        ] );
    }

    // ── Shortcodes (allow embedding in a WP page as fallback) ─────────────────

    public static function shortcode_cart( array $atts ): string {
        $lang = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $cart_manager = new \TitanCart\Modules\Cart\CartManager();
        $cart = $cart_manager->get_cart( null, $lang );
        return TemplateLoader::get_template( 'cart/cart.php', [
            'cart' => $cart,
            'lang' => $lang,
        ], true );
    }

    public static function shortcode_checkout( array $atts ): string {
        $lang = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $checkout_mgr = new \TitanCart\Modules\Checkout\CheckoutManager();
        return TemplateLoader::get_template( 'checkout/checkout.php', [
            'data' => $checkout_mgr->get_checkout_data( $lang ),
            'lang' => $lang,
        ], true );
    }

    public static function shortcode_account( array $atts ): string {
        $section = sanitize_key( $atts['section'] ?? 'dashboard' );
        return TemplateLoader::get_template( 'account/dashboard.php', [ 'section' => $section ], true );
    }

    public static function shortcode_shop( array $atts ): string {
        $atts = shortcode_atts( [
            'per_page' => SettingsService::int( 'appearance', 'products_per_page', 24 ),
            'status'   => 'active',
        ], $atts, 'titancart_shop' );

        $repo   = new \TitanCart\Modules\Products\ProductRepository();
        $lang   = function_exists( 'titancart_get_current_language' ) ? titancart_get_current_language() : 'en';
        $page   = max( 1, (int) get_query_var( 'tc_page', 1 ) );

        $result = $repo->get_list( [
            'status'         => sanitize_key( $atts['status'] ),
            'page'           => $page,
            'items_per_page' => (int) $atts['per_page'],
            'hide_single_group_members' => true, // roadmap #15
            'sort_by'        => 'date',
            'sort_order'     => 'desc',
        ], $lang );

        return TemplateLoader::get_template( 'shop/archive.php', [
            'products'     => $result['products'],
            'params'       => $result['params'],
            'category'     => null,
            'is_shortcode' => true,
        ], true );
    }

    // ── Admin bar visibility ─────────────────────────────────────────────────

    /**
     * Hide the WordPress admin bar for logged-in non-admin users (customers).
     *
     * Admins (manage_options) and shop managers still see it.
     */
    public static function maybe_hide_admin_bar( bool $show ): bool {
        if ( is_admin() ) {
            return $show; // Never interfere inside wp-admin.
        }

        // Hide for logged-in users who are NOT admins.
        if ( is_user_logged_in() && ! current_user_can( 'manage_options' ) ) {
            return false;
        }

        return $show;
    }

    // ── Nav Menu Meta Boxes (Appearance → Menus) ───────────────────────────

    /**
     * Register "TitanCart Pages" and "TitanCart Categories" meta boxes
     * on the Appearance → Menus screen.
     */
    public static function register_nav_menu_meta_boxes(): void {
        add_meta_box(
            'titancart-pages',
            __( 'TitanCart Pages', 'titancart-lite' ),
            [ self::class, 'nav_menu_pages_meta_box' ],
            'nav-menus',
            'side',
            'default'
        );

        add_meta_box(
            'titancart-categories',
            __( 'TitanCart Categories', 'titancart-lite' ),
            [ self::class, 'nav_menu_categories_meta_box' ],
            'nav-menus',
            'side',
            'default'
        );
    }

    /**
     * Render the "TitanCart Pages" meta box with checkboxes for Shop, Cart,
     * Checkout, My Account, Wishlist, and Search.
     */
    public static function nav_menu_pages_meta_box(): void {
        // Ensure the nav menu walker is loaded.
        if ( ! class_exists( 'Walker_Nav_Menu_Checklist' ) ) {
            require_once ABSPATH . 'wp-admin/includes/nav-menu.php';
        }

        $pages = [
            'shop'       => [ 'title' => __( 'Shop',        'titancart-lite' ), 'url' => SeoService::get_shop_url() ],
            'cart'       => [ 'title' => __( 'Cart',        'titancart-lite' ), 'url' => home_url( '/cart/' ) ],
            'checkout'   => [ 'title' => __( 'Checkout',    'titancart-lite' ), 'url' => home_url( '/checkout/' ) ],
            'my-account' => [ 'title' => __( 'My Account',  'titancart-lite' ), 'url' => home_url( '/my-account/' ) ],
            'search'     => [ 'title' => __( 'Search',      'titancart-lite' ), 'url' => home_url( '/search/' ) ],
        ];
        if ( ExtensionsController::is_active( 'wishlist' ) ) {
            $pages['wishlist'] = [ 'title' => __( 'Wishlist', 'titancart-lite' ), 'url' => home_url( '/my-account/wishlist/' ) ];
        }

        // Build fake menu item objects for the Walker.
        $items = [];
        $i     = -1;
        foreach ( $pages as $key => $page ) {
            $item                   = new \stdClass();
            $item->ID               = $i;
            $item->object_id        = $i;
            $item->db_id            = 0;
            $item->object           = 'titancart-page';
            $item->menu_item_parent = 0;
            $item->type             = 'custom';
            $item->title            = $page['title'];
            $item->url              = $page['url'];
            $item->target           = '';
            $item->attr_title       = '';
            $item->description      = '';
            $item->classes          = [ '' ];
            $item->xfn              = '';
            $items[]                = $item;
            $i--;
        }

        $walker = new \Walker_Nav_Menu_Checklist( [] );

        ?>
        <div id="titancart-pages" class="posttypediv">
            <div class="tabs-panel tabs-panel-active">
                <ul class="categorychecklist form-no-clear">
                    <?php echo walk_nav_menu_tree( array_map( 'wp_setup_nav_menu_item', $items ), 0, (object) [ 'walker' => $walker ] ); ?>
                </ul>
            </div>
            <p class="button-controls wp-clearfix">
                <span class="list-controls">
                    <label>
                        <input type="checkbox" class="select-all" />
                        <?php esc_html_e( 'Select All', 'titancart-lite' ); ?>
                    </label>
                </span>
                <span class="add-to-menu">
                    <input type="submit"
                           class="button submit-add-to-menu right"
                           value="<?php esc_attr_e( 'Add to Menu', 'titancart-lite' ); ?>"
                           name="add-titancart-pages-menu-item"
                           id="submit-titancart-pages" />
                    <span class="spinner"></span>
                </span>
            </p>
        </div>
        <?php
    }

    /**
     * Render the "TitanCart Categories" meta box with checkboxes pulled
     * from the tc_categories table.
     */
    public static function nav_menu_categories_meta_box(): void {
        // Ensure the nav menu walker is loaded.
        if ( ! class_exists( 'Walker_Nav_Menu_Checklist' ) ) {
            require_once ABSPATH . 'wp-admin/includes/nav-menu.php';
        }

        if ( ! function_exists( 'titancart_db' ) ) {
            echo '<p>' . esc_html__( 'TitanCart plugin is not active.', 'titancart-lite' ) . '</p>';
            return;
        }

        $db         = titancart_db();
        $cat_table  = $db->table( 'categories' );
        $trans_table = $db->table( 'category_translations' );
        $lang       = 'en';

        $cats = $db->getArray(
            "SELECT c.id, c.slug, c.parent_id, COALESCE( ct.name, c.slug, '' ) AS name
             FROM {$cat_table} c
             LEFT JOIN {$trans_table} ct ON ct.category_id = c.id AND ct.language_code = ?s
             WHERE c.status = 'active' AND c.deleted_at IS NULL
               AND ( ct.name IS NOT NULL AND ct.name != '' OR c.slug != '' )
             ORDER BY c.lft ASC, c.position ASC",
            $lang
        );

        if ( empty( $cats ) ) {
            echo '<p>' . esc_html__( 'No categories found. Create categories in TitanCart first.', 'titancart-lite' ) . '</p>';
            return;
        }

        // Build a depth map from parent_id for indentation.
        $depth_map = [];
        $id_map    = [];
        foreach ( $cats as $cat ) {
            $id_map[ (int) $cat['id'] ] = $cat;
        }
        foreach ( $cats as $cat ) {
            $d = 0;
            $pid = (int) $cat['parent_id'];
            while ( $pid > 0 && isset( $id_map[ $pid ] ) && $d < 10 ) {
                $d++;
                $pid = (int) $id_map[ $pid ]['parent_id'];
            }
            $depth_map[ (int) $cat['id'] ] = $d;
        }

        // Build fake menu item objects.
        $items = [];
        $i     = -100;
        foreach ( $cats as $cat ) {
            $item                   = new \stdClass();
            $item->ID               = $i;
            $item->object_id        = $i;
            $item->db_id            = 0;
            $item->object           = 'titancart-category';
            $item->menu_item_parent = 0;
            $item->type             = 'custom';
            $item->title            = str_repeat( '— ', $depth_map[ (int) $cat['id'] ] ?? 0 ) . $cat['name'];
            $item->url              = SeoService::category_url( $cat['slug'] );
            $item->target           = '';
            $item->attr_title       = '';
            $item->description      = '';
            $item->classes          = [ '' ];
            $item->xfn              = '';
            $items[]                = $item;
            $i--;
        }

        $walker = new \Walker_Nav_Menu_Checklist( [] );

        ?>
        <div id="titancart-categories" class="posttypediv">
            <div class="tabs-panel tabs-panel-active" style="max-height:200px;">
                <ul class="categorychecklist form-no-clear">
                    <?php echo walk_nav_menu_tree( array_map( 'wp_setup_nav_menu_item', $items ), 0, (object) [ 'walker' => $walker ] ); ?>
                </ul>
            </div>
            <p class="button-controls wp-clearfix">
                <span class="list-controls">
                    <label>
                        <input type="checkbox" class="select-all" />
                        <?php esc_html_e( 'Select All', 'titancart-lite' ); ?>
                    </label>
                </span>
                <span class="add-to-menu">
                    <input type="submit"
                           class="button submit-add-to-menu right"
                           value="<?php esc_attr_e( 'Add to Menu', 'titancart-lite' ); ?>"
                           name="add-titancart-categories-menu-item"
                           id="submit-titancart-categories" />
                    <span class="spinner"></span>
                </span>
            </p>
        </div>
        <?php
    }

    // ── Body classes ───────────────────────────────────────────────────────────

    public static function body_classes( array $classes ): array {
        if ( get_query_var( 'tc_shop' ) || get_query_var( 'tc_category' ) ) {
            $classes[] = 'titancart-shop';
        }
        if ( get_query_var( 'tc_tag' ) ) {
            $classes[] = 'titancart-shop';
            $classes[] = 'titancart-tag';
        }
        if ( get_query_var( 'tc_product' ) ) {
            $classes[] = 'titancart-product';
        }
        if ( get_query_var( 'tc_cart' ) ) {
            $classes[] = 'titancart-cart';
        }
        if ( get_query_var( 'tc_checkout' ) ) {
            $classes[] = 'titancart-checkout';
        }
        if ( get_query_var( 'tc_order_received' ) ) {
            $classes[] = 'titancart-order-received';
        }
        if ( get_query_var( 'tc_account' ) ) {
            $classes[] = 'titancart-account';
        }
        if ( get_query_var( 'tc_search' ) ) {
            $classes[] = 'titancart-search';
        }
        return $classes;
    }

    // ── Document title ─────────────────────────────────────────────────────────

    public static function page_title( array $parts ): array {
        if ( get_query_var( 'tc_product' ) ) {
            global $wpdb;
            $p    = $wpdb->prefix . TITANCART_TABLE_PREFIX;
            $slug = sanitize_text_field( get_query_var( 'tc_product' ) );
            $row  = $wpdb->get_row( $wpdb->prepare(
                "SELECT t.name, t.meta_title FROM {$p}products p
                 LEFT JOIN {$p}product_translations t ON t.product_id = p.id AND t.language_code = 'en'
                 WHERE p.slug = %s AND p.deleted_at IS NULL LIMIT 1",
                $slug
            ) );
            if ( $row ) {
                $title = ! empty( $row->meta_title ) ? $row->meta_title : $row->name;
                if ( $title ) {
                    $parts['title'] = $title;
                }
            }
        } elseif ( get_query_var( 'tc_category' ) ) {
            global $wpdb;
            $p    = $wpdb->prefix . TITANCART_TABLE_PREFIX;
            $slug = sanitize_text_field( get_query_var( 'tc_category' ) );
            $row  = $wpdb->get_row( $wpdb->prepare(
                "SELECT t.name, t.meta_title FROM {$p}categories c
                 LEFT JOIN {$p}category_translations t ON t.category_id = c.id AND t.language_code = 'en'
                 WHERE c.slug = %s AND c.deleted_at IS NULL LIMIT 1",
                $slug
            ) );
            if ( $row ) {
                $title = ! empty( $row->meta_title ) ? $row->meta_title : $row->name;
                if ( $title ) {
                    $parts['title'] = $title;
                }
            }
        } elseif ( get_query_var( 'tc_tag' ) ) {
            global $wpdb;
            $p    = $wpdb->prefix . TITANCART_TABLE_PREFIX;
            $slug = sanitize_text_field( get_query_var( 'tc_tag' ) );
            $name = $wpdb->get_var( $wpdb->prepare(
                "SELECT name FROM {$p}tags WHERE slug = %s LIMIT 1",
                $slug
            ) );
            if ( $name ) {
                /* translators: %s = tag name */
                $parts['title'] = sprintf( __( 'Products tagged "%s"', 'titancart-lite' ), $name );
            } else {
                $parts['title'] = __( 'Tag', 'titancart-lite' );
            }
        } elseif ( get_query_var( 'tc_shop' ) ) {
            $parts['title'] = __( 'Shop', 'titancart-lite' );
        } elseif ( get_query_var( 'tc_cart' ) ) {
            $parts['title'] = __( 'Cart', 'titancart-lite' );
        } elseif ( get_query_var( 'tc_checkout' ) ) {
            $parts['title'] = __( 'Checkout', 'titancart-lite' );
        } elseif ( get_query_var( 'tc_order_received' ) ) {
            $parts['title'] = __( 'Order Received', 'titancart-lite' );
        } elseif ( get_query_var( 'tc_account' ) ) {
            $parts['title'] = __( 'My Account', 'titancart-lite' );
        } elseif ( get_query_var( 'tc_search' ) ) {
            $q = sanitize_text_field( wp_unslash( $_GET['q'] ?? $_GET['s'] ?? '' ) );
            /* translators: %s: search query entered by the visitor. */
            $parts['title'] = $q ? sprintf( __( 'Search: %s', 'titancart-lite' ), $q ) : __( 'Search', 'titancart-lite' );
        }
        return $parts;
    }

    // ── Helper ─────────────────────────────────────────────────────────────────

    public static function is_tc_page(): bool {
        return (bool) (
            get_query_var( 'tc_shop' )           ||
            get_query_var( 'tc_category' )       ||
            get_query_var( 'tc_tag' )            ||
            get_query_var( 'tc_product' )        ||
            get_query_var( 'tc_cart' )           ||
            get_query_var( 'tc_checkout' )       ||
            get_query_var( 'tc_order_received' ) ||
            get_query_var( 'tc_account' )        ||
            get_query_var( 'tc_search' )
        );
    }

    /**
     * Search WordPress pages by title/content when search_in_pages setting is ON.
     * Returns empty array when setting is OFF or no matches found.
     */
    private static function search_wp_pages( string $query ): array {
        if ( $query === '' || ! SettingsService::bool( 'general', 'search_in_pages', false ) ) {
            return [];
        }

        $wp_pages = get_posts( [
            'post_type'      => 'page',
            'post_status'    => 'publish',
            's'              => $query,
            'posts_per_page' => 5,
            'orderby'        => 'relevance',
        ] );

        $results = [];
        foreach ( $wp_pages as $p ) {
            $results[] = [
                'id'      => $p->ID,
                'title'   => $p->post_title,
                'url'     => get_permalink( $p->ID ),
                'excerpt' => wp_trim_words( $p->post_excerpt ?: $p->post_content, 25, '…' ),
            ];
        }
        return $results;
    }
}
