@using CRS.HybridSearch.Models.HybridMenu
@using CRS.HybridSearch.Domain.HybridMenu
@using CRS.HybridSearch.Services.HybridMenu
@using CRS.HybridSearch.Configuration
@using Microsoft.AspNetCore.Html
@using Smartstore.Collections
@using Smartstore.Core.Content.Menus
@using Smartstore.Core.Content.Media
@using Smartstore.Core.Localization
@using Smartstore.Core.Seo
@using Smartstore.Web.Rendering
@using Smartstore.Web.Rendering.Menus
@using Microsoft.EntityFrameworkCore

@inject ICrsHybridMenuService HybridMenuService
@inject ICategorySimilarityService CategorySimilarityService
@inject IMediaService MediaService
@inject Smartstore.Core.Data.SmartDbContext Db

@model HybridMenuNavigationModel
@{
    Layout = "";
    var menuModel = Model?.Menu;
    if (menuModel?.Root == null) { return; }
    var settings = Model.Settings;
    var languageId = WorkContext.WorkingLanguage.Id;
    var storeId = CommonServices.StoreContext.CurrentStore.Id;

    // Resolve all HybridMenu-Records for the visible top-level nodes once.
    var topNodes = menuModel.Root.Children
        .Where(x => x.Value.Visible && x.Value.EntityName == "Category")
        .ToList();
    var topCategoryIds = topNodes.Select(x => x.Value.EntityId).ToArray();
    var hmRecords = await HybridMenuService.ResolveBatchAsync("Category", topCategoryIds);
}

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

<div class="megamenu simple crs-hybridmenu hybridmenu" data-min-height="@settings.HybridMenuMinHeight">
    @{ await Html.RenderPartialAsync("Components/Menu/Navbar", menuModel); }
</div>

