<?php
/**
 * TitanCart single product page — ShopNest-inspired layout.
 *
 * Override: {theme}/titancart/product/single.php
 *
 * @var array $product  Full product array from ProductRepository::get() + enrichments
 * @package TitanCart
 */

defined( 'ABSPATH' ) || exit;

if ( ! $product ) {
    get_header();
    echo '<div class="tc-pdp"><p>Product not found.</p></div>';
    get_footer();
    return;
}

get_header();

$product_id  = (int) ( $product['id'] ?? 0 );
$name        = $product['name']        ?? '';
// ── Effective shelf price (roadmap #4 / KI-08) ──
// Lowest of {manual list-price sale, Bulk-Buys tier, active catalog promo} for
// the current viewer at qty 1. Identical to the raw price/list_price when no
// tier/promo applies, so the headline price, the "was" strikethrough, the
// discount %, and the bulk-tier grid's "below current price" guard all stay
// coherent. The qty>=2 bulk-tier grid is itself catalog-correct with no extra work:
// it filters and computes savings against this effective $price, so a shown tier's
// raw price always equals lowest-wins (= what the cart charges at that qty) and a
// tier the catalog beats is filtered out (A2). Per-variant matrix prices are decorated
// at their build point in StorefrontModule (member price/list_price). S272 / KI-08.
// class_exists: Lite strips the Promotions module (R1 §🅰) — base price shape.
if ( class_exists( \TitanCart\Modules\Promotions\CatalogPromotionService::class ) ) {
    $tc_ep_pdp = \TitanCart\Modules\Promotions\CatalogPromotionService::instance()
                       ->effective_price_for_viewer( $product_id, 1, [
                           'price'      => (float) ( $product['price'] ?? 0 ),
                           'list_price' => (float) ( $product['list_price'] ?? 0 ),
                       ] );
} else {
    $tc_ep_pdp = [
        'price'      => (float) ( $product['price'] ?? 0 ),
        'on_sale'    => false,
        'compare_at' => (float) ( $product['list_price'] ?? 0 ),
    ];
}
$price       = (float) $tc_ep_pdp['price'];
$list_price  = $tc_ep_pdp['on_sale'] ? (float) $tc_ep_pdp['compare_at'] : (float) ( $product['list_price'] ?? 0 );
$desc        = $product['description'] ?? '';
$short_desc  = $product['short_desc']  ?? '';
$sku         = $product['sku']         ?? '';
$slug        = $product['slug']        ?? '';
$in_stock    = ( $product['stock_status'] ?? 'out_of_stock' ) === 'in_stock';
$stock_qty   = (int) ( $product['stock_quantity'] ?? 0 );
// S274: the "Show number of items available" appearance setting was only surfaced on
// shop cards (card.php) — also show the count on the PDP's Available row when on.
$show_stock  = \TitanCart\Services\SettingsService::bool( 'appearance', 'show_number_available', false );

// $purchasable = "the user can put this in the cart right now."
// Diverges from $in_stock only when out_of_stock_actions = 'buy' (buy-in-advance):
// the customer-facing UI shows Add to Cart even with zero/negative stock.
// 'subscribe' and 'none' (default) keep $purchasable === $in_stock so the
// waitlist form / out-of-stock state renders unchanged.
// CartManager::add_to_cart honors the same field server-side. S122 fix.
$_oos_action = $product['out_of_stock_actions'] ?? 'none';
$purchasable = $in_stock || ( $_oos_action === 'buy' );
$images      = $product['images']      ?? [];
$options     = $product['options']     ?? [];
$features    = $product['features']    ?? [];
$reviews     = $product['reviews']     ?? [];
$review_count= (int) ( $product['review_count'] ?? $product['rating_count'] ?? 0 );
$review_avg  = (float) ( $product['review_avg'] ?? $product['rating_avg'] ?? 0 );
$review_dist = $product['review_dist'] ?? [];
$sizing      = $product['sizing_chart'] ?? null;
$related     = $product['related']     ?? [];
$bundles     = $product['bundles']     ?? [];
$breadcrumb  = $product['category_breadcrumb'] ?? [];
$currency    = apply_filters( 'titancart_currency_symbol', '$' );
$discount    = ( $list_price > $price && $list_price > 0 )
    ? round( ( ( $list_price - $price ) / $list_price ) * 100 )
    : 0;
$min_qty     = max( 1, (int) ( $product['min_quantity'] ?? 1 ) );
$max_qty     = (int) ( $product['max_quantity'] ?? 0 );
$qty_step    = max( 1, (int) ( $product['quantity_step'] ?? 1 ) );
// KI-123 (S478): the quantity control's ceiling is what the cart will actually
// accept — the per-product / global max AND, when inventory is tracked with no
// backorders, the units in stock. Mirrors CartManager::add_item()'s clamp so the
// customer can't ask for 3 and silently receive 1. 0 = no ceiling.
$_g_max_qty  = \TitanCart\Services\SettingsService::int( 'checkout', 'max_qty_per_product', 0 );
$qty_ceiling = $max_qty > 0 ? $max_qty : $_g_max_qty;
$_stock_cap  = \TitanCart\Services\SettingsService::bool( 'general', 'catalog_track_inventory', true )
    && ! empty( $product['track_inventory'] )
    && empty( $product['is_downloadable'] )
    && empty( $product['allow_backorders'] )
    && ! \TitanCart\Services\SettingsService::bool( 'general', 'catalog_allow_negative_inventory', false )
    && ( $product['out_of_stock_actions'] ?? 'none' ) !== 'buy'
    && $stock_qty > 0;
if ( $_stock_cap ) {
    $qty_ceiling = $qty_ceiling > 0 ? min( $qty_ceiling, $stock_qty ) : $stock_qty;
}
$qty_ceiling = max( $qty_ceiling, 0 );
$api_url     = rest_url( 'titancart/v1' );
$nonce       = wp_create_nonce( 'wp_rest' );
$guest_policy   = is_user_logged_in() ? 'allow' : \TitanCart\Services\SettingsService::get( 'checkout', 'allow_shopping_unlogged', 'allow' );
$_hide_price    = ( $guest_policy === 'hide_price' );
$_deny_cart     = ( $guest_policy === 'hide_price' || $guest_policy === 'deny' );
$show_option_modifiers = \TitanCart\Services\SettingsService::bool( 'general', 'catalog_display_option_modifiers', true );
$show_tax_on_catalog   = \TitanCart\Services\SettingsService::bool( 'appearance', 'show_tax_on_catalog', false );
$details_in_tabs       = \TitanCart\Services\SettingsService::bool( 'appearance', 'product_details_in_tabs', true );
$qty_changer           = \TitanCart\Services\SettingsService::bool( 'appearance', 'use_qty_value_changer', true );
$thumbnail_gallery     = \TitanCart\Services\SettingsService::bool( 'appearance', 'thumbnail_gallery', true );
$qty_dropdown_count    = \TitanCart\Services\SettingsService::int( 'checkout', 'number_available_quantities', 0 );
$pdp_layout            = \TitanCart\Services\SettingsService::get( 'appearance', 'product_detail_layout', 'default' );
// Per-product layout override (set via Product Editor → Extra → Product details view).
if ( ! empty( $product['details_view'] ) && in_array( $product['details_view'], [ 'default', 'wide', 'gallery' ], true ) ) {
    $pdp_layout = $product['details_view'];
}
// When no explicit layout is chosen, follow the product image shape so the PDP
// matches the aspect ratio: square → 1:1 layout, wide → 4:3 layout, tall → the
// existing default (3:4). The shape is set per theme via the Theme Options
// preset sync (thumbnails.product_image_shape). Manual choices above still win.
if ( $pdp_layout === 'default' ) {
    $img_shape = \TitanCart\Services\SettingsService::get( 'thumbnails', 'product_image_shape', 'tall' );
    if ( $img_shape === 'square' ) {
        $pdp_layout = 'square';
    } elseif ( $img_shape === 'wide' ) {
        $pdp_layout = 'ratio43';
    }
    // 'tall' → keep 'default' (the existing 3:4 layout).
}
$allow_compare         = \TitanCart\Services\SettingsService::bool( 'general', 'catalog_allow_compare', true );
$wishlist_enabled      = \TitanCart\REST\Extensions\ExtensionsController::is_active( 'wishlist' );
$catalog_mode_on       = \TitanCart\REST\Extensions\ExtensionsController::is_active( 'catalog-mode' );
$ppu_on                = \TitanCart\REST\Extensions\ExtensionsController::is_active( 'price-per-unit' );
$social_buttons_on     = \TitanCart\REST\Extensions\ExtensionsController::is_active( 'social-buttons' );

// ── Downloadable Products: PDP gate resolution (S138 / Phase 5) ──────────────
// Resolved server-side at render time so the cart row starts in the correct
// state — no flash-of-unguarded-button. Three independent flags drive the
// branching below:
//
//   $_is_downloadable — product flag (set in admin → Product editor → Downloads tab)
//   $_dl_master_on    — global setting `catalog_enable_downloads`. When OFF,
//                       downloadable products cannot be added to the cart at
//                       all; PDP swaps the cart row for an "unavailable" notice.
//                       Server-side enforcement is in CartManager + CheckoutManager.
//   $_pdp_agreement   — the resolved (per-product override → global default)
//                       license agreement, ONLY when required=1. Informational
//                       agreements (required=0) and missing agreements both
//                       leave this null (no gate rendered).
//
// The agreement query (one PK lookup) only runs when both prior flags pass —
// most product PDPs do zero extra work.
$_is_downloadable = ! empty( $product['is_downloadable'] );
$_dl_master_on    = \TitanCart\Services\SettingsService::bool( 'general', 'catalog_enable_downloads', true );
$_pdp_agreement   = null;
if ( $_is_downloadable && $_dl_master_on && $purchasable && ! $_deny_cart ) {
    try {
        $_dl_mgr        = new \TitanCart\Modules\Downloadables\DownloadableManager();
        $_pdp_agreement = $_dl_mgr->get_applicable_agreement( $product_id, 'en' );
        // Mirror controller logic (DownloadableController::get_product_agreement):
        // informational agreements (required=0) don't block — surface as null
        // so the storefront renders no gate.
        if ( $_pdp_agreement && empty( $_pdp_agreement['required'] ) ) {
            $_pdp_agreement = null;
        }
    } catch ( \Throwable $e ) {
        // Fail-open: a resolver error must not strand the PDP. Server-side
        // enforcement at CheckoutManager re-runs the same resolver and will
        // catch a missing acceptance with a customer-facing error.
        $_pdp_agreement = null;
    }
}
// content_hash is a COMPUTED field — get_applicable_agreement() returns raw DB
// columns only. Derive it here exactly as DownloadableController does so the
// value submitted by the browser matches the server's canonical hash (and so we
// don't read an undefined array key, which prints a PHP warning into the page).
$_pdp_agreement_hash = $_pdp_agreement
    ? hash( 'sha256', (string) $_pdp_agreement['content'] )
    : '';

// Product Videos — build thumb + embed data for gallery integration.
$_raw_videos          = $product['videos'] ?? '';
$product_videos       = is_string( $_raw_videos ) ? ( json_decode( $_raw_videos, true ) ?: [] ) : ( is_array( $_raw_videos ) ? $_raw_videos : [] );
$show_videos_before   = \TitanCart\Services\SettingsService::bool( 'appearance', 'show_videos_before_images', false );
$autoplay_videos      = \TitanCart\Services\SettingsService::bool( 'appearance', 'autoplay_videos', false );
$video_thumbs = [];
foreach ( $product_videos as $_v ) {
    $_url  = $_v['url'] ?? '';
    $_host = strtolower( $_v['hosting'] ?? '' );
    if ( ! $_url ) continue;

    $vt = [ 'thumb' => '', 'embed' => '', 'hosting' => $_host ];

    if ( strpos( $_host, 'youtube' ) !== false || strpos( $_url, 'youtube.com' ) !== false || strpos( $_url, 'youtu.be' ) !== false ) {
        preg_match( '/(?:v=|youtu\.be\/)([\w\-]+)/', $_url, $_m );
        if ( ! empty( $_m[1] ) ) {
            $vt['thumb'] = 'https://img.youtube.com/vi/' . $_m[1] . '/mqdefault.jpg';
            $vt['embed'] = 'https://www.youtube.com/embed/' . $_m[1] . '?autoplay=1&rel=0';
        }
    } elseif ( strpos( $_host, 'vimeo' ) !== false || strpos( $_url, 'vimeo.com' ) !== false ) {
        preg_match( '/vimeo\.com\/(\d+)/', $_url, $_m );
        if ( ! empty( $_m[1] ) ) {
            $vt['embed'] = 'https://player.vimeo.com/video/' . $_m[1] . '?autoplay=1';
        }
    } else {
        $vt['embed'] = $_url; // HTML5 video direct URL
    }
    if ( $vt['embed'] ) $video_thumbs[] = $vt;
}
$has_videos = ! empty( $video_thumbs );

// Catalog Mode overrides.
if ( $catalog_mode_on ) {
    $cm_type        = \TitanCart\Services\SettingsService::get( 'catalog_mode', 'catalog_mode_type', 'catalog' );
    $cm_hide_price  = \TitanCart\Services\SettingsService::bool( 'catalog_mode', 'hide_prices', true );
    $cm_inquiry_url = \TitanCart\Services\SettingsService::get( 'catalog_mode', 'inquiry_url', '/contact/' );
    $cm_inquiry_text= \TitanCart\Services\SettingsService::get( 'catalog_mode', 'inquiry_button_text', 'Request a Quote' );
    if ( $cm_hide_price ) { $_hide_price = true; }
    $_deny_cart = true;
}

// Price Per Unit for PDP. (S124 — per-product overrides win when all three
// per-product fields are populated; otherwise fall back to global defaults.
// Per-product columns: unit_name VARCHAR, units_in_product DECIMAL, show_price_per
// DECIMAL — all DEFAULT 0/'' so non-zero/non-empty = "configured per-product".)
$ppu_pdp_html = '';
if ( $ppu_on && ! $_hide_price && $price > 0 ) {
    $pp_units    = (float)  ( $product['units_in_product'] ?? 0 );
    $pp_show_per = (float)  ( $product['show_price_per']   ?? 0 );
    $pp_unit     = trim( (string) ( $product['unit_name']  ?? '' ) );

    $ppu_units = 0; $ppu_per_x = 0; $ppu_unit = '';

    if ( $pp_units > 0 && $pp_show_per > 0 && $pp_unit !== '' ) {
        // Per-product configuration is fully populated — wins over globals.
        $ppu_units = $pp_units;
        $ppu_per_x = $pp_show_per;
        $ppu_unit  = $pp_unit;
    } elseif ( \TitanCart\Services\SettingsService::bool( 'price_per_unit', 'show_on_pdp', true ) ) {
        // Fall back to global defaults (preserves S119/early behavior).
        $ppu_units = (float) \TitanCart\Services\SettingsService::get( 'price_per_unit', 'default_units_in_product', 1 );
        $ppu_per_x = (float) \TitanCart\Services\SettingsService::get( 'price_per_unit', 'default_show_per_x', 1 );
        $ppu_unit  = (string) \TitanCart\Services\SettingsService::get( 'price_per_unit', 'default_unit_name', 'kg' );
    }

    // Guard against div-by-zero and meaningless 1:1:1 displays.
    if ( $ppu_units > 0 && $ppu_per_x > 0 && $ppu_unit !== '' ) {
        $per_unit_price = ( $price / $ppu_units ) * $ppu_per_x;
        if ( abs( $per_unit_price - $price ) > 0.01 ) {
            $ppu_label    = $ppu_per_x > 1 ? rtrim( rtrim( number_format( $ppu_per_x, 4, '.', '' ), '0' ), '.' ) . ' ' . $ppu_unit : $ppu_unit;
            $ppu_pdp_html = '<span class="tc-pdp__ppu">(' . esc_html( \TitanCart\Services\CurrencyService::format( $per_unit_price ) . ' per ' . $ppu_label ) . ')</span>';
        }
    }
}

