/**
 * Reusable icon-grid selector (replaces a plain <select>).
 *
 * Usage:
 *   import { IconSelect } from '../core/icon-select.ts';
 *
 *   const sel = new IconSelect({
 *       target: document.getElementById('my-select'),   // the <select> to enhance
 *       items: [
 *           { value: 'fire',  icon: '<svg>…</svg>', label: 'Fire',  desc: 'Warm flickering effect' },
 *           { value: 'water', icon: '<svg>…</svg>', label: 'Water', desc: 'Cool flowing colors' },
 *       ],
 *       onChange: (value) => { … },   // optional callback after selection
 *       columns: 2,                   // grid columns (default: 2)
 *   });
 *
 * The original <select> is hidden but stays in the DOM (value kept in sync).
 * Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
 */

import { desktopFocus } from './ui.ts';
import { escapeHtml } from './api.ts';

const POPUP_CLASS = 'icon-select-popup';
const FOCUSED_CLASS = 'focused';
const FOCUSED_SELECTOR = `.icon-select-cell.${FOCUSED_CLASS}`;
const CELL_SELECTOR = '.icon-select-cell';
const NAVIGABLE_SELECTOR = '.icon-select-cell:not(.disabled)';

/**
 * Escape a value for use inside a double-quoted HTML attribute.
 * `escapeHtml` (text-content escape) does not escape `"`, which leaves a
 * stored-XSS vector when interpolating user-typed labels into attribute
 * contexts like `data-value="${value}"`. This belt-and-braces helper
 * covers ``& < > " '`` so the result is safe in any attribute slot.
 */
function escAttr(text: string | undefined | null): string {
    if (text == null) return '';
    return String(text)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}

/**
 * Defensive sink for `IconSelectItem.icon`.
 *
 * `item.icon` is interpolated raw into the cell innerHTML (see `_buildGrid`
 * and `_syncTrigger`). Every current caller (audited via the IconSelect
 * caller scan) feeds the field with one of:
 *   * a constant `ICON_*` literal,
 *   * the output of a lookup table (`getValueSourceIcon`, `getColorStripIcon`,
 *     `getDeviceTypeIcon`, …) — table values are project-owned constants,
 *   * `renderDeviceIcon(d.icon)` — `d.icon` is a stored id mapped to a
 *     project-owned SVG via `getDeviceIconDef`; unknown ids return `''`.
 * None of those paths embed user input.
 *
 * The sanitiser here is **defence in depth** for a future caller who
 * forgets that contract: reject icon strings containing `<script>` /
 * `javascript:` / `on*=` attributes and fall back to the empty string
 * so the cell still renders the label + desc. We deliberately do not
 * try to "fix" the icon — silent partial sanitisation is worse than a
 * missing icon for a developer who needs to spot the regression.
 */
const _DANGEROUS_ICON = /<script\b|<\/script>|\bjavascript:|\s+on[a-z]+\s*=|<iframe\b|<embed\b|<object\b/i;

function sanitiseIcon(icon: string | undefined | null): string {
    if (!icon) return '';
    if (_DANGEROUS_ICON.test(icon)) {
        // Loud about it in dev consoles; silent in production builds where
        // `console.warn` is typically tree-shaken away.
        try {
            console.warn('[IconSelect] Rejected icon containing forbidden HTML markers');
        } catch {
            // window.console can be undefined in odd embed scenarios.
        }
        return '';
    }
    return icon;
}

/** All registered IconSelect instances; lets `closeAllIconSelects` reach scroll-listener state. */
const _registry: Set<IconSelect> = new Set();

/** Close every open icon-select popup (and tear down their scroll listeners). */
export function closeAllIconSelects() {
    for (const sel of _registry) {
        sel._closeIfOpen();
    }
}

