@inherits Smartstore.Web.Razor.SmartRazorPage<SearchResultModel>
@using Smartstore
@using Smartstore.Core.Content.Media
@using Smartstore.Web.Models.Search

@model SearchResultModel

@inject CRS.HybridSearch.Configuration.HybridSearchSettings _settings
@inject Smartstore.Core.Catalog.Search.SearchSettings _searchSettings
@inject IMediaService _mediaService

@{
    // "Darstellung" settings drive the InstantSearch panel chrome — bg-picture,
    // glass effect, corner radius, hover-3D. Same CSS-variable pattern as the
    // HybridMenu dropdown so the look stays consistent.
    string _isBgUrl = null;
    if (_settings.InstantSearchBgPictureId > 0)
    {
        var _bgFile = await _mediaService.GetFileByIdAsync(_settings.InstantSearchBgPictureId);
        _isBgUrl = _bgFile != null ? _mediaService.GetUrl(_bgFile, null, null, false) : null;
    }
    var _isBgColor = string.IsNullOrWhiteSpace(_settings.InstantSearchBgColor) ? "#ffffff" : _settings.InstantSearchBgColor.Trim();
    var _isBgSize = _settings.InstantSearchBgPictureCover ? "cover" : "contain";
    var _isBgOpacity = Math.Clamp(_settings.InstantSearchBgPictureOpacity, 0, 100) / 100.0;
    // Both panels share the global GlassOpacity slider, but each panel's "usable"
    // opacity range differs because the background layers behind them are different.
    // Empirically:
    //   - InstantSearch is only readable from 0.90..1.00
    //   - HybridMenu is readable from 0.45..1.00 (handled in HybridMenu.cshtml)
    // We remap the 0..1 slider into THIS panel's usable band so the user has a full
    // slider that traverses both panels' sweet-spots in sync.
    var _isGlassRaw = Math.Clamp(_settings.InstantSearchGlassOpacity, 0, 100) / 100.0;
    // Glass alpha range = 0.35..1.00 — same mapping as HybridMenu so both panels share
    // the same frosted-glass strength at any slider position. Wider range means the
    // backdrop-filter blur stays visually effective even at higher alpha values.
    var _isGlassAlpha = 0.35 + 0.65 * _isGlassRaw;
    var _isRadius = Math.Max(0, _settings.InstantSearchBorderRadius);
    string _isHexToRgb(string hex)
    {
        if (string.IsNullOrEmpty(hex)) return "255, 255, 255";
        var h = hex.TrimStart('#');
        if (h.Length == 3) h = string.Concat(h[0], h[0], h[1], h[1], h[2], h[2]);
        if (h.Length < 6) return "255, 255, 255";
        try
        {
            var r = Convert.ToInt32(h.Substring(0, 2), 16);
            var g = Convert.ToInt32(h.Substring(2, 2), 16);
            var b = Convert.ToInt32(h.Substring(4, 2), 16);
            return $"{r}, {g}, {b}";
        }
        catch { return "255, 255, 255"; }
    }
    var _isBgColorRgb = _isHexToRgb(_isBgColor);
    // BG-color may be a hex (`#347468`), a CSS variable (`var(--blue)` from theme tokens)
    // or a literal CSS color name. For hex we can build `rgba(R, G, B, alpha)`, but for
    // `var(...)` we need `color-mix()` because the actual RGB triplet isn't known at
    // render time. Detect the format and pick the right expression for both modes.
    var _isBgIsCssExpr = _isBgColor != null && (_isBgColor.TrimStart().StartsWith("var(") || _isBgColor.TrimStart().StartsWith("color-mix("));
    var _isGlassAlphaStr = _isGlassAlpha.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
    var _isGlassAlphaPercent = (int)Math.Round(_isGlassAlpha * 100);
    var _isBgGlassExpr = _isBgIsCssExpr
        ? $"color-mix(in srgb, {_isBgColor} {_isGlassAlphaPercent}%, transparent)"
        : $"rgba({_isBgColorRgb}, {_isGlassAlphaStr})";
    var _isBgOpacityStr = _isBgOpacity.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
    var _isBgPictureOpacityGlassStr = (_isBgOpacity * _isGlassAlpha).ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
    var _isPanelClasses = new List<string> { "crs-instasearch-panel" };
    if (_isBgUrl != null) _isPanelClasses.Add("has-bg");
    if (_settings.InstantSearchGlassEnabled) _isPanelClasses.Add("glass");
    if (_settings.InstantSearchHover3D) _isPanelClasses.Add("hover3d");
    var _isPanelStyle = new List<string>
    {
        $"--is-bg-color:{_isBgColor}",
        $"--is-bg-color-rgb:{_isBgColorRgb}",
        $"--is-radius:{_isRadius}px",
        $"--is-glass-alpha:{_isGlassAlpha.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}"
    };
    if (_isBgUrl != null)
    {
        _isPanelStyle.Add($"--is-bg-image:url('{_isBgUrl}')");
        _isPanelStyle.Add($"--is-bg-size:{_isBgSize}");
        _isPanelStyle.Add($"--is-bg-opacity:{_isBgOpacity.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}");
    }
}

<link rel="stylesheet" href="~/Modules/CRS.HybridSearch/css/crs-instasearch.css" asp-append-version="true" />

@*
    Panel-chrome — emitted INLINE so it gets Razor-resolved bg-color, glass-alpha,
    radius and bg-picture URL straight into CSS without needing JS or
    custom-property mirroring on a parent the style targets. The
    `:has(.crs-instasearch-panel)` selector applies the chrome to the OUTER
    `#instasearch-drop-body` (not the inner wrapper), so the panel background
    ALWAYS covers the entire popover — top-row, products, AND the async-appended
    KI tiles — independent of where the inner wrapper visually ends. Glass and
    non-glass paths are emitted separately so the user's bg-color setting applies
    in both modes.