// Social Buttons settings.
$social_share_html = '';
if ( $social_buttons_on ) {
    $sb_show_products = \TitanCart\Services\SettingsService::bool( 'social_buttons', 'show_on_products', true );
    if ( $sb_show_products ) {
        $sb_style    = \TitanCart\Services\SettingsService::get( 'social_buttons', 'button_style', 'icon_only' );
        $sb_position = \TitanCart\Services\SettingsService::get( 'social_buttons', 'button_position', 'below_cart' );
        $share_url   = urlencode(
            home_url(
                isset( $_SERVER['REQUEST_URI'] )
                    ? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) )
                    : '/product/' . $slug . '/'
            )
        );
        $share_title = urlencode( $name );

        $sb_networks = [];
        if ( \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_facebook', true ) )
            $sb_networks[] = [ 'icon' => 'facebook', 'label' => 'Facebook', 'url' => 'https://www.facebook.com/sharer/sharer.php?u=' . $share_url ];
        if ( \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_x', true ) ) {
            $x_via = \TitanCart\Services\SettingsService::get( 'social_buttons', 'x_via', '' );
            $sb_networks[] = [ 'icon' => 'twitter-x', 'label' => 'X', 'url' => 'https://twitter.com/intent/tweet?url=' . $share_url . '&text=' . $share_title . ( $x_via ? '&via=' . urlencode( $x_via ) : '' ) ];
        }
        if ( \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_pinterest', true ) ) {
            $pin_img = ! empty( $images[0]['url'] ) ? urlencode( $images[0]['url'] ) : '';
            $sb_networks[] = [ 'icon' => 'pinterest', 'label' => 'Pinterest', 'url' => 'https://pinterest.com/pin/create/button/?url=' . $share_url . '&media=' . $pin_img . '&description=' . $share_title ];
        }
        if ( \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_linkedin', false ) )
            $sb_networks[] = [ 'icon' => 'linkedin', 'label' => 'LinkedIn', 'url' => 'https://www.linkedin.com/sharing/share-offsite/?url=' . $share_url ];
        if ( \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_whatsapp', false ) )
            $sb_networks[] = [ 'icon' => 'whatsapp', 'label' => 'WhatsApp', 'url' => 'https://wa.me/?text=' . $share_title . '%20' . $share_url ];
        if ( \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_email', true ) )
            $sb_networks[] = [ 'icon' => 'envelope', 'label' => 'Email', 'url' => 'mailto:?subject=' . $share_title . '&body=' . $share_url ];
        $sb_copy_link = \TitanCart\Services\SettingsService::bool( 'social_buttons', 'enable_copy_link', true );
    }
}

// Age Verification gate.
$age_gate_active = false;
$age_verification_on = \TitanCart\REST\Extensions\ExtensionsController::is_active( 'age-verification' );
if ( $age_verification_on ) {
    $is_age_restricted = (int) ( $product['age_restricted'] ?? 0 );
    if ( $is_age_restricted ) {
        $age_limit       = (int) ( $product['age_limit'] ?? 0 );
        if ( $age_limit <= 0 ) {
            $age_limit   = \TitanCart\Services\SettingsService::int( 'age_verification', 'default_age_limit', 18 );
        }
        $age_verify_type = \TitanCart\Services\SettingsService::get( 'age_verification', 'verification_type', 'confirm' );
        $age_message     = \TitanCart\Services\SettingsService::get( 'age_verification', 'warning_message', 'This product is age-restricted. You must be at least {age} years old to view or purchase this item.' );
        $age_message     = str_replace( '{age}', $age_limit, $age_message );
        $age_deny_action = \TitanCart\Services\SettingsService::get( 'age_verification', 'deny_action', 'redirect' );
        $age_remember    = \TitanCart\Services\SettingsService::int( 'age_verification', 'remember_days', 30 );

        // Already verified to at least this product's required age? Uses the same
        // age-aware check as the server-side cart/checkout guard so the template
        // and the guard never disagree (a higher-age product re-gates a visitor
        // who only verified a lower age; legacy "=1" cookies count as the default).
        if ( ! \TitanCart\Extensions\AgeVerificationExtension::meets( $age_limit ) ) {
            $age_gate_active = true;
        }
    }
}

// ── Spec features detection ─────────────────────────────────────
// Drives both the Attributes-tab inclusion check and the spec
// table rendering inside the panel below.
// A feature with show_on_pdp = 0 (e.g. a pure storefront "filter"
// attribute like Color Family) is excluded from the PDP entirely —
// it has no role on the product page even when it carries a value.
// Default 1 (column absent / NULL) keeps every legacy feature visible.
$spec_features = array_filter( $features, function( $f ) {
    if ( isset( $f['show_on_pdp'] ) && (int) $f['show_on_pdp'] === 0 ) {
        return false;
    }
    $has_val = ! empty( $f['variant_name'] ) || ! empty( $f['value_str'] ) || ! empty( $f['value_text'] )
        || ( isset( $f['value_int'] ) && $f['value_int'] !== '' && $f['value_int'] !== null )
        || ( isset( $f['value_float'] ) && $f['value_float'] !== '' && $f['value_float'] !== null )
        || ! empty( $f['value_date'] );
    return $has_val;
} );

// ── Weight conversion ──
// Product stores weight in its own weight_unit (kg/g/lb/oz).
// Global setting weight_symbol + weight_in_grams define the display unit.
$_p_weight_raw  = (float) ( $product['weight'] ?? 0 );
$_p_weight_unit = $product['weight_unit'] ?? 'kg';
$_display_w_sym = \TitanCart\Services\SettingsService::get( 'general', 'weight_symbol', 'lbs' );
$_w_in_grams    = (float) \TitanCart\Services\SettingsService::get( 'general', 'weight_in_grams', '453.6' );
// Grams per stored unit.
$_grams_map = [ 'kg' => 1000, 'g' => 1, 'lb' => 453.592, 'oz' => 28.3495 ];
$_stored_g  = $_grams_map[ $_p_weight_unit ] ?? 1000;
// Convert: stored → grams → display unit.
$_p_weight = $_p_weight_raw > 0 && $_w_in_grams > 0
    ? ( $_p_weight_raw * $_stored_g ) / $_w_in_grams
    : 0;

// ── Dimensions conversion ──
// Product stores in its own dimension_unit (cm/mm/in).
// Global setting dimensions_unit defines the display unit.
$_p_length_raw = (float) ( $product['length'] ?? 0 );
$_p_width_raw  = (float) ( $product['width']  ?? 0 );
$_p_height_raw = (float) ( $product['height'] ?? 0 );
$_p_dim_unit   = $product['dimension_unit'] ?? 'cm';
$_display_dim  = \TitanCart\Services\SettingsService::get( 'general', 'dimensions_unit', 'in' );
// Centimetres per unit.
$_cm_map     = [ 'cm' => 1, 'mm' => 0.1, 'in' => 2.54 ];
$_stored_cm  = $_cm_map[ $_p_dim_unit ]  ?? 1;
$_display_cm = $_cm_map[ $_display_dim ] ?? 2.54;
// Convert: stored → cm → display unit.
$_conv_dim = fn( float $v ) => $v > 0 ? ( $v * $_stored_cm ) / $_display_cm : 0;
$_p_length = $_conv_dim( $_p_length_raw );
$_p_width  = $_conv_dim( $_p_width_raw );
$_p_height = $_conv_dim( $_p_height_raw );

$_has_physical = $_p_weight > 0 || $_p_length > 0 || $_p_width > 0 || $_p_height > 0;

// ── Build tabs (S125 — wires Custom Tabs B10) ─────────────────────────────
// Source: $product['tabs'] — resolved by StorefrontModule from
// tc_product_tabs honoring admin status overrides, content overrides,
// position ordering, and product-specific tabs. This block applies
// content-availability auto-hide for built-in tabs (so an Attributes
// tab with no spec data doesn't render as an empty panel) and falls
// back to a hardcoded baseline if the resolver returned nothing usable
// (defensive — should not normally trigger because Installer seeds
// the four primary tabs and seed_default_tabs() is idempotent).
//
// Tab entry shape after this block:
//   key      string  data-tab / data-panel attribute (e.g. 'description')
//   label    string  visible nav button text
//   type     string  'builtin' | 'richtext' | 'sizing_chart' | 'template'
//   content  string  raw markup for richtext (post-content_override)
//   template string  template path for type='template' (extension contract)
//   id       int     row id from tc_product_tabs (0 for fallback baseline)
$tabs          = [];
$resolved_tabs = isset( $product['tabs'] ) && is_array( $product['tabs'] ) ? $product['tabs'] : [];

foreach ( $resolved_tabs as $_rt ) {
    if ( ! is_array( $_rt ) ) continue;
    $_tk = (string) ( $_rt['tab_key']  ?? '' );
    $_tt = (string) ( $_rt['tab_type'] ?? 'richtext' );
    if ( $_tk === '' ) continue;

    if ( $_tt === 'builtin' ) {
        // Attributes hides when there's no spec data. Description and
        // Reviews always show when active — they have empty-state UI
        // inside the panel itself.
        if ( $_tk === 'attributes' && empty( $spec_features ) && ! $_has_physical ) continue;
    } elseif ( $_tt === 'sizing_chart' ) {
        if ( ! $sizing ) continue;
    } elseif ( $_tt === 'richtext' ) {
        $_rt_content = (string) ( $_rt['content'] ?? '' );
        if ( trim( wp_strip_all_tags( $_rt_content ) ) === '' ) continue;
    } elseif ( $_tt === 'template' ) {
        // Template tabs are owned by the registering extension/theme.
        // Drop if no template_path provided.
        if ( empty( $_rt['template_path'] ) ) continue;
    } else {
        // Unknown tab type — drop for safety.
        continue;
    }

    $tabs[] = [
        'key'      => $_tk,
        'label'    => (string) ( $_rt['title'] ?? '' ),
        'type'     => $_tt,
        'content'  => (string) ( $_rt['content']       ?? '' ),
        'template' => (string) ( $_rt['template_path'] ?? '' ),
        'id'       => (int)    ( $_rt['id']            ?? 0 ),
    ];
}

// Fallback baseline. Restores the pre-S125 hardcoded list when the
// resolver returned nothing — e.g. on a fresh install where the seed
// migration hasn't run, or where all global tabs were manually deleted.
if ( empty( $tabs ) ) {
    $tabs[] = [ 'key' => 'description', 'label' => 'Description',      'type' => 'builtin', 'content' => '', 'template' => '', 'id' => 0 ];
    $tabs[] = [ 'key' => 'reviews',     'label' => 'Customer Reviews', 'type' => 'builtin', 'content' => '', 'template' => '', 'id' => 0 ];
    if ( $sizing ) {
        $tabs[] = [ 'key' => 'sizing_chart', 'label' => 'Sizing Chart', 'type' => 'sizing_chart', 'content' => '', 'template' => '', 'id' => 0 ];
    }
    if ( ! empty( $spec_features ) || $_has_physical ) {
        $tabs[] = [ 'key' => 'attributes', 'label' => 'Specifications', 'type' => 'builtin', 'content' => '', 'template' => '', 'id' => 0 ];
    }
}

$_tc_is_theme = defined( 'TITANCART_THEME_ACTIVE' );
$_last_crumb  = ! empty( $breadcrumb ) ? $breadcrumb[ count( $breadcrumb ) - 1 ]['name'] : '';

// ─── Schema.org JSON-LD (S199) ─────────────────────────────────────
// Inline emission keeps $product in local scope and avoids wp_head
// global-state bridging. SchemaService::product_jsonld() returns the
// full ld+json structured-data block (built via core tag APIs), or empty
// string when the payload would be unusable (missing name, etc.).
// Marketplace extension contributes the seller block via the
// titancart_jsonld_payload filter when $product['vendor'] is set.
// Structured data is a SEO-extension feature → gate on its toggle so switching
// the SEO extension off disables JSON-LD too (KI-35, S352). SEO ships in Lite.
if ( \TitanCart\REST\Extensions\ExtensionsController::is_active( 'seo' ) ) {
    \TitanCart\Services\SchemaService::print_product_jsonld( $product ); // WP.org R2 item 6: core API prints the ld+json block — no echo-of-variable.
}
?>

<?php if ( $_tc_is_theme ) :
  $_b_cls = 'tc-shop__banner tc-shop__banner--theme';
  $_b_sty = ''; $_b_ov = '';
  $_b_img      = get_theme_mod( 'tct_shop_banner_image' );
  $_b_img_dark = get_theme_mod( 'tct_shop_banner_image_dark' );
  if ( $_b_img ) {
      $_b_url      = wp_get_attachment_image_url( (int) $_b_img, 'full' );
      $_b_url_dark = $_b_img_dark ? wp_get_attachment_image_url( (int) $_b_img_dark, 'full' ) : '';
      if ( $_b_url ) {
          $_b_pos = get_theme_mod( 'tct_shop_banner_position', 'center center' );
          $_b_ht  = get_theme_mod( 'tct_shop_banner_height', 280 );
          $_b_ov  = get_theme_mod( 'tct_shop_banner_overlay', 'rgba(0,0,0,0.45)' );
          $_b_sty = sprintf( 'background-image:url(%s);background-size:cover;background-position:%s;height:%dpx;',
              esc_url( $_b_url ), esc_attr( $_b_pos ), (int) $_b_ht );
          $_b_cls .= ' tc-shop__banner--has-image';
          // Dark-mode banner image: append --tc-banner-img-dark CSS variable
          // and add the dark-image class. Pairs with the rule in shop.css
          // (line ~264) that swaps background-image when [data-theme="dark"]
          // is active. Inline style is required because the light URL is
          // also inline — otherwise specificity wouldn't match. Mirrors the
          // pattern in archive.php exactly so PDP and shop pages share the
          // same dark-banner customizer setting (tct_shop_banner_image_dark).
          if ( $_b_url_dark ) {
              $_b_sty .= sprintf( '--tc-banner-img-dark:url(%s);', esc_url( $_b_url_dark ) );
              $_b_cls .= ' tc-shop__banner--has-dark-image';
          }
      }
  }
?>
<!-- ═══ Full-Width Page Header Banner (Theme) ═══ -->
<section class="<?php echo esc_attr( $_b_cls ); ?>" style="<?php echo esc_attr( $_b_sty ); ?>">
  <?php if ( $_b_ov ) : ?><div class="tc-shop__banner-overlay" style="background:<?php echo esc_attr( $_b_ov ); ?>;position:absolute;top:0;left:0;right:0;bottom:0;"></div><?php endif; ?>
  <div class="tc-shop__banner-inner">
    <h2 class="tc-shop__banner-title"><?php echo esc_html( $_last_crumb ?: __( 'Product', 'titancart-lite' ) ); ?></h2>
    <nav class="tc-breadcrumb tc-breadcrumb--centered">
      <a href="<?php echo esc_url( home_url( '/' ) ); ?>">Home</a>
      <i class="bi bi-chevron-right"></i>
      <a href="<?php echo esc_url( titancart_shop_url() ); ?>">Shop</a>
      <?php foreach ( $breadcrumb as $crumb ) : ?>
        <i class="bi bi-chevron-right"></i>
        <a href="<?php echo esc_url( $crumb['url'] ); ?>"><?php echo esc_html( $crumb['name'] ); ?></a>
      <?php endforeach; ?>
      <i class="bi bi-chevron-right"></i>
      <span class="tc-breadcrumb__current"><?php echo esc_html( $name ); ?></span>
    </nav>
  </div>
</section>
<?php endif; ?>

<?php
// ── Age Verification Gate (rendered BEFORE product content, fixed-position overlay) ──
if ( $age_gate_active ) : ?>
<div class="tc-age-gate" id="tc-age-gate">
  <div class="tc-age-gate__card">
    <div class="tc-age-gate__icon"><i class="bi bi-shield-exclamation"></i></div>
    <h3 class="tc-age-gate__title">Age Verification Required</h3>
    <p class="tc-age-gate__message"><?php echo esc_html( $age_message ); ?></p>

    <?php if ( $age_verify_type === 'confirm' ) : ?>
    <div class="tc-age-gate__actions">
      <button type="button" class="tc-age-gate__btn tc-age-gate__btn--yes" id="tc-age-confirm">
        I am <?php echo esc_html( $age_limit ); ?>+ years old
      </button>
      <button type="button" class="tc-age-gate__btn tc-age-gate__btn--no" id="tc-age-deny">
        I am under <?php echo esc_html( $age_limit ); ?>
      </button>
    </div>
    <?php elseif ( $age_verify_type === 'birthday' ) : ?>
    <div class="tc-age-gate__birthday">
      <label class="tc-age-gate__label">Enter your date of birth:</label>
      <input type="date" id="tc-age-dob" class="tc-age-gate__input"
             max="<?php echo esc_attr( gmdate( 'Y-m-d' ) ); ?>">
      <button type="button" class="tc-age-gate__btn tc-age-gate__btn--yes" id="tc-age-dob-submit">Verify</button>
    </div>
    <?php elseif ( $age_verify_type === 'checkbox' ) : ?>
    <div class="tc-age-gate__checkbox">
      <label class="tc-age-gate__check-label">
        <input type="checkbox" id="tc-age-check">
        I confirm I am <?php echo esc_html( $age_limit ); ?> years of age or older
      </label>
      <button type="button" class="tc-age-gate__btn tc-age-gate__btn--yes" id="tc-age-check-submit" disabled>Continue</button>
    </div>
    <?php endif; ?>
  </div>
</div>
<?php
// WP.org R2: inline JS is a NOWDOC (no interpolation) with %%TOKEN%% slots filled by
// strtr() from wp_json_encode'd / int-cast values, then attached via
// wp_add_inline_script on a footer stub handle. No output buffering, no markup literals.
$tc_age_limit = (int) $age_limit;
$tc_age_deny  = wp_json_encode( $age_deny_action );
$tc_age_rem   = (int) $age_remember;
$tc_age_home  = wp_json_encode( home_url( '/' ) );
$tc_age_js    = <<<'JS'
(function(){
  var gate = document.getElementById('tc-age-gate');
  if (!gate) return;
  var AGE_LIMIT = %%AGE_LIMIT%%;
  var DENY_ACTION = %%DENY_ACTION%%;
  var REMEMBER = %%REMEMBER%%;
  var HOME = %%HOME%%;

  function accept() {
    // Store the verified age (a true lower bound — confirm/checkbox assert >= the
    // limit, birthday only calls accept() when age >= the limit). The server-side
    // cart/checkout guard reads this. Always set a cookie so the guard can see the
    // verification; when REMEMBER is 0 it's a session cookie (forgotten on close).
    var c = 'tc_age_verified=' + AGE_LIMIT + ';path=/;SameSite=Lax';
    if (REMEMBER > 0) {
      var d = new Date(); d.setTime(d.getTime() + REMEMBER*86400000);
      c += ';expires=' + d.toUTCString();
    }
    document.cookie = c;
    gate.style.opacity = '0';
    setTimeout(function(){ gate.style.display = 'none'; }, 250);
  }
  function deny() {
    if (DENY_ACTION === 'redirect') { window.location.href = HOME; }
    else if (DENY_ACTION === 'message') {
      gate.querySelector('.tc-age-gate__card').innerHTML = '<div class="tc-age-gate__icon"><i class="bi bi-x-circle" style="color:#dc2626"></i></div><h3 class="tc-age-gate__title">Access Denied</h3><p class="tc-age-gate__message">Sorry, you must be at least ' + AGE_LIMIT + ' years old to view this product.</p><a href="' + HOME + '" class="tc-age-gate__btn tc-age-gate__btn--yes" style="text-decoration:none;margin-top:16px">Return to Homepage</a>';
    }
    else { window.location.href = HOME; }
  }

  // Confirm type
  var yesBtn = document.getElementById('tc-age-confirm');
  var noBtn  = document.getElementById('tc-age-deny');
  if (yesBtn) yesBtn.addEventListener('click', accept);
  if (noBtn)  noBtn.addEventListener('click', deny);

  // Birthday type
  var dobBtn = document.getElementById('tc-age-dob-submit');
  if (dobBtn) dobBtn.addEventListener('click', function() {
    var dob = document.getElementById('tc-age-dob').value;
    if (!dob) return;
    var bd = new Date(dob);
    var now = new Date();
    var age = now.getFullYear() - bd.getFullYear();
    var m = now.getMonth() - bd.getMonth();
    if (m < 0 || (m === 0 && now.getDate() < bd.getDate())) age--;
    if (age >= AGE_LIMIT) accept(); else deny();
  });

  // Checkbox type
  var chk = document.getElementById('tc-age-check');
  var chkBtn = document.getElementById('tc-age-check-submit');
  if (chk && chkBtn) {
    chk.addEventListener('change', function(){ chkBtn.disabled = !chk.checked; });
    chkBtn.addEventListener('click', function(){ if (chk.checked) accept(); });
  }
})();
JS;
$tc_age_js = strtr( $tc_age_js, [
	'%%AGE_LIMIT%%'   => (string) $tc_age_limit,
	'%%DENY_ACTION%%' => $tc_age_deny,
	'%%REMEMBER%%'    => (string) $tc_age_rem,
	'%%HOME%%'        => $tc_age_home,
] );
wp_register_script( 'titancart-single-inline', false, array(), TITANCART_VERSION, true );
wp_enqueue_script( 'titancart-single-inline' );
wp_add_inline_script( 'titancart-single-inline', $tc_age_js );
?>
<?php endif; // age gate ?>

<div class="tc-pdp<?php echo $pdp_layout !== 'default' ? ' tc-pdp--' . esc_attr( $pdp_layout ) : ''; ?>" data-product-id="<?php echo esc_attr( $product_id ); ?>"
     data-api="<?php echo esc_url( $api_url ); ?>" data-nonce="<?php echo esc_attr( $nonce ); ?>"
     data-allow-compare="<?php echo $allow_compare ? '1' : '0'; ?>"
     data-wishlist-on="<?php echo $wishlist_enabled ? '1' : '0'; ?>">

  <?php if ( ! $_tc_is_theme ) : ?>
  <!-- ═══ Breadcrumb (Plugin) ═══ -->
  <nav class="tc-breadcrumb">
    <a href="<?php echo esc_url( titancart_shop_url() ); ?>">Home</a>
    <span class="tc-breadcrumb__sep"><svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l5 5-5 5"/></svg></span>
    <?php foreach ( $breadcrumb as $crumb ) : ?>
      <a href="<?php echo esc_url( $crumb['url'] ); ?>"><?php echo esc_html( $crumb['name'] ); ?></a>
      <span class="tc-breadcrumb__sep"><svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l5 5-5 5"/></svg></span>
    <?php endforeach; ?>
    <span class="tc-breadcrumb__current"><?php echo esc_html( $name ); ?></span>
  </nav>
  <?php endif; ?>

  <!-- ═══ Product: Gallery + Info ═══ -->
  <div class="tc-pdp__main">

    <!-- Gallery -->
    <?php $show_thumbs = $thumbnail_gallery && ( ( ! empty( $images ) && count( $images ) > 1 ) || $has_videos ); ?>
    <div class="tc-pdp__gallery<?php echo ! $show_thumbs ? ' tc-pdp__gallery--no-thumbs' : ''; ?>">
      <?php if ( ! empty( $images ) || $has_videos ) : ?>
        <?php if ( $show_thumbs ) : ?>
        <div class="tc-pdp__thumbs-wrap">
          <button class="tc-pdp__thumbs-arrow tc-pdp__thumbs-arrow--up" aria-label="Scroll thumbnails up" style="display:none;">
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10l4-4 4 4"/></svg>
          </button>
          <div class="tc-pdp__thumbs">
            <?php if ( $show_videos_before ) : ?>
              <?php foreach ( $video_thumbs as $vi => $vt ) : ?>
                <button class="tc-pdp__thumb tc-pdp__thumb--video" data-video-embed="<?php echo esc_url( $vt['embed'] ); ?>" title="Play video">
                  <?php if ( $vt['thumb'] ) : ?>
                    <img src="<?php echo esc_url( $vt['thumb'] ); ?>" alt="Video" loading="lazy" />
                  <?php else : ?>
                    <span class="tc-pdp__thumb-video-icon">▶</span>
                  <?php endif; ?>
                  <span class="tc-pdp__thumb-play">▶</span>
                </button>
              <?php endforeach; ?>
            <?php endif; ?>
            <?php foreach ( $images as $i => $img ) : ?>
              <button class="tc-pdp__thumb<?php echo $i === 0 ? ' tc-pdp__thumb--active' : ''; ?>"
                      data-index="<?php echo esc_attr( $i ); ?>"
                      data-src="<?php echo esc_url( $img['url'] ); ?>">
                <img src="<?php echo esc_url( $img['thumbnail'] ?: $img['thumb'] ); ?>"
                     alt="<?php echo esc_attr( $img['alt_text'] ?? $name ); ?>" loading="lazy" />
              </button>
            <?php endforeach; ?>
            <?php if ( ! $show_videos_before ) : ?>
              <?php foreach ( $video_thumbs as $vi => $vt ) : ?>
                <button class="tc-pdp__thumb tc-pdp__thumb--video" data-video-embed="<?php echo esc_url( $vt['embed'] ); ?>" title="Play video">
                  <?php if ( $vt['thumb'] ) : ?>
                    <img src="<?php echo esc_url( $vt['thumb'] ); ?>" alt="Video" loading="lazy" />
                  <?php else : ?>
                    <span class="tc-pdp__thumb-video-icon">▶</span>
                  <?php endif; ?>
                  <span class="tc-pdp__thumb-play">▶</span>
                </button>
              <?php endforeach; ?>
            <?php endif; ?>
          </div>
          <button class="tc-pdp__thumbs-arrow tc-pdp__thumbs-arrow--down" aria-label="Scroll thumbnails down" style="display:none;">
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
          </button>
        </div>
        <?php endif; ?>
        <div class="tc-pdp__main-img-wrap">
          <?php
          // Product Ribbons (core) — rule-based stack (Sale -X% / New / Featured /
          // Best Seller). Replaces the native -X% badge; effective price/list_price
          // passed so the Sale ribbon matches the displayed discount.
          echo wp_kses_post( titancart_product_ribbons_html( array_merge( $product, [ 'price' => $price, 'list_price' => $list_price ] ), $discount > 0 ) ); // WP.org R2 item 6: kses_post at echo (div/span + class/style — safecss allows background).
          ?>
          <?php if ( ! empty( $images ) ) : ?>
            <img id="tc-pdp-main-img" src="<?php echo esc_url( $images[0]['url'] ); ?>"
                 alt="<?php echo esc_attr( $images[0]['alt_text'] ?? $name ); ?>" class="tc-pdp__main-img" />
          <?php elseif ( $has_videos && $video_thumbs[0]['thumb'] ) : ?>
            <img id="tc-pdp-main-img" src="<?php echo esc_url( $video_thumbs[0]['thumb'] ); ?>"
                 alt="Video thumbnail" class="tc-pdp__main-img" style="cursor:pointer" data-video-embed="<?php echo esc_url( $video_thumbs[0]['embed'] ); ?>" />
          <?php endif; ?>
        </div>
      <?php else : ?>
        <div class="tc-pdp__main-img-wrap tc-pdp__main-img-wrap--empty">
          <svg width="64" height="64" viewBox="0 0 64 64" fill="none" stroke="#D4D4D4" stroke-width="1.2">
            <rect x="6" y="6" width="52" height="52" rx="6"/><circle cx="24" cy="24" r="5"/>
            <path d="M6 46l14-14 10 10 8-8 20 20"/></svg>
        </div>
      <?php endif; ?>
    </div>

    <!-- Info -->
    <div class="tc-pdp__info">

      <?php
      // Brand eyebrow (S31) — show the BRAND above the title (mirrors the product
      // cards), replacing the old category label. Prefer the list-resolved value;
      // otherwise scan the purpose='brand' feature (the PDP's source). Value only,
      // no "Brand" label. Honors show_on_pdp so a filter-only brand stays hidden.
      $pdp_brand = trim( (string) ( $product['brand'] ?? '' ) );
      if ( $pdp_brand === '' && ! empty( $features ) ) {
          foreach ( $features as $_bf ) {
              if ( ( $_bf['feature_purpose'] ?? '' ) === 'brand'
                   && (int) ( $_bf['show_on_pdp'] ?? 1 ) !== 0
                   && trim( (string) ( $_bf['variant_name'] ?? '' ) ) !== '' ) {
                  $pdp_brand = trim( (string) $_bf['variant_name'] );
                  break;
              }
          }
      }
      ?>
      <?php if ( $pdp_brand !== '' ) : ?>
        <p class="tc-pdp__brand"><?php echo esc_html( $pdp_brand ); ?></p>
      <?php endif; ?>

      <h2 class="tc-pdp__title"><?php echo esc_html( $name ); ?></h2>

      <div class="tc-pdp__rating">
        <span class="tc-stars"><?php for ( $s = 1; $s <= 5; $s++ ) echo $s <= round( $review_avg ) ? '★' : '☆'; ?></span>
        <span class="tc-pdp__rating-text">(<?php echo esc_html( $review_count ); ?> reviews)</span>
      </div>

      <?php if ( $_hide_price ) : ?>
        <div class="tc-pdp__price">
          <a href="<?php echo esc_url( home_url( '/my-account/' ) ); ?>" style="color:#2563EB;font-weight:600"><?php esc_html_e( 'Login to see price', 'titancart-lite' ); ?></a>
        </div>
      <?php else : ?>
        <div class="tc-pdp__price">
          <span class="tc-pdp__price-now" id="tc-pdp-price" data-base-price="<?php echo esc_attr( number_format( $price, 4, '.', '' ) ); ?>" data-symbol="<?php echo esc_attr( $currency ); ?>"><?php echo esc_html( $currency . number_format( $price, 2 ) ); ?></span>
          <?php if ( $list_price > $price ) : ?>
            <del class="tc-pdp__price-was"><?php echo esc_html( $currency . number_format( $list_price, 2 ) ); ?></del>
            <span class="tc-pdp__price-discount">-<?php echo esc_html( $discount ); ?>%</span>
          <?php endif; ?>
          <?php if ( $show_tax_on_catalog ) : ?>
            <span class="tc-pdp__tax-note" style="font-size:12px;color:#737373;margin-left:8px"><?php esc_html_e( 'incl. tax', 'titancart-lite' ); ?></span>
          <?php endif; ?>
          <?php echo wp_kses_post( $ppu_pdp_html ); // WP.org R2 item 6: kses_post at echo. ?>
        </div>
      <?php endif; ?>

      <?php
      // ═══ Price-meta pills (S124) ═════════════════════════════════════════
      // Reward Points "Earn N points" + "Pay with N points" pills render here,
      // close to the price stack. Gated by ! $_hide_price so login-walled
      // products never leak price information through points displays.
      // Also fires for any future price-context badges (volume tiers, etc).
      if ( ! $_hide_price ) {
          do_action( 'titancart_pdp_price_meta', $product );
      }
      ?>

      <?php
      // ═══ Product Tags ════════════════════════════════════════════════════
      // S123: Render tag chips between price-meta and short description. Each
      // chip links to /product-tag/{slug}/ — handled by SeoService rewrite rule.
      // Tags are loaded by StorefrontModule's tc_product enrichment block.
      $product_tags = is_array( $product['tags'] ?? null ) ? $product['tags'] : [];
      if ( ! empty( $product_tags ) ) : ?>
        <div class="tc-pdp__tags" aria-label="<?php esc_attr_e( 'Product tags', 'titancart-lite' ); ?>">
          <?php foreach ( $product_tags as $_tag ) :
              $_tag_name = $_tag['name'] ?? '';
              $_tag_slug = $_tag['slug'] ?? '';
              if ( $_tag_name === '' || $_tag_slug === '' ) continue; ?>
            <a class="tc-pdp__tag-chip"
               href="<?php echo esc_url( \TitanCart\Services\SeoService::tag_url( $_tag_slug ) ); ?>"
               rel="tag">
              <i class="bi bi-tag" aria-hidden="true"></i><span><?php echo esc_html( $_tag_name ); ?></span>
            </a>
          <?php endforeach; ?>
        </div>
      <?php endif; ?>

      <?php if ( $short_desc ) : ?>
        <p class="tc-pdp__short-desc"><?php echo wp_kses_post( $short_desc ); ?></p>
      <?php endif; ?>

      <?php
      // ═══ Bulk Discounts (Quantity Tiers) ═════════════════════════════════
      // S120: Surface tc_product_prices rows where min_quantity > 1 and the
      // cached effective price beats the base price. Mirrors CartManager's
      // current_usergroup_ids() resolution so the displayed tier price is
      // exactly what cart will charge for this viewer.
      $_viewer_groups = [ 0 ]; // 0 = "All" — every customer always belongs.
      if ( is_user_logged_in() ) {
          $_db_t = titancart_db();
          $_uid  = (int) $_db_t->getField(
              "SELECT id FROM {$_db_t->table('customers')} WHERE wp_user_id = ?i LIMIT 1",
              get_current_user_id()
          );
          if ( $_uid ) {
              $_extra = $_db_t->getColumn(
                  "SELECT group_id FROM {$_db_t->table('customer_usergroups')} WHERE customer_id = ?i",
                  $_uid
              );
              if ( ! empty( $_extra ) ) {
                  $_viewer_groups = array_unique( array_merge( $_viewer_groups, array_map( 'intval', $_extra ) ) );
              }
          }
      }

      $_tiers = [];
      if ( ! empty( $product['prices'] ) && is_array( $product['prices'] ) ) {
          foreach ( $product['prices'] as $_pr ) {
              $_qty = max( 1, (int) ( $_pr['min_quantity'] ?? 1 ) );
              $_grp = (int) ( $_pr['usergroup_id'] ?? 0 );
              $_p   = (float) ( $_pr['price'] ?? 0 );
              if ( $_qty <= 1 )                                 continue; // Skip base "1+" tier.
              if ( ! in_array( $_grp, $_viewer_groups, true ) ) continue; // Not for this viewer.
              if ( $_p <= 0 || $_p >= $price )                  continue; // Not actually a discount.
              // For each min_quantity, keep the lowest price across applicable groups.
              if ( ! isset( $_tiers[ $_qty ] ) || $_p < $_tiers[ $_qty ]['price'] ) {
                  $_tiers[ $_qty ] = [ 'qty' => $_qty, 'price' => $_p ];
              }
          }
          ksort( $_tiers, SORT_NUMERIC );
      }
      ?>
      <?php if ( ! empty( $_tiers ) && ! $_hide_price && ! $_deny_cart ) : ?>
      <div class="tc-pdp__tiers" aria-label="<?php esc_attr_e( 'Quantity discounts', 'titancart-lite' ); ?>">
        <div class="tc-pdp__tiers-title"><?php esc_html_e( 'Buy more, save more', 'titancart-lite' ); ?></div>
        <div class="tc-pdp__tiers-grid">
          <?php foreach ( $_tiers as $_t ) :
              $_save_pct = $price > 0 ? (int) round( ( ( $price - $_t['price'] ) / $price ) * 100 ) : 0;
          ?>
            <div class="tc-pdp__tier" data-min-qty="<?php echo esc_attr( $_t['qty'] ); ?>">
              <div class="tc-pdp__tier-qty"><?php echo esc_html( $_t['qty'] ); ?>+</div>
              <div class="tc-pdp__tier-price"><?php echo esc_html( $currency . number_format( $_t['price'], 2 ) ); ?></div>
              <?php if ( $_save_pct > 0 ) : ?>
                <?php /* translators: %d: discount percentage. */ ?>
                <div class="tc-pdp__tier-save"><?php echo esc_html( sprintf( __( 'Save %d%%', 'titancart-lite' ), $_save_pct ) ); ?></div>
              <?php endif; ?>
            </div>
          <?php endforeach; ?>
        </div>
      </div>
      <?php endif; ?>

      <?php do_action( 'titancart_pdp_after_price', $product ); ?>

      <!-- ═══ Variation Selectors ═══ -->
      <?php
      $vg = $product['variation_group'] ?? null;
      if ( $vg && ! empty( $vg['axes'] ) ) :
        $current_combo = $vg['current_combo'] ?? [];
      ?>
        <div class="tc-pdp__variations" id="tc-variations"
             data-members="<?php echo esc_attr( wp_json_encode( $vg['members'] ) ); ?>"
             data-current="<?php echo esc_attr( wp_json_encode( $current_combo ) ); ?>">
          <?php foreach ( $vg['axes'] as $axis ) :
            $fid      = $axis['feature_id'];
            $atype    = $axis['type']; // E=Color Swatches, T=Size Pills, S=Dropdown
            $selected = isset( $current_combo[ $fid ] ) ? (int) $current_combo[ $fid ] : 0;
          ?>
            <div class="tc-pdp__var-axis" data-feature="<?php echo esc_attr( $fid ); ?>">
              <p class="tc-pdp__var-label">
                Select <?php echo esc_html( $axis['name'] ); ?>
                <?php if ( $sizing && strtolower( $axis['name'] ) === 'size' ) : ?>
                  <a href="#" class="tc-pdp__size-guide-link" onclick="document.querySelector('[data-tab=sizing]').click();return false;">Size Guide</a>
                <?php endif; ?>
              </p>

              <?php if ( $atype === 'E' ) : ?>
                <div class="tc-pdp__var-swatches">
                  <?php foreach ( $axis['variants'] as $v ) :
                    $active = (int) $v['id'] === $selected ? ' tc-pdp__var-swatch--active' : '';
                    $hex    = $v['color_hex'] ?: '';
                  ?>
                    <button type="button" class="tc-pdp__var-swatch<?php echo esc_attr( $active ); ?>"
                            data-feature="<?php echo esc_attr( $fid ); ?>"
                            data-variant="<?php echo esc_attr( $v['id'] ); ?>"
                            data-hex="<?php echo esc_attr( $hex ); ?>"
                            title="<?php echo esc_attr( $v['name'] ); ?>">
                      <span class="tc-pdp__var-swatch-circle" style="background:<?php echo esc_attr( $hex ?: '#ccc' ); ?>"></span>
                    </button>
                  <?php endforeach; ?>
                </div>

              <?php elseif ( $atype === 'I' ) : ?>
                <?php
                // Build variant_id → member image lookup for this axis
                $var_images = [];
                foreach ( $vg['members'] as $_m ) {
                    $combo = $_m['combination'] ?? [];
                    if ( isset( $combo[ $fid ] ) && ! empty( $_m['image'] ) ) {
                        $var_images[ (int) $combo[ $fid ] ] = $_m['image'];
                    }
                }
                ?>
                <div class="tc-pdp__var-images">
                  <?php foreach ( $axis['variants'] as $v ) :
                    $active   = (int) $v['id'] === $selected ? ' tc-pdp__var-img--active' : '';
                    $img_url  = $var_images[ (int) $v['id'] ] ?? '';
                  ?>
                    <button type="button" class="tc-pdp__var-img<?php echo esc_attr( $active ); ?>"
                            data-feature="<?php echo esc_attr( $fid ); ?>"
                            data-variant="<?php echo esc_attr( $v['id'] ); ?>"
                            title="<?php echo esc_attr( $v['name'] ); ?>">
                      <?php if ( $img_url ) : ?>
                        <img src="<?php echo esc_url( $img_url ); ?>" alt="<?php echo esc_attr( $v['name'] ); ?>" loading="lazy" />
                      <?php else : ?>
                        <span class="tc-pdp__var-img-placeholder"><?php echo esc_html( mb_substr( $v['name'], 0, 2 ) ); ?></span>
                      <?php endif; ?>
                    </button>
                  <?php endforeach; ?>
                </div>

              <?php elseif ( $atype === 'S' ) : ?>
                <select class="tc-select tc-pdp__var-select" data-feature="<?php echo esc_attr( $fid ); ?>">
                  <option value="">Choose <?php echo esc_attr( $axis['name'] ); ?></option>
                  <?php foreach ( $axis['variants'] as $v ) : ?>
                    <option value="<?php echo esc_attr( $v['id'] ); ?>" <?php selected( (int) $v['id'], $selected ); ?>>
                      <?php echo esc_html( $v['name'] ); ?>
                    </option>
                  <?php endforeach; ?>
                </select>

              <?php else : /* T = Size Pills, or any other type */ ?>
                <div class="tc-pdp__var-pills">
                  <?php foreach ( $axis['variants'] as $v ) :
                    $active = (int) $v['id'] === $selected ? ' tc-pdp__var-pill--active' : '';
                  ?>
                    <button type="button" class="tc-pdp__var-pill<?php echo esc_attr( $active ); ?>"
                            data-feature="<?php echo esc_attr( $fid ); ?>"
                            data-variant="<?php echo esc_attr( $v['id'] ); ?>">
                      <?php echo esc_html( $v['name'] ); ?>
                    </button>
                  <?php endforeach; ?>
                </div>
              <?php endif; ?>
            </div>
          <?php endforeach; ?>
        </div>
      <?php endif; ?>

      <!-- ═══ Static Attribute Indicators (single-value color swatches / size pills) ═══ -->
      <?php
      // Color-name-to-hex fallback map (same values as JS CMAP used for variation swatches).
      $tc_cmap = [
          'red'=>'#EF4444','blue'=>'#3B82F6','green'=>'#22C55E','yellow'=>'#EAB308',
          'orange'=>'#F97316','purple'=>'#A855F7','pink'=>'#EC4899','black'=>'#171717',
          'white'=>'#FAFAFA','gray'=>'#6B7280','grey'=>'#6B7280','navy'=>'#1E3A5F',
          'beige'=>'#D4B896','gold'=>'#D4AF37','silver'=>'#C0C0C0','teal'=>'#14B8A6',
          'coral'=>'#F87171','indigo'=>'#6366F1','brown'=>'#92400E','cream'=>'#FFFDD0',
          'maroon'=>'#800000','olive'=>'#808000','cyan'=>'#06B6D4','magenta'=>'#D946EF',
          'charcoal'=>'#374151','ivory'=>'#FFFFF0','lavender'=>'#A78BFA','mint'=>'#34D399',
          'peach'=>'#FDBA74','rose'=>'#FB7185','burgundy'=>'#7F1D1D','tan'=>'#D2B48C',
          'turquoise'=>'#2DD4BF','khaki'=>'#BDB76B','salmon'=>'#FA8072','plum'=>'#9333EA',
          'slate'=>'#64748B','wine'=>'#722F37','rust'=>'#B45309','sand'=>'#C2B280',
          'sky blue'=>'#38BDF8','forest green'=>'#166534','hot pink'=>'#EC4899',
          'light blue'=>'#93C5FD','dark blue'=>'#1E40AF','light green'=>'#86EFAC',
          'dark green'=>'#166534','light gray'=>'#D1D5DB','dark gray'=>'#374151',
          'space gray'=>'#4B5563',
      ];

      // Determine which feature IDs are already shown as interactive variation selectors.
      $vg_axis_fids = [];
      if ( $vg && ! empty( $vg['axes'] ) ) {
          foreach ( $vg['axes'] as $_ax ) {
              $vg_axis_fids[] = (int) $_ax['feature_id'];
          }
      }

      // Collect features that should render as visual indicators:
      // - Must have a variant_id and variant_name (select-type with a chosen value)
      // - Type E (color swatch) OR purpose variation_split/variation_shared (size pill)
      // - NOT already rendered as an interactive variation axis
      // - NOT brand-purpose attributes (brands display as text, not swatches)
      // Group by feature_id so multiple values (e.g. two colors) share one label row.
      $visual_groups = []; // keyed by feature_id
      foreach ( $features as $_f ) {
          $fid     = (int) ( $_f['feature_id'] ?? 0 );
          $ftype   = $_f['feature_type']   ?? '';
          $fpurp   = $_f['feature_purpose'] ?? '';
          $vname   = $_f['variant_name']   ?? '';
          $vid     = (int) ( $_f['variant_id'] ?? 0 );
          $hex     = $_f['color_hex']      ?? '';

          // #5: a feature flagged show_on_pdp = 0 (pure storefront filter, e.g.
          // Color Family) never renders on the PDP, even as a visual swatch.
          // Default 1 (column absent / NULL) preserves every legacy feature.
          if ( isset( $_f['show_on_pdp'] ) && (int) $_f['show_on_pdp'] === 0 ) continue;

          if ( ! $vid || $vname === '' ) continue;
          if ( in_array( $fid, $vg_axis_fids, true ) ) continue;

          // For type E (color — not brand), resolve hex via DB value or CMAP name-based fallback.
          $is_brand = ( $fpurp === 'brand' );
          if ( $ftype === 'E' && ! $is_brand && ! $hex ) {
              $hex = $tc_cmap[ strtolower( trim( $vname ) ) ] ?? '';
          }

          $is_color = ( $ftype === 'E' && ! $is_brand );
          $is_variation_attr = in_array( $fpurp, [ 'variation_split', 'variation_shared' ], true );

          // S31 — brand no longer renders as an attribute row; it's shown as the
          // eyebrow above the product title (mirrors the cards). Only colors +
          // variation attributes remain as visual indicator rows.
          if ( $is_color || $is_variation_attr ) {
              // Brands always render as text, even if feature_type is E.
              $effective_type = $is_brand ? 'brand' : $ftype;
              if ( ! isset( $visual_groups[ $fid ] ) ) {
                  $visual_groups[ $fid ] = [
                      'feature_id'   => $fid,
                      'feature_name' => $_f['feature_name'] ?? '',
                      'type'         => $effective_type,
                      'purpose'      => $fpurp,
                      'values'       => [],
                  ];
              }
              $visual_groups[ $fid ]['values'][] = [
                  'variant_id'   => $vid,
                  'variant_name' => $vname,
                  'color_hex'    => $hex,
                  'image_url'    => $_f['variant_image_url'] ?? '',
                  'image_id'     => (int) ( $_f['image_id'] ?? 0 ),
                  // S478: per-variant price modifier of a shared variation — the product's own
                  // amount or the attribute's site-wide amount (titancart.php helper decides).
                  'modifier'     => ( $fpurp === 'variation_shared' && function_exists( 'titancart_product_shared_variation_prices' ) )
                      ? (float) ( titancart_product_shared_variation_prices( $product_id )[ (int) $fid ][ (int) $vid ] ?? 0 )
                      : 0.0,
              ];
          }
      }

      if ( ! empty( $visual_groups ) ) :
      ?>
        <div class="tc-pdp__attr-indicators">
          <?php foreach ( $visual_groups as $_vg ) :
            $is_shared = ( $_vg['purpose'] === 'variation_shared' && count( $_vg['values'] ) > 1 );
          ?>
            <div class="tc-pdp__attr-indicator<?php echo $is_shared ? ' tc-pdp__shared-variation' : ''; ?>"
                 <?php if ( $is_shared ) : ?>data-feature-id="<?php echo esc_attr( $_vg['feature_id'] ); ?>"<?php endif; ?>>
              <p class="tc-pdp__var-label"><?php echo esc_html( $_vg['feature_name'] ); ?>
                <?php if ( $is_shared && $_vg['type'] !== 'S' ) : // the dropdown already shows the choice — no echo beside the label (Sean, S478) ?>
                  <span class="tc-pdp__var-selected-label" id="tc-shared-label-<?php echo esc_attr( $_vg['feature_id'] ); ?>"><?php echo esc_html( $_vg['values'][0]['variant_name'] ); ?></span>
                <?php endif; ?>
              </p>
              <?php if ( $is_shared && $_vg['type'] === 'S' ) : ?>
                <?php // S478: display type "Dropdown" for a shared variation — one <select>, first choice pre-selected. ?>
                <select class="tc-select tc-pdp__shared-select" data-feature-id="<?php echo esc_attr( $_vg['feature_id'] ); ?>" aria-label="<?php echo esc_attr( $_vg['feature_name'] ); ?>">
                  <?php foreach ( $_vg['values'] as $_vi => $_va ) :
                    $_mod       = (float) ( $_va['modifier'] ?? 0 );
                    $_mod_label = 0.0 !== $_mod ? ( ( $_mod > 0 ? '+' : '-' ) . $currency . number_format( abs( $_mod ), 2 ) ) : '';
                  ?>
                    <option value="<?php echo esc_attr( $_va['variant_id'] ); ?>"
                            data-variant-name="<?php echo esc_attr( $_va['variant_name'] ); ?>"
                            data-price-modifier="<?php echo esc_attr( $_mod ); ?>"
                            <?php selected( $_vi, 0 ); ?>><?php echo esc_html( $_va['variant_name'] . ( $_mod_label ? ' (' . $_mod_label . ')' : '' ) ); ?></option>
                  <?php endforeach; ?>
                </select>
              <?php else : ?>
              <?php foreach ( $_vg['values'] as $_vi => $_va ) : ?>
                <?php if ( $is_shared ) : ?>
                  <?php
                  // S478: "+$5.00" / "-$2.00" label for a variant that changes the price.
                  $_mod       = (float) ( $_va['modifier'] ?? 0 );
                  $_mod_label = 0.0 !== $_mod ? ( ( $_mod > 0 ? '+' : '-' ) . $currency . number_format( abs( $_mod ), 2 ) ) : '';
                  ?>
                  <?php if ( $_vg['type'] === 'I' ) : ?>
                    <?php
                    // Use variant's own image if assigned, fall back to product's main image
                    $vi_url = ! empty( $_va['image_url'] ) ? $_va['image_url'] : '';
                    if ( ! $vi_url ) {
                        $vi_url = ! empty( $images[0]['thumbnail'] ) ? $images[0]['thumbnail'] : ( ! empty( $images[0]['thumb'] ) ? $images[0]['thumb'] : ( $images[0]['url'] ?? '' ) );
                    }
                    // Find matching gallery image index + full-size URL for main image swap
                    $vi_gallery_idx = -1;
                    $vi_full_url    = '';
                    if ( ! empty( $_va['image_id'] ) ) {
                        foreach ( $images as $_gi => $_gimg ) {
                            if ( (int) ( $_gimg['image_id'] ?? $_gimg['id'] ?? 0 ) === (int) $_va['image_id'] ) {
                                $vi_gallery_idx = $_gi;
                                $vi_full_url    = $_gimg['url'] ?? '';
                                break;
                            }
                        }
                    }
                    ?>
                    <button type="button"
                            class="tc-pdp__var-img tc-pdp__shared-btn<?php echo $_vi === 0 ? ' tc-pdp__shared-btn--active tc-pdp__var-img--active' : ''; ?>"
                            data-variant-id="<?php echo esc_attr( $_va['variant_id'] ); ?>"
                            data-variant-name="<?php echo esc_attr( $_va['variant_name'] ); ?>"
                            data-price-modifier="<?php echo esc_attr( $_mod ); ?>"
                            <?php if ( $vi_gallery_idx >= 0 ) : ?>data-gallery-index="<?php echo esc_attr( $vi_gallery_idx ); ?>"<?php endif; ?>
                            <?php if ( $vi_full_url ) : ?>data-full-url="<?php echo esc_url( $vi_full_url ); ?>"<?php endif; ?>
                            title="<?php echo esc_attr( $_va['variant_name'] . ( $_mod_label ? ' (' . $_mod_label . ')' : '' ) ); ?>">
                      <?php if ( $vi_url ) : ?>
                        <img src="<?php echo esc_url( $vi_url ); ?>" alt="<?php echo esc_attr( $_va['variant_name'] ); ?>" loading="lazy" />
                      <?php else : ?>
                        <span class="tc-pdp__var-img-placeholder"><?php echo esc_html( mb_substr( $_va['variant_name'], 0, 2 ) ); ?></span>
                      <?php endif; ?>
                    </button>
                  <?php elseif ( $_vg['type'] === 'E' ) : ?>
                    <button type="button"
                            class="tc-pdp__attr-swatch tc-pdp__shared-btn<?php echo $_vi === 0 ? ' tc-pdp__shared-btn--active' : ''; ?>"
                            data-variant-id="<?php echo esc_attr( $_va['variant_id'] ); ?>"
                            data-variant-name="<?php echo esc_attr( $_va['variant_name'] ); ?>"
                            data-price-modifier="<?php echo esc_attr( $_mod ); ?>"
                            title="<?php echo esc_attr( $_va['variant_name'] . ( $_mod_label ? ' (' . $_mod_label . ')' : '' ) ); ?>">
                      <span class="tc-pdp__var-swatch-circle" style="background:<?php echo esc_attr( $_va['color_hex'] ?: '#ccc' ); ?>"></span>
                    </button>
                  <?php else : ?>
                    <button type="button"
                            class="tc-pdp__attr-pill tc-pdp__shared-btn<?php echo $_vi === 0 ? ' tc-pdp__shared-btn--active' : ''; ?>"
                            data-variant-id="<?php echo esc_attr( $_va['variant_id'] ); ?>"
                            data-variant-name="<?php echo esc_attr( $_va['variant_name'] ); ?>"
                            data-price-modifier="<?php echo esc_attr( $_mod ); ?>">
                      <?php echo esc_html( $_va['variant_name'] ); ?><?php if ( $_mod_label ) : ?> <span class="tc-pdp__pill-mod"><?php echo esc_html( $_mod_label ); ?></span><?php endif; ?>
                    </button>
                  <?php endif; ?>
                <?php else : ?>
                  <?php if ( $_vg['type'] === 'I' ) : ?>
                    <?php
                    $vi_url = ! empty( $_va['image_url'] ) ? $_va['image_url'] : '';
                    if ( ! $vi_url ) {
                        $vi_url = ! empty( $images[0]['thumbnail'] ) ? $images[0]['thumbnail'] : ( ! empty( $images[0]['thumb'] ) ? $images[0]['thumb'] : ( $images[0]['url'] ?? '' ) );
                    }
                    ?>
                    <span class="tc-pdp__var-img" title="<?php echo esc_attr( $_va['variant_name'] ); ?>">
                      <?php if ( $vi_url ) : ?>
                        <img src="<?php echo esc_url( $vi_url ); ?>" alt="<?php echo esc_attr( $_va['variant_name'] ); ?>" loading="lazy" />
                      <?php else : ?>
                        <span class="tc-pdp__var-img-placeholder"><?php echo esc_html( mb_substr( $_va['variant_name'], 0, 2 ) ); ?></span>
                      <?php endif; ?>
                    </span>
                  <?php elseif ( $_vg['type'] === 'E' ) : ?>
                    <span class="tc-pdp__attr-swatch" title="<?php echo esc_attr( $_va['variant_name'] ); ?>">
                      <span class="tc-pdp__var-swatch-circle" style="background:<?php echo esc_attr( $_va['color_hex'] ?: '#ccc' ); ?>"></span>
                    </span>
                  <?php elseif ( $_vg['type'] === 'brand' ) : ?>
                    <span class="tc-pdp__attr-brand"><?php echo esc_html( $_va['variant_name'] ); ?></span>
                  <?php else : ?>
                    <span class="tc-pdp__attr-pill"><?php echo esc_html( $_va['variant_name'] ); ?></span>
                  <?php endif; ?>
                <?php endif; ?>
              <?php endforeach; ?>
              <?php endif; // shared dropdown vs. per-value controls ?>
            </div>
          <?php endforeach; ?>
        </div>
      <?php endif; ?>

      <!-- Options (add-on services) -->
      <?php if ( ! $_deny_cart && $purchasable ) : ?>
        <?php foreach ( $options as $option ) :
          \TitanCart\Storefront\TemplateLoader::get_template( 'product/option.php', compact( 'option', 'product_id', 'show_option_modifiers' ) );
        endforeach; ?>

      <!-- Quantity -->
      <div class="tc-pdp__qty-section">
        <p class="tc-pdp__var-label">Quantity</p>
        <?php if ( $qty_dropdown_count > 0 ) :
          // Build dropdown options from min_qty to max, stepping by qty_step
          $_qty_opts = [];
          $_effective_max = ( $qty_ceiling > 0 ) ? $qty_ceiling : ( $min_qty + ( $qty_dropdown_count - 1 ) * $qty_step );
          for ( $_q = $min_qty; $_q <= $_effective_max && count( $_qty_opts ) < $qty_dropdown_count; $_q += $qty_step ) {
              $_qty_opts[] = $_q;
          }
        ?>
        <select id="tc-pdp-qty" class="tc-pdp__qty-select">
          <?php foreach ( $_qty_opts as $_qv ) : ?>
            <option value="<?php echo esc_attr( $_qv ); ?>"<?php selected( $_qv, $min_qty ); ?>><?php echo esc_html( $_qv ); ?></option>
          <?php endforeach; ?>
        </select>
        <?php else : ?>
        <div class="tc-pdp__qty-ctrl">
          <?php if ( $qty_changer ) : ?>
          <button class="tc-pdp__qty-btn" id="tc-qty-minus" type="button" aria-label="Decrease"><i class="bi bi-dash"></i></button>
          <?php endif; ?>
          <input type="number" id="tc-pdp-qty" class="tc-pdp__qty-input"
                 value="<?php echo esc_attr( $min_qty ); ?>" min="<?php echo esc_attr( $min_qty ); ?>"
                 <?php if ( $qty_ceiling > 0 ) : ?>max="<?php echo esc_attr( $qty_ceiling ); ?>"<?php endif; ?>
                 step="<?php echo esc_attr( $qty_step ); ?>" <?php echo $qty_changer ? 'readonly' : ''; ?> />
          <?php if ( $qty_changer ) : ?>
          <button class="tc-pdp__qty-btn" id="tc-qty-plus" type="button" aria-label="Increase"><i class="bi bi-plus"></i></button>
          <?php endif; ?>
        </div>
        <?php endif; ?>
        <?php if ( $_stock_cap && $qty_ceiling === $stock_qty && ( $show_stock || $stock_qty < $min_qty + $qty_step ) ) : ?>
          <?php /* KI-123: the ceiling is the stock — say so when the count is shown anyway, or when the customer cannot go past the minimum. */ ?>
          <?php /* translators: %d: units available */ ?>
          <p class="tc-pdp__qty-hint"><?php echo esc_html( sprintf( __( 'Only %d available', 'titancart-lite' ), $stock_qty ) ); ?></p>
        <?php endif; ?>
      </div>
      <?php endif; /* ! $_deny_cart && $purchasable */ ?>

      <?php
      // ─── S138 Phase 5: Downloadable-product gates (master toggle + agreement)
      // Three-state branch wrapping the existing cart row / OOS-notify section:
      //
      //   1. Master toggle OFF + product downloadable
      //      → Render an "unavailable" notice card; suppress add-to-cart entirely.
      //         Server side, CartManager::add_item rejects the same combination
      //         with a customer-facing error (defense in depth).
      //
      //   2. Master ON + agreement required + acceptance not yet recorded
      //      → Render the agreement gate above the cart row. The add-to-cart
      //         button starts `disabled`; JS enables it after the customer
      //         checks the acceptance box (and POSTs /agreements/accept for
      //         logged-in users; guest acceptance is bound at place_order time).
      //
      //   3. Otherwise (default)
      //      → Existing cart row / OOS-notify renders unchanged.
      ?>
      <?php if ( $_is_downloadable && ! $_dl_master_on ) : ?>
      <!-- Downloadable Products: master toggle disabled site-wide. -->
      <div class="tc-pdp__dl-disabled" role="status" aria-live="polite">
        <i class="bi bi-cloud-slash" aria-hidden="true"></i>
        <div class="tc-pdp__dl-disabled-text">
          <strong><?php esc_html_e( 'Downloads are temporarily unavailable.', 'titancart-lite' ); ?></strong>
          <p><?php esc_html_e( 'This product is being prepared for digital delivery. Please check back soon.', 'titancart-lite' ); ?></p>
        </div>
      </div>
      <?php else : ?>
        <?php if ( $_pdp_agreement ) : ?>
        <!-- License agreement gate (S138 Phase 5). The acceptance checkbox is
             the act of acceptance — for logged-in customers, checking it POSTs
             to /agreements/accept with the rendered content_hash; for guests,
             the acceptance is bound to billing email at checkout submit. The
             content_hash captured here is for tamper-detection only; the
             server's current text always wins for the stored evidence. -->
        <div class="tc-pdp__agreement"
             data-tc-agreement-gate
             data-agreement-id="<?php echo esc_attr( (int) $_pdp_agreement['id'] ); ?>"
             data-agreement-version="<?php echo esc_attr( (int) $_pdp_agreement['version'] ); ?>"
             data-content-hash="<?php echo esc_attr( (string) $_pdp_agreement_hash ); ?>"
             data-logged-in="<?php echo is_user_logged_in() ? '1' : '0'; ?>">
          <h3 class="tc-pdp__agreement-title">
            <i class="bi bi-file-earmark-text" aria-hidden="true"></i>
            <?php echo esc_html( (string) $_pdp_agreement['title'] ); ?>
          </h3>
          <div class="tc-pdp__agreement-content" tabindex="0">
            <?php
            // Server-controlled HTML: the agreement editor is admin-only and
            // sanitises with wp_kses_post on save. Echoed through wp_kses_post
            // again here purely as defense in depth.
            echo wp_kses_post( (string) $_pdp_agreement['content'] );
            ?>
          </div>
          <label class="tc-pdp__agreement-check">
            <input type="checkbox" class="tc-pdp__agreement-input" />
            <span><?php esc_html_e( 'I have read and agree to the terms above.', 'titancart-lite' ); ?></span>
          </label>
          <p class="tc-pdp__agreement-msg" role="alert" aria-live="polite"></p>
        </div>
        <?php endif; ?>

      <!-- Cart + Wishlist + Notify Me -->
      <?php if ( $purchasable ) : ?>
      <div class="tc-pdp__cart-row">
        <?php if ( $catalog_mode_on && isset( $cm_type ) && $cm_type === 'inquiry' ) : ?>
        <a href="<?php echo esc_url( $cm_inquiry_url ); ?>" class="tc-pdp__add-btn tc-pdp__inquiry-btn" style="text-decoration:none">
          <i class="bi bi-envelope"></i>
          <?php echo esc_html( $cm_inquiry_text ); ?>
        </a>
        <?php elseif ( ! $_deny_cart ) : ?>
        <?php
        // Pre-order state: customer can buy but the product isn't actually in stock.
        // (Only true when out_of_stock_actions = 'buy' — see $purchasable derivation at top.)
        // Relabel the button so the customer knows they're committing to a pre-order,
        // not a normal in-stock purchase. Stock pill at L992 still says "Out of Stock"
        // for honesty — the combination communicates "ships when restocked." S122.
        $_is_preorder = ( $purchasable && ! $in_stock );
        ?>
        <button class="tc-pdp__add-btn tc-add-to-cart-btn<?php echo $_is_preorder ? ' tc-pdp__add-btn--preorder' : ''; ?>"
                data-product-id="<?php echo esc_attr( $product_id ); ?>"
                <?php if ( $_is_preorder ) : ?>data-preorder="1"<?php endif; ?>
                <?php if ( $_pdp_agreement ) : ?>data-tc-agreement-pending="1" disabled<?php endif; ?>>
          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
            <path d="M1 1h2.5l1.2 9a1.5 1.5 0 001.5 1.3h7.3a1.5 1.5 0 001.5-1.1L16.5 5H4.5"/>
            <circle cx="7" cy="15" r="1"/><circle cx="14" cy="15" r="1"/></svg>
          <?php
          if ( $_is_preorder ) {
              esc_html_e( 'Pre-Order', 'titancart-lite' );
          } else {
              esc_html_e( 'Add to cart', 'titancart-lite' );
          }
          ?>
        </button>
        <?php endif; ?>
        <?php if ( $wishlist_enabled ) : ?>
        <button class="tc-pdp__wish-btn tc-wishlist-toggle" data-product-id="<?php echo esc_attr( $product_id ); ?>" title="<?php esc_attr_e( 'Wishlist', 'titancart-lite' ); ?>" aria-label="<?php esc_attr_e( 'Wishlist', 'titancart-lite' ); ?>">
          <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5">
            <path d="M10 17s-7-4.35-7-8.5C3 5.46 5.46 3 8.5 3c1.74 0 3.1.81 3.5 2 .4-1.19 1.76-2 3.5-2C18.54 3 21 5.46 21 8.5c0 4.15-7 8.5-7 8.5z" transform="translate(-2,-1.5) scale(.95)"/></svg>
          <span class="tc-pdp__btn-label"><?php esc_html_e( 'Wishlist', 'titancart-lite' ); ?></span>
        </button>
        <?php endif; ?>
        <?php if ( $allow_compare ) : ?>
        <button class="tc-pdp__compare-btn tc-compare-toggle" data-product-id="<?php echo esc_attr( $product_id ); ?>" title="<?php esc_attr_e( 'Compare', 'titancart-lite' ); ?>" aria-label="<?php esc_attr_e( 'Compare', 'titancart-lite' ); ?>">
          <i class="bi bi-arrow-left-right"></i>
          <span class="tc-pdp__btn-label"><?php esc_html_e( 'Compare', 'titancart-lite' ); ?></span>
        </button>
        <?php endif; ?>
      </div>
      <?php if ( $_is_preorder ?? false ) : ?>
      <p class="tc-pdp__preorder-note" style="margin:8px 0 0;font-size:13px;color:#666;display:flex;align-items:center;gap:6px;">
        <i class="bi bi-truck" aria-hidden="true"></i>
        <?php esc_html_e( 'Ships when this product is back in stock.', 'titancart-lite' ); ?>
      </p>
      <?php endif; ?>
      <?php else : ?>
      <!-- Stock Notification Box -->
      <div class="tc-pdp__stocknotify" id="tc-notify-wrap" data-product-id="<?php echo esc_attr( $product_id ); ?>">
        <h3 class="tc-pdp__stocknotify-title"><?php esc_html_e( 'Seize The Opportunity When This Product Becomes Available.', 'titancart-lite' ); ?></h3>
        <p class="tc-pdp__stocknotify-subtitle"><?php esc_html_e( 'Register your email address below to receive a notification as soon as this product becomes available.', 'titancart-lite' ); ?></p>

        <!-- Email / SMS tabs -->
        <div class="tc-pdp__stocknotify-tabs">
          <button type="button" class="tc-pdp__stocknotify-tab tc-pdp__stocknotify-tab--active" data-notify-tab="email">
            <i class="bi bi-envelope-fill"></i> <?php esc_html_e( 'Email', 'titancart-lite' ); ?>
          </button>
          <button type="button" class="tc-pdp__stocknotify-tab tc-pdp__stocknotify-tab--disabled" data-notify-tab="sms" title="<?php esc_attr_e( 'SMS notifications — coming soon as a premium extension', 'titancart-lite' ); ?>" disabled>
            <i class="bi bi-chat-dots-fill"></i> <?php esc_html_e( 'SMS', 'titancart-lite' ); ?>
          </button>
        </div>

        <!-- Email form -->
        <div class="tc-pdp__stocknotify-form" id="tc-notify-form">
          <input type="email" class="tc-pdp__stocknotify-input" id="tc-notify-email"
                 placeholder="<?php esc_attr_e( 'Enter your valid email address', 'titancart-lite' ); ?>"
                 value="<?php echo esc_attr( is_user_logged_in() ? wp_get_current_user()->user_email : '' ); ?>"
                 required>
          <button type="button" class="tc-pdp__stocknotify-btn" id="tc-notify-submit">
            <?php esc_html_e( 'NOTIFY ME', 'titancart-lite' ); ?>
          </button>
        </div>

        <p class="tc-pdp__notify-msg" id="tc-notify-msg" style="display:none"></p>

        <p class="tc-pdp__stocknotify-privacy">
          <i class="bi bi-shield-check"></i>
          <?php esc_html_e( "We'll notify you when this product is in stock. Your address is kept confidential by us.", 'titancart-lite' ); ?>
        </p>
      </div>

      <!-- Out of Stock — Sold Out button row -->
      <div class="tc-pdp__cart-row">
        <button class="tc-pdp__add-btn tc-pdp__add-btn--soldout" disabled>
          <?php esc_html_e( 'Sold Out', 'titancart-lite' ); ?>
        </button>
        <?php if ( $wishlist_enabled ) : ?>
        <button class="tc-pdp__wish-btn tc-wishlist-toggle" data-product-id="<?php echo esc_attr( $product_id ); ?>" title="<?php esc_attr_e( 'Wishlist', 'titancart-lite' ); ?>" aria-label="<?php esc_attr_e( 'Wishlist', 'titancart-lite' ); ?>">
          <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5">
            <path d="M10 17s-7-4.35-7-8.5C3 5.46 5.46 3 8.5 3c1.74 0 3.1.81 3.5 2 .4-1.19 1.76-2 3.5-2C18.54 3 21 5.46 21 8.5c0 4.15-7 8.5-7 8.5z" transform="translate(-2,-1.5) scale(.95)"/></svg>
          <span class="tc-pdp__btn-label"><?php esc_html_e( 'Wishlist', 'titancart-lite' ); ?></span>
        </button>
        <?php endif; ?>
        <?php if ( $allow_compare ) : ?>
        <button class="tc-pdp__compare-btn tc-compare-toggle" data-product-id="<?php echo esc_attr( $product_id ); ?>" title="<?php esc_attr_e( 'Compare', 'titancart-lite' ); ?>" aria-label="<?php esc_attr_e( 'Compare', 'titancart-lite' ); ?>">
          <i class="bi bi-arrow-left-right"></i>
          <span class="tc-pdp__btn-label"><?php esc_html_e( 'Compare', 'titancart-lite' ); ?></span>
        </button>
        <?php endif; ?>
      </div>
      <?php endif; ?>
      <?php endif; /* end S138 master-off wrapper */ ?>

      <?php
      // ── Social Share Buttons ──
      if ( $social_buttons_on && ! empty( $sb_networks ) && isset( $sb_position ) && $sb_position === 'below_cart' ) :
      ?>
      <div class="tc-pdp__social-share">
        <span class="tc-pdp__social-label">Share:</span>
        <?php foreach ( $sb_networks as $sn ) : ?>
        <a href="<?php echo esc_url( $sn['url'] ); ?>" target="_blank" rel="noopener noreferrer"
           class="tc-pdp__social-btn" title="<?php echo esc_attr( $sn['label'] ); ?>">
          <i class="bi bi-<?php echo esc_attr( $sn['icon'] ); ?>"></i>
          <?php if ( $sb_style === 'icon_label' ) : ?>
            <span><?php echo esc_html( $sn['label'] ); ?></span>
          <?php endif; ?>
        </a>
        <?php endforeach; ?>
        <?php if ( ! empty( $sb_copy_link ) ) :
            // Build the share URL from the actual request, not get_permalink()
            // (global $post may have been overwritten by an upstream loop).
            $tc_copy_url = home_url(
                isset( $_SERVER['REQUEST_URI'] )
                    ? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) )
                    : '/'
            );
        ?>
        <button type="button" class="tc-pdp__social-btn tc-pdp__copy-link-btn" title="Copy link"
                data-copy-url="<?php echo esc_url( $tc_copy_url ); ?>">
          <i class="bi bi-link-45deg"></i>
          <?php if ( $sb_style === 'icon_label' ) : ?>
            <span>Copy Link</span>
          <?php endif; ?>
        </button>
        <?php endif; ?>
      </div>
      <?php endif; ?>

      <!-- Delivery -->
      <?php
      // Allow an extension (e.g. TitanCart Essentials -> Estimated Delivery Date)
      // to replace this block with a configurable estimate. A null/empty return
      // keeps the default block below unchanged.
      $tc_delivery_html = apply_filters( 'titancart_pdp_delivery_html', null, $product );
      if ( is_string( $tc_delivery_html ) && $tc_delivery_html !== '' ) :
          echo wp_kses_post( $tc_delivery_html ); // WP.org R2 item 6: kses at echo — filter output is third-party markup.
      else :
      ?>
      <div class="tc-pdp__delivery">
        <p class="tc-pdp__delivery-item">
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="1" y="4" width="9" height="7" rx="1"/><path d="M10 7h3l2 2v2h-5V7z"/><circle cx="5" cy="12" r="1.2"/><circle cx="13" cy="12" r="1.2"/></svg>
          <span><strong>Free shipping &amp; returns:</strong> On all orders over $200!</span>
        </p>
        <p class="tc-pdp__delivery-item">
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2"><circle cx="8" cy="8" r="6.5"/><path d="M8 4v4l3 2"/></svg>
          <span><strong>Estimated Delivery:</strong> 5–10 business days</span>
        </p>
      </div>
      <?php endif; ?>

      <!-- Divider + Meta -->
      <hr class="tc-pdp__divider">
      <div class="tc-pdp__meta">
        <?php if ( $sku ) : ?>
          <p class="tc-pdp__meta-row"><strong>Product Code:</strong> <?php echo esc_html( $sku ); ?></p>
        <?php endif; ?>
        <p class="tc-pdp__meta-row"><strong>Available:</strong>
          <?php
          if ( ! $in_stock ) {
              echo esc_html__( 'Out of Stock', 'titancart-lite' );
          } elseif ( $show_stock && $stock_qty > 0 ) {
              /* translators: %d = number of units in stock */
              echo esc_html( sprintf( __( '%d in stock', 'titancart-lite' ), $stock_qty ) );
          } else {
              echo esc_html__( 'In Stock', 'titancart-lite' );
          }
          ?>
        </p>
        <?php if ( ! empty( $breadcrumb ) ) : ?>
          <p class="tc-pdp__meta-row"><strong>Categories:</strong>
            <?php foreach ( $breadcrumb as $i => $crumb ) : ?><?php if ( $i > 0 ) echo ', '; ?><a href="<?php echo esc_url( $crumb['url'] ); ?>"><?php echo esc_html( $crumb['name'] ); ?></a><?php endforeach; ?>
          </p>
        <?php endif; ?>
        <?php do_action( 'titancart_product_single_meta' ); ?>
      </div>

    </div><!-- .tc-pdp__info -->
  </div><!-- .tc-pdp__main -->

  <!-- ═══ Tabs ═══ -->
  <div class="tc-pdp__tabs<?php echo ! $details_in_tabs ? ' tc-pdp__tabs--stacked' : ''; ?>" id="tc-pdp-tabs">
    <div class="tc-pdp__tab-nav">
      <?php foreach ( $tabs as $i => $t ) : ?>
        <button class="tc-pdp__tab-btn<?php echo $i === 0 ? ' tc-pdp__tab-btn--active' : ''; ?>"
                data-tab="<?php echo esc_attr( $t['key'] ); ?>"><?php echo esc_html( $t['label'] ); ?></button>
      <?php endforeach; ?>
    </div>
    <div class="tc-pdp__tab-content">

      <?php
      // ── Tab panels (S125 — wires Custom Tabs B10) ───────────────────
      // Single foreach drives all panels in the same order as the nav,
      // so admin tab reordering carries through to both. The first tab
      // in $tabs gets the --active class. Each iteration switches on
      // tab type/key:
      //
      //   builtin/description  → render $desc (with empty-state fallback)
      //   builtin/reviews      → render summary + list + form (id=tc-pdp-reviews)
      //   builtin/attributes   → render grouped feature table + dims
      //   sizing_chart         → render headers/rows/notes from $sizing
      //   richtext             → wp_kses_post of $t['content']
      //   template             → fire 'titancart_render_tab_template'
      //                          and 'titancart_render_tab_template_{key}'
      //                          actions for extension-owned rendering
      //
      // Below the description panel we emit the social-share strip when
      // sb_position === 'below_description', preserving pre-S125 layout
      // and following the description tab if admin reorders it.
      foreach ( $tabs as $_i => $_t ) :
          $_is_active = ( $_i === 0 );
          $_tk        = $_t['key'];
          $_tt        = $_t['type'];
      ?>

      <div class="tc-pdp__tab-panel<?php echo $_is_active ? ' tc-pdp__tab-panel--active' : ''; ?>"
           data-panel="<?php echo esc_attr( $_tk ); ?>"<?php if ( $_tk === 'reviews' ) : // WP.org R2 item 6: inline literal attribute, no echo-of-variable. ?> id="tc-pdp-reviews"<?php endif; ?>>
        <h3 class="tc-pdp__section-heading"><?php echo esc_html( $_t['label'] ); ?></h3>

        <?php if ( $_tt === 'builtin' && $_tk === 'description' ) : ?>
          <?php if ( $desc ) : ?><?php echo wp_kses_post( $desc ); ?><?php else : ?>
            <p class="tc-muted">No description available.</p>
          <?php endif; ?>

        <?php elseif ( $_tt === 'builtin' && $_tk === 'reviews' ) : ?>
        <div class="tc-pdp__reviews">
          <div class="tc-reviews__summary">
            <div class="tc-reviews__summary-score">
              <span class="tc-reviews__avg"><?php echo esc_html( number_format( $review_avg, 1 ) ); ?></span>
              <span class="tc-stars"><?php for ($s=1;$s<=5;$s++) echo $s<=round($review_avg)?'★':'☆'; ?></span>
              <span class="tc-reviews__count tc-muted">(<?php echo esc_html( $review_count ); ?> Ratings)</span>
            </div>
            <?php if ( $review_dist ) : ?>
              <div class="tc-reviews__summary-bars">
                <?php foreach ( $review_dist as $star => $count ) :
                  $pct = $review_count > 0 ? round( $count / $review_count * 100 ) : 0;
                ?>
                  <div class="tc-reviews__bar-row">
                    <span class="tc-reviews__bar-label"><?php echo (int) $star; ?> ★</span>
                    <div class="tc-reviews__bar-track"><div class="tc-reviews__bar-fill" style="width:<?php echo esc_attr( $pct ); ?>%"></div></div>
                    <span class="tc-reviews__bar-count tc-muted"><?php echo (int) $count; ?></span>
                  </div>
                <?php endforeach; ?>
              </div>
            <?php endif; ?>
          </div>

          <?php if ( $reviews ) : ?>
            <div class="tc-reviews__list">
              <?php foreach ( $reviews as $review ) : ?>
                <article class="tc-review">
                  <header class="tc-review__header">
                    <div class="tc-review__meta">
                      <div class="tc-review__avatar"><?php echo esc_html( strtoupper( substr( $review['name'] ?? 'A', 0, 1 ) ) ); ?></div>
                      <div>
                        <strong class="tc-review__author"><?php echo esc_html( $review['name'] ?? 'Anonymous' ); ?></strong>
                        <time class="tc-review__date tc-muted"><?php echo esc_html( gmdate( 'j F Y', strtotime( $review['created_at'] ) ) ); ?></time>
                      </div>
                    </div>
                    <span class="tc-stars"><?php for($s=1;$s<=5;$s++) echo $s<=(int)$review['rating']?'★':'☆'; ?></span>
                  </header>
                  <div class="tc-review__content"><?php echo esc_html( $review['content'] ?? '' ); ?></div>
                </article>
              <?php endforeach; ?>
            </div>
          <?php else : ?>
            <p class="tc-muted" style="text-align:center;padding:32px 0;">No reviews yet. Be the first to review this product!</p>
          <?php endif; ?>

          <?php
          // Render the submission form via the [titancart_review_form] shortcode
          // so ReviewsModule::render_review_form() runs the proper
          // ReviewManager::can_review() check (already-reviewed detection,
          // login requirement, IP/customer eligibility) instead of hardcoding
          // can_review=true. The shortcode delegates to the same template
          // (templates/product/review-form.php) — themes can still override
          // by placing a copy at {theme}/titancart/product/review-form.php.
          if ( $product_id ) {
              echo do_shortcode( '[titancart_review_form product_id="' . (int) $product_id . '"]' );
          }
          ?>
        </div>

        <?php elseif ( $_tt === 'sizing_chart' && $sizing ) : ?>
          <?php
          $sz      = $sizing['data']  ?? [];
          $headers = $sz['headers']   ?? [];
          $rows    = $sz['rows']      ?? [];
          $unit    = $sz['unit']      ?? 'in';
          $notes   = $sizing['notes'] ?? '';
          ?>
          <?php if ( $notes ) : ?>
            <div class="tc-pdp__sizing-notes"><strong>How to Measure:</strong> <?php echo esc_html( $notes ); ?></div>
          <?php endif; ?>
          <p class="tc-pdp__sizing-unit">All measurements in <?php echo $unit === 'cm' ? 'centimeters (cm)' : 'inches (in)'; ?></p>
          <?php if ( $headers && $rows ) : ?>
            <table class="tc-pdp__sizing-table">
              <thead><tr><?php foreach ( $headers as $h ) : ?><th><?php echo esc_html( $h ); ?></th><?php endforeach; ?></tr></thead>
              <tbody><?php foreach ( $rows as $row ) : ?><tr><?php foreach ( $row as $cell ) : ?><td><?php echo esc_html( $cell ); ?></td><?php endforeach; ?></tr><?php endforeach; ?></tbody>
            </table>
          <?php endif; ?>

        <?php elseif ( $_tt === 'builtin' && $_tk === 'attributes' ) : ?>
          <table class="tc-features-table">
            <tbody>
              <?php
                // Group feature values by feature_name so multi-value attributes
                // (e.g. Color: Black, Pink, Cream) show as one row.
                // Iterate $spec_features (not $features) so the show_on_pdp = 0
                // gate + has-value filter applied above govern the rendered table
                // identically to the tab-visibility check — a hidden attribute
                // (e.g. Color Family) never appears even when the tab is shown for
                // other spec data or physical dimensions.
                $grouped_specs = [];
                foreach ( $spec_features as $feat ) {
                    $val = '';
                    if ( ! empty( $feat['variant_name'] ) ) {
                        $val = $feat['variant_name'];
                    } elseif ( ! empty( $feat['value_str'] ) ) {
                        $val = $feat['value_str'];
                    } elseif ( $feat['value_int'] !== null && $feat['value_int'] !== '' ) {
                        $val = $feat['value_int'];
                    } elseif ( $feat['value_float'] !== null && $feat['value_float'] !== '' ) {
                        $val = $feat['value_float'];
                    } elseif ( ! empty( $feat['value_date'] ) ) {
                        $val = $feat['value_date'];
                    } elseif ( ! empty( $feat['value_text'] ) ) {
                        $val = $feat['value_text'];
                    }
                    if ( $val === '' && empty( $feat['variant_id'] ) ) continue;

                    $fname = $feat['feature_name'] ?? '';
                    $fid   = $feat['feature_id'] ?? 0;
                    $skey  = $fid . ':' . $fname;
                    if ( ! isset( $grouped_specs[ $skey ] ) ) {
                        $grouped_specs[ $skey ] = [ 'name' => $fname, 'values' => [] ];
                    }
                    if ( $val !== '' && ! in_array( $val, $grouped_specs[ $skey ]['values'], true ) ) {
                        $grouped_specs[ $skey ]['values'][] = $val;
                    }
                }
                foreach ( $grouped_specs as $spec ) :
                    if ( empty( $spec['values'] ) ) continue;
              ?>
                <tr><th><?php echo esc_html( $spec['name'] ); ?></th><td><?php echo esc_html( implode( ', ', $spec['values'] ) ); ?></td></tr>
              <?php endforeach; ?>
              <?php if ( $_p_weight > 0 ) : ?>
                <tr><th><?php esc_html_e( 'Weight', 'titancart-lite' ); ?></th><td><?php echo esc_html( rtrim( rtrim( number_format( $_p_weight, 2 ), '0' ), '.' ) . ' ' . $_display_w_sym ); ?></td></tr>
              <?php endif; ?>
              <?php if ( $_p_length > 0 || $_p_width > 0 || $_p_height > 0 ) :
                $_dims = [];
                if ( $_p_length > 0 ) $_dims[] = rtrim( rtrim( number_format( $_p_length, 2 ), '0' ), '.' );
                if ( $_p_width  > 0 ) $_dims[] = rtrim( rtrim( number_format( $_p_width,  2 ), '0' ), '.' );
                if ( $_p_height > 0 ) $_dims[] = rtrim( rtrim( number_format( $_p_height, 2 ), '0' ), '.' );
              ?>
                <tr><th><?php esc_html_e( 'Dimensions', 'titancart-lite' ); ?></th><td><?php echo esc_html( implode( ' × ', $_dims ) . ' ' . $_display_dim ); ?></td></tr>
              <?php endif; ?>
            </tbody>
          </table>

        <?php elseif ( $_tt === 'richtext' ) : ?>
          <?php echo wp_kses_post( (string) $_t['content'] ); ?>

        <?php elseif ( $_tt === 'template' && ! empty( $_t['template'] ) ) : ?>
          <?php
          // Template tabs are owned by the registering extension/theme.
          // Two action hooks fire so listeners can target specifically:
          //   - titancart_render_tab_template            (generic)
          //   - titancart_render_tab_template_{tab_key}  (per-key)
          // Listener should echo content directly. $_t carries the full
          // tab entry (key/label/type/template/id); $product is the full
          // resolved product array.
          do_action( 'titancart_render_tab_template', $_t, $product );
          do_action( 'titancart_render_tab_template_' . $_tk, $_t, $product );
          ?>

        <?php endif; ?>

      </div><!-- .tc-pdp__tab-panel data-panel="<?php echo esc_attr( $_tk ); ?>" -->

      <?php
      // Social Share Buttons — "below_description" position. Emitted
      // immediately after the description panel so admin tab reordering
      // keeps the share strip visually anchored to the description tab.
      if ( $_tt === 'builtin' && $_tk === 'description'
           && $social_buttons_on && ! empty( $sb_networks )
           && isset( $sb_position ) && $sb_position === 'below_description' ) :
      ?>
      <div class="tc-pdp__social-share" style="margin:16px 0 24px">
        <span class="tc-pdp__social-label">Share:</span>
        <?php foreach ( $sb_networks as $sn ) : ?>
        <a href="<?php echo esc_url( $sn['url'] ); ?>" target="_blank" rel="noopener noreferrer"
           class="tc-pdp__social-btn" title="<?php echo esc_attr( $sn['label'] ); ?>">
          <i class="bi bi-<?php echo esc_attr( $sn['icon'] ); ?>"></i>
          <?php if ( $sb_style === 'icon_label' ) : ?><span><?php echo esc_html( $sn['label'] ); ?></span><?php endif; ?>
        </a>
        <?php endforeach; ?>
        <?php if ( ! empty( $sb_copy_link ) ) :
            // Build the share URL from the actual request, not get_permalink()
            // (global $post may have been overwritten by an upstream loop).
            // Mirrors the below_cart render block at lines ~1105-1120; the click
            // handler lives in assets/js/cart.js (.tc-pdp__copy-link-btn) with
            // Clipboard API → execCommand fallback, so insecure contexts and
            // NotAllowedError no longer leak to the console.
            $tc_copy_url = home_url(
                isset( $_SERVER['REQUEST_URI'] )
                    ? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) )
                    : '/'
            );
        ?>
        <button type="button" class="tc-pdp__social-btn tc-pdp__copy-link-btn" title="Copy link"
                data-copy-url="<?php echo esc_url( $tc_copy_url ); ?>">
          <i class="bi bi-link-45deg"></i>
          <?php if ( $sb_style === 'icon_label' ) : ?><span>Copy Link</span><?php endif; ?>
        </button>
        <?php endif; ?>
      </div>
      <?php endif; ?>

      <?php endforeach; // tabs ?>


    </div><!-- .tc-pdp__tab-content -->
  </div>

  <?php
  // ─────────────────────────────────────────────────────────────────────────
  // Bundles widget — wires Bundles B12 (S126).
  // Renders one card per active bundle that contains this product. Layout
  // matches the "Buy Together" pattern: title + horizontal product strip
  // (image, name, discounted/full price) joined by + separators, with a
  // right-side price-summary panel and an "Add all to cart" button.
  // The data shape comes from StorefrontModule's bundle resolver
  // (filter hook: titancart_product_bundles_storefront).
  // Position (S127 follow-up): after Tabs section, immediately before the
  // Recommended Products separator. Per Sean's UX call — bundles are most
  // discoverable as a "before you check out the rest, here's a kit deal"
  // hand-off between product detail and recommendations.
  // ─────────────────────────────────────────────────────────────────────────
  if ( ! empty( $bundles ) ) :
  ?>
  <div class="tc-pdp__bundles" id="tc-pdp-bundles">
    <?php foreach ( $bundles as $bundle ) :
        $b_items = is_array( $bundle['items'] ?? null ) ? $bundle['items'] : [];
        if ( empty( $b_items ) ) continue;
        $b_id    = (int) ( $bundle['id'] ?? 0 );
        $b_name  = (string) ( $bundle['name'] ?? __( 'Buy Together', 'titancart-lite' ) );
        $b_full  = (float) ( $bundle['total_full']    ?? 0 );
        $b_cost  = (float) ( $bundle['total_cost']    ?? 0 );
        $b_save  = (float) ( $bundle['total_savings'] ?? 0 );
        $has_savings = ( $b_save > 0.005 ); // half-cent epsilon
    ?>
    <section class="tc-pdp__bundle" data-bundle-id="<?php echo esc_attr( $b_id ); ?>">
      <h2 class="tc-pdp__bundle-title"><?php echo esc_html( $b_name ); ?></h2>
      <?php if ( ! empty( $bundle['description'] ) ) : ?>
        <p class="tc-pdp__bundle-desc"><?php echo wp_kses_post( $bundle['description'] ); ?></p>
      <?php endif; ?>

      <div class="tc-pdp__bundle-card">
        <div class="tc-pdp__bundle-strip">
          <?php foreach ( $b_items as $idx => $item ) :
              $i_name   = (string) ( $item['product_name']     ?? '' );
              $i_url    = (string) ( $item['product_url']      ?? '' );
              $i_image  = (string) ( $item['product_image']    ?? '' );
              $i_price  = (float)  ( $item['product_price']    ?? 0 );
              $i_disc   = (float)  ( $item['discounted_price'] ?? 0 );
              $i_qty    = max( 1, (int) ( $item['quantity']    ?? 1 ) );
              $is_self  = ! empty( $item['is_self'] );
              $has_disc = ( $i_disc > 0 && $i_disc < $i_price - 0.005 );
          ?>
            <?php if ( $idx > 0 ) : ?>
              <div class="tc-pdp__bundle-plus" aria-hidden="true">+</div>
            <?php endif; ?>
            <div class="tc-pdp__bundle-item<?php echo $is_self ? ' tc-pdp__bundle-item--self' : ''; ?>">
              <a class="tc-pdp__bundle-thumb"
                 href="<?php echo esc_url( $i_url ); ?>"
                 <?php echo $is_self ? 'aria-current="page"' : ''; ?>>
                <?php if ( $i_image ) : ?>
                  <img src="<?php echo esc_url( $i_image ); ?>"
                       alt="<?php echo esc_attr( $i_name ); ?>"
                       loading="lazy" />
                <?php else : ?>
                  <div class="tc-pdp__bundle-thumb-placeholder" aria-hidden="true"></div>
                <?php endif; ?>
              </a>
              <a class="tc-pdp__bundle-name" href="<?php echo esc_url( $i_url ); ?>">
                <?php echo esc_html( $i_name ); ?>
              </a>
              <?php if ( $i_qty > 1 ) : ?>
                <div class="tc-pdp__bundle-qty">× <?php echo (int) $i_qty; ?></div>
              <?php endif; ?>
              <div class="tc-pdp__bundle-price">
                <span class="tc-pdp__bundle-price-current"><?php
                  echo esc_html( $currency . number_format( $i_disc, 2 ) );
                ?></span>
                <?php if ( $has_disc ) : ?>
                  <span class="tc-pdp__bundle-price-was"><?php
                    echo esc_html( $currency . number_format( $i_price, 2 ) );
                  ?></span>
                <?php endif; ?>
              </div>
            </div>
          <?php endforeach; ?>
        </div>

        <aside class="tc-pdp__bundle-summary" aria-label="<?php esc_attr_e( 'Bundle total', 'titancart-lite' ); ?>">
          <div class="tc-pdp__bundle-summary-label"><?php esc_html_e( 'Price for all', 'titancart-lite' ); ?></div>
          <?php if ( $has_savings ) : ?>
            <div class="tc-pdp__bundle-summary-discount">
              <?php
              printf(
                /* translators: %s: total savings amount with currency symbol. */
                esc_html__( 'Order discount: %s', 'titancart-lite' ),
                esc_html( $currency . number_format( $b_save, 2 ) )
              );
              ?>
            </div>
          <?php endif; ?>
          <?php if ( $has_savings ) : ?>
            <div class="tc-pdp__bundle-summary-was"><?php
              echo esc_html( $currency . number_format( $b_full, 2 ) );
            ?></div>
          <?php endif; ?>
          <div class="tc-pdp__bundle-summary-total"><?php
            echo esc_html( $currency . number_format( $b_cost, 2 ) );
          ?></div>
          <button type="button"
                  class="tc-pdp__bundle-btn tc-add-bundle-btn"
                  data-bundle-id="<?php echo esc_attr( $b_id ); ?>">
            <?php esc_html_e( 'Add all to cart', 'titancart-lite' ); ?>
          </button>
        </aside>
      </div>
    </section>
    <?php endforeach; ?>
  </div>
  <?php endif; ?>

  <!-- ═══ Recommended Products Carousel ═══ -->
  <?php if ( $related ) : ?>
    <div class="tc-pdp__related">
      <div class="tc-pdp__related-header">
        <h2 class="tc-pdp__related-title">Recommended Products</h2>
        <div class="tc-carousel__nav">
          <button class="tc-carousel__arrow tc-carousel__arrow--left" id="tc-rec-prev" type="button" aria-label="Previous">
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4L6 9l5 5"/></svg>
          </button>
          <button class="tc-carousel__arrow tc-carousel__arrow--right" id="tc-rec-next" type="button" aria-label="Next">
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4l5 5-5 5"/></svg>
          </button>
        </div>
      </div>
      <div class="tc-carousel" id="tc-rec-carousel">
        <div class="tc-carousel__track" id="tc-rec-track">
          <?php foreach ( $related as $rp ) : ?>
            <div class="tc-carousel__slide">
              <?php \TitanCart\Storefront\TemplateLoader::get_template( 'product/card.php', [ 'product' => $rp ] ); ?>
            </div>
          <?php endforeach; ?>
        </div>
      </div>
    </div>
  <?php endif; ?>

