<?php
declare(strict_types=1);

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

/**
 * Products element renderer (shared).
 *
 * Phase 1, step 29. Handles both `products` and `product_carousel` element
 * types via a single renderer:
 *
 *   - element type `product_carousel` ⇒ caller forces display_mode='carousel'
 *     and dispatches here (see the case in impulse-page.php / impulse-preview.php).
 *   - element type `products` honours `display_mode`: 'grid' (default),
 *     'carousel', 'single'.
 *
 * Pre-extraction differences resolved:
 *
 *   - The two parent files had divergent `impulse_render_product_card`
 *     signatures (frontend's 4-arg modern vs editor's 20-arg positional).
 *     That helper now lives in `_product_card.php` with the frontend's
 *     signature as canonical; every call site here passes a `$product`
 *     object directly.
 *
 *   - Editor's `product_carousel` case shipped extra "effect" modes
 *     (`fade`, `card_flip`, `depth_rush`, etc.) that the live frontend
 *     never had. Dropped — designers were previewing effects that didn't
 *     ship to production. carousel_mode collapses to either 'snap' or
 *     'scroll' (the two modes the live frontend supports).
 *
 *   - Editor's renderer fabricated 4 hardcoded sample products when the
 *     DB returned none. Preserved here only in editor context so the
 *     builder still shows realistic-looking cards on a blank page.
 *     Frontend just renders nothing when there are no products (matches
 *     the published-page behavior of "block disappears when empty").
 *
 *   - Editor pre-resolved `columns` / `gap` against `$impulse_preview_device`
 *     and rendered only that one device's layout. Frontend emits CSS for
 *     all three breakpoints and lets the browser pick. Unified: both
 *     contexts emit the @media rules now (editor preview gains live
 *     responsive flow on iframe resize — matches the animator/header
 *     direction we agreed on).
 *
 * @sideeffect Sets $GLOBALS['impulse_needs_carousel'] when display_mode is
 *             'carousel', so the carousel JS loader knows to enqueue.
 * @sideeffect Calls `wp_add_inline_style('instinctor-page-inline', ...)`
 *             with the grid/carousel responsive CSS.
 */
function impulse_element_render_products($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    $isEditor = ($context === 'editor');

    $source       = (string) ($settings['source']      ?? 'query');
    $queryType    = (string) ($settings['query_type']  ?? 'recent');
    $count        = intval($settings['count']          ?? 8);
    $columns      = intval($settings['columns']        ?? 4);
    $gap          = intval($settings['gap']            ?? 10);
    $sectionBg    = (string) ($settings['section_bg']  ?? '');
    $secPadV      = intval($settings['section_padding_v'] ?? 0);
    $secPadH      = intval($settings['section_padding_h'] ?? 0);
    $secMaxWidth  = intval($settings['section_max_width']  ?? 1200);
    $displayMode  = (string) ($settings['display_mode'] ?? 'grid');
    $visClass     = function_exists('impulse_get_visibility_classes')
        ? impulse_get_visibility_classes($settings)
        : '';

    // Limit when inside a slider (parent block does its own paging).
    if (isset($settings['slide_index']) || !empty($GLOBALS['impulse_inside_slider'])) {
        $count = min($count, 4);
    }

    // ── Resolve card design ─────────────────────────────────────────────
    $design = !empty($settings['card_design']) ? $settings['card_design'] : (function_exists('get_option') ? get_option('instinctor_card_design', []) : []);
    if (!is_array($design)) $design = [];

    // ── Query products from instinctor_products ─────────────────────────
    // Builder-demo hook: when $GLOBALS['impulse_bd_products_override'] is set
    // (the marketing-page Products demo on instinctor.com), use that array
    // verbatim and skip the DB query + sample-placeholder fallback. The
    // global is unset immediately by the caller after render so this branch
    // never runs in production. Lets the demo show real-looking product
    // imagery on instinctor.com without seeding actual catalog rows.
    if (!empty($GLOBALS['impulse_bd_products_override'])
        && is_array($GLOBALS['impulse_bd_products_override'])) {
        $products = array_slice($GLOBALS['impulse_bd_products_override'], 0, max(1, $count));
    } else {
        $products = impulse_products__query($source, $queryType, $settings, $count);
    }


    // ── Empty handling ──────────────────────────────────────────────────
    // Editor preview substitutes 4 sample products so designers see card
    // styling on a blank page. Frontend renders nothing (the block hides).
    if (empty($products)) {
        if ($isEditor) {
            $products = impulse_products__sample_placeholders(min(4, max(1, $count)));
        } else {
            return '';
        }
    }

    // Carousel mode flags the global enqueue + sets display_mode if caller didn't.
    if ($displayMode === 'carousel') {
        $GLOBALS['impulse_needs_carousel'] = true;
        $GLOBALS['impulse_has_carousel']   = true;
    }

    ob_start();

    $rawId = (string) ($el['id'] ?? uniqid());
    $rawId = preg_replace('/[^a-z0-9_-]/i', '', $rawId);

    if ($displayMode === 'carousel') {
        impulse_products__render_carousel($products, $design, $settings, $rawId, $sectionBg, $secPadV, $secPadH, $secMaxWidth, $gap, $visClass);
    } elseif ($displayMode === 'single') {
        impulse_products__render_single($products, $design, $settings, $rawId, $sectionBg, $secPadV, $secPadH, $secMaxWidth, $visClass);
    } elseif ($displayMode === 'menu') {
        impulse_products__render_menu($products, $design, $settings, $rawId, $sectionBg, $secPadV, $secPadH, $secMaxWidth, $visClass, $isEditor);
    } else {
        impulse_products__render_grid($products, $design, $settings, $rawId, $sectionBg, $secPadV, $secPadH, $secMaxWidth, $gap, $columns, $visClass);
    }

    return (string) ob_get_clean();
}