*@
<style>
.instasearch-drop:has(.crs-instasearch-panel) > #instasearch-drop-body,
.instasearch-drop:has(.crs-instasearch-panel) > .instasearch-drop-body {
    margin-top: 0 !important;
    margin-bottom: 0 !important;
@if (_settings.InstantSearchGlassEnabled)
{
    @:    background-color: @_isBgGlassExpr !important;
    @:    background-image: none !important;
    @:    border: 1px solid rgba(0, 0, 0, 0.10) !important;
    @:    box-shadow:
    @:        inset  0  -1px 0 rgba(0, 0, 0, 0.14),
    @:        inset -1px   0 0 rgba(0, 0, 0, 0.10),
    @:        inset  0  -8px 24px -8px rgba(0, 0, 0, 0.12),
    @:        0  2px  4px rgba(0, 0, 0, 0.10),
    @:        0  6px 14px rgba(0, 0, 0, 0.14),
    @:        0 16px 32px rgba(0, 0, 0, 0.22) !important;
    @* Same backdrop-filter strength as HybridMenu (28px blur + saturate 170% + contrast 1.12)
       so both panels share the frosted-glass blur look behind their tint. *@
    @:    backdrop-filter: saturate(170%) contrast(1.12) blur(28px) !important;
    @:    -webkit-backdrop-filter: saturate(170%) contrast(1.12) blur(28px) !important;
}
else
{
    @:    background-color: @_isBgColor !important;
    @:    background-image: none !important;
    @:    border: 0 !important;
    @:    box-shadow: none !important;
    @:    backdrop-filter: none !important;
    @:    -webkit-backdrop-filter: none !important;
}
    border-radius: 0 0 @(_isRadius)px @(_isRadius)px !important;
    overflow: hidden;
    position: relative;
}
@if (_settings.InstantSearchGlassEnabled)
{
    @* Strip outer drop chrome + emit backdrop-filter ALSO on the drop itself so the blur
       takes effect even when the page's stacking context behaves unexpectedly. Some
       themes wrap the search input in an isolated parent that swallows the drop-body's
       own backdrop-filter; putting the same filter on the absolutely positioned drop
       guarantees the page behind gets blurred at the outermost layer. *@
    @:.instasearch-drop:has(.crs-instasearch-panel) {
    @:    background: transparent !important;
    @:    background-color: transparent !important;
    @:    box-shadow: none !important;
    @:    border-color: transparent !important;
    @:    backdrop-filter: saturate(170%) contrast(1.12) blur(28px) !important;
    @:    -webkit-backdrop-filter: saturate(170%) contrast(1.12) blur(28px) !important;
    @:}
}
@if (_isBgUrl != null)
{
    @:.instasearch-drop:has(.crs-instasearch-panel) > #instasearch-drop-body,
    @:.instasearch-drop:has(.crs-instasearch-panel) > .instasearch-drop-body {
    @:    --is-bg-image: url('@_isBgUrl');
    @:    --is-bg-size: @_isBgSize;
    @:    --is-bg-opacity: @_isBgOpacityStr;
    @:}
    @:.instasearch-drop:has(.crs-instasearch-panel) > #instasearch-drop-body::before,
    @:.instasearch-drop:has(.crs-instasearch-panel) > .instasearch-drop-body::before {
    @:    content: '';
    @:    position: absolute;
    @:    inset: 0;
    @:    background-image: var(--is-bg-image);
    @:    background-size: var(--is-bg-size, contain);
    @:    background-position: right bottom;
    @:    background-repeat: no-repeat;
    if (_settings.InstantSearchGlassEnabled)
    {
        @:    opacity: @_isBgPictureOpacityGlassStr;
    }
    else
    {
        @:    opacity: @_isBgOpacityStr;
    }
    @:    pointer-events: none;
    @:    z-index: 0;
    @:    border-radius: inherit;
    @:}
    @:.instasearch-drop:has(.crs-instasearch-panel) > #instasearch-drop-body > *,
    @:.instasearch-drop:has(.crs-instasearch-panel) > .instasearch-drop-body > * {
    @:    position: relative;
    @:    z-index: 1;
    @:}
}
.crs-instasearch-panel,
.crs-instasearch-panel.glass {
    background: transparent !important;
    background-color: transparent !important;
    background-image: none !important;
    border: 0 !important;
    border-radius: 0 !important;
    box-shadow: none !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
}
</style>

@{
    var hitGroups = Model.HitGroups.Where(x => x.Hits.Count > 0).OrderBy(x => x.Ordinal).ToArray();
    var numProducts = Model.TopProducts.Items.Count;
    var hasContent = numProducts > 0 || hitGroups.Length > 0;

    var categoryGroup = hitGroups.FirstOrDefault(g => g.Name == "TopCategories");
    var manufacturerGroup = hitGroups.FirstOrDefault(g => g.Name == "TopManufacturers");
    var spellCheckerGroup = hitGroups.FirstOrDefault(g => g.Name == "SpellChecker");
    var hasCategories = categoryGroup != null;
    var hasManufacturers = manufacturerGroup != null;
    var hasSpellChecker = spellCheckerGroup != null;
    var hasTopRow = hasCategories || hasManufacturers || hasSpellChecker;

    // KI product IDs from vector search (stored by LuceneSearchEngine in HttpContext.Items)
    var vectorIds = Context.Items.TryGetValue("HybridSearch_VectorProductIds", out var _vObj)
        && _vObj is HashSet<int> _vids ? _vids : null;
    // Lucene-only IDs (products Lucene found WITHOUT KI injection)
    var luceneIds = Context.Items.TryGetValue("HybridSearch_LuceneProductIds", out var _lObj)
        && _lObj is HashSet<int> _lids ? _lids : null;
    // Scores for debug display (only when ShowSearchTime is active)
    var luceneScores = Context.Items.TryGetValue("HybridSearch_LuceneScores", out var _lsObj)
        && _lsObj is Dictionary<int, float> _ls ? _ls : null;
    var kiScores = Context.Items.TryGetValue("HybridSearch_VectorProductScores", out var _ksObj)
        && _ksObj is Dictionary<int, double> _ks ? _ks : null;
}

