/**
 * AIEO Navigator — Ctrl+K command palette runtime.
 *
 * Two-pane (left list + right detail) palette inspired by the Vultr Panel
 * pattern. Listens for the `aieo:navigator:open` CustomEvent (fired by
 * the host theme's Fast Finder trigger button + the Ctrl+K / ⌘K key
 * handler), then:
 *
 *   1. Lazily fetches `wp-content/uploads/aieo/navigator.json` (cached
 *      in localStorage by version stamp — invalidates on regen).
 *   2. Renders the modal shell with two panes: left = filterable tree,
 *      right = clickable detail (image, description, sub-items) for
 *      the highlighted item.
 *   3. Filters as the user types with Greek-aware normalisation
 *      (lowercase + diacritic strip + σ↔ς equivalence).
 *   4. Keyboard nav: ↑↓ select, ↵ open URL, → expand/drill into right
 *      pane, ← collapse, Esc close.
 *   5. **Collapse-by-default** — only level-0 items render until their
 *      parent is expanded by click or →. Limits initial DOM cost on
 *      sites with deep category trees and matches the Vultr-Panel UX.
 *   6. **Context boost** — when no query is typed, items whose `id` is
 *      in `aieoNavigator.context.boost` (current page's category /
 *      ancestors / brand / blog category) float to the top of their
 *      group section so the most-relevant entries surface first.
 */