</div>

<?php
// WP.org R2: NOWDOC + strtr() slots (see age-gate block above for the rationale).
$tc_pdp_sym = wp_json_encode( $currency );
$tc_pdp_pid = (int) $product_id;
$tc_pdp_js  = <<<'JS'
(function() {
  'use strict';

  // ── Thumbnails ──
  document.querySelectorAll('.tc-pdp__thumb').forEach(function(btn) {
    btn.addEventListener('click', function() {
      var main = document.getElementById('tc-pdp-main-img');
      if (main) main.src = btn.dataset.src;
      document.querySelectorAll('.tc-pdp__thumb').forEach(function(b){ b.classList.remove('tc-pdp__thumb--active'); });
      btn.classList.add('tc-pdp__thumb--active');
    });
  });

  // ── Thumbnail Arrow Navigation ──
  (function() {
    var wrap = document.querySelector('.tc-pdp__thumbs-wrap');
    if (!wrap) return;
    var container = wrap.querySelector('.tc-pdp__thumbs');
    var upBtn = wrap.querySelector('.tc-pdp__thumbs-arrow--up');
    var downBtn = wrap.querySelector('.tc-pdp__thumbs-arrow--down');
    if (!container || !upBtn || !downBtn) return;

    function isHorizontal() {
      var style = window.getComputedStyle(container);
      return style.flexDirection === 'row';
    }

    function updateArrows() {
      var horiz = isHorizontal();
      var scrollPos = horiz ? container.scrollLeft : container.scrollTop;
      var scrollSize = horiz ? container.scrollWidth : container.scrollHeight;
      var clientSize = horiz ? container.clientWidth : container.clientHeight;
      var overflows = scrollSize > clientSize + 2;

      upBtn.style.display = (overflows && scrollPos > 2) ? 'flex' : 'none';
      downBtn.style.display = (overflows && scrollPos + clientSize < scrollSize - 2) ? 'flex' : 'none';
    }

    function getScrollStep() {
      var thumb = container.querySelector('.tc-pdp__thumb');
      if (!thumb) return 100;
      var horiz = isHorizontal();
      var gap = parseInt(window.getComputedStyle(container).gap) || 10;
      return (horiz ? thumb.offsetWidth : thumb.offsetHeight) + gap;
    }

    upBtn.addEventListener('click', function() {
      var step = getScrollStep();
      var horiz = isHorizontal();
      container.scrollBy({ left: horiz ? -step : 0, top: horiz ? 0 : -step, behavior: 'smooth' });
    });

    downBtn.addEventListener('click', function() {
      var step = getScrollStep();
      var horiz = isHorizontal();
      container.scrollBy({ left: horiz ? step : 0, top: horiz ? 0 : step, behavior: 'smooth' });
    });

    container.addEventListener('scroll', updateArrows);

    // We need overflow:auto for scrollBy to work, but hide the scrollbar via CSS
    container.style.overflow = 'auto';
    container.style.scrollbarWidth = 'none';
    container.classList.add('tc-pdp__thumbs--nav');

    updateArrows();
    window.addEventListener('resize', updateArrows);
    // Re-check after images load
    container.querySelectorAll('img').forEach(function(img) {
      if (!img.complete) img.addEventListener('load', updateArrows);
    });

    // Scroll active thumb into view when clicked
    document.querySelectorAll('.tc-pdp__thumb').forEach(function(btn) {
      btn.addEventListener('click', function() {
        var horiz = isHorizontal();
        var pos = horiz ? btn.offsetLeft : btn.offsetTop;
        var size = horiz ? btn.offsetWidth : btn.offsetHeight;
        var scrollPos = horiz ? container.scrollLeft : container.scrollTop;
        var clientSize = horiz ? container.clientWidth : container.clientHeight;
        if (pos < scrollPos) {
          container.scrollTo({ left: horiz ? pos : 0, top: horiz ? 0 : pos, behavior: 'smooth' });
        } else if (pos + size > scrollPos + clientSize) {
          var target = pos + size - clientSize;
          container.scrollTo({ left: horiz ? target : 0, top: horiz ? 0 : target, behavior: 'smooth' });
        }
        setTimeout(updateArrows, 350);
      });
    });
  })();

  // ── Quantity ±  ──
  var qtyInput = document.getElementById('tc-pdp-qty');
  var minusBtn = document.getElementById('tc-qty-minus');
  var plusBtn  = document.getElementById('tc-qty-plus');
  if (qtyInput && minusBtn && plusBtn) {
    var step = parseInt(qtyInput.step)||1, mn = parseInt(qtyInput.min)||1, mx = parseInt(qtyInput.max)||9999;
    minusBtn.addEventListener('click', function(){ qtyInput.value = Math.max(mn, (parseInt(qtyInput.value)||mn) - step); });
    plusBtn.addEventListener('click', function(){ qtyInput.value = Math.min(mx, (parseInt(qtyInput.value)||mn) + step); });
  }

  // ── Tabs ──
  document.querySelectorAll('.tc-pdp__tab-btn').forEach(function(btn) {
    btn.addEventListener('click', function() {
      var key = btn.dataset.tab;
      document.querySelectorAll('.tc-pdp__tab-btn').forEach(function(b){ b.classList.toggle('tc-pdp__tab-btn--active', b===btn); });
      document.querySelectorAll('.tc-pdp__tab-panel').forEach(function(p){ p.classList.toggle('tc-pdp__tab-panel--active', p.dataset.panel===key); });
    });
  });

  // ── Review link → tab ──
  var rl = document.querySelector('.tc-pdp__rating-text');
  if (rl) rl.style.cursor = 'pointer';
  if (rl) rl.addEventListener('click', function() {
    var btn = document.querySelector('[data-tab=reviews]');
    if (btn) btn.click();
    var el = document.getElementById('tc-pdp-reviews');
    if (el) el.scrollIntoView({ behavior:'smooth', block:'start' });
  });

  // ── Variations ──
  var varWrap = document.getElementById('tc-variations');
  if (varWrap) {
    var members = JSON.parse(varWrap.dataset.members||'[]');
    var currentSel = JSON.parse(varWrap.dataset.current||'{}');
    var SYM = %%SYM%%;

    var CMAP = {'red':'#EF4444','blue':'#3B82F6','green':'#22C55E','yellow':'#EAB308','orange':'#F97316','purple':'#A855F7','pink':'#EC4899','black':'#171717','white':'#FAFAFA','gray':'#6B7280','navy':'#1E3A5F','beige':'#D4B896','gold':'#D4AF37','silver':'#C0C0C0','teal':'#14B8A6','coral':'#F87171','indigo':'#6366F1','brown':'#92400E','cream':'#FFFDD0'};
    varWrap.querySelectorAll('.tc-pdp__var-swatch').forEach(function(btn) {
      var c = btn.querySelector('.tc-pdp__var-swatch-circle');
      if (c && !btn.dataset.hex) { var l = (btn.title||'').toLowerCase(); if (CMAP[l]) c.style.background = CMAP[l]; }
    });

    function onSelect(fid, vid) {
      currentSel[fid] = parseInt(vid);
      var m = findMember(currentSel);
      if (m) applyVariation(m);
    }

    varWrap.querySelectorAll('.tc-pdp__var-swatch, .tc-pdp__var-pill, .tc-pdp__var-img').forEach(function(btn) {
      btn.addEventListener('click', function() {
        var axis = btn.closest('.tc-pdp__var-axis');
        if (axis) axis.querySelectorAll('.tc-pdp__var-swatch, .tc-pdp__var-pill, .tc-pdp__var-img').forEach(function(b) {
          b.classList.remove('tc-pdp__var-swatch--active','tc-pdp__var-pill--active','tc-pdp__var-img--active');
        });
        var cls = btn.classList.contains('tc-pdp__var-swatch') ? 'tc-pdp__var-swatch--active'
                : btn.classList.contains('tc-pdp__var-img') ? 'tc-pdp__var-img--active'
                : 'tc-pdp__var-pill--active';
        btn.classList.add(cls);
        onSelect(btn.dataset.feature, btn.dataset.variant);
      });
    });

    varWrap.querySelectorAll('.tc-pdp__var-select').forEach(function(sel) {
      sel.addEventListener('change', function() {
        if (sel.value) onSelect(sel.dataset.feature, sel.value);
      });
    });

    function findMember(sel) {
      for (var i=0;i<members.length;i++) { var m=members[i],c=m.combination||{},ok=true;
        for (var f in sel) { if (String(c[f])!==String(sel[f])) { ok=false; break; } }
        if (ok) return m; } return null;
    }

    function applyVariation(m) {
      // Each variation is a separate product — navigate to its page
      if (m.url && m.product_id !== %%PID%%) {
        window.location.href = m.url;
        return;
      }
      // Same product (shouldn't normally happen) — update inline
      var pe = document.getElementById('tc-pdp-price');
      if (pe) pe.textContent = SYM + parseFloat(m.price).toFixed(2);
      var we = document.querySelector('.tc-pdp__price-was');
      var de = document.querySelector('.tc-pdp__price-discount');
      if (m.list_price > m.price && m.list_price > 0) {
        var d = Math.round(((m.list_price-m.price)/m.list_price)*100);
        if (we) { we.textContent = SYM+parseFloat(m.list_price).toFixed(2); we.style.display=''; }
        if (de) { de.textContent = '-'+d+'%'; de.style.display=''; }
      } else { if (we) we.style.display='none'; if (de) de.style.display='none'; }
      if (m.image) { var mi=document.getElementById('tc-pdp-main-img'); if(mi) mi.src=m.image; }
      var cb = document.querySelector('.tc-pdp__add-btn');
      if (cb) { cb.dataset.productId=m.product_id; cb.disabled=m.stock_status!=='in_stock'; }
    }
  }

  // ── Shared Variation Selectors ──
  (function() {
    var groups = document.querySelectorAll('.tc-pdp__shared-variation');
    if (!groups.length) return;

    // S478: price preview — base price + the active variant's modifier on every
    // axis. Mirrors CartManager::add_item(): the cart line's unit_price is the
    // effective shelf price plus these modifiers, so the number shown here is
    // the number charged.
    var priceEl = document.getElementById('tc-pdp-price');
    function refreshSharedPrice() {
      if (!priceEl || !priceEl.dataset.basePrice) return;
      var total = parseFloat(priceEl.dataset.basePrice) || 0;
      groups.forEach(function(g) {
        var sel = g.querySelector('.tc-pdp__shared-select');
        if (sel) {
          var o = sel.options[sel.selectedIndex];
          if (o) total += parseFloat(o.dataset.priceModifier) || 0;
          return;
        }
        var a = g.querySelector('.tc-pdp__shared-btn--active');
        if (a) total += parseFloat(a.dataset.priceModifier) || 0;
      });
      priceEl.textContent = (priceEl.dataset.symbol || '') + Math.max(0, total).toFixed(2);
    }
    refreshSharedPrice();

    groups.forEach(function(group) {
      var fid = group.dataset.featureId;
      var btns = group.querySelectorAll('.tc-pdp__shared-btn');
      var label = document.getElementById('tc-shared-label-' + fid);

      // S478: display type "Dropdown" — the <select> is the control.
      var sel = group.querySelector('.tc-pdp__shared-select');
      if (sel) {
        sel.addEventListener('change', function() {
          var o = sel.options[sel.selectedIndex];
          if (label) label.textContent = o ? (o.dataset.variantName || o.textContent) : '';
          refreshSharedPrice();
        });
        return;
      }

      btns.forEach(function(btn) {
        btn.addEventListener('click', function() {
          // Update active state
          btns.forEach(function(b) { b.classList.remove('tc-pdp__shared-btn--active', 'tc-pdp__var-img--active'); });
          btn.classList.add('tc-pdp__shared-btn--active');
          if (btn.classList.contains('tc-pdp__var-img')) btn.classList.add('tc-pdp__var-img--active');
          // Update label text
          if (label) label.textContent = btn.dataset.variantName || '';
          refreshSharedPrice();
          // Swap main gallery image to variant's linked image
          var fullUrl = btn.dataset.fullUrl;
          var galIdx  = btn.dataset.galleryIndex;
          if (fullUrl) {
            var main = document.getElementById('tc-pdp-main-img');
            if (main) main.src = fullUrl;
          }
          if (galIdx !== undefined && galIdx !== null) {
            var thumb = document.querySelector('.tc-pdp__thumb[data-index="' + galIdx + '"]');
            if (thumb) {
              document.querySelectorAll('.tc-pdp__thumb').forEach(function(b){ b.classList.remove('tc-pdp__thumb--active'); });
              thumb.classList.add('tc-pdp__thumb--active');
            }
          }
        });
      });
    });
  })();

  // ── Recommended Products Carousel ──
  var track = document.getElementById('tc-rec-track');
  var prevBtn = document.getElementById('tc-rec-prev');
  var nextBtn = document.getElementById('tc-rec-next');
  if (track && prevBtn && nextBtn) {
    var carousel = document.getElementById('tc-rec-carousel');
    function getSlideWidth() {
      var slide = track.querySelector('.tc-carousel__slide');
      if (!slide) return 280;
      var style = getComputedStyle(track);
      var gap = parseFloat(style.gap) || 24;
      return slide.offsetWidth + gap;
    }
    function scrollCarousel(dir) {
      var sw = getSlideWidth();
      var visibleWidth = carousel.offsetWidth;
      var slidesToScroll = Math.max(1, Math.floor(visibleWidth / sw));
      carousel.scrollBy({ left: dir * sw * slidesToScroll, behavior: 'smooth' });
    }
    prevBtn.addEventListener('click', function() { scrollCarousel(-1); });
    nextBtn.addEventListener('click', function() { scrollCarousel(1); });
  }
})();
JS;
$tc_pdp_js = strtr( $tc_pdp_js, [ '%%SYM%%' => $tc_pdp_sym, '%%PID%%' => (string) $tc_pdp_pid ] );
wp_register_script( 'titancart-single-inline', false, array(), TITANCART_VERSION, true );
wp_enqueue_script( 'titancart-single-inline' );
wp_add_inline_script( 'titancart-single-inline', $tc_pdp_js );
?>