<style>
    /* Headers: 50% larger fonts, centered, modern */
    .hs-header { color: #222; font-weight: 600; font-size: 1.4rem; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.5rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; text-align: center; width: 100%; }
    .instasearch-group-header { color: #222 !important; font-weight: 600 !important; font-size: 1.2rem !important; text-transform: uppercase !important; letter-spacing: 0.03em !important; font-family: system-ui, -apple-system, "Segoe UI", sans-serif !important; text-align: center !important; }

    /* Prices */
    .hs-price { font-weight: 600; font-size: 0.85rem; color: #222; }
    .hs-price-old { text-decoration: line-through; color: #999; font-size: 0.75rem; margin-right: 0.3rem; }
    .hs-price-saving { color: #c00; font-size: 0.7rem; font-weight: 600; }

    /* Tiles — every tile in the grid has identical outer dimensions regardless of whether
       it has a thumb, a long/short title, a description, or a price. The card has a fixed
       height; child elements get fixed slot heights with truncate (single-line) so a long
       title can't push the price down and shift the row. The price sits at the bottom via
       `margin-top:auto` so tiles without a price still align cleanly. */
    .hs-tile {
        /* "Engraved in glass" outline — very subtle 4-edge bevel. The border stays
           near-transparent so the tile blends into the (possibly glass) panel behind. */
        border: 1px solid rgba(0, 0, 0, 0.03);
        box-shadow:
            inset 0   1px 0 rgba(255, 255, 255, 0.25),
            inset 1px   0 0 rgba(255, 255, 255, 0.11),
            inset 0  -1px 0 rgba(0, 0, 0, 0.06),
            inset -1px  0 0 rgba(0, 0, 0, 0.03);
        border-radius: 6px;
        padding: 8px;
        text-align: center;
        transition: box-shadow 0.15s;
        height: 188px;             /* fixed: 80(thumb) + 4(gap) + 18(title) + 16(desc) + 24(badge/score) + 24(price) + 16(padding+border) */
        /* `width:100% + box-sizing:border-box` makes the anchor fill its parent `.col`
           regardless of its (text) content — without it `<a>` is intrinsically content-sized
           (a flex container with no width auto-shrinks to its contents) so short product names
           rendered narrower tiles than long ones. */
        width: 100%;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
        overflow: hidden;          /* hard clip if something overflows the fixed slots */
    }
    .hs-tile:hover {
        /* Keep the subtle 4-edge engraving on hover, stacked with a lift-shadow. */
        box-shadow:
            inset 0   1px 0 rgba(255, 255, 255, 0.32),
            inset 1px   0 0 rgba(255, 255, 255, 0.16),
            inset 0  -1px 0 rgba(0, 0, 0, 0.08),
            inset -1px  0 0 rgba(0, 0, 0, 0.04),
            0 2px 8px rgba(0, 0, 0, 0.10);
    }
    .hs-tile-thumb { height: 80px; flex: 0 0 80px; overflow: hidden; margin-bottom: 0.25rem; display: flex; align-items: center; justify-content: center; }
    .hs-tile-thumb img { max-height: 80px; object-fit: contain; }
    .hs-tile .fw-semibold { line-height: 1.2; height: 18px; flex: 0 0 18px; overflow: hidden; }
    .hs-tile .text-muted { line-height: 1.2; height: 16px; flex: 0 0 16px; overflow: hidden; }
    /* Price block: fixed-height slot pinned to bottom so tiles without a price still
       leave the same vertical space (avoids text-baseline jumping between rows). */
    .hs-tile-price { margin-top: auto; height: 24px; flex: 0 0 24px; line-height: 1.2; overflow: hidden; }
    /* Each tile sits in its own .col — center the (content-width) tile horizontally within
       its column so the grid keeps a clean visual rhythm regardless of how wide the
       individual tile naturally renders. justify-content centers on the main axis (row). */
    #instasearch-tiles .row > .col { display: flex; justify-content: center; }

    /* Full-view tiles — same idea, larger fixed height. */
    #instasearch-full .row > .col { display: flex; justify-content: center; }
    #instasearch-full .hs-tile { height: 320px; }
    #instasearch-full .hs-tile-thumb { height: 200px; flex: 0 0 200px; }
    #instasearch-full .hs-tile-thumb img { max-height: 200px; }
    #instasearch-full .hs-tile .fw-semibold { height: 36px; flex: 0 0 36px; line-height: 1.2; }
    #instasearch-full .hs-tile .text-muted { height: 32px; flex: 0 0 32px; line-height: 1.2; }

    /* KI results */
    .hs-ai-badge { display: inline-flex; align-items: center; gap: 3px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; font-size: 0.6rem; font-weight: 700; padding: 1px 5px; border-radius: 3px; letter-spacing: 0.03em; vertical-align: middle; }
    .hs-ai-badge i { font-size: 0.55rem; }
    .hs-ai-item { border-left: 2px solid #8b5cf6; }
    .hs-ai-tile { border-color: #c4b5fd; }
    .hs-ai-section { border-top: 1px solid #e5e7eb; margin-top: 0.5rem; padding-top: 0.5rem; }
    .hs-ai-header { color: #6366f1; font-weight: 600; font-size: 0.85rem; display: flex; align-items: center; gap: 6px; justify-content: center; margin-bottom: 0.4rem; }
    .hs-ai-loading { text-align: center; padding: 8px; color: #8b5cf6; font-size: 0.75rem; }
    .hs-ai-loading i { animation: spin 1s linear infinite; }
    @@keyframes spin { to { transform: rotate(360deg); } }

    /* Row view: compact padding */
    #instasearch-rows .instasearch-hit { padding-top: 1px !important; padding-bottom: 1px !important; }

    /* Full view: full-width dropdown, positioned below the search bar */
    .instasearch-full-active .instasearch-drop {
        position: fixed !important;
        left: 5px !important;
        right: 5px !important;
        width: auto !important;
        max-width: none !important;
        min-width: 0 !important;
        z-index: 9999;
        max-height: 80vh;
        overflow-y: auto;
    }
    #instasearch-full .hs-tile { padding: 12px; }
    #instasearch-full .hs-tile img { max-height: 200px; width: 100%; object-fit: contain; }
    #instasearch-full .hs-tile .fw-semibold { font-size: 0.95rem; white-space: normal; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }

    /* Responsive: 3 columns under 650px, 2 columns under 400px */
    @@media (max-width: 649px) {
        #instasearch-tiles .row { --bs-columns: 3 !important; }
        #instasearch-tiles .row > .col { flex: 0 0 calc(100% / 3); max-width: calc(100% / 3); }
        #instasearch-full .row { --bs-columns: 3 !important; }
        #instasearch-full .row > .col { flex: 0 0 calc(100% / 3); max-width: calc(100% / 3); }
    }
    @@media (max-width: 549px) {
        /* Each top-zone column takes full width on mobile */
        .instasearch-row > .instasearch-col {
            flex: 0 0 100% !important;
            max-width: 100% !important;
            display: grid !important;
            grid-template-columns: max-content 1fr;
            gap: 10px;
            align-items: start;
            padding: 6px 12px !important;
            margin-bottom: 4px;
        }
        /* Group header: right-aligned label in left column */
        .instasearch-row > .instasearch-col .instasearch-group-header {
            text-align: right !important;
            margin: 0 !important;
            font-size: 0.95rem !important;
            line-height: 1.6 !important;
            padding-top: 6px;
            font-weight: 600 !important;
            color: #555 !important;
            text-transform: uppercase !important;
            letter-spacing: 0.02em !important;
        }
        /* Hits container: left-aligned, flex-wrap for chips */
        .instasearch-row > .instasearch-col .instasearch-hits,
        .instasearch-row > .instasearch-col > div:not(.instasearch-group-header) {
            display: flex !important;
            flex-wrap: wrap !important;
            gap: 6px !important;
            text-align: left !important;
            margin: 0 !important;
            padding: 0 !important;
            list-style: none;
        }
        /* Each hit is a tap-friendly chip */
        .instasearch-row > .instasearch-col .instasearch-hit {
            display: inline-block !important;
            padding: 8px 12px !important;
            margin: 0 !important;
            background: #f3f4f6;
            border-radius: 16px;
            font-size: 0.85rem !important;
            line-height: 1.3 !important;
            color: #222 !important;
            text-decoration: none !important;
            min-height: 36px;
            white-space: nowrap;
        }
        .instasearch-row > .instasearch-col .instasearch-hit:hover,
        .instasearch-row > .instasearch-col .instasearch-hit:active {
            background: #e5e7eb;
        }
        /* SpellChecker col on mobile: override its default padding */
        .instasearch-row > .instasearch-col[style*="padding: 0 20px"] { padding: 6px 12px !important; }

        /* KI search results (merged async): ensure product name is visible on mobile */
        .hs-ki-merged .fw-semibold { white-space: normal !important; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
    }
    @@media (max-width: 399px) {
        #instasearch-tiles .row { --bs-columns: 2 !important; }
        #instasearch-tiles .row > .col { flex: 0 0 50%; max-width: 50%; }
        #instasearch-full .row { --bs-columns: 2 !important; }
        #instasearch-full .row > .col { flex: 0 0 50%; max-width: 50%; }
    }
</style>

<div class="@string.Join(' ', _isPanelClasses)" style="@string.Join(';', _isPanelStyle)">
@if (!hasContent)
{
    <div class="instasearch-no-hits">
        @Html.Raw(T("Search.NoResultsText"))
    </div>
}
else
{
    @* Top zone: Categories | Manufacturers | SpellChecker *@
    if (hasTopRow)
    {
        var colCount = (hasCategories ? 1 : 0) + (hasManufacturers ? 1 : 0) + (hasSpellChecker ? 1 : 0);
        var colClass = colCount == 3 ? "col-4" : colCount == 2 ? "col-6" : "col-12";

        <div class="row no-gutters instasearch-row">
            @if (hasCategories)
            {
                <div class="@colClass instasearch-col">
                    <partial name="InstantSearch.HitGroup" model="categoryGroup" />
                </div>
            }
            @if (hasManufacturers)
            {
                <div class="@colClass instasearch-col">
                    <partial name="InstantSearch.HitGroup" model="manufacturerGroup" />
                </div>
            }
            @if (hasSpellChecker)
            {
                <div class="@colClass instasearch-col" style="padding: 0 20px;">
                    <h6 class="instasearch-group-header">@spellCheckerGroup.DisplayName</h6>
                    <div style="line-height: 1.8;">
                        @for (var i = 0; i < spellCheckerGroup.Hits.Count; i++)
                        {
                            var hit = spellCheckerGroup.Hits[i];
                            <a href="@hit.Url" class="instasearch-hit hs-spellcheck-item" style="display: inline-block; padding: 4px 8px;">@hit.Label</a>
                        }
                    </div>
                </div>
            }
        </div>
    }

    @* Products with row/tile toggle *@
    if (numProducts > 0)
    {
        var hasThumbs = Model.TopProducts.Items.Any(x => x.Image?.File != null);

        <div class="text-center mb-2 mt-1 position-relative">
            <span class="hs-header">@T("Search.Hits")</span>
            <div class="position-absolute d-flex align-items-center" style="right: 0; top: 50%; transform: translateY(-50%); gap: 6px;">
                @if (ViewData["SearchTimeMs"] is int searchTimeMs)
                {
                    <span class="text-muted" style="font-size: 0.7rem; font-family: system-ui, sans-serif;" title="@BuildPerfTooltip()">@searchTimeMs ms @BuildPerfDetail()</span>
                }
                <div class="instasearch-view-toggle">
                    <button type="button" class="btn btn-sm btn-light instasearch-view-btn" data-view="rows" title="@T("Products.ViewModeList")">
                        <i class="fa fa-th-list"></i>
                    </button>
                    <button type="button" class="btn btn-sm btn-light instasearch-view-btn active" data-view="tiles" title="@T("Products.ViewModeGrid")">
                        <i class="fa fa-th"></i>
                    </button>
                    @*<button type="button" class="btn btn-sm btn-light instasearch-view-btn" data-view="full" title="Vollansicht">
                        <i class="fa fa-expand"></i>
                    </button>*@
                </div>
            </div>
        </div>

        @* === ROW VIEW (hidden by default - tiles is default) === *@
        <ul class="instasearch-hits instasearch-rows d-none@(hasThumbs ? " has-thumbs" : "")" id="instasearch-rows" aria-label="@T("Search.Hits")">
            @foreach (var hit in Model.TopProducts.Items)
            {
                <li>
                    <a class="instasearch-hit" asp-route="Product" asp-route-SeName="@hit.SeName" asp-area="" role="option">
                        <span class="instasearch-hit-wrapper" style="min-height: 72px; align-items: center;">
                            @* Always render the thumb slot when any hit in this result-set has an image,
                               so picture-less rows keep the same left padding and the text column stays
                               aligned across all rows. Without this, items without an image collapse the
                               whole row to the left, producing a ragged left edge. *@
                            <span sm-if="hasThumbs" class="instasearch-hit-thumb d-block" style="width: 60px; min-width: 60px;">
                                @if (hit.Image?.File != null)
                                {
                                    <img sm-model="@hit.Image" class="img-fluid" alt="" role="none" />
                                }
                            </span>
                            <span class="d-flex flex-column" style="overflow: hidden; flex: 1; gap: 2px;">
                                <span class="d-block text-truncate fw-semibold" sm-language-attributes-for="hit.Name">
                                    @Html.Raw(Model.Highlight(hit.Name, "name", Model.Query, null, "<span class='instasearch-match'>", "</span>"))
                                    @{ RenderScoreBadge(hit.Id); }
                                    @if (false && _settings.ShowSearchTime && vectorIds != null && vectorIds.Contains(hit.Id) && (luceneIds == null || !luceneIds.Contains(hit.Id)))
                                    {
                                        <span class="hs-ki-badge" title="Dieses Produkt wurde durch semantische KI-Suche gefunden"><i class="fa fa-brain"></i> KI</span>
                                    }
                                </span>
                                @if (hit.ShortDescription.Value.HasValue())
                                {
                                    <span class="d-block text-truncate small text-muted" sm-language-attributes-for="hit.ShortDescription">
                                        @hit.ShortDescription.Value.RemoveHtml().Truncate(120)
                                    </span>
                                }
                                @{ RenderPrice(hit); }
                            </span>
                        </span>
                    </a>
                </li>
            }
        </ul>

        @* === TILE VIEW (default) === *@
        @* All slots are emitted on EVERY tile so heights match across the entire grid —
           an empty desc/price div with the fixed slot height keeps the row aligned. *@
        <div class="instasearch-tiles" id="instasearch-tiles">
            <div class="row row-cols-4 g-2">
                @foreach (var hit in Model.TopProducts.Items)
                {
                    <div class="col">
                        <a class="d-block text-decoration-none text-body hs-tile" asp-route="Product" asp-route-SeName="@hit.SeName" asp-area="">
                            @if (hasThumbs)
                            {
                                <div class="hs-tile-thumb">
                                    @if (hit.Image?.File != null)
                                    {
                                        <img sm-model="@hit.Image" class="img-fluid" alt="" />
                                    }
                                </div>
                            }
                            <div class="text-truncate small fw-semibold" sm-language-attributes-for="hit.Name">
                                @Html.Raw(Model.Highlight(hit.Name, "name", Model.Query, null, "<span class='instasearch-match'>", "</span>"))
                            </div>
                            @{ RenderScoreBadge(hit.Id); }
                            <div class="text-truncate text-muted" style="font-size: 0.7rem;" sm-language-attributes-for="hit.ShortDescription">
                                @(hit.ShortDescription.Value.HasValue() ? hit.ShortDescription.Value.RemoveHtml().Truncate(50) : "")
                            </div>
                            <div class="hs-tile-price">
                                @{ RenderPrice(hit); }
                            </div>
                        </a>
                    </div>
                }
            </div>
        </div>

        @* === FULL VIEW (full-width tiles, hidden by default) === *@
        <div class="instasearch-tiles d-none" id="instasearch-full">
            <div class="row row-cols-6 g-2">
                @foreach (var hit in Model.TopProducts.Items)
                {
                    <div class="col">
                        <a class="d-block text-decoration-none text-body hs-tile" asp-route="Product" asp-route-SeName="@hit.SeName" asp-area="">
                            @if (hasThumbs)
                            {
                                <div class="hs-tile-thumb">
                                    @if (hit.Image?.File != null)
                                    {
                                        <img sm-model="@hit.Image" class="img-fluid" alt="" />
                                    }
                                </div>
                            }
                            <div class="fw-semibold" sm-language-attributes-for="hit.Name">
                                @Html.Raw(Model.Highlight(hit.Name, "name", Model.Query, null, "<span class='instasearch-match'>", "</span>"))
                                @{ RenderScoreBadge(hit.Id); }
                            </div>
                            <div class="text-muted mt-1" style="font-size: 0.8rem;" sm-language-attributes-for="hit.ShortDescription">
                                @(hit.ShortDescription.Value.HasValue() ? hit.ShortDescription.Value.RemoveHtml().Truncate(100) : "")
                            </div>
                            <div class="hs-tile-price">
                                @{ RenderPrice(hit); }
                            </div>
                        </a>
                    </div>
                }
            </div>
        </div>

        @* KI results are now fused directly into the main product list above
           via LuceneSearchEngine.BuildQueryAndFilter KI-ID injection.
           KI-only products are marked with the KI badge (see @if block in row/tile rendering).
           No separate async block needed. *@

        <script>
            (function () {
                var btns = document.querySelectorAll('.instasearch-view-btn');
                var rows = document.getElementById('instasearch-rows');
                var tiles = document.getElementById('instasearch-tiles');
                var full = document.getElementById('instasearch-full');
                var form = document.querySelector('.instasearch-form');
                if (!btns.length || !rows || !tiles || !full) return;

                function applyView(view) {
                    btns.forEach(function (b) {
                        b.classList.toggle('active', b.getAttribute('data-view') === view);
                    });
                    rows.classList.toggle('d-none', view !== 'rows');
                    tiles.classList.toggle('d-none', view !== 'tiles');
                    full.classList.toggle('d-none', view !== 'full');

                    // Full view: expand dropdown to full width below the search bar
                    if (form) {
                        form.classList.toggle('instasearch-full-active', view === 'full');
                        if (view === 'full') {
                            var drop = form.querySelector('.instasearch-drop');
                            if (drop) {
                                var rect = form.getBoundingClientRect();
                                drop.style.top = (rect.bottom + window.scrollY) + 'px';
                            }
                        }
                    }
                }

                // Restore saved view preference
                var saved = sessionStorage.getItem('hs-instant-view');
                if (saved) applyView(saved);

                btns.forEach(function (btn) {
                    btn.addEventListener('click', function () {
                        var view = this.getAttribute('data-view');
                        sessionStorage.setItem('hs-instant-view', view);
                        applyView(view);
                    });
                });
            })();

        </script>

    }
}
</div>@* /.crs-instasearch-panel *@

@* KI async merge: ALWAYS rendered (even when Lucene has 0 results).
   ki-merge.js loads KI results and inserts them into the list, or creates
   a new list when Lucene returned nothing ("no results" gets hidden). *@
@{
    // Two modes controlled by `InstantSearchCapKiAtLimit`:
    //   - Cap-Mode (default): total displayed = SearchSettings.InstantSearchNumberOfProducts;
    //     KI fills only what Lucene left empty. Predictable popover size.
    //   - Add-Mode: KI is appended on top regardless. We pass a generous cap so the JS still
    //     terminates, but it's effectively unbounded for the typical KI result-set size.
    var _kiMaxTotal = Math.Max(0, _searchSettings.InstantSearchNumberOfProducts);
    var _kiMaxAdd = _settings.InstantSearchCapKiAtLimit
        ? Math.Max(0, _kiMaxTotal - Model.TopProducts.Items.Count)
        : Math.Max(0, _settings.InstantSearchMaxKiHits);
}
@if (_settings.EnableVectorSearch && _kiMaxAdd > 0)
{
    var _kiTerm = Model.Query?.DefaultTerm ?? "";
    var _kiExclude = string.Join(",", Model.TopProducts.Items.Select(x => x.Id));
    var _kiUrl = Url.Content("~/vectorsearch/instantresults");
    var _kiJsPath = Url.Content("~/Modules/CRS.HybridSearch/wwwroot/js/ki-merge.js");
    <div id="hs-ki-merge-data" style="display:none"
         data-term="@_kiTerm"
         data-exclude="@_kiExclude"
         data-url="@_kiUrl"
         data-maxadd="@_kiMaxAdd"
         data-showscores="@(_settings.ShowSearchTime ? "1" : "0")"></div>
    <script>
    (function(){
        var d=document.getElementById('hs-ki-merge-data');
        if(!d)return;
        var term=d.getAttribute('data-term'),excl=d.getAttribute('data-exclude'),url=d.getAttribute('data-url'),showScores=d.getAttribute('data-showscores')==='1';
        // Absolute cap from Smartstore SearchSettings.InstantSearchNumberOfProducts —
        // how many KI tiles may still be appended on top of the Lucene results. Zero
        // means Lucene already filled the quota; we bail before doing the XHR.
        var maxAdd=parseInt(d.getAttribute('data-maxadd')||'0',10);
        if(!term||term.length<3)return;
        if(!isFinite(maxAdd)||maxAdd<=0)return;
        var exSet=new Set((excl||'').split(',').map(Number).filter(Boolean));
        var x=new XMLHttpRequest();
        x.open('POST',url);
        x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
        x.onload=function(){
            try{
                var data=JSON.parse(x.responseText);
                if(!data.results||!data.results.length)return;
                var ki=data.results.filter(function(r){return!exSet.has(r.productId);});
                if(!ki.length)return;
                // Cap KI additions to whatever's left in the InstantSearch quota.
                if(ki.length>maxAdd){ki=ki.slice(0,maxAdd);}
                // Remove previous KI-merged items (handles race conditions from rapid keystrokes)
                document.querySelectorAll('.hs-ki-merged').forEach(function(el){el.remove();});
                var nh=document.querySelector('.instasearch-no-hits');
                if(nh)nh.style.display='none';

                // KI-only path: Lucene returned 0 hits, so the Razor block (view toggle +
                // rows + tiles containers) was never emitted. Build them now and wire the
                // toggle to the same sessionStorage preference that the Lucene-path uses
                // — otherwise KI-only results always show in tiles even if the user has
                // list view selected.
                var rl=document.querySelector('#instasearch-rows');
                var tg=document.querySelector('#instasearch-tiles .row');
                if(!tg||!rl){
                    // KI-only path: append new header + result containers INSIDE the panel
                    // wrapper. Falling back to `.instasearch-drop` (as previously) put them
                    // OUTSIDE both `.crs-instasearch-panel` AND `#instasearch-drop-body`, so
                    // in glass mode (where drop is transparent) the KI tiles ended up floating
                    // below the glass-area with no background — looked like the glass "ends"
                    // after the top-row. Wrapper-first keeps everything inside the chrome.
                    var p=document.querySelector('.crs-instasearch-panel')
                        ||(nh?nh.parentElement:null)
                        ||document.querySelector('#instasearch-drop-body')
                        ||document.querySelector('.instasearch-drop-body')
                        ||document.querySelector('.instasearch-drop')
                        ||document.body;
                    var savedView=sessionStorage.getItem('hs-instant-view')||'tiles';
                    if(!document.querySelector('.instasearch-view-toggle')){
                        var hdr=document.createElement('div');
                        hdr.className='text-center mb-2 mt-1 position-relative hs-ki-merged';
                        hdr.innerHTML='<span class="hs-header">KI</span>'+
                            '<div class="position-absolute d-flex align-items-center" style="right:0;top:50%;transform:translateY(-50%);gap:6px;">'+
                            '<div class="instasearch-view-toggle">'+
                            '<button type="button" class="btn btn-sm btn-light instasearch-view-btn'+(savedView==='rows'?' active':'')+'" data-view="rows" title="List"><i class="fa fa-th-list"></i></button>'+
                            '<button type="button" class="btn btn-sm btn-light instasearch-view-btn'+(savedView==='tiles'?' active':'')+'" data-view="tiles" title="Grid"><i class="fa fa-th"></i></button>'+
                            '</div></div>';
                        p.appendChild(hdr);
                    }
                    if(!rl){
                        rl=document.createElement('ul');
                        rl.id='instasearch-rows';
                        rl.className='instasearch-hits instasearch-rows has-thumbs hs-ki-merged-container'+(savedView==='rows'?'':' d-none');
                        p.appendChild(rl);
                    }
                    if(!tg){
                        var td=document.createElement('div');td.className='instasearch-tiles hs-ki-merged-container'+(savedView==='tiles'?'':' d-none');td.id='instasearch-tiles';
                        var g=document.createElement('div');g.className='row row-cols-4 g-2';
                        td.appendChild(g);p.appendChild(td);tg=g;
                    }
                    // Wire view-toggle handlers (same behaviour as Razor block).
                    document.querySelectorAll('.instasearch-view-btn').forEach(function(btn){
                        if(btn.dataset.hsBound==='1')return;
                        btn.dataset.hsBound='1';
                        btn.addEventListener('click',function(){
                            var v=this.getAttribute('data-view');
                            sessionStorage.setItem('hs-instant-view',v);
                            document.querySelectorAll('.instasearch-view-btn').forEach(function(b){b.classList.toggle('active',b.getAttribute('data-view')===v);});
                            var r=document.getElementById('instasearch-rows');
                            var t=document.getElementById('instasearch-tiles');
                            if(r)r.classList.toggle('d-none',v!=='rows');
                            if(t)t.classList.toggle('d-none',v!=='tiles');
                        });
                    });
                }
                ki.forEach(function(r){
                    var c=document.createElement('div');c.className='col hs-ki-merged';
                    // All slots are always emitted with empty content as needed — same fixed
                    // heights as the Razor tiles so KI rows align flush with Lucene rows.
                    var h='<div class="hs-tile-thumb">'+(r.imageUrl?'<img src="'+r.imageUrl+'" class="img-fluid" alt="">':'')+'</div>';
                    h+='<div class="text-truncate small fw-semibold">'+r.name+'</div>';
                    h+='<div class="text-truncate text-muted" style="font-size:0.7rem">'+(r.shortDescription||'')+'</div>';
                    h+='<div class="hs-tile-price">';
                    if(showScores)h+='<span class="hs-ki-badge"><i class="fa fa-brain"></i> '+(r.score||'KI')+'</span>';
                    if(r.price)h+='<span class="hs-price ms-1">'+r.price+'</span>';
                    h+='</div>';
                    c.innerHTML='<a class="d-block text-decoration-none text-body hs-tile" href="'+r.url+'">'+h+'</a>';
                    tg.appendChild(c);
                });
                if(rl){ki.forEach(function(r){
                    var li=document.createElement('li');li.className='hs-ki-merged';
                    var h='<span class="instasearch-hit-wrapper" style="min-height:60px;align-items:center">';
                    // Always render thumb slot — preserves left-column alignment whether the
                    // KI-merged item has an image or not (matches the Razor-rendered rows).
                    h+='<span class="instasearch-hit-thumb d-block" style="width:60px;min-width:60px">';
                    if(r.imageUrl)h+='<img src="'+r.imageUrl+'" class="img-fluid" alt="">';
                    h+='</span>';
                    h+='<span class="d-flex flex-column" style="overflow:hidden;flex:1;gap:2px">';
                    h+='<span class="d-block text-truncate fw-semibold">'+r.name+(showScores?' <span class="hs-ki-badge"><i class="fa fa-brain"></i> '+(r.score||'KI')+'</span>':'')+'</span>';
                    if(r.shortDescription)h+='<span class="d-block text-truncate small text-muted">'+r.shortDescription+'</span>';
                    if(r.price)h+='<span class="d-block mt-1"><span class="hs-price">'+r.price+'</span></span>';
                    h+='</span>';
                    h+='</span>';
                    li.innerHTML='<a class="instasearch-hit" href="'+r.url+'">'+h+'</a>';
                    rl.appendChild(li);
                });}
            }catch(e){console.warn('KI merge:',e);}
        };
        x.send('q='+encodeURIComponent(term)+'&exclude='+(excl||''));
    })();
    </script>
}

@functions {
    /// <summary>Renders a small score debug badge for a product (only when ShowSearchTime is active).</summary>
    void RenderScoreBadge(int productId)
    {
        if (!_settings.ShowSearchTime) return;

        var vectorIds = Context.Items.TryGetValue("HybridSearch_VectorProductIds", out var vo) && vo is HashSet<int> vi ? vi : null;
        var luceneIds = Context.Items.TryGetValue("HybridSearch_LuceneProductIds", out var lo) && lo is HashSet<int> li ? li : null;
        var luceneScores = Context.Items.TryGetValue("HybridSearch_LuceneScores", out var lso) && lso is Dictionary<int, float> ls ? ls : null;
        var kiScores = Context.Items.TryGetValue("HybridSearch_VectorProductScores", out var kso) && kso is Dictionary<int, double> ks ? ks : null;

        var isKi = vectorIds != null && vectorIds.Contains(productId);
        var isLucene = luceneIds == null || luceneIds.Contains(productId);
        var kiScore = kiScores != null && kiScores.TryGetValue(productId, out var kv) ? kv : 0;
        var lScore = luceneScores != null && luceneScores.TryGetValue(productId, out var lv) ? lv : 0;

        if (isKi && !isLucene)
        {
            // KI-only
            <span class="hs-ki-badge" title="KI-Score (Cosine Similarity)"><i class="fa fa-brain"></i> @kiScore.ToString("F2")</span>
        }
        else if (isKi && isLucene)
        {
            // Both
            <span class="badge bg-secondary" style="font-size:0.55rem;vertical-align:middle;" title="Lucene BM25 Score">L:@lScore.ToString("F1")</span>
            <span class="hs-ki-badge hs-ki-double" style="font-size:0.55rem;" title="KI Cosine Score">KI:@kiScore.ToString("F2")</span>
        }
        else if (lScore > 0)
        {
            // Lucene-only
            <span class="badge bg-secondary" style="font-size:0.55rem;vertical-align:middle;" title="Lucene BM25 Score">L:@lScore.ToString("F1")</span>
        }
    }

    string BuildPerfDetail()
    {
        var parts = new List<string>();
        if (ViewData["PerfSearchMs"] is long searchMs)
            parts.Add($"S:{searchMs}");
        if (ViewData["PerfFullResultMs"] is long fullMs)
            parts.Add($"FR:{fullMs}");
        if (ViewData["PerfSortedSetMs"] is long ssMs)
            parts.Add($"SS:{ssMs}");
        if (ViewData["PerfFacetMs"] is long facetMs)
            parts.Add($"F:{facetMs}");
        if (ViewData["PerfSpellMs"] is long spellMs)
            parts.Add($"SC:{spellMs}");
        return parts.Count > 0 ? $"({string.Join(" ", parts)})" : "";
    }

    string BuildPerfTooltip()
    {
        var parts = new List<string>();
        if (ViewData["PerfSearchMs"] is long searchMs)
            parts.Add($"Search: {searchMs}ms");
        if (ViewData["PerfSearchHits"] is int hits)
            parts.Add($"Total hits: {hits}");
        if (ViewData["PerfFullResultMs"] is long fullMs)
            parts.Add($"FullResult: {fullMs}ms");
        if (ViewData["PerfSortedSetMs"] is long ssMs)
            parts.Add($"SortedSetFacets: {ssMs}ms");
        if (ViewData["PerfFacetMs"] is long facetMs)
            parts.Add($"Facets: {facetMs}ms");
        if (ViewData["PerfSpellMs"] is long spellMs)
            parts.Add($"SpellCheck: {spellMs}ms");
        return string.Join(", ", parts);
    }

    void RenderPrice(Smartstore.Web.Models.Catalog.ProductSummaryItemModel hit)
    {
        try
        {
            var cp = hit?.CustomProperties;
            if (cp == null || !cp.TryGetValue("HsPrice", out var priceObj) || priceObj == null)
                return;

            var finalPrice = System.Convert.ToDecimal(priceObj, System.Globalization.CultureInfo.InvariantCulture);
            if (finalPrice <= 0)
                return;

            var hasOldPrice = cp.TryGetValue("HsOldPrice", out var oldPriceObj) && oldPriceObj != null;
            var oldPrice = hasOldPrice ? System.Convert.ToDecimal(oldPriceObj, System.Globalization.CultureInfo.InvariantCulture) : 0m;
            var saving = cp.TryGetValue("HsSaving", out var savingObj) && savingObj != null
                ? System.Convert.ToDecimal(savingObj, System.Globalization.CultureInfo.InvariantCulture) : 0m;

            <span class="d-block mt-1">
                @if (hasOldPrice)
                {
                    <span class="hs-price-old">@oldPrice.ToString("C")</span>
                }
                <span class="hs-price">@finalPrice.ToString("C")</span>
                @if (saving > 0)
                {
                    <span class="hs-price-saving ms-1">-@saving.ToString("F0")%</span>
                }
            </span>
        }
        catch
        {
            // Silently ignore price rendering errors
        }
    }
}