// ─────────────────────────────────────────────────────────────────────────
// Query helper. Returns array of product objects (stdClass or row objects
// from instinctor_products), or [] when nothing matches / DB unavailable.
// ─────────────────────────────────────────────────────────────────────────
function impulse_products__query(string $source, string $queryType, array $settings, int $count): array {
    $useInstinctor = class_exists('\\Instinctor\\Core\\DB');
    if ($useInstinctor) {
        $pt = \Instinctor\Core\DB::prefix() . 'instinctor_products';
        $ct = \Instinctor\Core\DB::prefix() . 'instinctor_product_categories';
    } else {
        global $wpdb;
        if (!isset($wpdb)) return [];
        $pt = $wpdb->prefix . 'instinctor_products';
        $ct = $wpdb->prefix . 'instinctor_product_categories';
    }

    $where   = "status = 'published'";
    $orderBy = 'id DESC';

    if ($source === 'manual' && !empty($settings['product_ids'])) {
        $ids = array_filter(array_map('intval', explode(',', (string) $settings['product_ids'])));
        if ($ids) {
            $idsStr  = implode(',', $ids);
            $where  .= " AND id IN ($idsStr)";
            $orderBy = "FIELD(id, $idsStr)";
            $count   = count($ids);
        }
    } else {
        switch ($queryType) {
            case 'newest':
                break;
            case 'featured':
                $where .= ' AND is_featured = 1';
                break;
            case 'sale':
                $where .= ' AND sale_price IS NOT NULL AND sale_price > 0 AND sale_price < price';
                break;
            case 'category':
                if (!empty($settings['category_id'])) {
                    $catId  = intval($settings['category_id']);
                    $where .= " AND id IN (SELECT product_id FROM $ct WHERE category_id = $catId)";
                }
                break;
        }
    }

    $sql = "SELECT * FROM $pt WHERE $where ORDER BY $orderBy LIMIT %d";
    if ($useInstinctor) {
        $rows = \Instinctor\Core\DB::get_results(\Instinctor\Core\DB::prepare($sql, $count));
    } else {
        global $wpdb;
        $rows = $wpdb->get_results($wpdb->prepare($sql, $count)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql interpolates only the plugin's own prefixed table ($pt, 140/145) + literal $where and $orderBy built from intval'd ids (151-157); LIMIT bound via %d
    }
    return is_array($rows) ? $rows : [];
}

// ─────────────────────────────────────────────────────────────────────────
// Editor-only: build 4 sample stdClass products so the builder canvas shows
// realistic card layouts even when the site has no products yet.
// ─────────────────────────────────────────────────────────────────────────
function impulse_products__sample_placeholders(int $n): array {
    $samples = [
        ['name' => 'Premium Wireless Headphones', 'price' => 149.99, 'sale_price' => 99.99,  'is_featured' => 0],
        ['name' => 'Minimalist Watch',            'price' => 299.99, 'sale_price' => 0,      'is_featured' => 1],
        ['name' => 'Leather Backpack',            'price' => 189.99, 'sale_price' => 0,      'is_featured' => 0],
        ['name' => 'Smart Fitness Tracker',       'price' => 79.99,  'sale_price' => 59.99,  'is_featured' => 0],
    ];
    $out = [];
    for ($i = 0; $i < $n; $i++) {
        $s = $samples[$i % 4];
        $o = new stdClass();
        $o->id                 = 0;
        $o->name               = $s['name'];
        $o->slug               = '';
        $o->price              = $s['price'];
        $o->sale_price         = $s['sale_price'];
        $o->sku                = '';
        $o->stock_quantity     = null;
        $o->short_description  = 'Sample product description for preview purposes.';
        $o->description        = '';
        $o->images             = null;
        $o->gallery_images     = null;
        $o->featured_image     = null;
        $o->is_featured        = $s['is_featured'];
        $o->product_type       = '';
        $o->meta               = '{}';
        $out[] = $o;
    }
    return $out;
}

// ─────────────────────────────────────────────────────────────────────────
// Grid mode.
// ─────────────────────────────────────────────────────────────────────────
function impulse_products__render_grid(array $products, array $design, array $settings, string $rawId, string $sectionBg, int $secPadV, int $secPadH, int $secMaxWidth, int $gap, int $columns, string $visClass): void {
    $elId       = 'impulse-pgrid-' . $rawId;
    $flipCards  = (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none');
    $flipEffect = (string) ($settings['flip_card_effect'] ?? 'flip-h');

    // Trim to fit complete rows — no orphan partial second row.
    $productCount  = count($products);
    $effectiveCols = max(1, $columns);
    if ($productCount >= $effectiveCols * 2) {
        $rows     = 2;
        $products = array_slice($products, 0, $effectiveCols * 2);
    } else {
        $rows = 1;
        if ($productCount > $effectiveCols) {
            $products = array_slice($products, 0, $effectiveCols);
        }
    }

    // Two-layer centering. Outer is a stretched flex row that explicitly
    // justifies its inner child to centre — bulletproof against the
    // `.impulse-container > *{align-self:stretch}` cascade override.
    // The outer fully stretches (by design), the inner has the max-width
    // cap and is centred by flex justify-content, not by margin/align-self.
    // The earlier one-layer align-self:center attempt was visibly ignored
    // by Chrome on production. Sergei 2026-05-30.
    echo '<div style="display:flex;justify-content:center;width:100%;'
       . ($sectionBg && $sectionBg !== 'transparent' ? 'background:' . esc_attr($sectionBg) . ';' : '')
       . '">';
    echo '<div style="width:100%;max-width:' . (int) $secMaxWidth . 'px;box-sizing:border-box;'
       . 'padding:' . (int) max(20, $secPadV) . 'px ' . (int) max(20, $secPadH) . 'px;">';

    $gridStyle = 'display:flex;flex-wrap:wrap;justify-content:center;gap:' . $gap . 'px;';

    // Equal-share card width via flex-basis percentages — never partial cards.
    $cardW = 'calc((100% - ' . (($effectiveCols - 1) * $gap) . 'px) / ' . $effectiveCols . ')';
    $css   = '#' . $elId . ' > *{flex:0 0 ' . $cardW . ';max-width:' . $cardW . ';box-sizing:border-box;}';

    // Responsive breakpoints. Tablet defaults to min(cols, 2); mobile to 1.
    $tCols = isset($settings['columns_tablet']) ? max(1, intval($settings['columns_tablet'])) : min($effectiveCols, 2);
    $mCols = isset($settings['columns_mobile']) ? max(1, intval($settings['columns_mobile'])) : 1;
    $tW    = 'calc((100% - ' . (($tCols - 1) * $gap) . 'px) / ' . $tCols . ')';
    $mW    = 'calc((100% - ' . (($mCols - 1) * $gap) . 'px) / ' . $mCols . ')';
    $css  .= '@media(max-width:1024px){#' . $elId . ' > *{flex-basis:' . $tW . '!important;max-width:' . $tW . '!important}}';
    $css  .= '@media(max-width:768px){#'  . $elId . ' > *{flex-basis:' . $mW . '!important;max-width:' . $mW . '!important}}';
    // Emit the per-column width CSS as an in-block <style> so it travels WITH
    // the block HTML. The page-inline channel (wp_add_inline_style) is NOT
    // re-applied when the editor switches a block INTO grid mode (e.g. from
    // carousel or menu), which left the cards with no width constraint and
    // collapsed them to their intrinsic ~150px width ("tiny cards"). An
    // in-block <style> survives any block re-render in both engines.
    wp_add_inline_style('instinctor-page-inline', $css);

    $gridClass = 'impulse-product-grid';
    if ($visClass) $gridClass .= ' ' . $visClass;
    if (isset($settings['slide_index']) || !empty($GLOBALS['impulse_inside_slider'])) $gridClass .= ' impulse-grid-in-slider';

    // Quickview metadata (consumed by the modal JS).
    $md  = $settings['modal_design']     ?? [];
    $atc = $design['add_to_cart']        ?? [];
    $qvData = wp_json_encode([
        'show_desc'      => isset($md['show_desc'])      ? (bool) $md['show_desc']     : true,
        'show_desc_type' => $md['show_desc_type']        ?? 'short',
        'show_price'     => isset($md['show_price'])     ? (bool) $md['show_price']    : true,
        'show_qty'       => isset($md['show_qty'])       ? (bool) $md['show_qty']      : true,
        'max_width'      => intval($md['max_width']      ?? 900),
        'height'         => intval($md['height']         ?? 90),
        'btn_bg'         => $atc['bg_color']             ?? '#1d2327',
        'btn_text'       => $atc['text_color']           ?? '#ffffff',
        'btn_radius'     => intval($atc['border_radius'] ?? 0),
        'enable_modal'   => !isset($settings['enable_modal']) || $settings['enable_modal'] === true || $settings['enable_modal'] === '1' || $settings['enable_modal'] === 1,
        'addons'         => is_array($settings['addons'] ?? null) ? $settings['addons'] : [],
    ]);

    // Quickview modal styling (mirrors card shadow + bg).
    $mw          = intval($md['max_width'] ?? 900);
    $mh          = intval($md['height']    ?? 90);
    $cardShadow  = $design['card']['shadow'] ?? 'small';
    $shadowsMap  = [
        'none'   => 'none',
        'small'  => '0 8px 30px rgba(0,0,0,0.15)',
        'medium' => '0 25px 50px rgba(0,0,0,0.25)',
        'large'  => '0 40px 80px rgba(0,0,0,0.40)',
    ];
    $modalShadow = $shadowsMap[$cardShadow] ?? $shadowsMap['small'];
    $cardBg      = $design['card']['background'] ?? '#ffffff';
    if (function_exists('wp_add_inline_style')) {
        wp_add_inline_style('instinctor-page-inline',
            '.impulse-quickview-modal{max-width:' . $mw . 'px!important;max-height:' . $mh . 'vh!important;box-shadow:' . $modalShadow . '!important;}'
          . '.impulse-quickview-gallery{background:' . esc_attr($cardBg) . '!important;}'
          . '.impulse-quickview-details{background:' . esc_attr($cardBg) . '!important;}'
        );
    }

    echo '<div id="' . esc_attr($elId) . '" class="' . esc_attr($gridClass) . '" style="' . esc_attr($gridStyle) . '" data-cols-mobile="' . (int) $mCols . '" data-qv="' . esc_attr($qvData) . '">';
    foreach ($products as $product) {
        impulse_render_product_card($product, $design, $flipCards, $flipEffect);
    }
    echo '</div>';
    echo '</div>'; // inner wrapper (max-width)
    echo '</div>'; // outer flex centering wrapper

    // Two-row layouts scale to fit viewport so the second row doesn't push
    // the footer off-screen on short displays.
    if ($rows >= 2) {
        wp_add_inline_script('instinctor-frontend-js', '(function(){var g=document.getElementById(' . wp_json_encode($elId) . ');if(!g)return;function fit(){g.style.transform="";g.style.marginBottom="";var h=g.scrollHeight,max=window.innerHeight-40;if(h>max){var s=max/h;g.style.transformOrigin="top center";g.style.transform="scale("+s.toFixed(4)+")";g.style.marginBottom="-"+(h*(1-s)).toFixed(0)+"px"}}fit();window.addEventListener("resize",fit)})();', 'after');
    }
}

// ─────────────────────────────────────────────────────────────────────────
// Carousel mode. Two sub-modes: 'snap' (paginated) and 'scroll' (infinite
// marquee). The fancy editor-only effects (fade, card_flip, etc.) were
// dropped during extraction — they never shipped to the frontend.
// ─────────────────────────────────────────────────────────────────────────
function impulse_products__render_carousel(array $products, array $design, array $settings, string $rawId, string $sectionBg, int $secPadV, int $secPadH, int $secMaxWidth, int $gap, string $visClass): void {
    $carouselMode    = (string) ($settings['carousel_mode'] ?? 'snap');
    // Collapse the editor's effect modes back to 'snap' (which is what the
    // frontend actually renders).
    if (!in_array($carouselMode, ['snap', 'scroll'], true)) $carouselMode = 'snap';

    $visibleCount    = intval($settings['carousel_cards'] ?? $settings['columns'] ?? 4);
    $vctTablet       = intval($settings['columns_tablet'] ?? 2);
    $vctMobile       = intval($settings['columns_mobile'] ?? 1);
    $gapTablet       = intval($settings['gap_tablet']     ?? $gap);
    $gapMobile       = intval($settings['gap_mobile']     ?? $gap);
    $transitionSpeed = intval($settings['transition_speed'] ?? 400);
    $scrollSpeed     = intval($settings['scroll_speed']   ?? 40);
    $autoplay        = !empty($settings['autoplay']);
    $autoplaySpeed   = intval($settings['autoplay_speed'] ?? 3000);
    if ($autoplaySpeed <= 100) $autoplaySpeed *= 1000; // seconds → ms
    $pauseHover      = !empty($settings['pause_hover']);
    $flipCards       = ($carouselMode !== 'scroll') && (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none');
    $flipEffect      = (string) ($settings['flip_card_effect'] ?? 'flip-h');

    $elId        = 'impulse-pcarousel-' . $rawId;
    $slideWidth  = 'calc(' . (100 / max(1, $visibleCount)) . '% - ' . ($gap * ($visibleCount - 1) / max(1, $visibleCount)) . 'px)';
    $trackOverflow   = $carouselMode === 'scroll' ? 'hidden' : 'visible';
    $trackTransition = $carouselMode === 'scroll' ? 'none'   : 'transform ' . $transitionSpeed . 'ms ease-in-out';

    $containerStyle = 'position:relative;overflow:hidden;padding-top:40px;padding-bottom:40px;margin-top:-40px;margin-bottom:-40px;';
    if ($sectionBg && $sectionBg !== 'transparent') $containerStyle .= 'background:' . esc_attr($sectionBg) . ';';
    if ($secPadV > 0 || $secPadH > 0) $containerStyle .= 'padding:' . $secPadV . 'px ' . $secPadH . 'px;';
    if ($secMaxWidth > 0) {
        $containerStyle .= 'max-width:' . $secMaxWidth . 'px;margin-left:auto;margin-right:auto;';
    }

    $carouselClass = 'impulse-carousel impulse-product-carousel';
    if ($visClass) $carouselClass .= ' ' . $visClass;

    // Responsive slide widths
    $twTablet = 'calc(' . (100 / max(1, $vctTablet)) . '% - ' . ($gapTablet * ($vctTablet - 1) / max(1, $vctTablet)) . 'px)';
    $twMobile = 'calc(' . (100 / max(1, $vctMobile)) . '% - ' . ($gapMobile * ($vctMobile - 1) / max(1, $vctMobile)) . 'px)';
    if (function_exists('wp_add_inline_style')) {
        wp_add_inline_style('instinctor-page-inline',
            '@media(max-width:1024px){#' . $elId . ' .impulse-carousel-slides{gap:' . $gapTablet . 'px!important}#' . $elId . ' .impulse-carousel-slide{flex:0 0 ' . $twTablet . '!important}}'
          . '@media(max-width:768px){#'  . $elId . ' .impulse-carousel-slides{gap:' . $gapMobile . 'px!important}#' . $elId . ' .impulse-carousel-slide{flex:0 0 ' . $twMobile . '!important}}'
        );
    }

    // Quickview data
    $md2 = $settings['modal_design'] ?? [];
    $qvData = wp_json_encode([
        'show_desc'      => isset($md2['show_desc'])      ? (bool) $md2['show_desc']     : true,
        'show_desc_type' => $md2['show_desc_type']        ?? 'short',
        'show_sku'       => isset($md2['show_sku'])       ? (bool) $md2['show_sku']      : true,
        'show_price'     => isset($md2['show_price'])     ? (bool) $md2['show_price']    : true,
        'show_qty'       => isset($md2['show_qty'])       ? (bool) $md2['show_qty']      : true,
        'enable_modal'   => !isset($settings['enable_modal']) || $settings['enable_modal'] === true || $settings['enable_modal'] === '1' || $settings['enable_modal'] === 1,
        'addons'         => is_array($settings['addons'] ?? null) ? $settings['addons'] : [],
    ]);

    echo '<div class="impulse-pblock-outer" style="width:100%">';
    echo '<div id="' . esc_attr($elId) . '" class="' . esc_attr($carouselClass) . '"'
       . ' data-qv="' . esc_attr($qvData) . '"'
       . ' data-autoplay="' . ($autoplay ? 'true' : 'false') . '"'
       . ' data-speed="' . (int) $autoplaySpeed . '"'
       . ' data-pause="' . ($pauseHover ? 'true' : 'false') . '"'
       . ' data-slides-per-view="' . (int) $visibleCount . '"'
       . ' data-slides-per-view-tablet="' . (int) $vctTablet . '"'
       . ' data-slides-per-view-mobile="' . (int) $vctMobile . '"'
       . ' data-gap="' . (int) $gap . '"'
       . ' data-transition="' . (int) $transitionSpeed . '"'
       . ' data-mode="' . esc_attr($carouselMode) . '"'
       . ' data-scroll-speed="' . (int) $scrollSpeed . '"'
       . ' style="' . esc_attr($containerStyle) . '">';
    echo '<div class="impulse-carousel-track" style="overflow:' . esc_attr($trackOverflow) . '">';
    echo '<div class="impulse-carousel-slides" style="display:flex;gap:' . (int) $gap . 'px;transition:' . esc_attr($trackTransition) . '">';

    if ($carouselMode !== 'scroll') {
        foreach ($products as $product) {
            echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0">';
            impulse_render_product_card($product, $design, $flipCards, $flipEffect);
            echo '</div>';
        }
    } else {
        // Scroll: initial pass + clone pass for infinite marquee
        foreach ($products as $product) {
            echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0">';
            impulse_render_product_card($product, $design, false);
            echo '</div>';
        }
        foreach ($products as $product) {
            echo '<div class="impulse-carousel-slide impulse-clone" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0">';
            impulse_render_product_card($product, $design, false);
            echo '</div>';
        }
    }

    echo '</div></div></div>';
    echo '</div>'; // .impulse-pblock-outer
}

// ─────────────────────────────────────────────────────────────────────────
// Single mode. One centered card.
// ─────────────────────────────────────────────────────────────────────────
function impulse_products__render_single(array $products, array $design, array $settings, string $rawId, string $sectionBg, int $secPadV, int $secPadH, int $secMaxWidth, string $visClass): void {
    $cardWidth = max(250, min(500, intval($settings['card_max_width'] ?? 250)));
    $wrapStyle = 'display:flex;justify-content:center;';
    if ($sectionBg && $sectionBg !== 'transparent') $wrapStyle .= 'background:' . esc_attr($sectionBg) . ';';
    if ($secPadV > 0 || $secPadH > 0) $wrapStyle .= 'padding:' . $secPadV . 'px ' . $secPadH . 'px;';
    if ($secMaxWidth > 0) $wrapStyle .= 'max-width:' . $secMaxWidth . 'px;margin-left:auto;margin-right:auto;';

    // Honour the flip-card effect (H-Flip / V-Flip / Zoom / Push / Swing /
    // Fade) in single mode too. Was hardcoded false — the panel showed the
    // effect buttons but they did nothing on a single-card block.
    $flipCards  = (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none');
    $flipEffect = (string) ($settings['flip_card_effect'] ?? 'flip-h');

    $elId = 'impulse-psingle-' . $rawId;
    echo '<div class="impulse-pblock-outer" style="width:100%">';
    echo '<div id="' . esc_attr($elId) . '" style="' . esc_attr($wrapStyle) . '">';
    echo '<div style="width:' . (int) $cardWidth . 'px;max-width:100%">';
    impulse_render_product_card($products[0], $design, $flipCards, $flipEffect);
    echo '</div></div>';
    echo '</div>';
}

// ─────────────────────────────────────────────────────────────────────────
// Menu mode. Restaurant-menu list of REAL products: "Name …………… $Price"
// with the price_list leader separator, optional description / thumbnail, and
// an optional per-row Add-to-cart button (reuses the card's cart wiring:
// `.instinctor-add-to-cart-btn[data-product-id]`, bound globally in frontend.js).
// Visual settings (separator / sizes / colours / divider) reuse the price_list
// `pl_*` keys so the look is identical to the Price List block; `menu_*` keys
// carry the commerce-specific options. Shared by both render engines.
// ─────────────────────────────────────────────────────────────────────────
function impulse_products__render_menu(array $products, array $design, array $settings, string $rawId, string $sectionBg, int $secPadV, int $secPadH, int $secMaxWidth, string $visClass, bool $isEditor): void {
    $device = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';
    $read = function (string $key, $default) use ($settings, $isEditor) {
        if ($isEditor && function_exists('impulse_device_val')) {
            return impulse_device_val($settings, $key, $GLOBALS['impulse_preview_device'] ?? 'desktop', $default);
        }
        return $settings[$key] ?? $default;
    };

    // Visual settings shared with the Price List block (pl_*).
    $sep         = (string) ($settings['pl_separator'] ?? 'dots');
    if ($sep === 'dotted') $sep = 'dashed';
    $titleSize   = intval($read('pl_title_size', 16));
    $priceSize   = intval($read('pl_price_size', 16));
    $descSize    = intval($read('pl_desc_size', 13));
    $spacing     = intval($read('pl_spacing', 16));
    $titleColor  = (string) ($settings['pl_title_color']  ?? '#1d2327');
    $priceColor  = (string) ($settings['pl_price_color']  ?? '#1d2327');
    $descColor   = (string) ($settings['pl_desc_color']   ?? '#888888');
    $sepColor    = (string) ($settings['pl_sep_color']    ?? '#cccccc');
    $borderColor = (string) ($settings['pl_border_color'] ?? '#e0e0e0');
    $maxWidth    = intval($settings['section_max_width'] ?? 800);
    $showDivider = !isset($settings['pl_show_divider']) || !empty($settings['pl_show_divider']);

    // Commerce-specific (menu_*).
    $showDesc   = !isset($settings['menu_show_desc'])   || !empty($settings['menu_show_desc']);
    $showThumb  = !empty($settings['menu_show_thumb']);
    $showButton = !isset($settings['menu_show_button']) || !empty($settings['menu_show_button']);
    $btnLabel   = trim((string) ($settings['menu_btn_label'] ?? '')) !== '' ? (string) $settings['menu_btn_label'] : 'Add to cart';
    $btnBg      = (string) ($settings['menu_btn_bg']     ?? '#1d2327');
    $btnColor   = (string) ($settings['menu_btn_color']  ?? '#ffffff');
    $btnRadius  = intval($settings['menu_btn_radius'] ?? 6);
    $thumbSize  = max(24, intval($read('menu_thumb_size', 44)));
    $anyThumb   = false;

    $sc = esc_attr($sepColor);
    impulse_pl_sep_keyframes($sep);

    $curSym = class_exists('\\Instinctor\\Services\\CurrencyService') ? null : '$';
    $fmt = function ($v) use ($curSym) {
        if ($curSym === null) return \Instinctor\Services\CurrencyService::formatPrice((float) $v);
        return '$' . number_format((float) $v, 2);
    };

    // Resolve a product's primary image URL (mirrors the configurator resolver).
    $imgUrl = function ($p) {
        if (!empty($p->images)) {
            $imgs = is_string($p->images) ? json_decode($p->images, true) : $p->images;
            if (is_array($imgs) && !empty($imgs)) {
                $first = reset($imgs);
                $u = is_array($first) ? (string) ($first['url'] ?? '') : (string) $first;
                if ($u !== '') return $u;
            }
        }
        if (!empty($p->featured_image)) {
            if (ctype_digit((string) $p->featured_image)) {
                if (function_exists('impulse_resolve_image')) {
                    $r = impulse_resolve_image((int) $p->featured_image, 'large');
                    if ($r && !empty($r[0])) return (string) $r[0];
                }
            } else {
                return (string) $p->featured_image;
            }
        }
        return '';
    };

    $wrapStyle = $isEditor
        ? 'width:100%;max-width:var(--impulse-section-max-width,' . $maxWidth . 'px);margin:0 auto'
        : 'width:100%;max-width:' . $maxWidth . 'px;margin:0 auto';

    echo '<div style="display:flex;justify-content:center;width:100%;'
       . ($sectionBg && $sectionBg !== 'transparent' ? 'background:' . esc_attr($sectionBg) . ';' : '')
       . '">';
    echo '<div style="width:100%;max-width:' . (int) $maxWidth . 'px;box-sizing:border-box;padding:' . (int) max(0, $secPadV) . 'px ' . (int) max(0, $secPadH) . 'px;">';

    $menuClass = 'impulse-price-list impulse-product-menu';
    if ($visClass) $menuClass .= ' ' . $visClass;
    echo '<div class="' . esc_attr($menuClass) . '" style="' . esc_attr($wrapStyle) . '">';

    $idx = 0;
    foreach ($products as $product) {
        $name = (string) ($product->name ?? '');
        if ($name === '') continue;

        $reg  = (float) ($product->price ?? 0);
        $sale = !empty($product->sale_price) ? (float) $product->sale_price : 0.0;
        $hasSale = ($sale > 0 && $reg > 0 && $sale < $reg);
        $effective = $hasSale ? $sale : ($reg > 0 ? $reg : $sale);

        $desc = $showDesc ? trim((string) ($product->short_description ?? '')) : '';
        $thumb = $showThumb ? $imgUrl($product) : '';
        $pid = intval($product->id ?? 0);

        $dividerStyle = $showDivider
            ? ($isEditor
                ? 'border-bottom:1px solid var(--impulse-pl-border-color,' . esc_attr($borderColor) . ')'
                : 'border-bottom:1px solid ' . esc_attr($borderColor))
            : 'border-bottom:none';
        $spacingClause = $isEditor
            ? 'padding:var(--impulse-pl-spacing,' . $spacing . 'px) 0;'
            : 'padding:' . $spacing . 'px 0;';

        echo '<div class="impulse-price-item impulse-menu-item" style="' . esc_attr($spacingClause . $dividerStyle) . '">';
        echo '<div style="display:flex;align-items:baseline;gap:8px">';

        if ($thumb !== '') {
            $anyThumb = true;
            // Hover hook: class + data-full. Preview is hover-only (same as the
            // Library), so no zoom cursor and no click handler — a click in the
            // editor still selects the block.
            echo '<img class="impulse-menu-thumb" data-full="' . esc_url($thumb) . '" src="' . esc_url($thumb) . '" alt="' . esc_attr($name) . '" style="width:' . (int) $thumbSize . 'px;height:' . (int) $thumbSize . 'px;object-fit:contain;border-radius:6px;flex-shrink:0;align-self:center">';
        }

        $titleStyle = $isEditor
            ? 'font-weight:600;color:var(--impulse-pl-title-color,' . esc_attr($titleColor) . ');font-size:var(--impulse-pl-title-size,' . $titleSize . 'px);flex-shrink:0'
            : 'font-size:' . $titleSize . 'px;font-weight:600;color:' . esc_attr($titleColor);
        echo '<span class="impulse-pl-title" style="' . esc_attr($titleStyle) . '">' . esc_html($name) . '</span>';

        impulse_pl_separator_html($sep, $idx, $sc);

        $priceStyle = $isEditor
            ? 'font-weight:700;color:var(--impulse-pl-price-color,' . esc_attr($priceColor) . ');font-size:var(--impulse-pl-price-size,' . $priceSize . 'px);white-space:nowrap;flex-shrink:0'
            : 'font-size:' . $priceSize . 'px;font-weight:700;color:' . esc_attr($priceColor) . ';white-space:nowrap';
        echo '<span class="impulse-pl-price" style="' . esc_attr($priceStyle) . '">';
        if ($hasSale) {
            echo '<span style="color:#999;text-decoration:line-through;margin-right:6px;font-weight:400;font-size:' . (int) max(10, $priceSize - 2) . 'px">' . esc_html($fmt($reg)) . '</span>';
            echo esc_html($fmt($sale));
        } else {
            echo esc_html($fmt($effective));
        }
        echo '</span>';

        // Per-row order button. Shown when enabled and the row has a real price.
        // Out-of-stock (tracked stock <= 0) renders a disabled "Sold out".
        if ($showButton && $effective > 0) {
            $stock = $product->stock_quantity;
            $soldOut = ($stock !== null && $stock !== '' && intval($stock) <= 0);
            // flex:0 0 auto is REQUIRED: the global `.instinctor-add-to-cart-btn`
            // rule (frontend.css) sets `flex:1`, which would make the button
            // grow to fill the menu row. Override it so the button hugs its label.
            $btnBase = 'flex:0 0 auto;align-self:center;margin-left:4px;padding:6px 14px;border:none;border-radius:' . $btnRadius . 'px;font-weight:600;font-size:13px;white-space:nowrap';
            if ($soldOut) {
                // Disabled button (not a <span>): the button resets line-height
                // to its UA normal, matching the live cart buttons exactly. A
                // <span> inherits the page line-height (1.6) and rendered taller.
                echo '<button class="instinctor-add-to-cart-btn" disabled style="' . esc_attr($btnBase . ';background:#e0e0e0;color:#999;cursor:not-allowed') . '">Sold out</button>';
            } else {
                $meta = json_decode((string) ($product->meta ?? '{}'), true);
                $externalUrl = is_array($meta) ? trim((string) ($meta['external_url'] ?? '')) : '';
                $btnStyle = $btnBase . ';background:' . esc_attr($btnBg) . ';color:' . esc_attr($btnColor) . ';cursor:pointer';
                if ($externalUrl !== '' && filter_var($externalUrl, FILTER_VALIDATE_URL)) {
                    echo '<a class="instinctor-external-buy-btn" href="' . esc_url($externalUrl) . '" data-product-id="' . (int) $pid . '" style="' . esc_attr($btnStyle . ';display:inline-flex;align-items:center;line-height:normal;text-decoration:none') . '">' . esc_html($btnLabel) . '</a>';
                } else {
                    echo '<button class="instinctor-add-to-cart-btn" data-product-id="' . (int) $pid . '" style="' . esc_attr($btnStyle) . '">' . esc_html($btnLabel) . '</button>';
                }
            }
        }

        echo '</div>'; // flex row

        if ($desc !== '') {
            $descPad = $thumb !== '' ? ($thumbSize + 8) : 0;
            $descStyle = $isEditor
                ? 'font-size:var(--impulse-pl-desc-size,' . $descSize . 'px);color:var(--impulse-pl-desc-color,' . esc_attr($descColor) . ');margin-top:2px;padding-left:' . $descPad . 'px'
                : 'font-size:' . $descSize . 'px;color:' . esc_attr($descColor) . ';margin-top:4px;line-height:1.4;padding-left:' . $descPad . 'px';
            echo '<div class="impulse-pl-desc" style="' . esc_attr($descStyle) . '">' . esc_html($desc) . '</div>';
        }

        echo '</div>'; // item
        $idx++;
    }

    echo '</div>'; // .impulse-price-list
    echo '</div>'; // inner wrapper
    echo '</div>'; // outer flex centering

    // Hover image preview for menu thumbnails — the SAME mechanism as the
    // Library hover preview: a floating image that follows the cursor, with NO
    // backdrop/overlay and NO chrome (transparent, pointer-events:none so it
    // never steals the hover and flickers). Hover-only, like the Library.
    // Works in the editor canvas + the frontend (self-contained, one per page).
    if ($anyThumb && empty($GLOBALS['impulse_menu_preview_emitted'])) {
        $GLOBALS['impulse_menu_preview_emitted'] = true;
        wp_add_inline_style('instinctor-page-inline', ''
           . '.impulse-menu-preview{display:none;position:fixed;z-index:999999;background:transparent;border:none;border-radius:6px;pointer-events:none;width:280px;max-width:42vw;overflow:hidden;clip-path:circle(150% at 50% 50%);animation:impulse-menu-reveal 0.35s ease-in-out}'
           . '@keyframes impulse-menu-reveal{from{clip-path:circle(0% at 50% 50%)}to{clip-path:circle(150% at 50% 50%)}}'
           . '.impulse-menu-preview img{display:block;width:100%;height:auto;border-radius:6px}'
           . '');
        echo '<div class="impulse-menu-preview"><img alt=""></div>';
        // The preview MUST live directly on <body>: a 3D-transformed screen
        // ancestor becomes the containing block for position:fixed, which would
        // place + scale the preview relative to that box (off-screen, shrunk).
        // Promote one global preview to <body>, dedupe re-render copies, bind once.
        ob_start();
        echo '(function(){'
           . 'var GID="imp-menu-preview-global";'
           . 'var glob=document.getElementById(GID);'
           . 'if(!glob){var first=document.querySelector(".impulse-menu-preview");if(first){glob=first;glob.id=GID;document.body.appendChild(glob);}}'
           . 'var dups=document.querySelectorAll(".impulse-menu-preview");for(var i=0;i<dups.length;i++){if(dups[i].id!==GID&&dups[i].parentNode)dups[i].parentNode.removeChild(dups[i]);}'
           . 'if(!glob)return;'
           . 'if(window.__impMenuPreviewBound)return;window.__impMenuPreviewBound=true;'
           . 'function pvEl(){return document.getElementById(GID);}'
           . 'function place(e){var p=pvEl();if(!p)return;var pw=p.offsetWidth||280,ph=p.offsetHeight||200,ww=window.innerWidth,wh=window.innerHeight;'
           . 'var x=e.clientX+20;if(x+pw>ww-10)x=e.clientX-pw-20;if(x<10)x=10;'
           . 'var y=e.clientY-80;if(y+ph>wh-10)y=wh-ph-10;if(y<10)y=10;'
           . 'p.style.left=x+"px";p.style.top=y+"px";}'
           . 'document.addEventListener("mouseover",function(e){var t=e.target.closest&&e.target.closest(".impulse-menu-thumb");if(!t)return;var src=t.getAttribute("data-full");if(!src)return;var p=pvEl();if(!p)return;var im=p.querySelector("img");if(im.getAttribute("src")!==src)im.src=src;im.style.display="block";p.style.display="block";p.style.animation="none";void p.offsetWidth;p.style.animation="";place(e);});'
           . 'document.addEventListener("mousemove",function(e){var p=pvEl();if(p&&p.style.display==="block"&&e.target.closest&&e.target.closest(".impulse-menu-thumb"))place(e);});'
           . 'document.addEventListener("mouseout",function(e){if(e.target.closest&&e.target.closest(".impulse-menu-thumb")){var p=pvEl();if(p)p.style.display="none";}});'
           . '})();';
        $_inlineJs = ob_get_clean();
        wp_add_inline_script('instinctor-frontend-js', $_inlineJs, 'after');
    }
}