<!-- Recently viewed tracking — pushes this product ID to localStorage for JFY auto-fill -->
<?php
// WP.org R2: NOWDOC + strtr() slots.
$tc_rv_pid = (int) $product_id;
$tc_rv_js  = <<<'JS'
(function(){
  try {
    var pid = %%PID%%;
    if (!pid) return;
    var KEY = 'tc_recently_viewed';
    var MAX = 20;
    var rv = [];
    try { rv = JSON.parse(localStorage.getItem(KEY) || '[]'); } catch(e) { rv = []; }
    // Remove this product if already in list (move to front).
    rv = rv.filter(function(id) { return id !== pid; });
    // Add to front.
    rv.unshift(pid);
    // Cap at MAX items.
    if (rv.length > MAX) rv = rv.slice(0, MAX);
    localStorage.setItem(KEY, JSON.stringify(rv));
  } catch(e) { /* localStorage unavailable */ }
})();
JS;
$tc_rv_js = strtr( $tc_rv_js, [ '%%PID%%' => (string) $tc_rv_pid ] );
wp_register_script( 'titancart-single-inline', false, array(), TITANCART_VERSION, true );
wp_enqueue_script( 'titancart-single-inline' );
wp_add_inline_script( 'titancart-single-inline', $tc_rv_js );
?>