(function () {
    'use strict';

    var __ = ( window.wp && window.wp.i18n && window.wp.i18n.__ ) ? window.wp.i18n.__ : function (s) { return s; };
    var _x = ( window.wp && window.wp.i18n && window.wp.i18n._x ) ? window.wp.i18n._x : function (s) { return s; };
    var _n = ( window.wp && window.wp.i18n && window.wp.i18n._n ) ? window.wp.i18n._n : function (s, p, n) { return n === 1 ? s : p; };
    var _sprintf = ( window.wp && window.wp.i18n && window.wp.i18n.sprintf ) ? window.wp.i18n.sprintf : function (s) { return s; };

    var cfg = window.aieoNavigator || {};
    if (!cfg.jsonUrl) return;

    var root, input, leftEl, rightEl, railEl, countEl;
    var data = null;
    var allItems = [];      // flat array, group-tagged
    var filtered = [];      // filtered view (matches query)
    var visible = [];       // visible rows (filtered + collapse rules applied)
    var expanded = {};       // map of expanded parent ids
    var selectedIdx = 0;
    var openState = false;

    var CACHE_KEY = 'aieo_navigator_v' + (cfg.cacheVersion || '1');
    var BOOST = (cfg.context && Array.isArray(cfg.context.boost)) ? cfg.context.boost : [];

    // Greek-aware normalisation: lowercase, strip combining marks
    // (tonos / diaeresis), unify final-sigma. So «ΑΡΩΜΑΤΑ»,
    // «αρώματα» and «αρωματα» all match a query of «αρωμ».
    function normalize(s) {
        return String(s || '')
            .toLocaleLowerCase('el')
            .normalize('NFD')
            .replace(/[̀-ͯ]/g, '')
            .replace(/ς/g, 'σ');
    }

    function escHtml(s) {
        return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
            return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
        });
    }

    // ── Lifecycle ───────────────────────────────────────────────────────

    function open() {
        if (openState) return;
        openState = true;
        root = document.getElementById('aieo-navigator-root');
        if (!root) return;
        cacheRefs();
        wireEvents();
        root.removeAttribute('hidden');
        root.style.display = 'flex';
        document.body.style.overflow = 'hidden';
        loadData().then(function () {
            buildAllItems();
            // Auto-expand boosted ancestors so the shopper lands on
            // the palette already inside their current category, with
            // its subcategories visible — no second click needed to
            // drill down. The boost list already includes the current
            // term plus every ancestor (see PHP `current_context()`),
            // so flagging each one as `expanded` reveals the full
            // path from root to leaf.
            BOOST.forEach(function (id) { expanded[id] = true; });
            filter('');
            // Scoped archive? land focused on its category (its needs/brands),
            // not the first category in the list.
            preselectScopedCategory();
            injectArchiveFilters();
            requestAnimationFrame(function () {
                // Mobile: focusing the finder pops the on-screen keyboard and
                // buries the category list. Focus the first menu row instead
                // (a div — no keyboard) so the shopper sees the list and the
                // first item is the active/focused entry. Desktop keeps the
                // finder focused (type-to-search, no keyboard concern there).
                if (window.matchMedia && window.matchMedia('(max-width: 720px)').matches) {
                    var firstRow = leftEl && leftEl.querySelector('.aieo-nav-row');
                    if (firstRow) {
                        firstRow.setAttribute('tabindex', '-1');
                        firstRow.focus({ preventScroll: true });
                        return;
                    }
                }
                input && input.focus();
            });
        });
    }

    /**
     * Inject a fresh CLONE of the page's FiboFilters container into the
     * navigator's left pane on every open. Cloning (vs moving live) is
     * deliberate:
     *   - moving detaches the plugin's MutationObserver / event hooks
     *     from the source container and the plugin sometimes can't
     *     re-attach when the element is moved back, so a second open
     *     ends up with an empty wrap
     *   - cloning leaves the page's filter widget untouched and gives
     *     us a visual snapshot of whatever state it's in at open time
     * By the time the shopper taps the burger button (typically a few
     * seconds after page load) the plugin has populated the source
     * element, so the clone shows real filters, not the skeleton.
     *
     * We prefer the vertical container (full filter list) — falling
     * back to mobile and horizontal variants. CSS inside the modal
     * overrides the plugin's `data-min-screen` visibility gate so the
     * clone always paints regardless of the host viewport width.
     *
     * Tear-down on close removes the wrap so the next open re-clones
     * fresh against whatever state the source is currently in.
     */
    /**
     * Live-move the page's FiboFilters container into the navigator's
     * left pane on every open, restoring it on close. Tried cloning +
     * forwarding events earlier — works for *firing* the filter, but
     * the cloned UI is a static snapshot so checkbox state, slider
     * positions, and re-rendered count badges all drift out of sync.
     * Moving the live element keeps the plugin's own DOM authoritative
     * — clicks fire its native handlers, slider drag uses its own
     * mouse listeners, and visual state stays self-consistent.
     */
    var _archiveFilterEl   = null;
    var _archiveFilterHome = null;
    var _archiveFilterNext = null;
    function injectArchiveFilters() {
        if (!leftEl || !isProductArchive()) return;
        if (leftEl.querySelector('.aieo-nav-archive-filters')) return;
        // Honour the admin's group-order placement for `filters`.
        // `off` = don't inject; `rail` = TODO (not yet supported, falls
        // back to skipping); `main` = inject at the right slot in the
        // main list, position determined by the order in
        // cfg.groupsOrder relative to other groups (e.g. if the merchant
        // dragged Filters BELOW Categories, filters get inserted after
        // categories' rows instead of at the very top).
        var groupsOrder = (cfg.groupsOrder && cfg.groupsOrder.length) ? cfg.groupsOrder : [];
        var filtersCfg = null;
        for (var i = 0; i < groupsOrder.length; i++) {
            if (groupsOrder[i].slug === 'filters') { filtersCfg = groupsOrder[i]; break; }
        }
        if (filtersCfg && filtersCfg.placement !== 'main') return;
        var src = document.querySelector('.fibofilters-vertical-filters-container')
               || document.querySelector('.fibofilters-mobile-filters-container')
               || document.querySelector('.fibofilters-horizontal-filters-container');
        if (!src) return;
        // Remember exactly where the element lived so we can put it
        // back in the same position on close (matters for FiboFilters'
        // sibling-aware logic + downstream selectors).
        _archiveFilterEl   = src;
        _archiveFilterHome = src.parentNode;
        _archiveFilterNext = src.nextSibling;
        var wrap = document.createElement('div');
        wrap.className = 'aieo-nav-archive-filters';
        var heading = document.createElement('div');
        heading.className = 'aieo-nav-section';
        heading.textContent = (cfg.i18n && cfg.i18n.filters) || 'Φίλτρα';
        wrap.appendChild(heading);
        wrap.appendChild(src);                  // MOVE — preserves listeners + state
        // Find the right position based on group order. Walk the list
        // up to (but excluding) `filters` — what comes BEFORE it should
        // already be in the leftEl as section-headers + rows. Insert
        // our wrap AFTER the last preceding group's rows. If `filters`
        // is first in order, insert at the very top.
        var insertBeforeNode = null;   // null = append at end
        var precedingSlug = null;
        if (filtersCfg) {
            for (var k = 0; k < groupsOrder.length; k++) {
                if (groupsOrder[k].slug === 'filters') break;
                if (groupsOrder[k].placement === 'main') {
                    precedingSlug = groupsOrder[k].slug;
                }
            }
        }
        if (!precedingSlug) {
            // Filters is first — top of the column.
            insertBeforeNode = leftEl.firstChild;
        } else {
            // Find the section header for the LAST preceding "main" group
            // and insert before the NEXT section header (or at end if
            // none). Section headers carry `class="aieo-nav-section"` +
            // text matching the group label; we walk row-by-row.
            var sections = leftEl.querySelectorAll('.aieo-nav-section');
            // Find the section whose data-slug === precedingSlug, then
            // walk forward to find the NEXT section.
            for (var s = 0; s < sections.length; s++) {
                var sec = sections[s];
                if (sec.getAttribute('data-slug') === precedingSlug) {
                    insertBeforeNode = sections[s + 1] || null;
                    break;
                }
            }
        }
        leftEl.insertBefore(wrap, insertBeforeNode);

        // Kick FiboFilters to re-render its content for the new parent.
        // On the FIRST open this is a no-op (filter was already painted
        // at its native location). On the SECOND+ open, FiboFilters has
        // detached its observers from the source element when we moved
        // it out the previous time and on close moved it back — so when
        // we move it AGAIN now, the inside is intact in the DOM but the
        // plugin treats it as a stale shell. Dispatching a resize +
        // fibofilters-specific event nudges it to re-bind.
        try {
            window.dispatchEvent(new Event('resize'));
            // Custom events FiboFilters Pro is known to listen for. None
            // hurts when the plugin doesn't subscribe — the dispatcher
            // just no-ops.
            document.dispatchEvent(new CustomEvent('fibofilters:refresh', { bubbles: true }));
            document.dispatchEvent(new CustomEvent('fibofilters_refresh',  { bubbles: true }));
            // jQuery 'updated_wc_div' is a WC-blocks-style heartbeat that
            // some filter plugins re-render on. Same no-op semantics.
            if (window.jQuery) { window.jQuery(document.body).trigger('updated_wc_div'); }
            // If FiboFilters exposed a global init API, call it. Discover
            // its name dynamically so we survive plugin renames.
            var globals = ['fibofilters', 'FiboFilters', 'fibofiltersPro', 'FiboFiltersPro'];
            for (var gi = 0; gi < globals.length; gi++) {
                var g = window[globals[gi]];
                if (g && typeof g.init === 'function') { try { g.init(); } catch (e) {} break; }
                if (g && typeof g.refresh === 'function') { try { g.refresh(); } catch (e) {} break; }
                if (g && typeof g.reinit === 'function') { try { g.reinit(); } catch (e) {} break; }
            }
        } catch (e) {}
    }
    function restoreArchiveFilters() {
        // Put the live element back in its original spot BEFORE removing
        // the wrap — otherwise the live FiboFilters container gets nuked
        // when its parent (.aieo-nav-archive-filters) is removed and the
        // next open finds nothing to inject (only the section heading
        // appears). Order matters: move first, remove wrap second.
        //
        // insertBefore(el, null) appends at end — matches the behaviour
        // when `_archiveFilterNext` was null at capture time.
        if (_archiveFilterEl && _archiveFilterHome
            && _archiveFilterEl.parentNode !== _archiveFilterHome) {
            _archiveFilterHome.insertBefore(_archiveFilterEl, _archiveFilterNext || null);
        }
        if (leftEl) {
            var wrap = leftEl.querySelector('.aieo-nav-archive-filters');
            if (wrap && wrap.parentNode) {
                wrap.parentNode.removeChild(wrap);
            }
        }
        _archiveFilterEl   = null;
        _archiveFilterHome = null;
        _archiveFilterNext = null;
    }

    function close() {
        if (!openState) return;
        openState = false;
        // Move the FiboFilters container back to its original home
        // BEFORE the modal hides — keeps the plugin's references valid
        // and avoids paint flicker on the archive page beneath.
        restoreArchiveFilters();
        if (root) {
            root.setAttribute('hidden', '');
            root.style.display = 'none';
        }
        document.body.style.overflow = '';
        if (input) input.value = '';
        selectedIdx = 0;
        expanded = {};
    }

    function cacheRefs() {
        input = root.querySelector('.aieo-navigator-input');
        var body = root.querySelector('.aieo-navigator-body');
        if (body && !body.querySelector('.aieo-navigator-left')) {
            body.innerHTML =
                '<div class="aieo-navigator-left" role="listbox"></div>' +
                '<div class="aieo-navigator-right"></div>' +
                '<aside class="aieo-navigator-rail" role="navigation" aria-label="' + __('Tools', 'ai-eshop-optimizer') + '"></aside>';
        }
        leftEl  = root.querySelector('.aieo-navigator-left');
        rightEl = root.querySelector('.aieo-navigator-right');
        railEl  = root.querySelector('.aieo-navigator-rail');
        countEl = root.querySelector('.aieo-navigator-count');
        if (!countEl && input) {
            countEl = document.createElement('span');
            countEl.className = 'aieo-navigator-count';
            input.parentNode.insertBefore(countEl, input.nextSibling);
        }
    }

    function wireEvents() {
        if (input && !input._aieoBound) {
            input._aieoBound = true;
            input.addEventListener('input', function () { filter(input.value); });
            input.addEventListener('keydown', handleKeys);
        }
        root.querySelectorAll('[data-aieo-navigator-close]').forEach(function (el) {
            if (el._aieoBound) return;
            el._aieoBound = true;
            el.addEventListener('click', close);
        });
        if (!document._aieoEscBound) {
            document._aieoEscBound = true;
            document.addEventListener('keydown', function (e) {
                if (openState && e.key === 'Escape') {
                    e.preventDefault();
                    close();
                }
            });
        }
        installSizeControls();
        installResizeHandle();
        applyPersistedPreferences();
    }

    /* ── Size + width preferences (localStorage) ─────────────────────────
     * Two settings, both per-browser:
     *   aieo_nav_scale     '1' | '1.15' | '1.30'  (compact/cozy/comfortable)
     *   aieo_nav_left_pct  '25'..'65'              (desktop pane width %)
     * Read once at panel-open time, written on every interaction.
     * Values land on the panel root as CSS vars; CSS rules use them
     * via calc() to scale rows + grid columns.
     */
    // Three steps that cycle: Compact (1×) → Cozy (1.15×) → Comfortable (1.30×).
    var SCALE_STEPS = [
        { value: '1',    step: '1', label: __('Compact text size', 'ai-eshop-optimizer')      },
        { value: '1.15', step: '2', label: __('Cozy text size', 'ai-eshop-optimizer')         },
        { value: '1.30', step: '3', label: __('Comfortable text size', 'ai-eshop-optimizer')  }
    ];

    function getScale() {
        try {
            var v = localStorage.getItem('aieo_nav_scale');
            return (v === '1' || v === '1.15' || v === '1.30') ? v : '1';
        } catch (e) { return '1'; }
    }
    function setScale(v) {
        try { localStorage.setItem('aieo_nav_scale', v); } catch (e) {}
        var panel = root && (root.querySelector('.aieo-navigator-panel') || root);
        if (panel) panel.style.setProperty('--aieo-nav-scale', v);
        // Reflect on the cycle button.
        var btn = root && root.querySelector('.aieo-nav-size-cycle');
        if (btn) {
            var meta = SCALE_STEPS.find(function (s) { return s.value === v; }) || SCALE_STEPS[0];
            btn.setAttribute('data-step', meta.step);
            btn.setAttribute('aria-label', meta.label);
            /* translators: %s: text-size step label */
            btn.title = _sprintf(__('%s — click to cycle', 'ai-eshop-optimizer'), meta.label);
        }
    }
    function getLeftPct() {
        try {
            var v = parseInt(localStorage.getItem('aieo_nav_left_pct') || '', 10);
            if (isFinite(v) && v >= 25 && v <= 65) return String(v);
        } catch (e) {}
        return ''; // empty → CSS default kicks in (44 / 38)
    }
    function setLeftPct(v) {
        var pct = Math.max(25, Math.min(65, parseInt(v, 10) || 44));
        try { localStorage.setItem('aieo_nav_left_pct', String(pct)); } catch (e) {}
        if (root) root.style.setProperty('--aieo-nav-left-pct', String(pct));
    }

    function installSizeControls() {
        if (!root) return;
        var header = root.querySelector('.aieo-navigator-header');
        if (!header || header.querySelector('.aieo-nav-size-cycle')) return;
        var btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'aieo-nav-size-cycle';
        btn.textContent = 'A';
        btn.setAttribute('data-step', '1');
        var sizeCycleLabel = __('Text size — click to cycle', 'ai-eshop-optimizer');
        btn.setAttribute('aria-label', sizeCycleLabel);
        btn.title = sizeCycleLabel;
        btn.addEventListener('click', function () {
            var current = getScale();
            var idx = SCALE_STEPS.findIndex(function (s) { return s.value === current; });
            var next = SCALE_STEPS[(idx + 1) % SCALE_STEPS.length];
            setScale(next.value);
        });
        // Insert before the close button so the X stays at the rightmost
        // position of the header (where users expect it).
        var closeBtn = header.querySelector('[data-aieo-navigator-close]');
        if (closeBtn) {
            header.insertBefore(btn, closeBtn);
        } else {
            header.appendChild(btn);
        }
    }

    function installResizeHandle() {
        if (!root) return;
        // Skip on small viewports — single-column layout has nothing to drag.
        if (window.matchMedia && window.matchMedia('(max-width: 720px)').matches) return;
        var panel = root.querySelector('.aieo-navigator-panel');
        if (!panel || panel.querySelector('.aieo-nav-resize-handle')) return;
        var handle = document.createElement('div');
        handle.className = 'aieo-nav-resize-handle';
        handle.setAttribute('role', 'separator');
        handle.setAttribute('aria-orientation', 'vertical');
        handle.setAttribute('aria-label', __('Resize categories pane', 'ai-eshop-optimizer'));
        handle.title = __('Drag to resize', 'ai-eshop-optimizer');
        panel.appendChild(handle);
        positionResizeHandle();
        // Reposition on viewport changes.
        window.addEventListener('resize', positionResizeHandle);

        var dragging = false;
        var startX = 0;
        var startPct = 44;
        var panelRect = null;

        function onPointerDown(e) {
            dragging = true;
            handle.classList.add('is-dragging');
            startX = (e.touches ? e.touches[0].clientX : e.clientX);
            startPct = parseInt(getComputedStyle(panel).getPropertyValue('--aieo-nav-left-pct'), 10) || 44;
            panelRect = panel.getBoundingClientRect();
            document.addEventListener('mousemove', onPointerMove);
            document.addEventListener('mouseup', onPointerUp);
            document.addEventListener('touchmove', onPointerMove, { passive: false });
            document.addEventListener('touchend', onPointerUp);
            e.preventDefault();
        }
        function onPointerMove(e) {
            if (!dragging || !panelRect) return;
            var clientX = (e.touches ? e.touches[0].clientX : e.clientX);
            var deltaPx = clientX - startX;
            var deltaPct = (deltaPx / panelRect.width) * 100;
            var newPct = Math.max(25, Math.min(65, startPct + deltaPct));
            panel.style.setProperty('--aieo-nav-left-pct', String(Math.round(newPct)));
            positionResizeHandle();
            if (e.cancelable) e.preventDefault();
        }
        function onPointerUp() {
            if (!dragging) return;
            dragging = false;
            handle.classList.remove('is-dragging');
            document.removeEventListener('mousemove', onPointerMove);
            document.removeEventListener('mouseup', onPointerUp);
            document.removeEventListener('touchmove', onPointerMove);
            document.removeEventListener('touchend', onPointerUp);
            // Persist final value.
            var pct = parseInt(getComputedStyle(panel).getPropertyValue('--aieo-nav-left-pct'), 10) || 44;
            setLeftPct(pct);
        }
        handle.addEventListener('mousedown', onPointerDown);
        handle.addEventListener('touchstart', onPointerDown, { passive: false });
    }

    function positionResizeHandle() {
        if (!root) return;
        var panel = root.querySelector('.aieo-navigator-panel');
        var handle = panel && panel.querySelector('.aieo-nav-resize-handle');
        var leftPane = root.querySelector('.aieo-navigator-left');
        if (!handle || !leftPane || !panel) return;
        var panelRect = panel.getBoundingClientRect();
        var leftRect = leftPane.getBoundingClientRect();
        // Position the handle at the right edge of the left pane.
        handle.style.left = (leftRect.right - panelRect.left) + 'px';
    }

    function applyPersistedPreferences() {
        if (!root) return;
        var panel = root.querySelector('.aieo-navigator-panel') || root;
        // Apply scale (always — falls through to '1' on first run).
        panel.style.setProperty('--aieo-nav-scale', getScale());
        var pct = getLeftPct();
        if (pct) panel.style.setProperty('--aieo-nav-left-pct', pct);
        // Reflect the persisted scale in the toggle group's active state.
        setScale(getScale());
        // Re-position the resize handle after the row scale settles
        // (rows determine left-pane intrinsic width).
        setTimeout(positionResizeHandle, 50);
    }

    // ── Data load (localStorage cache) ──────────────────────────────────

    function loadData() {
        try {
            var cached = localStorage.getItem(CACHE_KEY);
            if (cached) {
                data = JSON.parse(cached);
                return Promise.resolve();
            }
        } catch (e) { /* fall through to fetch */ }
        return fetch(cfg.jsonUrl, { credentials: 'same-origin' })
            .then(function (r) { return r.json(); })
            .then(function (j) {
                data = j;
                try { localStorage.setItem(CACHE_KEY, JSON.stringify(j)); } catch (e) {}
            })
            .catch(function (e) {
                console.error('[aieo-navigator] failed to load json', e);
                data = { groups: [] };
            });
    }

    /**
     * On WooCommerce product archives the FiboFilters plugin already
     * renders a fully-built filter widget (`.fibofilters-mobile-filters-container`)
     * on the page. Detect that context once per open and skip the
     * navigator's global Brands + Needs groups — the in-archive filter
     * is already scoped to the visible products, so showing the global
     * lists is redundant + confusing. The cloned filter HTML is also
     * prepended to the navigator's left pane (see injectArchiveFilters).
     */
    function isProductArchive() {
        var c = document.body.classList;
        return c.contains('tax-product_cat')
            || c.contains('tax-product_tag')
            || c.contains('post-type-archive-product')
            // Need + brand archives carry the same in-page FiboFilters sidebar,
            // so the navigator should host them too (the page sidebar is hidden
            // on mobile by brand.css). Cover both taxonomy-slug variants.
            || c.contains('tax-pa_need')
            || c.contains('tax-pa-need')
            || c.contains('tax-product_brand')
            || c.contains('tax-pwb-brand');
    }
    var SUPPRESS_ON_ARCHIVE = { 'brands': true, 'needs': true };

    // ── Category-scoped needs ────────────────────────────────────────────
    // When the "scope needs to category" option is on AND the page has a
    // current category (a category archive, or a product's primary category),
    // the global needs list is replaced by just THIS category's needs
    // (precomputed server-side into data.needsByCategory) and every need link
    // is stamped with ?in_cat so its archive opens limited to the same category
    // subtree. `_needsScope` / `_brandsScope` are computed once per open in
    // buildAllItems and reused by renderHighlights; null = no scope (keep the
    // global list).
    var _needsScope  = null;
    var _brandsScope = null;
    // Build a scope from a per-category map (needsByCategory / brandsByCategory).
    // The baked map value is an OBJECT {term_id: per_category_count} (current
    // format — see generate_json / build_term_by_category_map); older JSON used a
    // plain [term_id] array, so accept BOTH. Returns
    // {catId, allowed:{id:true}, counts:{id:n}} or null when no scope applies.
    // NOTE: the previous version read `.length` and so silently returned null for
    // the object form — which left the left-pane (flat list) needs UNSCOPED on
    // every viewport. Desktop hid it because the right pane scopes independently
    // via scopedItemsFor(); mobile (flat list = the whole UI) showed the global
    // lists. This generic builder is what fixes the mobile bug.
    function buildScope(mapKey) {
        var ctx = cfg.context || {};
        if (!ctx.needsScoped || !ctx.category || !ctx.category.id) return null;
        if (!data || !data[mapKey]) return null;
        var entry = data[mapKey][String(ctx.category.id)];
        if (!entry) return null;
        var allowed = {}, counts = {}, any = false;
        if (Array.isArray(entry)) {
            for (var i = 0; i < entry.length; i++) {
                var aid = parseInt(entry[i], 10);
                if (aid) { allowed[aid] = true; any = true; }
            }
        } else {
            for (var k in entry) {
                if (!Object.prototype.hasOwnProperty.call(entry, k)) continue;
                var oid = parseInt(k, 10);
                if (oid) { allowed[oid] = true; counts[oid] = entry[k]; any = true; }
            }
        }
        return any ? { catId: ctx.category.id, allowed: allowed, counts: counts } : null;
    }
    function computeNeedsScope()  { return buildScope('needsByCategory'); }
    function computeBrandsScope() { return buildScope('brandsByCategory'); }
    // Need item ids are 'pa-need-<termId>' / 'pa_need-<termId>' — pull the id.
    function needTermId(id) {
        var m = /(\d+)$/.exec(String(id || ''));
        return m ? parseInt(m[1], 10) : 0;
    }
    function withInCat(url, catId) {
        if (!url || url === '#') return url;
        return url + (url.indexOf('?') === -1 ? '?' : '&') + 'in_cat=' + encodeURIComponent(catId);
    }
    // Lookup: term_id -> {label,url,count} for a navigator group (needs/brands),
    // built from the JSON group — present even when the group is suppressed from
    // the left pane on archives. Cached per group; reset per open in buildAllItems.
    var _byGroup = {};
    function termsByIdForGroup(slug) {
        if (_byGroup[slug]) return _byGroup[slug];
        var map = {};
        if (data && data.groups) {
            data.groups.forEach(function (g) {
                if (g.slug !== slug) return;
                (g.items || []).forEach(function (it) {
                    var tid = needTermId(it.id);
                    if (tid) { map[tid] = { label: it.label, url: it.url || '#', count: it.count }; }
                });
            });
        }
        _byGroup[slug] = map;
        return map;
    }
    // A category-map entry → renderable items with the PER-CATEGORY count and
    // ?in_cat-stamped links, sorted by count desc. Handles both the new
    // {term_id: count} object and the legacy [term_id] array (global counts).
    // null when the category has none or the map is absent (feature off / old JSON).
    function scopedItemsFor(mapObj, catId, groupSlug) {
        if (!catId || !mapObj) return null;
        var entry = mapObj[String(catId)];
        if (!entry) return null;
        var by = termsByIdForGroup(groupSlug);
        var out = [];
        if (Array.isArray(entry)) {
            for (var i = 0; i < entry.length; i++) {
                var n = by[parseInt(entry[i], 10)];
                if (n) { out.push({ label: n.label, url: withInCat(n.url, catId), count: n.count }); }
            }
        } else {
            for (var k in entry) {
                if (!Object.prototype.hasOwnProperty.call(entry, k)) continue;
                var m = by[parseInt(k, 10)];
                if (m) { out.push({ label: m.label, url: withInCat(m.url, catId), count: entry[k] }); }
            }
        }
        out.sort(function (a, b) { return (b.count || 0) - (a.count || 0); });
        return out.length ? out : null;
    }
    function scopedNeedsForCategory(catId)  { return data ? scopedItemsFor(data.needsByCategory,  catId, 'needs')  : null; }
    function scopedBrandsForCategory(catId) { return data ? scopedItemsFor(data.brandsByCategory, catId, 'brands') : null; }
    // term_id of a selected category row ('product_cat-<id>'), else 0.
    function selectedCategoryId(item) {
        if (!item || item.groupSlug !== 'categories') return 0;
        return (String(item.id).indexOf('product_cat-') === 0) ? needTermId(item.id) : 0;
    }

    function buildAllItems() {
        allItems = [];
        if (!data || !data.groups) return;
        var archive = isProductArchive();
        _needsScope  = computeNeedsScope();
        _brandsScope = computeBrandsScope();
        _byGroup    = {}; // term lookups rebuilt lazily from the freshly-loaded data
        data.groups.forEach(function (g) {
            var isNeeds  = (g.slug === 'needs');
            var isBrands = (g.slug === 'brands');
            // On archives, suppress redundant GLOBAL groups — the in-archive
            // FiboFilters takes their place at the top of the left pane.
            // EXCEPTION: keep the needs/brands group when a category scope is
            // active — it's then this category's own (relevant) list, not the
            // global one.
            if (archive && SUPPRESS_ON_ARCHIVE[g.slug]
                && !(isNeeds && _needsScope) && !(isBrands && _brandsScope)) { return; }
            // Default placement is 'main' for legacy navigator.json files
            // that pre-date the rail feature.
            var place = g.placement === 'rail' ? 'rail' : 'main';
            (g.items || []).forEach(function (it) {
                var url   = it.url || '#';
                var count = it.count;
                // Category-scoped needs / brands: drop terms not present in this
                // category subtree, stamp survivors with ?in_cat so their archive
                // opens limited to the same category, and swap the GLOBAL count for
                // the per-category one (so the flat list matches the right-pane
                // highlights + the scoped archive). buildAllItems runs on every
                // viewport — this is what makes the MOBILE left pane (the dominant
                // pane on phones) show category-relevant needs+brands instead of
                // the global lists.
                if (isNeeds && _needsScope) {
                    var ntid = needTermId(it.id);
                    if (!_needsScope.allowed[ntid]) { return; }
                    url = withInCat(url, _needsScope.catId);
                    if (_needsScope.counts && _needsScope.counts[ntid] != null) { count = _needsScope.counts[ntid]; }
                } else if (isBrands && _brandsScope) {
                    var btid = needTermId(it.id);
                    if (!_brandsScope.allowed[btid]) { return; }
                    url = withInCat(url, _brandsScope.catId);
                    if (_brandsScope.counts && _brandsScope.counts[btid] != null) { count = _brandsScope.counts[btid]; }
                }
                allItems.push({
                    id:         it.id,
                    label:      it.label,
                    url:        url,
                    level:      it.level || 0,
                    parent:     it.parent || null,
                    count:      count,
                    image:      it.image || '',
                    description: it.description || '',
                    excerpt:    it.excerpt || '',
                    event:      it.event || null,
                    groupSlug:  g.slug,
                    groupLabel: g.label,
                    groupPlacement: place,
                    _norm:      normalize(it.label + ' ' + (g.label || '') + ' ' + (it.description || '') + ' ' + (it.excerpt || '')),
                });
            });
        });
    }

    // ── Filter + collapse rules ─────────────────────────────────────────

    function filter(query) {
        var q = normalize(query).trim();
        if (!q) {
            filtered = allItems;
        } else {
            // When a query is active, ALL matching items show (regardless
            // of collapse state) so the user always sees what matches.
            var terms = q.split(/\s+/);
            filtered = allItems.filter(function (it) {
                return terms.every(function (t) { return it._norm.indexOf(t) !== -1; });
            });
        }
        applyContextBoost();
        buildVisible(q);
        selectedIdx = 0;
        if (countEl) countEl.textContent = visible.length + ' / ' + allItems.length;
        renderLeft();
        renderRight();
        renderRail();
    }

    /**
     * Render the right-hand rail with every rail-placement group as a
     * compact, always-visible link list. Toggles a body-level class so
     * the CSS grid switches to a 3-column layout when the rail has
     * content; falls back to the 2-column layout otherwise.
     */
    function renderRail() {
        if (!railEl || !data || !data.groups) return;
        var html = '';
        var has = false;
        data.groups.forEach(function (g) {
            if (g.placement !== 'rail') return;
            if (!g.items || !g.items.length) return;
            has = true;
            html += '<div class="aieo-nav-rail-group">';
            html +=   '<div class="aieo-nav-rail-title">' + escHtml(g.label) + '</div>';
            g.items.forEach(function (it) {
                var url = it.url || '#';
                var ev  = it.event ? (' data-aieo-event="' + escHtml(it.event) + '"') : '';
                html += '<a class="aieo-nav-rail-item" href="' + escHtml(url) + '"' + ev + '>'
                      +   '<span class="aieo-nav-rail-label">' + escHtml(it.label) + '</span>'
                      + '</a>';
            });
            html += '</div>';
        });
        railEl.innerHTML = html;
        // Body-level class so CSS can switch grid template columns.
        var body = root.querySelector('.aieo-navigator-body');
        if (body) body.classList.toggle('has-rail', has);

        // Custom-event items (no real URL) need a click intercept so the
        // hosting page can react (recently-viewed cart, etc.) instead of
        // navigating to "#".
        railEl.querySelectorAll('[data-aieo-event]').forEach(function (a) {
            a.addEventListener('click', function (e) {
                e.preventDefault();
                var name = a.getAttribute('data-aieo-event');
                if (name) document.dispatchEvent(new CustomEvent(name));
                close();
            });
        });
    }

    /**
     * Reorder `filtered` so context-boosted items float to the top of
     * their group section. Only applies to the empty-query state — once
     * the user types, the filter result order wins.
     */
    function applyContextBoost() {
        if (!BOOST.length || input && input.value.trim() !== '') return;
        var boostSet = {};
        BOOST.forEach(function (id) { boostSet[id] = true; });
        // Stable sort within each group: boosted items first, others after.
        var bySlug = {};
        filtered.forEach(function (it) {
            (bySlug[it.groupSlug] = bySlug[it.groupSlug] || []).push(it);
        });
        var order = [];
        Object.keys(bySlug).forEach(function (slug) {
            var arr = bySlug[slug];
            arr.sort(function (a, b) {
                var ab = boostSet[a.id] ? 0 : 1;
                var bb = boostSet[b.id] ? 0 : 1;
                return ab - bb;
            });
        });
        // Rebuild filtered in original group order, preserving slug ordering
        var seen = {};
        var rebuild = [];
        filtered.forEach(function (it) {
            if (seen[it.groupSlug]) return;
            seen[it.groupSlug] = true;
            rebuild = rebuild.concat(bySlug[it.groupSlug]);
        });
        filtered = rebuild;
    }

    /**
     * Apply collapse rules. When a query is active every match shows.
     * When the query is empty, only level=0 items show + children of
     * `expanded` parents — and children are emitted INLINE right after
     * their parent so the layout reads as a tree even when the
     * parent has been boosted to the top of the list.
     *
     * Per-group cap (`PER_GROUP_CAP`) limits the top-level rows of
     * each group so the largest group (e.g. needs with 99 entries)
     * doesn't push the smaller groups (brands / pages / blog) below
     * the fold. Truncated counts are stashed on `groupTruncated` so
     * `renderLeft` can render a "+N more — type to search" hint at
     * the bottom of each truncated section.
     */
    var PER_GROUP_CAP = 20;
    var groupTruncated = {};
    function buildVisible(query) {
        groupTruncated = {};
        if (query) { visible = filtered.slice(); return; }
        // Empty-query browse view: rail-placement groups are NOT mixed
        // into the main list (they're rendered as a permanent side rail
        // by renderRail()). When the user types, all items — including
        // rail ones — re-enter the main results, since search is the
        // one place keyboard navigation expects to find anything.
        filtered = filtered.filter(function (it) { return it.groupPlacement !== 'rail'; });
        // Index children by parent id for O(1) lookups during emit.
        var byParent = {};
        filtered.forEach(function (it) {
            if (it.parent) {
                (byParent[it.parent] = byParent[it.parent] || []).push(it);
            }
        });
        visible = [];
        function emit(item) {
            visible.push(item);
            if (expanded[item.id] && byParent[item.id]) {
                byParent[item.id].forEach(emit);
            }
        }
        // Walk the filtered list in (post-boost) order; emit every
        // level-0 row plus, depth-first, its expanded subtree, but
        // cap each group's top-level rows at PER_GROUP_CAP.
        var perGroupEmitted = {};
        var perGroupTotal = {};
        filtered.forEach(function (it) {
            if (it.level !== 0) return;
            var slug = it.groupSlug;
            perGroupTotal[slug] = (perGroupTotal[slug] || 0) + 1;
            if ((perGroupEmitted[slug] || 0) < PER_GROUP_CAP) {
                perGroupEmitted[slug] = (perGroupEmitted[slug] || 0) + 1;
                emit(it);
            }
        });
        Object.keys(perGroupTotal).forEach(function (slug) {
            var hidden = perGroupTotal[slug] - (perGroupEmitted[slug] || 0);
            if (hidden > 0) groupTruncated[slug] = hidden;
        });
    }

    // ── Render: left list ───────────────────────────────────────────────

    var LEFT_CAP = 100;

    function hasChildren(item) {
        // Cheap test — any item whose `id` is referenced as `parent` of
        // another item is a parent.
        if (item._kidsCache !== undefined) return item._kidsCache;
        var found = false;
        for (var i = 0; i < allItems.length; i++) {
            if (allItems[i].parent === item.id) { found = true; break; }
        }
        item._kidsCache = found;
        return found;
    }

    function chevronSvg(open) {
        return '<svg class="aieo-nav-chevron' + (open ? ' is-open' : '') + '" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
    }

    function renderLeft() {
        if (!leftEl) return;
        // The FiboFilters wrap (.aieo-nav-archive-filters) holds the
        // LIVE filter element that we moved out of the page. Setting
        // `leftEl.innerHTML = html` would obliterate it (innerHTML
        // doesn't just unparent — it disposes the descendants), so we
        // detach the wrap first and re-prepend it after the rebuild.
        // The element keeps its DOM identity, FiboFilters keeps its
        // references, async-loaded content keeps painting.
        var preserved = leftEl.querySelector('.aieo-nav-archive-filters');
        if (preserved) leftEl.removeChild(preserved);
        if (visible.length === 0) {
            leftEl.innerHTML = '<div class="aieo-nav-empty">' + escHtml((cfg.i18n && cfg.i18n.noResults) || __('No results', 'ai-eshop-optimizer')) + '</div>';
            if (preserved) leftEl.insertBefore(preserved, leftEl.firstChild);
            return;
        }
        var capped = visible.slice(0, LEFT_CAP);
        var html = '';
        var lastGroup = null;
        capped.forEach(function (it, i) {
            if (it.groupSlug !== lastGroup) {
                // Append the "+N more" hint for the PREVIOUS group as
                // we transition into a new section header — keeps the
                // hint pinned to the bottom of its group.
                if (lastGroup && groupTruncated[lastGroup]) {
                    html += '<div class="aieo-nav-truncated">+' + groupTruncated[lastGroup]
                          + ' ' + escHtml((cfg.i18n && cfg.i18n.moreTypeToSearch) || __('more — type to search', 'ai-eshop-optimizer')) + '</div>';
                }
                html += '<div class="aieo-nav-section" data-slug="' + escHtml(it.groupSlug) + '">' + escHtml(it.groupLabel) + '</div>';
                lastGroup = it.groupSlug;
            }
            var active = (i === selectedIdx) ? ' is-active' : '';
            var hasKids = hasChildren(it);
            var meta = (it.count != null) ? '<span class="aieo-nav-row-meta">' + it.count + '</span>' : '';
            var chev = hasKids ? chevronSvg(!!expanded[it.id]) : '<span class="aieo-nav-chevron-spacer"></span>';
            html += '<div class="aieo-nav-row' + active + '" data-idx="' + i + '" data-id="' + escHtml(it.id) + '" data-level="' + it.level + '" data-has-kids="' + (hasKids ? '1' : '0') + '">'
                  +   chev
                  +   '<span class="aieo-nav-row-label">' + escHtml(it.label) + '</span>'
                  +   meta
                  + '</div>';
        });
        // Closing "+N more" hint for the very last rendered group.
        if (lastGroup && groupTruncated[lastGroup]) {
            html += '<div class="aieo-nav-truncated">+' + groupTruncated[lastGroup]
                  + ' ' + escHtml((cfg.i18n && cfg.i18n.moreTypeToSearch) || 'ακόμη — γράψε για αναζήτηση') + '</div>';
        }
        if (visible.length > capped.length) {
            /* translators: %d: number of additional results not shown */
            html += '<div class="aieo-nav-hint">' + _sprintf(__('+%d more — keep typing', 'ai-eshop-optimizer'), (visible.length - capped.length)) + '</div>';
        }
        leftEl.innerHTML = html;
        // Re-attach the preserved FiboFilters wrap above the rebuilt
        // list so it stays at the top of the column on every render.
        if (preserved) leftEl.insertBefore(preserved, leftEl.firstChild);

        leftEl.querySelectorAll('.aieo-nav-row').forEach(function (row) {
            row.addEventListener('mouseenter', function () {
                selectedIdx = parseInt(row.getAttribute('data-idx'), 10);
                refreshActive();
                renderRight();
            });
            row.addEventListener('click', function (e) {
                var idx     = parseInt(row.getAttribute('data-idx'), 10);
                var id      = row.getAttribute('data-id');
                var hasKids = row.getAttribute('data-has-kids') === '1';
                var clickedChev = e.target && e.target.closest && e.target.closest('.aieo-nav-chevron');

                // Parent rows: a click on the chevron OR anywhere on
                // the row toggles expand. Preserves the prior
                // "click parent to expand" mental model — the right
                // pane still updates so the user can see the parent
                // term's detail / image / kids preview.
                if (hasKids && (clickedChev || !input.value.trim())) {
                    expanded[id] = !expanded[id];
                    buildVisible(input.value.trim() ? input.value : '');
                    selectedIdx = idx;
                    renderLeft();
                    renderRight();
                    return;
                }
                // LEAF rows (no children) OR query-active state →
                // navigate straight to the URL. The right pane is
                // bypassed; the user already knows where they want
                // to go and shouldn't need a second click. Falls
                // back to select + show right pane only when there's
                // no navigable URL (custom event-only items handled
                // by openItem itself).
                var item = visible[idx];
                if (item && (item.url || item.event)) {
                    openItem(item);
                    return;
                }
                selectedIdx = idx;
                refreshActive();
                renderRight();
            });
            row.addEventListener('dblclick', function () {
                openItem(visible[parseInt(row.getAttribute('data-idx'), 10)]);
            });
        });

        scrollActiveIntoView();
    }

    function refreshActive() {
        if (!leftEl) return;
        leftEl.querySelectorAll('.aieo-nav-row').forEach(function (row) {
            row.classList.toggle('is-active', parseInt(row.getAttribute('data-idx'), 10) === selectedIdx);
        });
    }

    function scrollActiveIntoView() {
        if (!leftEl) return;
        var row = leftEl.querySelector('.aieo-nav-row.is-active');
        if (row) row.scrollIntoView({ block: 'nearest' });
    }

    /**
     * On a scoped archive (the page localizes cfg.context.category — e.g.
     * /need/<slug>/?in_cat=<cat> or /brand/<slug>/?in_cat=<cat>), open the
     * palette already focused on that category: select its row so the right
     * pane shows ITS needs + brands, instead of defaulting to the first
     * category in the list. Relies on the category being in `visible` —
     * the boost auto-expands its ancestors so it always is.
     */
    function preselectScopedCategory() {
        var ctx = cfg.context || {};
        if (!ctx.category || !ctx.category.id) return;
        var targetId = 'product_cat-' + ctx.category.id;
        for (var i = 0; i < visible.length; i++) {
            if (visible[i].id === targetId) {
                selectedIdx = i;
                refreshActive();
                renderRight();
                scrollActiveIntoView();
                return;
            }
        }
    }

    // ── Render: right pane (rich detail, clickable) ─────────────────────

    function renderHighlightSection(label, items, sectionTag) {
        if (!items || !items.length) return '';
        // sectionTag (data-section attr) lets the admin's mobile-hide CSS
        // target individual highlight blocks (needs / brands) without
        // having to match on the human label, which can change.
        var tag = sectionTag ? ' data-section="' + escHtml(sectionTag) + '"' : '';
        var html = '<div class="aieo-nav-highlight-section"' + tag + '>'
                 +   '<div class="aieo-nav-right-section">' + escHtml(label) + '</div>';
        items.forEach(function (k) {
            var meta = (k.count != null) ? '<span class="aieo-nav-row-meta">' + k.count + '</span>' : '';
            html += '<a class="aieo-nav-right-kid" href="' + escHtml(k.url || '#') + '">'
                  +   '<span class="aieo-nav-row-label">' + escHtml(k.label) + '</span>'
                  +   meta
                  + '</a>';
        });
        html += '</div>';
        return html;
    }

    /**
     * Order the right-pane highlight blocks (needs / brands) to match the
     * merchant-curated group order (data.groups) — the SAME order the left
     * pane uses. Dragging "Brands" above "Needs" in Navigator → Group order
     * now reorders the middle column too, instead of the old hardcoded
     * needs-then-brands. Returns the default needs-then-brands order for
     * legacy navigator.json files where one or both slugs are absent, so
     * un-reordered installs are unaffected.
     */
    function highlightsInGroupOrder() {
        var order = ['needs', 'brands'];
        if (!data || !data.groups) return order;
        var rank = {};
        data.groups.forEach(function (g, i) {
            if (g && (g.slug === 'needs' || g.slug === 'brands')) rank[g.slug] = i;
        });
        if (rank.needs == null || rank.brands == null) return order;
        return order.sort(function (a, b) { return rank[a] - rank[b]; });
    }

    /**
     * Build the always-visible "Top brands" + "Top needs" highlights block.
     * Sourced from the JSON's `highlights` payload (computed server-side
     * from AIEO's popularity table for brands; product-tag count for needs).
     */
    function renderHighlights(scopeCatId) {
        if (!data || !data.highlights) return '';
        var h = data.highlights;
        var i18n = cfg.i18n || {};
        // The category in focus, in priority order:
        //   1. the SELECTED category in the navigator (interactive — click a
        //      category and its needs + brands appear here, with per-category
        //      counts),
        //   2. the page-context category (browsing on a category / product page).
        // None → the global Top Needs / Top Brands.
        var effCat = scopeCatId || (_needsScope ? _needsScope.catId : 0);
        var needs, needsLabel, brands, brandsLabel;
        if (effCat) {
            needs       = scopedNeedsForCategory(effCat);
            needsLabel  = i18n.categoryNeeds  || (i18n.topNeeds  || 'Needs');
            brands      = scopedBrandsForCategory(effCat);
            brandsLabel = i18n.categoryBrands || (i18n.topBrands || 'Brands');
        } else {
            needs       = h.needs;
            needsLabel  = i18n.topNeeds  || 'Top Needs';
            brands      = h.brands;
            brandsLabel = i18n.topBrands || 'Top Brands';
        }
        // renderHighlightSection returns '' for an empty/null list, so a category
        // with no needs (or no brands) simply omits that section — never falls
        // back to the global list. The two sections are emitted in the
        // merchant-defined group order (see highlightsInGroupOrder) so the
        // middle column matches the left pane.
        var sectionHtml = {
            needs:  renderHighlightSection(needsLabel,  needs,  'needs'),
            brands: renderHighlightSection(brandsLabel, brands, 'brands')
        };
        return highlightsInGroupOrder().map(function (slug) { return sectionHtml[slug]; }).join('');
    }

    function renderRight() {
        if (!rightEl) return;
        var it = visible[selectedIdx];
        // Selected a category? → the highlights' needs become THAT category's
        // needs (interactive scoping). Otherwise falls back to page-context /
        // global inside renderHighlights().
        var highlightsHtml = renderHighlights(selectedCategoryId(it));

        if (!it) {
            // No selection — show highlights only so the right pane is
            // never empty.
            rightEl.innerHTML = highlightsHtml
                ? '<div class="aieo-nav-right-highlights">' + highlightsHtml + '</div>'
                : '';
            return;
        }

        var crumbs = [escHtml(it.groupLabel)];
        if (it.parent) {
            var p = allItems.find(function (x) { return x.id === it.parent; });
            if (p) {
                if (p.parent) {
                    var pp = allItems.find(function (x) { return x.id === p.parent; });
                    if (pp) crumbs.push(escHtml(pp.label));
                }
                crumbs.push(escHtml(p.label));
            }
        }

        var img = it.image ? '<img class="aieo-nav-right-image" src="' + escHtml(it.image) + '" alt=""/>' : '';
        var blurb = it.description || it.excerpt || '';
        var blurbHtml = blurb ? '<div class="aieo-nav-right-desc">' + escHtml(blurb) + '</div>' : '';

        // Children of this item — render as clickable list when present.
        var kids = allItems.filter(function (x) { return x.parent === it.id; });
        var kidsHtml = '';
        if (kids.length) {
            kidsHtml += '<div class="aieo-nav-right-section">'
                     +   escHtml((cfg.i18n && cfg.i18n.children) || 'Children')
                     + '</div>';
            kids.slice(0, 30).forEach(function (k) {
                var meta = (k.count != null) ? '<span class="aieo-nav-row-meta">' + k.count + '</span>' : '';
                kidsHtml += '<a class="aieo-nav-right-kid" href="' + escHtml(k.url) + '">'
                          +   '<span class="aieo-nav-row-label">' + escHtml(k.label) + '</span>'
                          +   meta
                          + '</a>';
            });
        }

        var titleHtml = '<a class="aieo-nav-right-title" href="' + escHtml(it.url) + '">' + escHtml(it.label) + '</a>';

        rightEl.innerHTML =
            '<div class="aieo-nav-right-head">' +
                titleHtml +
                '<div class="aieo-nav-right-sub">' + crumbs.join(' › ') + '</div>' +
            '</div>' +
            (img ? '<div class="aieo-nav-right-imagewrap">' + img + '</div>' : '') +
            blurbHtml +
            kidsHtml +
            (highlightsHtml ? '<div class="aieo-nav-right-highlights">' + highlightsHtml + '</div>' : '');
    }

    function openItem(it) {
        if (!it) return;
        if (it.event) {
            window.dispatchEvent(new CustomEvent(it.event));
            close();
            return;
        }
        if (it.url && it.url !== '#') window.location.href = it.url;
    }

    // ── Keyboard ────────────────────────────────────────────────────────

    function handleKeys(e) {
        switch (e.key) {
            case 'ArrowDown':
                e.preventDefault();
                if (selectedIdx < visible.length - 1) {
                    selectedIdx++;
                    refreshActive();
                    scrollActiveIntoView();
                    renderRight();
                }
                break;
            case 'ArrowUp':
                e.preventDefault();
                if (selectedIdx > 0) {
                    selectedIdx--;
                    refreshActive();
                    scrollActiveIntoView();
                    renderRight();
                }
                break;
            case 'ArrowRight': {
                // Expand the highlighted item (if it has children).
                var it = visible[selectedIdx];
                if (it && hasChildren(it) && !expanded[it.id] && !input.value.trim()) {
                    e.preventDefault();
                    expanded[it.id] = true;
                    buildVisible('');
                    renderLeft();
                    renderRight();
                }
                break;
            }
            case 'ArrowLeft': {
                // Collapse — if expanded, collapse it; otherwise jump up
                // to the parent row.
                var it2 = visible[selectedIdx];
                if (it2 && expanded[it2.id]) {
                    e.preventDefault();
                    expanded[it2.id] = false;
                    buildVisible('');
                    renderLeft();
                    renderRight();
                } else if (it2 && it2.parent) {
                    e.preventDefault();
                    var pIdx = visible.findIndex(function (x) { return x.id === it2.parent; });
                    if (pIdx >= 0) {
                        selectedIdx = pIdx;
                        refreshActive();
                        scrollActiveIntoView();
                        renderRight();
                    }
                }
                break;
            }
            case 'Enter':
                e.preventDefault();
                openItem(visible[selectedIdx]);
                break;
            case 'Escape':
                e.preventDefault();
                close();
                break;
        }
    }

    window.addEventListener('aieo:navigator:open', open);
    window.addEventListener('aieo:navigator:close', close);

})();