// Global listeners (registered once)
let _globalListenerAdded = false;
function _ensureGlobalListener() {
    if (_globalListenerAdded) return;
    _globalListenerAdded = true;
    document.addEventListener('click', (e) => {
        const target = e.target as HTMLElement;
        if (!target.closest(`.${POPUP_CLASS}`) && !target.closest('.icon-select-trigger')) {
            closeAllIconSelects();
        }
    });
    document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') closeAllIconSelects();
    });
}

export interface IconSelectItem {
    value: string;
    icon: string;
    label: string;
    desc?: string;
    /** Render as a non-selectable section header (used by EntityPalette for grouping). */
    header?: boolean;
}

export interface IconSelectOpts {
    target: HTMLSelectElement;
    items: IconSelectItem[];
    onChange?: (value: string) => void;
    columns?: number;
    placeholder?: string;
    searchable?: boolean;
    searchPlaceholder?: string;
}

/**
 * Move a keyboard cursor over a grid of cells.
 *
 * Returns the new focused index (clamped). Marks the chosen cell with the
 * shared `.focused` class and scrolls it into view.
 */
function applyFocus(grid: HTMLElement, cells: HTMLElement[], idx: number): number {
    grid.querySelectorAll(FOCUSED_SELECTOR).forEach(c => c.classList.remove(FOCUSED_CLASS));
    if (cells.length === 0) return -1;
    const clamped = Math.max(0, Math.min(idx, cells.length - 1));
    cells[clamped].classList.add(FOCUSED_CLASS);
    cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
    return clamped;
}

/**
 * Compute the column count of a CSS grid by comparing `offsetTop` of cells
 * in the first row. Triggers a layout read — callers should cache the result
 * and invalidate only when the grid is rebuilt or filtered.
 */
function detectColumns(cells: HTMLElement[]): number {
    if (cells.length === 0) return 1;
    const firstTop = cells[0].offsetTop;
    let cols = 0;
    for (const c of cells) {
        if (c.offsetTop !== firstTop) break;
        cols++;
    }
    return Math.max(1, cols);
}

interface GridNavAction {
    /** New focused index, or -1 to leave focus unchanged. */
    nextIndex: number;
    /** True when the key was consumed (caller should preventDefault). */
    handled: boolean;
    /** True when Enter was pressed and a cell should be picked. */
    pick: boolean;
}

/**
 * Pure keyboard-nav state machine shared by IconSelect and the standalone
 * type-picker overlay. Returns what should happen; the caller decides
 * preventDefault, stopPropagation, and cell-pick wiring.
 */
function handleGridKey(
    key: string,
    cur: number,
    cellCount: number,
    columns: number,
): GridNavAction {
    if (cellCount === 0) return { nextIndex: -1, handled: false, pick: false };
    const safe = cur >= 0 && cur < cellCount ? cur : 0;
    switch (key) {
        case 'ArrowRight':
            return { nextIndex: Math.min(safe + 1, cellCount - 1), handled: true, pick: false };
        case 'ArrowLeft':
            return { nextIndex: Math.max(safe - 1, 0), handled: true, pick: false };
        case 'ArrowDown':
            return { nextIndex: Math.min(safe + columns, cellCount - 1), handled: true, pick: false };
        case 'ArrowUp':
            return { nextIndex: Math.max(safe - columns, 0), handled: true, pick: false };
        case 'Home':
            return { nextIndex: 0, handled: true, pick: false };
        case 'End':
            return { nextIndex: cellCount - 1, handled: true, pick: false };
        case 'Enter':
            return { nextIndex: safe, handled: true, pick: true };
        default:
            return { nextIndex: -1, handled: false, pick: false };
    }
}

export class IconSelect {
    _select: HTMLSelectElement;
    _items: IconSelectItem[];
    _onChange: ((value: string) => void) | undefined;
    _columns: number;
    _placeholder: string;
    _searchable: boolean;
    _searchPlaceholder: string;
    _trigger: HTMLButtonElement;
    _popup: HTMLDivElement;
    _searchInput: HTMLInputElement | null = null;
    _scrollHandler: (() => void) | null = null;
    _scrollTargets: (HTMLElement | Window)[] = [];
    _focusedIndex: number = -1;

    constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) {
        _ensureGlobalListener();

        this._select = target;
        this._items = items;
        this._onChange = onChange;
        this._columns = columns;
        this._placeholder = placeholder;
        this._searchable = searchable;
        this._searchPlaceholder = searchPlaceholder;

        // Ensure the native select has an <option> for every item so .value can
        // be set programmatically. HTML-authored selects sometimes ship empty,
        // and assigning .value to a select with no matching option is a no-op,
        // which leaves the trigger label blank.
        if (this._select.options.length === 0) {
            for (const item of this._items) {
                const opt = document.createElement('option');
                opt.value = item.value;
                opt.textContent = item.label;
                this._select.appendChild(opt);
            }
        }

        // Hide the native select
        this._select.style.display = 'none';

        // Build trigger button (inserted next to select)
        this._trigger = document.createElement('button');
        this._trigger.type = 'button';
        this._trigger.className = 'icon-select-trigger';
        this._trigger.setAttribute('aria-haspopup', 'listbox');
        this._trigger.setAttribute('aria-expanded', 'false');
        this._trigger.addEventListener('click', (e) => {
            e.stopPropagation();
            this._toggle();
        });
        this._select.parentNode!.insertBefore(this._trigger, this._select.nextSibling);

        // Build popup (portaled to body to avoid overflow clipping)
        this._popup = document.createElement('div');
        this._popup.className = POPUP_CLASS;
        this._popup.tabIndex = -1;
        this._popup.setAttribute('role', 'listbox');
        this._popup.addEventListener('click', (e) => e.stopPropagation());
        this._popup.addEventListener('keydown', (e) => this._handleKeydown(e));
        this._popup.innerHTML = this._buildGrid();
        document.body.appendChild(this._popup);

        this._bindPopupEvents();

        // Sync to current select value
        this._syncTrigger();

        _registry.add(this);
    }

    _bindPopupEvents() {
        // Bind item clicks
        this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
            cell.setAttribute('role', 'option');
            cell.addEventListener('click', () => {
                this.setValue((cell as HTMLElement).dataset.value!, true);
                this._closeIfOpen();
            });
        });

        // Bind search input
        this._searchInput = this._popup.querySelector('.icon-select-search') as HTMLInputElement | null;
        if (this._searchInput) {
            this._searchInput.addEventListener('input', () => {
                const q = this._searchInput!.value.toLowerCase().trim();
                this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
                    const el = cell as HTMLElement;
                    el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q));
                });
                // Re-anchor keyboard cursor to first visible cell after filtering
                this._setFocusedIndex(0);
            });
        }
    }

    /** Cells eligible for keyboard navigation (selectable + visible). */
    _getNavigableCells(): HTMLElement[] {
        return Array.from(this._popup.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
    }

    /** Move the keyboard cursor to cell `idx` (clamped), scrolling it into view. */
    _setFocusedIndex(idx: number) {
        const cells = this._getNavigableCells();
        this._focusedIndex = applyFocus(this._popup, cells, idx);
        if (this._focusedIndex >= 0) {
            const activeId = cells[this._focusedIndex].id || `icon-select-cell-${this._focusedIndex}`;
            cells[this._focusedIndex].id = activeId;
            this._popup.setAttribute('aria-activedescendant', activeId);
        } else {
            this._popup.removeAttribute('aria-activedescendant');
        }
    }

    _handleKeydown(e: KeyboardEvent) {
        if (!this._popup.classList.contains('open')) return;
        const cells = this._getNavigableCells();
        const action = handleGridKey(e.key, this._focusedIndex, cells.length, this._columns);
        if (!action.handled) return;
        e.preventDefault();
        e.stopPropagation();
        if (action.pick) {
            const cell = cells[action.nextIndex];
            if (!cell) return;
            this.setValue(cell.dataset.value!, true);
            this._closeIfOpen();
            desktopFocus(this._trigger);
            return;
        }
        this._setFocusedIndex(action.nextIndex);
    }

    _buildGrid() {
        // item.icon is a raw SVG string by design (callers pass project-owned
        // icon literals). label/desc/value are user-visible text and may
        // originate from user input — escape them everywhere they cross
        // an innerHTML boundary. `sanitiseIcon` is the defence-in-depth
        // guard for icon strings that should never reach here from user
        // input — see its block comment for the audit conclusion.
        const cells = this._items.map(item => {
            const search = (item.label + ' ' + (item.desc || '')).toLowerCase();
            return `<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr(search)}">
                <span class="icon-select-cell-icon">${sanitiseIcon(item.icon)}</span>
                <span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
                ${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
            </div>`;
        }).join('');

        const searchHTML = this._searchable
            ? `<input class="icon-select-search" type="text" placeholder="${escAttr(this._searchPlaceholder)}" autocomplete="off">`
            : '';
        return searchHTML + `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
    }

    _syncTrigger() {
        const val = this._select.value;
        const item = this._items.find(i => i.value === val);
        if (item) {
            this._trigger.innerHTML =
                `<span class="icon-select-trigger-icon">${sanitiseIcon(item.icon)}</span>` +
                `<span class="icon-select-trigger-label">${escapeHtml(item.label)}</span>` +
                `<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
        } else if (this._placeholder) {
            this._trigger.innerHTML =
                `<span class="icon-select-trigger-label">${escapeHtml(this._placeholder)}</span>` +
                `<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
        }
        // Update active state in grid
        this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
            const el = cell as HTMLElement;
            const active = el.dataset.value === val;
            el.classList.toggle('active', active);
            el.setAttribute('aria-selected', active ? 'true' : 'false');
        });
    }

    _positionPopup() {
        const rect = this._trigger.getBoundingClientRect();
        const gap = 6;               // visual gap between trigger and popup
        const pad = 8;               // min distance from viewport edge
        const popupW = Math.max(rect.width, 200);
        const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
        const spaceAbove = rect.top - gap - pad;

        // Determine direction
        const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
        const available = openUp ? spaceAbove : spaceBelow;

        // Horizontal: clamp so popup doesn't overflow right edge
        let left = rect.left;
        if (left + popupW > window.innerWidth - pad) {
            left = window.innerWidth - pad - popupW;
        }
        if (left < pad) left = pad;

        this._popup.style.left = left + 'px';
        this._popup.style.width = popupW + 'px';
        this._popup.style.maxHeight = available + 'px';

        if (openUp) {
            this._popup.style.top = '';
            this._popup.style.bottom = (window.innerHeight - rect.top + gap) + 'px';
        } else {
            this._popup.style.top = (rect.bottom + gap) + 'px';
            this._popup.style.bottom = '';
        }
    }

    _toggle() {
        const wasOpen = this._popup.classList.contains('open');
        closeAllIconSelects();
        if (!wasOpen) {
            this._positionPopup();
            this._popup.classList.add('open');
            this._trigger.setAttribute('aria-expanded', 'true');
            this._addScrollListener();
            if (this._searchInput) {
                this._searchInput.value = '';
                this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
                    (cell as HTMLElement).classList.remove('disabled');
                });
                requestAnimationFrame(() => desktopFocus(this._searchInput!));
            } else {
                // No search input — focus the popup itself so it captures keydown
                requestAnimationFrame(() => desktopFocus(this._popup));
            }
            // Seed keyboard cursor on the currently-selected cell (or first cell)
            const cells = this._getNavigableCells();
            const activeIdx = cells.findIndex(c => c.dataset.value === this._select.value);
            this._setFocusedIndex(activeIdx >= 0 ? activeIdx : 0);
        }
    }

    /** Close the popup if it is open and tear down listeners / focus state. */
    _closeIfOpen() {
        if (!this._popup.classList.contains('open')) return;
        this._popup.classList.remove('open');
        this._trigger.setAttribute('aria-expanded', 'false');
        this._removeScrollListener();
        this._clearFocusedCell();
    }

    _clearFocusedCell() {
        this._popup.querySelectorAll(FOCUSED_SELECTOR)
            .forEach(c => c.classList.remove(FOCUSED_CLASS));
        this._focusedIndex = -1;
        this._popup.removeAttribute('aria-activedescendant');
    }

    /** Close popup when any scrollable ancestor scrolls (prevents stale position). */
    _addScrollListener() {
        if (this._scrollHandler) return;
        this._scrollHandler = () => {
            this._closeIfOpen();
        };
        // Listen on capture phase to catch scroll on any ancestor
        let el: Node | null = this._trigger.parentNode;
        this._scrollTargets = [];
        while (el && el !== document) {
            const htmlEl = el as HTMLElement;
            if (htmlEl.scrollHeight > htmlEl.clientHeight || htmlEl.classList?.contains('modal-content')) {
                htmlEl.addEventListener('scroll', this._scrollHandler, { passive: true });
                this._scrollTargets.push(htmlEl);
            }
            el = el.parentNode;
        }
        window.addEventListener('scroll', this._scrollHandler, { passive: true });
        this._scrollTargets.push(window);
    }

    _removeScrollListener() {
        if (!this._scrollHandler) return;
        for (const el of this._scrollTargets) {
            el.removeEventListener('scroll', this._scrollHandler);
        }
        this._scrollTargets = [];
        this._scrollHandler = null;
    }

    /** Change the value programmatically. */
    setValue(value: string, fireChange = false) {
        this._select.value = value;
        this._syncTrigger();
        if (fireChange) {
            // Fire native change event so existing onchange handlers work
            this._select.dispatchEvent(new Event('change', { bubbles: true }));
            if (this._onChange) this._onChange(value);
        }
    }

    /** Refresh labels (e.g. after language change). Call with new items array. */
    updateItems(items: IconSelectItem[]) {
        this._items = items;
        this._popup.innerHTML = this._buildGrid();
        this._bindPopupEvents();
        this._syncTrigger();
    }

    /** Remove the enhancement, restore native <select>. */
    destroy() {
        this._removeScrollListener();
        _registry.delete(this);
        this._trigger.remove();
        this._popup.remove();
        this._select.style.display = '';
    }
}

/**
 * Show a standalone type-picker overlay.
 *
 * Displays a centered modal with an icon grid. When the user picks a type,
 * the overlay closes and `onPick(value)` is called. Clicking the backdrop
 * or pressing Escape dismisses without picking.
 */
export interface FilterTab {
    key: string;
    label: string;
}

export function showTypePicker({ title, items, onPick, filterTabs, onFilterChange }: {
    title: string;
    items: IconSelectItem[];
    onPick: (value: string) => void;
    filterTabs?: FilterTab[];
    onFilterChange?: (key: string) => IconSelectItem[];
}) {
    const showFilter = items.length > 9;

    function buildCells(cellItems: IconSelectItem[]): string {
        // item.icon is trusted raw SVG. label/desc/value are escaped at
        // every innerHTML boundary because callers route user-typed text
        // (device names, entity labels) through this picker. `sanitiseIcon`
        // is defence-in-depth for the icon channel — see its block comment.
        return cellItems.map(item =>
            `<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr((item.label + ' ' + (item.desc || '')).toLowerCase())}" role="option">
                <span class="icon-select-cell-icon">${sanitiseIcon(item.icon)}</span>
                <span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
                ${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
            </div>`
        ).join('');
    }

    // Build filter tabs HTML
    const tabsHtml = filterTabs && filterTabs.length > 0
        ? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
            `<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${escAttr(tab.key)}">${escapeHtml(tab.label)}</button>`
          ).join('')}</div>`
        : '';

    // Create overlay
    const overlay = document.createElement('div');
    overlay.className = 'type-picker-overlay';
    overlay.innerHTML = `
        <div class="type-picker-dialog" role="dialog" aria-modal="true" aria-label="${escAttr(title)}">
            <div class="type-picker-title">${escapeHtml(title)}</div>
            ${tabsHtml}
            ${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
            <div class="icon-select-grid" role="listbox">${buildCells(items)}</div>
        </div>`;
    document.body.appendChild(overlay);

    const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };

    const grid = overlay.querySelector('.icon-select-grid') as HTMLElement;
    const filterInput = (showFilter
        ? overlay.querySelector('.type-picker-filter') as HTMLInputElement
        : null);

    let focusedIdx = -1;
    // Cache the column count; recomputed only when the grid is rebuilt or filtered.
    let cachedColumns = 1;

    const getNavCells = (): HTMLElement[] =>
        Array.from(grid.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));

    const refreshColumns = () => {
        cachedColumns = detectColumns(getNavCells());
    };

    const setFocused = (idx: number) => {
        const cells = getNavCells();
        focusedIdx = applyFocus(grid, cells, idx);
        if (focusedIdx >= 0) {
            const id = cells[focusedIdx].id || `type-picker-cell-${focusedIdx}`;
            cells[focusedIdx].id = id;
            grid.setAttribute('aria-activedescendant', id);
        } else {
            grid.removeAttribute('aria-activedescendant');
        }
    };

    function bindCellClicks() {
        grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
            cell.addEventListener('click', () => {
                if (cell.classList.contains('disabled')) return;
                close();
                onPick((cell as HTMLElement).dataset.value!);
            });
        });
    }
    bindCellClicks();

    // Filter tabs logic
    if (filterTabs && onFilterChange) {
        overlay.querySelectorAll('.type-picker-tab').forEach(btn => {
            btn.addEventListener('click', () => {
                overlay.querySelectorAll('.type-picker-tab').forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                const key = (btn as HTMLElement).dataset.filterKey!;
                const newItems = onFilterChange(key);
                grid.innerHTML = buildCells(newItems);
                bindCellClicks();
                refreshColumns();
                setFocused(0);
            });
        });
    }

    // Filter logic
    if (filterInput) {
        filterInput.addEventListener('input', () => {
            const q = filterInput.value.toLowerCase().trim();
            grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
                const el = cell as HTMLElement;
                const match = !q || el.dataset.search!.includes(q);
                el.classList.toggle('disabled', !match);
            });
            refreshColumns();
            setFocused(0);
        });
        requestAnimationFrame(() => setTimeout(() => desktopFocus(filterInput), 200));
    }

    // Backdrop click
    overlay.addEventListener('click', (e) => {
        if (e.target === overlay) close();
    });

    // Keyboard navigation
    const onKey = (e: KeyboardEvent) => {
        if (e.key === 'Escape') { close(); return; }

        // Don't hijack arrow keys while the user is editing the filter
        // input — let the caret move inside the text field normally.
        if (filterInput && document.activeElement === filterInput
            && (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
                || e.key === 'Home' || e.key === 'End')) {
            return;
        }

        const cells = getNavCells();
        if (cells.length === 0) return;
        const action = handleGridKey(e.key, focusedIdx, cells.length, cachedColumns);
        if (!action.handled) return;
        e.preventDefault();
        if (action.pick) {
            // Treat focusedIdx === -1 (rAF race before initial setFocused) as
            // the first cell, matching the visual cursor seed.
            const idx = action.nextIndex >= 0 ? action.nextIndex : 0;
            const cell = cells[idx];
            if (!cell) return;
            close();
            onPick(cell.dataset.value!);
            return;
        }
        setFocused(action.nextIndex);
    };
    document.addEventListener('keydown', onKey);

    // Animate in, prime the column cache, and seed keyboard cursor on first cell.
    requestAnimationFrame(() => {
        overlay.classList.add('open');
        refreshColumns();
        setFocused(0);
    });
}