<?php if ( ! $purchasable ) :
// Waitlist "Notify Me" handler.
// WP.org R2: NOWDOC + strtr() slots.
$tc_wl_url   = wp_json_encode( rest_url( 'titancart/v1/waitlist/subscribe' ) );
$tc_wl_nonce = wp_json_encode( wp_create_nonce( 'wp_rest' ) );
$tc_wl_js    = <<<'JS'
(function(){
  var wrap = document.getElementById('tc-notify-wrap');
  if (!wrap) return;
  var btn   = document.getElementById('tc-notify-submit');
  var email = document.getElementById('tc-notify-email');
  var msg   = document.getElementById('tc-notify-msg');
  var pid   = parseInt(wrap.dataset.productId) || 0;
  if (!btn || !email || !pid) return;

  btn.addEventListener('click', function() {
    var val = (email.value || '').trim();
    if (!val || val.indexOf('@') < 1) {
      msg.textContent = 'Please enter a valid email address.';
      msg.className = 'tc-pdp__notify-msg tc-pdp__notify-msg--error';
      msg.style.display = '';
      return;
    }
    btn.disabled = true;
    btn.textContent = 'SUBSCRIBING…';
    msg.style.display = 'none';

    fetch(%%URL%%, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': %%NONCE%%
      },
      body: JSON.stringify({ product_id: pid, email: val, name: '' })
    })
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d.success || d.message) {
        msg.textContent = d.message || 'You will be notified when this product is back in stock.';
        msg.className = 'tc-pdp__notify-msg tc-pdp__notify-msg--success';
        msg.style.display = '';
        document.getElementById('tc-notify-form').style.display = 'none';
      } else {
        msg.textContent = d.error || 'Something went wrong. Please try again.';
        msg.className = 'tc-pdp__notify-msg tc-pdp__notify-msg--error';
        msg.style.display = '';
        btn.disabled = false;
        btn.textContent = 'NOTIFY ME';
      }
    })
    .catch(function() {
      msg.textContent = 'Network error. Please try again.';
      msg.className = 'tc-pdp__notify-msg tc-pdp__notify-msg--error';
      msg.style.display = '';
      btn.disabled = false;
      btn.textContent = 'NOTIFY ME';
    });
  });

  email.addEventListener('keydown', function(e) {
    if (e.key === 'Enter') { e.preventDefault(); btn.click(); }
  });
})();
JS;
$tc_wl_js = strtr( $tc_wl_js, [ '%%URL%%' => $tc_wl_url, '%%NONCE%%' => $tc_wl_nonce ] );
wp_register_script( 'titancart-single-inline', false, array(), TITANCART_VERSION, true );
wp_enqueue_script( 'titancart-single-inline' );
wp_add_inline_script( 'titancart-single-inline', $tc_wl_js );
endif;
?>