<div class="megamenu-dropdown-container simple crs-hybridmenu-dropdown-container">
    @foreach (var node in menuModel.Root.Children.Where(x => x.Value.Visible))
    {
        var item = node.Value;
        hmRecords.TryGetValue(item.EntityId, out var rec);
        rec ??= new CrsHybridMenuRecord { IsActive = true, TeaserType = TeaserType.None, EntityType = "Category", DisplayCategoryPicture = true, MaxSubItemsPerCategory = 8 };
        // Skip the dropdown markup entirely when the node has no children — Smartstore's
        // megaMenu JS needs a target dropdown div by id; without children there's nothing
        // to show anyway (the top-level link still renders, just no dropdown trigger).
        if (!node.HasChildren) { continue; }
        // Per-category "Hybrid-Menu aus": render the Smartstore-standard drilldown markup
        // for this tab (same one Main.cshtml would have produced if we hadn't overridden
        // the view) so the user still gets a regular dropdown — just without our chrome,
        // teaser, bg-picture, etc. continue to the next node afterwards.
        if (!rec.IsActive)
        {
            <div id="dropdown-menu-@item.Id" data-id="@item.Id" role="menu" aria-labelledby="main-nav-item-@item.Id">
                @{ await CreateStandardDrilldownAsync(node.Children, true); }
            </div>
            continue;
        }
        var hasChildren = node.HasChildren;
        // BG-Picture: per-category record wins (incl. align + opacity overrides), with
        // a fallback to the GLOBAL "Darstellung"-Setting (settings.InstantSearchBgPictureId)
        // so users who set the picture only in the InstantSearch settings still get it on
        // the menu panels too. Falls back to no picture if neither is configured.
        string bgUrl = null;
        bool bgCover;
        int bgOpacityRaw;
        string bgPos;
        if (rec.DisplayBgPicture && rec.BgPictureId > 0)
        {
            var bgFile = await MediaService.GetFileByIdAsync(rec.BgPictureId);
            bgUrl = bgFile != null ? MediaService.GetUrl(bgFile, null, null, false) : null;
            bgCover = rec.BgPictureCover;
            bgOpacityRaw = rec.BgPictureOpacity;
            bgPos = $"{rec.BgAlignX.ToString().ToLower()} {rec.BgAlignY.ToString().ToLower()}";
        }
        else if (settings.InstantSearchBgPictureId > 0)
        {
            var bgFile = await MediaService.GetFileByIdAsync(settings.InstantSearchBgPictureId);
            bgUrl = bgFile != null ? MediaService.GetUrl(bgFile, null, null, false) : null;
            bgCover = settings.InstantSearchBgPictureCover;
            bgOpacityRaw = settings.InstantSearchBgPictureOpacity;
            bgPos = "right bottom"; // global Darstellung-setting has no align dimension
        }
        else
        {
            bgCover = false;
            bgOpacityRaw = 100;
            bgPos = "right bottom";
        }
        // CSS variables drive a `::before` overlay so the picture can have its own opacity,
        // independent of the dropdown's bg-color or glass effect.
        var bgSize = bgCover ? "cover" : "contain";
        var bgOpacity = Math.Clamp(bgOpacityRaw, 0, 100) / 100.0;
        // Panel chrome (bg-color, glass, radius) now shares ONE source of truth with the
        // InstantSearch panel — the "Darstellung" settings under General. Per-tab overrides
        // were removed to reduce config surface; one consistent look for both popups.
        var bgColor = string.IsNullOrWhiteSpace(settings.InstantSearchBgColor) ? "#ffffff" : settings.InstantSearchBgColor.Trim();
        // Remap the 0..1 slider value into the HybridMenu's usable opacity range
        // (0.35..1.00). The InstantSearch panel uses a different mapping (0.92..1.00)
        // in its own view — same slider, panel-specific transfer curve, so both
        // panels traverse their sweet spots in sync as the user drags the slider.
        var glassRaw = Math.Clamp(settings.InstantSearchGlassOpacity, 0, 100) / 100.0;
        var glassAlpha = 0.35 + 0.65 * glassRaw;
        var radius = Math.Max(0, settings.InstantSearchBorderRadius);
        // Parse hex (#rgb, #rrggbb, optional alpha) into "r, g, b" — needed because the
        // Glass effect builds its semi-transparent fill via `rgba(var(--hm-bg-color-rgb), alpha)`.
        // `color-mix` was too fragile in some browsers and Bootstrap's `--bs-dropdown-bg`
        // chain kept winning. Defaulting to "255, 255, 255" if the value isn't parseable.
        string HexToRgbTriplet(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 bgColorRgb = HexToRgbTriplet(bgColor);
        // bg-color may be a hex (`#347468`) or a CSS variable / expression (`var(--blue)`,
        // `color-mix(...)`) from theme tokens. For hex we can build `rgba(R, G, B, alpha)`,
        // but for var() the RGB triplet isn't known at render time so we fall back to
        // `color-mix()`. The result is emitted as a `--hm-bg-glass` custom property that
        // the glass-mode CSS rule reads directly — works in both cases without branching
        // in CSS.
        var bgIsCssExpr = bgColor != null && (bgColor.TrimStart().StartsWith("var(") || bgColor.TrimStart().StartsWith("color-mix("));
        var glassAlphaPercent = (int)Math.Round(glassAlpha * 100);
        var bgGlassExpr = bgIsCssExpr
            ? $"color-mix(in srgb, {bgColor} {glassAlphaPercent}%, transparent)"
            : $"rgba({bgColorRgb}, {glassAlpha.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)})";
        var dropdownClasses = new List<string> { "dropdown-menu", "crs-hybridmenu-dropdown" };
        if (bgUrl != null) dropdownClasses.Add("has-bg");
        if (settings.InstantSearchGlassEnabled) dropdownClasses.Add("glass");
        // Coarse text-size selector → CSS class. Small/Medium/Large maps to
        // ~0.88 / 1.0 / 1.15 font-scale on the dropdown (handled by crs-hybridmenu.css).
        dropdownClasses.Add(settings.HybridMenuTextSize switch
        {
            CRS.HybridSearch.Domain.HybridMenu.MenuTextSize.Small => "txt-sm",
            CRS.HybridSearch.Domain.HybridMenu.MenuTextSize.Large => "txt-lg",
            _ => "txt-md"
        });

        var styleParts = new List<string>();
        if (settings.HybridMenuMinHeight > 0) styleParts.Add($"min-height:{settings.HybridMenuMinHeight}px");
        styleParts.Add($"--hm-bg-color:{bgColor}");
        styleParts.Add($"--hm-bg-color-rgb:{bgColorRgb}");
        styleParts.Add($"--hm-bg-glass:{bgGlassExpr}");
        styleParts.Add($"--hm-radius:{radius}px");
        styleParts.Add($"--hm-glass-alpha:{glassAlpha.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}");
        if (bgUrl != null)
        {
            styleParts.Add($"--hm-bg-image:url('{bgUrl}')");
            styleParts.Add($"--hm-bg-size:{bgSize}");
            styleParts.Add($"--hm-bg-pos:{bgPos}");
            styleParts.Add($"--hm-bg-opacity:{bgOpacity.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}");
        }

        <div id="dropdown-menu-@item.Id"
             data-id="@item.Id"
             role="menu"
             aria-labelledby="main-nav-item-@item.Id">
            <div class="@string.Join(' ', dropdownClasses)"
                 style="@string.Join(';', styleParts)">

                <zone name="hybridmenu_dropdown_top"></zone>

                @{
                    string catPicUrl = null;
                    if (rec.DisplayCategoryPicture && item.ImageId.HasValue && item.ImageId.Value > 0)
                    {
                        var catPic = await MediaService.GetFileByIdAsync(item.ImageId.Value);
                        catPicUrl = catPic != null ? MediaService.GetUrl(catPic, 0, null, false) : null;
                    }

                    // KI cross-category suggestions ("Passt dazu" column on the right). Only
                    // rendered when the operator enabled the feature globally AND vector search
                    // is on AND we actually have similarity rows for this category.
                    IList<RelatedCategoryDto> relatedCats = null;
                    string kiDebugReason = null;
                    if (!settings.HybridMenuShowRelated) kiDebugReason = "HybridMenuShowRelated=false";
                    else if (!settings.EnableVectorSearch) kiDebugReason = "EnableVectorSearch=false";
                    else if (item.EntityName != "Category") kiDebugReason = $"EntityName='{item.EntityName}' != Category";
                    else if (item.EntityId <= 0) kiDebugReason = $"EntityId={item.EntityId} <= 0";
                    else
                    {
                        var topN = Math.Max(1, settings.HybridMenuRelatedTopN);
                        relatedCats = await CategorySimilarityService.GetRelatedCategoriesAsync(
                            item.EntityId, languageId, topN);
                        if (relatedCats == null || relatedCats.Count == 0)
                            kiDebugReason = $"GetRelatedCategoriesAsync(catId={item.EntityId}, lang={languageId}, topN={topN}) returned 0 rows";
                    }
                    var hasRelated = relatedCats != null && relatedCats.Count > 0;
                }
                @* DEBUG: visible in view-source so we can see why the column isn't rendering. *@
                <!-- HM-KI-RELATED: catId=@item.EntityId entityName='@item.EntityName' workingLang=@languageId hasRelated=@hasRelated reason='@kiDebugReason' -->

                @* Top-row grid: cat-pic on the left (220px), main in the middle (1fr),
                   related-categories on the right (200px). Columns appear or disappear via
                   `has-side` / `has-related` modifier classes — driven by CSS. *@
                <div class="crs-hm-grid @(catPicUrl != null ? "has-side" : "") @(hasRelated ? "has-related" : "")">
                    @if (catPicUrl != null)
                    {
                        <aside class="crs-hm-side">
                            <div class="crs-hm-category-pic">
                                <img src="@catPicUrl" alt="@item.Text" loading="lazy" />
                            </div>
                        </aside>
                    }
                    <div class="crs-hm-main">
                        @{
                            // Read localized variants for the current working language. Without
                            // `GetLocalized`, EF returns the default-language column value and
                            // the per-language tabs in the admin tab were effectively ignored.
                            var localizedSummary = rec.GetLocalized(x => x.Summary)?.Value;
                        }
                        @if (!string.IsNullOrWhiteSpace(localizedSummary))
                        {
                            <div class="crs-hm-summary">
                                <h6>@item.Text</h6>
                                <p>@Html.Raw(localizedSummary)</p>
                            </div>
                        }

                        @if (hasChildren)
                        {
                            var isBrandTab = item.Id == "brand";
                            if (isBrandTab)
                            {
                                @* Brand-Tab gets its own layout: Simple = vertical list with logo
                                   left + name right (35x35px), Advanced = compact tile grid (about
                                   half the size of product tiles). The brand display-type setting
                                   from Configure → Hybrid-Menu drives the variant. *@
                                var brandNodes = node.Children.Where(c => c.Value.Visible).ToList();
                                var brandClass = settings.HybridMenuBrandDisplayType == BrandDisplayType.Advanced
                                    ? "crs-hm-brand crs-hm-brand--tiles"
                                    : "crs-hm-brand crs-hm-brand--list";
                                <div class="@brandClass">
                                    @foreach (var bn in brandNodes)
                                    {
                                        var brand = bn.Value;
                                        var brandUrl = brand.GenerateUrl(this.ViewContext);
                                        var hasLogo = !string.IsNullOrEmpty(brand.ImageUrl);
                                        if (settings.HybridMenuBrandDisplayType == BrandDisplayType.Advanced)
                                        {
                                            <a class="crs-hm-brand-tile" href="@brandUrl" title="@brand.Text">
                                                <span class="crs-hm-brand-tile-thumb">
                                                    @if (hasLogo) { <img src="@brand.ImageUrl" alt="" loading="lazy" /> }
                                                </span>
                                                <span class="crs-hm-brand-tile-name">@brand.Text</span>
                                            </a>
                                        }
                                        else
                                        {
                                            <a class="crs-hm-brand-row" href="@brandUrl" title="@brand.Text">
                                                <span class="crs-hm-brand-row-logo">
                                                    @if (hasLogo) { <img src="@brand.ImageUrl" alt="" loading="lazy" /> }
                                                </span>
                                                <span class="crs-hm-brand-row-name">@brand.Text</span>
                                            </a>
                                        }
                                    }
                                </div>
                            }
                            else
                            {
                                @* The 1st sub-level is rendered as bold headings (`.crs-hm-heading`);
                                   all deeper levels are flattened into a single nested <ul> recursively
                                   via the RenderSubTree helper, up to MAX_DEPTH=4 so very deep catalog
                                   trees don't explode the dropdown. CSS multi-column balances the
                                   heading groups into 3 stacked columns. `break-inside: avoid` keeps
                                   each heading attached to its sub-list. *@
                                <div class="crs-hm-columns" data-max-per-column="@settings.HybridMenuMaxItemsPerColumn">
                                    @{
                                        var groups = node.Children.Where(c => c.Value.Visible).ToList();
                                    }
                                    @foreach (var sub in groups)
                                    {
                                        var subItem = sub.Value;
                                        var subUrl = subItem.GenerateUrl(this.ViewContext);
                                        <div class="crs-hm-group">
                                            <a class="crs-hm-heading" href="@subUrl">@subItem.Text</a>
                                            @if (sub.HasChildren)
                                            {
                                                @RenderSubTree(sub, rec, 1)
                                            }
                                        </div>
                                    }
                                </div>
                            }
                        }

                        @{
                            var localizedTeaserHtml = rec.GetLocalized(x => x.TeaserHtml)?.Value;
                        }
                        @if (rec.TeaserType == TeaserType.Html && !string.IsNullOrWhiteSpace(localizedTeaserHtml))
                        {
                            <div class="crs-hm-teaser crs-hm-teaser-html" data-col-span="@rec.HtmlColumnSpan">
                                @Html.Raw(localizedTeaserHtml)
                            </div>
                        }
                        @* Rotator (TeaserType.AIMatching) is rendered FULL-WIDTH below the grid,
                           not inside .crs-hm-main — see the block after the grid closes. *@
                    </div>@* .crs-hm-main *@

                    @* KI cross-category column ("Passt dazu"). Sits as the right-hand aside
                       of the grid; only rendered when the similarity service returned hits. *@
                    @if (hasRelated)
                    {
                        @* Using <div>s instead of <ul>/<li> on purpose — Smartstore's
                           smartstore.megamenu.js wraps every <ul> in the dropdown with a
                           `nav-slider` container that sets `overflow-x: hidden` and clips
                           the content. Plain divs aren't touched, so the list renders normally. *@
                        <aside class="crs-hm-related">
                            <h6 class="crs-hm-related-heading">@T("Plugins.CRS.HybridSearch.HybridMenu.RelatedHeading")</h6>
                            <div class="crs-hm-related-list">
                                @foreach (var r in relatedCats)
                                {
                                    // Skip the entry entirely when no slug resolved — Smartstore's
                                    // Category route is SeName-only (no by-id fallback), so without
                                    // a slug we'd render `/catalog/category/{id}/` which 404s.
                                    if (string.IsNullOrEmpty(r.Slug)) { continue; }
                                    var rUrl = Url.RouteUrl("Category", new { SeName = r.Slug });
                                    <div class="crs-hm-related-item"><a href="@rUrl">@r.Name</a></div>
                                }
                            </div>
                        </aside>
                    }
                </div>@* .crs-hm-grid *@

                @* ---- Full-width rotator strip ---- *@
                <!-- HM-KI-ROTATOR: catId=@item.EntityId teaserType=@rec.TeaserType selectMode=@rec.TeaserRotatorItemSelectType -->
                @if (rec.TeaserType == TeaserType.AIMatching)
                {
                    var rotatorProducts = await HybridMenuService.GetRotatorProductsAsync(rec, item.EntityId, storeId, languageId);
                    <!-- HM-KI-ROTATOR-RESULT: catId=@item.EntityId products=@(rotatorProducts?.Count ?? -1) -->
                    if (rotatorProducts != null && rotatorProducts.Count > 0)
                    {
                        var localizedRotatorHeading = rec.GetLocalized(x => x.RotatorHeading)?.Value;
                        <div class="crs-hm-rotator-row">
                            @if (!string.IsNullOrWhiteSpace(localizedRotatorHeading))
                            {
                                <h6 class="crs-hm-rotator-heading">@localizedRotatorHeading</h6>
                            }
                            <div class="crs-hm-rotator-tiles">
                                @foreach (var product in rotatorProducts)
                                {
                                    var pName = product.GetLocalized(x => x.Name)?.Value ?? product.Name;
                                    var pSlug = await product.GetActiveSlugAsync();
                                    var pUrl = !string.IsNullOrEmpty(pSlug)
                                        ? Url.RouteUrl("Product", new { SeName = pSlug })
                                        : Url.Action("Product", "Catalog", new { id = product.Id });
                                    // Image lookup with fallback: MainPictureId first, then the
                                    // lowest-DisplayOrder ProductMediaFile. Products whose
                                    // MainPictureId got nulled (picture re-ordering / partial
                                    // imports) but still have ProductMediaFile rows used to
                                    // render with empty thumbs — the fallback fixes that.
                                    string pImgUrl = null;
                                    int? pImgId = product.MainPictureId.GetValueOrDefault() > 0
                                        ? product.MainPictureId.Value
                                        : await Db.ProductMediaFiles
                                            .AsNoTracking()
                                            .Where(pmf => pmf.ProductId == product.Id)
                                            .OrderBy(pmf => pmf.DisplayOrder)
                                            .Select(pmf => (int?)pmf.MediaFileId)
                                            .FirstOrDefaultAsync();
                                    if (pImgId.GetValueOrDefault() > 0)
                                    {
                                        var pImg = await MediaService.GetFileByIdAsync(pImgId.Value);
                                        pImgUrl = pImg != null ? MediaService.GetUrl(pImg, 200, null, false) : null;
                                    }
                                    var pShortRaw = product.GetLocalized(x => x.ShortDescription)?.Value ?? "";
                                    var pShort = pShortRaw.Length > 60 ? pShortRaw.Substring(0, 60) + "…" : pShortRaw;
                                    var pPrice = product.Price.ToString("C", System.Globalization.CultureInfo.GetCultureInfo("de-AT"));
                                    <a class="crs-hm-rotator-tile" href="@pUrl" title="@pName">
                                        <span class="crs-hm-rotator-tile-thumb">
                                            @if (pImgUrl != null)
                                            {
                                                <img src="@pImgUrl" alt="" loading="lazy" />
                                            }
                                        </span>
                                        <span class="crs-hm-rotator-tile-name">@pName</span>
                                        <span class="crs-hm-rotator-tile-desc">@pShort</span>
                                        <span class="crs-hm-rotator-tile-price">@pPrice</span>
                                    </a>
                                }
                            </div>
                        </div>
                    }
                }

                @{
                    var localizedBgLink = rec.GetLocalized(x => x.BgLink)?.Value;
                }
                @if (!string.IsNullOrWhiteSpace(localizedBgLink))
                {
                    <a class="crs-hm-bglink" href="@localizedBgLink" aria-label="@item.Text"></a>
                }

                <zone name="hybridmenu_dropdown_bottom"></zone>
            </div>
        </div>
    }
</div>

@{
    // Fallback drilldown renderer used when a category has Hybrid-Menu = OFF
    // (rec.IsActive == false). Mirrors `Smartstore.Web/Views/Shared/Components/Menu/Main.cshtml`'s
    // `CreateDrilldownMenuAsync` so the dropdown markup is identical to what the standard
    // Smartstore mega-menu would have emitted — i.e. the user gets a plain dropdown back,
    // just for the categories where they opted out of our chrome.
    async Task CreateStandardDrilldownAsync(IEnumerable<TreeNode<MenuItem>> nodes, bool isRoot, string parentId = "")
    {
        if (isRoot)
        {
            if (!string.IsNullOrEmpty(parentId))
            {
                @Html.Raw($"<div id='dropdown-submenu-{parentId}' class='dropdown-menu' aria-labelledby='dropdown-item-link-{parentId}' role='menu'>")
            }
            else
            {
                @Html.Raw("<div class='dropdown-menu'>")
            }
        }
        foreach (var n in nodes.Where(x => x.Value.Visible))
        {
            var it = n.Value;
            var itUrl = it.GenerateUrl(this.ViewContext);
            var attrs = it.GetCombinedAttributes().PrependCssClass("dropdown-item");
            if (n.HasChildren)
            {
                attrs["aria-haspopup"] = "menu";
                attrs["aria-expanded"] = "false";
                attrs["aria-controls"] = "dropdown-submenu-" + it.Id;
            }
            attrs["id"] = "dropdown-item-link-" + it.Id;
            <div id="dropdown-item-@it.Id" data-id="@it.Id" attr-class='(n.HasChildren, "dropdown-group")' role="none">
                <a href="@itUrl" attrs="@attrs" role="menuitem">
                    <span sm-language-attributes-for="it">@it.Text</span>
                </a>
                @if (n.HasChildren)
                {
                    await CreateStandardDrilldownAsync(n.Children, true, it.Id);
                }
            </div>
        }
        if (isRoot)
        {
            @Html.Raw("</div>")
        }
    }
}

<script src="~/Modules/CRS.HybridSearch/js/crs-hybridmenu.js"></script>
<script>
    $(function () {
        // Smartstore megaMenu binds to .megamenu-container (the outer wrapper from _Layout.cshtml).
        // Our view sits inside that container, so the same selector applies. Without this init,
        // the dropdowns never open on hover/click.
        $(".megamenu-container").megaMenu();
        if (window.CrsHybridMenu && CrsHybridMenu.init) {
            CrsHybridMenu.init('@Url.Action("HybridMenuRotatorProducts", "HybridSearchAdmin", new { area = "Admin" })');
        }
    });
</script>

@functions {
    /// <summary>
    /// Recursively renders the descendant tree of a heading node as nested &lt;ul&gt;-lists.
    /// Tree-level semantics (1-based, matching the admin UI):
    ///   - depth 1 → "level 2" (the items directly under a bold heading), capped by MaxSubItemsPerCategory
    ///   - depth 2 → "level 3" (one indent below those), capped by MaxSubItemsLevel3
    ///   - depth 3+ → not rendered (the dropdown would explode and the catalog tree of most
    ///                shops doesn't carry usable info that deep).
    /// Overflow on either level is collapsed into a localized "more..." entry that links
    /// to the parent's category page so the user can keep navigating.
    /// </summary>
    private string RenderSubTreeHtml(TreeNode<MenuItem> node, CRS.HybridSearch.Domain.HybridMenu.CrsHybridMenuRecord rec, int depth)
    {
        // Level 3 (depth 2) is the deepest we render. Level-3 cap = 0 also short-circuits here.
        if (depth > 2) return string.Empty;
        var visible = node.Children.Where(c => c.Value.Visible).ToList();
        if (visible.Count == 0) return string.Empty;

        // Per-depth cap. Resolved from the GLOBAL HybridSearchSettings (the per-category
        // values that used to drive these are dead now). 0 on depth 2 = hide entirely.
        var globalSettings = (CRS.HybridSearch.Configuration.HybridSearchSettings)Context.RequestServices
            .GetService(typeof(CRS.HybridSearch.Configuration.HybridSearchSettings));
        var rawCap = depth == 1
            ? globalSettings.HybridMenuMaxSubItemsLevel1
            : globalSettings.HybridMenuMaxSubItemsLevel2;
        if (depth == 2 && rawCap == 0) return string.Empty;
        var max = rawCap > 0 ? rawCap : int.MaxValue;

        var hasMore = visible.Count > max;
        var take = max;
        // Inline-mode produced inconsistent layouts (a heading with multiple flat children
        // rendered side-by-side, while a heading with one child + grand-children stacked
        // vertically). User decided every level should stack vertically with progressive
        // indentation — DisplaySubItemsInline is now ignored in rendering; the DB column
        // is kept for rollback / migration safety only.
        var listClass = "crs-hm-subitems";

        var moreLabel = T("Plugins.CRS.HybridSearch.HybridMenu.More").Value;
        var sb = new System.Text.StringBuilder();
        sb.Append("<ul class=\"").Append(listClass).Append("\" data-level=\"").Append(depth).Append("\">");
        foreach (var child in visible.Take(take))
        {
            var url = child.Value.GenerateUrl(this.ViewContext);
            sb.Append("<li><a href=\"").Append(System.Net.WebUtility.HtmlEncode(url)).Append("\">")
              .Append(System.Net.WebUtility.HtmlEncode(child.Value.Text))
              .Append("</a>");
            // Recurse only when there's room left (depth 1 → render depth 2; depth 2 → stop).
            if (child.HasChildren && depth < 2)
            {
                sb.Append(RenderSubTreeHtml(child, rec, depth + 1));
            }
            sb.Append("</li>");
        }
        if (hasMore)
        {
            var moreUrl = node.Value.GenerateUrl(this.ViewContext);
            sb.Append("<li class=\"crs-hm-more\"><a href=\"").Append(System.Net.WebUtility.HtmlEncode(moreUrl)).Append("\">")
              .Append(System.Net.WebUtility.HtmlEncode(moreLabel))
              .Append("</a></li>");
        }
        sb.Append("</ul>");
        return sb.ToString();
    }

    private IHtmlContent RenderSubTree(TreeNode<MenuItem> node, CRS.HybridSearch.Domain.HybridMenu.CrsHybridMenuRecord rec, int depth)
        => new HtmlString(RenderSubTreeHtml(node, rec, depth));
}