<?php if ( $has_videos ) : ?>
<!-- Video Modal -->
<div id="tc-video-modal" style="display:none;position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);align-items:center;justify-content:center;cursor:pointer" onclick="tcCloseVideoModal()">
  <div style="position:relative;width:90vw;max-width:900px;aspect-ratio:16/9;cursor:default" onclick="event.stopPropagation()">
    <iframe id="tc-video-iframe" src="" style="width:100%;height:100%;border:0;border-radius:10px" allow="autoplay;encrypted-media;fullscreen" allowfullscreen></iframe>
    <button onclick="tcCloseVideoModal()" style="position:absolute;top:-14px;right:-14px;width:32px;height:32px;border-radius:50%;border:none;background:#fff;color:#333;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3)">×</button>
  </div>
</div>

<?php
// WP.org R2: inline CSS is built as a PHP string (no output buffering, no markup
// literals) and attached via wp_add_inline_style on a stub handle.
$tc_video_css = <<<'CSS'
.tc-pdp__thumb--video { position: relative; }
.tc-pdp__thumb--video img { width: 100%; height: 100%; object-fit: cover; }
.tc-pdp__thumb-play {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  background: rgba(0,0,0,0.35); color: #fff; font-size: 18px; border-radius: inherit;
  transition: background 0.15s;
}
.tc-pdp__thumb--video:hover .tc-pdp__thumb-play { background: rgba(0,0,0,0.55); }
.tc-pdp__thumb-video-icon {
  display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;
  background: #1a1a2e; color: #fff; font-size: 20px;
}
#tc-video-modal[style*="flex"] { display: flex !important; }

/* ── Product tag chips (S123) ─────────────────────────────────────── */
.tc-pdp__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 12px 0 4px;
}
.tc-pdp__tag-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 4px 10px;
  font-size: 12px;
  font-weight: 500;
  line-height: 1.4;
  color: #525252;
  background: #f5f5f5;
  border: 1px solid #e5e5e5;
  border-radius: 999px;
  text-decoration: none;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.tc-pdp__tag-chip:hover,
.tc-pdp__tag-chip:focus-visible {
  background: #2563EB10;
  border-color: #2563EB40;
  color: #2563EB;
  text-decoration: none;
}
.tc-pdp__tag-chip i { font-size: 11px; opacity: 0.7; }
.tc-pdp__tag-chip:hover i { opacity: 1; }
CSS;
wp_register_style( 'titancart-single-inline', false, array(), TITANCART_VERSION );
wp_enqueue_style( 'titancart-single-inline' );
wp_add_inline_style( 'titancart-single-inline', $tc_video_css );
?>

<?php
// WP.org R2: inline JS is built as a PHP string (no output buffering, no markup
// literals) and attached via wp_add_inline_script on a footer stub handle.
$tc_video_js = <<<'JS'
function tcOpenVideoModal(embedUrl) {
  var m = document.getElementById('tc-video-modal');
  var f = document.getElementById('tc-video-iframe');
  if (!m || !f) return;
  f.src = embedUrl;
  m.style.display = 'flex';
  document.body.style.overflow = 'hidden';
}
function tcCloseVideoModal() {
  var m = document.getElementById('tc-video-modal');
  var f = document.getElementById('tc-video-iframe');
  if (!m || !f) return;
  f.src = '';
  m.style.display = 'none';
  document.body.style.overflow = '';
  // Re-select first image thumb so gallery isn't blank.
  var firstThumb = document.querySelector('.tc-pdp__thumb[data-src]');
  if (firstThumb) {
    firstThumb.click();
  }
}
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') tcCloseVideoModal(); });
document.addEventListener('click', function(e) {
  var btn = e.target.closest('[data-video-embed]');
  if (btn) { e.preventDefault(); tcOpenVideoModal(btn.dataset.videoEmbed); }
});
JS;
wp_register_script( 'titancart-single-inline', false, array(), TITANCART_VERSION, true );
wp_enqueue_script( 'titancart-single-inline' );
wp_add_inline_script( 'titancart-single-inline', $tc_video_js );
?>
<?php endif; ?>

<?php
// ─── S138 Phase 5: PDP agreement gate JS ───────────────────────────────────
// Only emitted when the gate widget actually rendered. Wires the acceptance
// checkbox to the add-to-cart button:
//
//   - Logged-in customer
//       → On check, POST /agreements/accept with { agreement_id, content_hash }
//         (server pulls customer_id + email from the WP session).
//         On 201, enable the add-to-cart button.
//         On error, surface the message and leave the button disabled.
//
//   - Guest
//       → No POST at PDP time (controller rejects acceptance without an
//         email anchor — see DownloadableController::accept_agreement
//         docblock). Checking the box is a UI-only commitment that the
//         server validates and binds to billing.email at place_order time.
//
// Pattern #71 (S137): no JSON-column reads on the client, so no decode-
// before-merge concerns here. The endpoint returns plain JSON.
if ( ! empty( $_pdp_agreement ) ) :
// WP.org R2: NOWDOC + strtr() slots.
$tc_ag_api   = wp_json_encode( rest_url( 'titancart/v1' ) );
$tc_ag_nonce = wp_json_encode( wp_create_nonce( 'wp_rest' ) );
$tc_ag_js    = <<<'JS'
(function () {
    'use strict';
    var gate = document.querySelector( '[data-tc-agreement-gate]' );
    if ( ! gate ) return;
    var box = gate.querySelector( '.tc-pdp__agreement-input' );
    var msg = gate.querySelector( '.tc-pdp__agreement-msg' );
    var btn = document.querySelector( '.tc-add-to-cart-btn[data-tc-agreement-pending="1"]' );
    if ( ! box || ! btn ) return;

    var apiBase   = %%API%%;
    var nonce     = %%NONCE%%;
    var aId       = parseInt( gate.dataset.agreementId, 10 );
    var hash      = gate.dataset.contentHash || '';
    var loggedIn  = gate.dataset.loggedIn === '1';

    function setMsg( text, kind ) {
        if ( ! msg ) return;
        msg.textContent = text || '';
        msg.dataset.kind = kind || '';
    }

    function enableButton() {
        btn.disabled = false;
        btn.removeAttribute( 'data-tc-agreement-pending' );
    }
    function disableButton() {
        btn.disabled = true;
        btn.setAttribute( 'data-tc-agreement-pending', '1' );
    }

    box.addEventListener( 'change', function () {
        if ( ! box.checked ) {
            disableButton();
            setMsg( '', '' );
            return;
        }

        // Guest: no POST possible without an email anchor. The checkbox is
        // sufficient UI commitment; CheckoutManager binds the acceptance to
        // billing.email at place-order time and stores order-anchored
        // chargeback evidence there.
        if ( ! loggedIn ) {
            enableButton();
            setMsg( '', '' );
            return;
        }

        // Logged-in: record the acceptance now so a server-side has_accepted
        // check at checkout finds a row anchored to customer_id. Place-order
        // re-records with order_id binding regardless.
        setMsg( '', '' );
        box.disabled = true;

        fetch( apiBase + '/agreements/accept', {
            method: 'POST',
            credentials: 'same-origin',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce':   nonce
            },
            body: JSON.stringify( {
                agreement_id: aId,
                content_hash: hash
            } )
        } )
        .then( function ( r ) {
            return r.json().then( function ( data ) {
                return { ok: r.ok, status: r.status, data: data };
            } );
        } )
        .then( function ( res ) {
            box.disabled = false;
            if ( res.ok ) {
                enableButton();
                setMsg( '', '' );
            } else {
                box.checked = false;
                disableButton();
                setMsg(
                    ( res.data && res.data.message ) ||
                    'Could not record your acceptance. Please try again.',
                    'error'
                );
            }
        } )
        .catch( function () {
            box.disabled = false;
            box.checked  = false;
            disableButton();
            setMsg( 'Network error recording acceptance. Please try again.', 'error' );
        } );
    } );
})();
JS;
$tc_ag_js = strtr( $tc_ag_js, [ '%%API%%' => $tc_ag_api, '%%NONCE%%' => $tc_ag_nonce ] );
wp_register_script( 'titancart-single-inline', false, array(), TITANCART_VERSION, true );
wp_enqueue_script( 'titancart-single-inline' );
wp_add_inline_script( 'titancart-single-inline', $tc_ag_js );
endif;
?>

<?php get_footer(); ?>
