/**
 * Compose Farm Web UI JavaScript
 */

// ============================================================================
// CONSTANTS
// ============================================================================

// ANSI escape codes for terminal output
const ANSI = {
    RED: '\x1b[31m',
    DIM: '\x1b[2m',
    RESET: '\x1b[0m',
    CRLF: '\r\n'
};

// Terminal color theme (dark mode matching PicoCSS)
const TERMINAL_THEME = {
    background: '#1a1a2e',
    foreground: '#e4e4e7',
    cursor: '#e4e4e7',
    cursorAccent: '#1a1a2e',
    black: '#18181b',
    red: '#ef4444',
    green: '#22c55e',
    yellow: '#eab308',
    blue: '#3b82f6',
    magenta: '#a855f7',
    cyan: '#06b6d4',
    white: '#e4e4e7',
    brightBlack: '#52525b',
    brightRed: '#f87171',
    brightGreen: '#4ade80',
    brightYellow: '#facc15',
    brightBlue: '#60a5fa',
    brightMagenta: '#c084fc',
    brightCyan: '#22d3ee',
    brightWhite: '#fafafa'
};

// Language detection from file path
const LANGUAGE_MAP = {
    'yaml': 'yaml', 'yml': 'yaml',
    'json': 'json',
    'js': 'javascript', 'mjs': 'javascript',
    'ts': 'typescript', 'tsx': 'typescript',
    'py': 'python',
    'sh': 'shell', 'bash': 'shell',
    'md': 'markdown',
    'html': 'html', 'htm': 'html',
    'css': 'css',
    'sql': 'sql',
    'toml': 'toml',
    'ini': 'ini', 'conf': 'ini',
    'dockerfile': 'dockerfile',
    'env': 'plaintext'
};

// Detect Mac for keyboard shortcut display
const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const MOD_KEY = IS_MAC ? '⌘' : 'Ctrl';

// ============================================================================
// STATE
// ============================================================================

// Store active terminals and editors
const terminals = {};
const editors = {};
let monacoLoaded = false;
let monacoLoading = false;

// LocalStorage key prefix for active tasks (scoped by page)
const TASK_KEY_PREFIX = 'cf_task:';
const getTaskKey = () => TASK_KEY_PREFIX + window.location.pathname;

// Exec terminal state
let execTerminalWrapper = null;  // {term, dispose}
let execWs = null;

// ============================================================================
// UTILITIES
// ============================================================================

/**
 * Get Monaco language from file path
 * @param {string} path - File path
 * @returns {string} Monaco language identifier
 */
function getLanguageFromPath(path) {
    const ext = path.split('.').pop().toLowerCase();
    return LANGUAGE_MAP[ext] || 'plaintext';
}
window.getLanguageFromPath = getLanguageFromPath;

/**
 * Create WebSocket connection with standard handlers
 * @param {string} path - WebSocket path
 * @returns {WebSocket}
 */
function createWebSocket(path) {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    return new WebSocket(`${protocol}//${window.location.host}${path}`);
}
window.createWebSocket = createWebSocket;

/**
 * Wait for xterm.js to load, then execute callback
 * @param {function} callback - Function to call when xterm is ready
 * @param {number} maxAttempts - Max attempts (default 20 = 2 seconds)
 */
function whenXtermReady(callback, maxAttempts = 20) {
    const tryInit = (attempts) => {
        if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') {
            callback();
        } else if (attempts > 0) {
            setTimeout(() => tryInit(attempts - 1), 100);
        } else {
            console.error('xterm.js failed to load');
        }
    };
    tryInit(maxAttempts);
}

// ============================================================================
// TERMINAL
// ============================================================================

/**
 * Create a terminal with fit addon and resize observer
 * @param {HTMLElement} container - Container element
 * @param {object} extraOptions - Additional terminal options
 * @param {function} onResize - Optional callback called with (cols, rows) after resize
 * @returns {{term: Terminal, fitAddon: FitAddon, dispose: function}}
 */
function createTerminal(container, extraOptions = {}, onResize = null) {
    container.innerHTML = '';

    const term = new Terminal({
        convertEol: true,
        theme: TERMINAL_THEME,
        fontSize: 13,
        fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
        scrollback: 5000,
        ...extraOptions
    });

    const fitAddon = new FitAddon.FitAddon();
    term.loadAddon(fitAddon);
    term.open(container);

    const handleResize = () => {
        fitAddon.fit();
        onResize?.(term.cols, term.rows);
    };

    // Use ResizeObserver only (handles both container and window resize)
    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(container);

    handleResize(); // Initial fit

    return {
        term,
        fitAddon,
        dispose() {
            resizeObserver.disconnect();
            term.dispose();
        }
    };
}

/**
 * Initialize a terminal and connect to WebSocket for streaming
 */
function initTerminal(elementId, taskId) {
    const container = document.getElementById(elementId);
    if (!container) {
        console.error('Terminal container not found:', elementId);
        return;
    }

    const wrapper = createTerminal(container);
    const { term } = wrapper;
    const ws = createWebSocket(`/ws/terminal/${taskId}`);

    const taskKey = getTaskKey();
    ws.onopen = () => {
        term.write(`${ANSI.DIM}[Connected]${ANSI.RESET}${ANSI.CRLF}`);
        setTerminalLoading(true);
        localStorage.setItem(taskKey, taskId);
    };
    ws.onmessage = (event) => {
        term.write(event.data);
        if (event.data.includes('[Done]') || event.data.includes('[Failed]')) {
            localStorage.removeItem(taskKey);
        }
    };
    ws.onclose = () => setTerminalLoading(false);
    ws.onerror = (error) => {
        term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`);
        console.error('WebSocket error:', error);
        setTerminalLoading(false);
    };

    terminals[taskId] = { ...wrapper, ws };
    return { term, ws };
}

/**
 * Initialize an interactive exec terminal
 */
function initExecTerminal(stack, container, host) {
    const containerEl = document.getElementById('exec-terminal-container');
    const terminalEl = document.getElementById('exec-terminal');

    if (!containerEl || !terminalEl) {
        console.error('Exec terminal elements not found');
        return;
    }

    // Unhide the terminal container first, then expand/scroll
    containerEl.classList.remove('hidden');
    expandCollapse(document.getElementById('exec-collapse'), containerEl);

    // Clean up existing (use wrapper's dispose to clean up ResizeObserver)
    if (execWs) { execWs.close(); execWs = null; }
    if (execTerminalWrapper) { execTerminalWrapper.dispose(); execTerminalWrapper = null; }

    // Create WebSocket first so resize callback can use it
    execWs = createWebSocket(`/ws/exec/${stack}/${container}/${host}`);

    // Resize callback sends size to WebSocket
    const sendSize = (cols, rows) => {
        if (execWs && execWs.readyState === WebSocket.OPEN) {
            execWs.send(JSON.stringify({ type: 'resize', cols, rows }));
        }
    };

    execTerminalWrapper = createTerminal(terminalEl, { cursorBlink: true }, sendSize);
    const term = execTerminalWrapper.term;

    execWs.onopen = () => { sendSize(term.cols, term.rows); term.focus(); };
    execWs.onmessage = (event) => term.write(event.data);
    execWs.onclose = () => term.write(`${ANSI.CRLF}${ANSI.DIM}[Connection closed]${ANSI.RESET}${ANSI.CRLF}`);
    execWs.onerror = (error) => {
        term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`);
        console.error('Exec WebSocket error:', error);
    };

    term.onData((data) => {
        if (execWs && execWs.readyState === WebSocket.OPEN) {
            execWs.send(data);
        }
    });
}

window.initExecTerminal = initExecTerminal;

/**
 * Expand a collapse component and scroll to a target element
 * @param {HTMLInputElement} toggle - The checkbox input that controls the collapse
 * @param {HTMLElement} [scrollTarget] - Element to scroll to (defaults to collapse container)
 */
function expandCollapse(toggle, scrollTarget = null) {
    if (!toggle) return;

    // Find the parent collapse container
    const collapse = toggle.closest('.collapse');
    if (!collapse) return;

    const target = scrollTarget || collapse;
    const scrollToTarget = () => {
        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
    };

    if (!toggle.checked) {
        // Collapsed - expand first, then scroll after transition
        const onTransitionEnd = () => {
            collapse.removeEventListener('transitionend', onTransitionEnd);
            scrollToTarget();
        };
        collapse.addEventListener('transitionend', onTransitionEnd);
        toggle.checked = true;
    } else {
        // Already expanded - just scroll
        scrollToTarget();
    }
}

/**
 * Expand terminal collapse and scroll to it
 */
function expandTerminal() {
    expandCollapse(document.getElementById('terminal-toggle'));
}

/**
 * Show/hide terminal loading spinner
 */
function setTerminalLoading(loading) {
    const spinner = document.getElementById('terminal-spinner');
    if (spinner) {
        spinner.classList.toggle('hidden', !loading);
    }
}

// ============================================================================
// EDITOR (Monaco)
// ============================================================================

/**
 * Load Monaco editor dynamically (only once)
 */
function loadMonaco(callback) {
    if (monacoLoaded) {
        callback();
        return;
    }

    if (monacoLoading) {
        // Wait for it to load
        const checkInterval = setInterval(() => {
            if (monacoLoaded) {
                clearInterval(checkInterval);
                callback();
            }
        }, 100);
        return;
    }

    monacoLoading = true;

    // Load the Monaco loader script
    // Use local paths when running from vendored wheel, CDN otherwise
    const monacoBase = window.CF_VENDORED
        ? '/static/vendor/monaco'
        : 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
    const script = document.createElement('script');
    script.src = monacoBase + '/loader.js';
    script.onload = function() {
        require.config({ paths: { vs: monacoBase }});
        require(['vs/editor/editor.main'], function() {
            monacoLoaded = true;
            monacoLoading = false;
            callback();
        });
    };
    document.head.appendChild(script);
}

/**
 * Create a Monaco editor instance
 * @param {HTMLElement} container - Container element
 * @param {string} content - Initial content
 * @param {string} language - Editor language (yaml, plaintext, etc.)
 * @param {object} opts - Options: { readonly, onSave }
 * @returns {object} Monaco editor instance
 */
function createEditor(container, content, language, opts = {}) {
    const { readonly = false, onSave = null } = opts;

    const options = {
        value: content,
        language,
        theme: 'vs-dark',
        minimap: { enabled: false },
        automaticLayout: true,
        scrollBeyondLastLine: false,
        fontSize: 14,
        lineNumbers: 'on',
        wordWrap: 'on'
    };

    if (readonly) {
        options.readOnly = true;
        options.domReadOnly = true;
    }

    const editor = monaco.editor.create(container, options);

    // Add Command+S / Ctrl+S handler for editable editors
    if (!readonly) {
        editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
            if (onSave) {
                onSave(editor);
            } else {
                saveAllEditors();
            }
        });
    }

    return editor;
}
window.createEditor = createEditor;

/**
 * Initialize all Monaco editors on the page
 */
function initMonacoEditors() {
    // Dispose existing editors
    Object.values(editors).forEach(ed => ed?.dispose?.());
    for (const key in editors) delete editors[key];

    const editorConfigs = [
        { id: 'compose-editor', language: 'yaml', readonly: false },
        { id: 'env-editor', language: 'plaintext', readonly: false },
        { id: 'config-editor', language: 'yaml', readonly: false },
        { id: 'state-viewer', language: 'yaml', readonly: true }
    ];

    // Check if any editor elements exist
    const hasEditors = editorConfigs.some(({ id }) => document.getElementById(id));
    if (!hasEditors) return;

    // Load Monaco and create editors
    loadMonaco(() => {
        editorConfigs.forEach(({ id, language, readonly }) => {
            const el = document.getElementById(id);
            if (!el) return;

            const content = el.dataset.content || '';
            editors[id] = createEditor(el, content, language, { readonly });
            if (!readonly) {
                editors[id].saveUrl = el.dataset.saveUrl;
            }
        });
    });
}

/**
 * Save all editors
 */
async function saveAllEditors() {
    const saveBtn = getSaveButton();
    const results = [];

    for (const [id, editor] of Object.entries(editors)) {
        if (!editor || !editor.saveUrl) continue;

        const content = editor.getValue();
        try {
            const response = await fetch(editor.saveUrl, {
                method: 'PUT',
                headers: { 'Content-Type': 'text/plain' },
                body: content
            });
            const data = await response.json();
            if (!response.ok || !data.success) {
                results.push({ id, success: false, error: data.detail || 'Unknown error' });
            } else {
                results.push({ id, success: true });
            }
        } catch (e) {
            results.push({ id, success: false, error: e.message });
        }
    }

    // Show result
    if (saveBtn && results.length > 0) {
        saveBtn.textContent = 'Saved!';
        setTimeout(() => saveBtn.textContent = saveBtn.id === 'save-config-btn' ? 'Save Config' : 'Save All', 2000);
        refreshDashboard();
    }
}

/**
 * Initialize save button handler
 */
function initSaveButton() {
    const saveBtn = getSaveButton();
    if (!saveBtn) return;

    saveBtn.onclick = saveAllEditors;
}

function getSaveButton() {
    return document.getElementById('save-btn') || document.getElementById('save-config-btn');
}

// ============================================================================
// UI HELPERS
// ============================================================================

/**
 * Refresh dashboard partials by dispatching a custom event.
 * Elements with hx-trigger="cf:refresh from:body" will automatically refresh.
 */
function refreshDashboard() {
    document.body.dispatchEvent(new CustomEvent('cf:refresh'));
}

/**
 * Filter sidebar stacks by name and host
 */
function sidebarFilter() {
    const input = document.getElementById('sidebar-filter');
    const clearBtn = document.getElementById('sidebar-filter-clear');
    const q = (input?.value || '').toLowerCase();
    const h = document.getElementById('sidebar-host-select')?.value || '';
    let n = 0;
    document.querySelectorAll('#sidebar-stacks li').forEach(li => {
        const show = (!q || li.dataset.stack.includes(q)) && (!h || !li.dataset.h || li.dataset.h === h);
        li.hidden = !show;
        if (show) n++;
    });
    document.getElementById('sidebar-count').textContent = '(' + n + ')';
    // Show/hide clear button based on input value
    if (clearBtn) {
        clearBtn.classList.toggle('hidden', !q);
    }
}
window.sidebarFilter = sidebarFilter;

/**
 * Clear sidebar filter input and refresh list
 */
function clearSidebarFilter() {
    const input = document.getElementById('sidebar-filter');
    if (input) {
        input.value = '';
        input.focus();
    }
    sidebarFilter();
}
window.clearSidebarFilter = clearSidebarFilter;

// Play intro animation on command palette button
function playFabIntro() {
    const fab = document.getElementById('cmd-fab');
    if (!fab) return;
    setTimeout(() => {
        fab.style.setProperty('--cmd-pos', '0');
        fab.style.setProperty('--cmd-opacity', '1');
        fab.style.setProperty('--cmd-blur', '30');
        setTimeout(() => {
            fab.style.removeProperty('--cmd-pos');
            fab.style.removeProperty('--cmd-opacity');
            fab.style.removeProperty('--cmd-blur');
        }, 3000);
    }, 500);
}

// ============================================================================
// COMMAND PALETTE
// ============================================================================

(function() {
    const dialog = document.getElementById('cmd-palette');
    const input = document.getElementById('cmd-input');
    const list = document.getElementById('cmd-list');
    const fab = document.getElementById('cmd-fab');
    const themeBtn = document.getElementById('theme-btn');
    if (!dialog || !input || !list) return;

    // Load icons from template (rendered server-side from icons.html)
    const iconTemplate = document.getElementById('cmd-icons');
    const icons = {};
    if (iconTemplate) {
        iconTemplate.content.querySelectorAll('[data-icon]').forEach(el => {
            icons[el.dataset.icon] = el.innerHTML;
        });
    }

    // All available DaisyUI themes
    const THEMES = ['light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk'];
    const THEME_KEY = 'cf_theme';

    const colors = { stack: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7', theme: '#ec4899', service: '#14b8a6' };
    let commands = [];
    let filtered = [];
    let selected = 0;

    const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
    const nav = (url, afterNav) => () => {
        // Set hash before HTMX swap so inline scripts can read it
        const hashIndex = url.indexOf('#');
        if (hashIndex !== -1) {
            window.location.hash = url.substring(hashIndex);
        }
        htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
            history.pushState({}, '', url);
            window.scrollTo(0, 0);
            afterNav?.();
        });
    };
    // Navigate to dashboard (if needed) and trigger action
    const dashboardAction = (endpoint) => async () => {
        if (window.location.pathname !== '/') {
            await htmx.ajax('GET', '/', {target: '#main-content', select: '#main-content', swap: 'outerHTML'});
            history.pushState({}, '', '/');
            window.scrollTo(0, 0);
        }
        htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
    };
    // Get saved theme from localStorage (source of truth)
    const getSavedTheme = () => localStorage.getItem(THEME_KEY) || 'dark';

    // Apply theme and save to localStorage
    const setTheme = (theme) => () => {
        document.documentElement.setAttribute('data-theme', theme);
        localStorage.setItem(THEME_KEY, theme);
    };
    // Preview theme without saving (for hover). Guards against undefined/invalid themes.
    const previewTheme = (theme) => {
        if (theme) document.documentElement.setAttribute('data-theme', theme);
    };
    // Restore theme from localStorage (source of truth)
    const restoreTheme = () => {
        document.documentElement.setAttribute('data-theme', getSavedTheme());
    };
    // Generate color swatch HTML for a theme
    const themeSwatch = (theme) => `<span class="flex gap-0.5" data-theme="${theme}"><span class="w-2 h-4 rounded-l bg-primary"></span><span class="w-2 h-4 bg-secondary"></span><span class="w-2 h-4 bg-accent"></span><span class="w-2 h-4 rounded-r bg-neutral"></span></span>`;

    const cmd = (type, name, desc, action, icon = null, themeId = null) => ({ type, name, desc, action, icon, themeId });

    // Reopen palette with theme filter
    const openThemePicker = () => {
        // Small delay to let dialog close before reopening
        setTimeout(() => open('theme:'), 50);
    };

    function buildCommands() {
        const openExternal = (url) => () => window.open(url, '_blank');

        const actions = [
            cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
            cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
            cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download),
            cmd('action', 'Update All', 'Update all stacks except web', dashboardAction('update-all'), icons.refresh_cw),
            cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette),
            cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
            cmd('app', 'Live Stats', 'View all containers across hosts', nav('/live-stats'), icons.box),
            cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal),
            cmd('app', 'Edit Config', 'Edit compose-farm.yaml', nav('/console#editor'), icons.file_code),
            cmd('app', 'Docs', 'Open documentation', openExternal('https://compose-farm.nijho.lt/'), icons.book_open),
            cmd('app', 'GitHub Repo', 'Open GitHub repository', openExternal('https://github.com/basnijholt/compose-farm'), icons.external_link),
        ];

        // Add stack-specific actions if on a stack page
        const match = window.location.pathname.match(/^\/stack\/(.+)$/);
        if (match) {
            const stack = decodeURIComponent(match[1]);
            const stackCmd = (name, desc, endpoint, icon) => cmd('stack', name, `${desc} ${stack}`, post(`/api/stack/${stack}/${endpoint}`), icon);
            actions.unshift(
                stackCmd('Up', 'Start', 'up', icons.play),
                stackCmd('Down', 'Stop', 'down', icons.square),
                stackCmd('Restart', 'Restart', 'restart', icons.rotate_cw),
                stackCmd('Pull', 'Pull', 'pull', icons.cloud_download),
                stackCmd('Update', 'Pull + recreate', 'update', icons.refresh_cw),
                stackCmd('Logs', 'View logs for', 'logs', icons.file_text),
            );

            // Add Open Website commands if website URLs are available
            const websiteUrlsAttr = document.querySelector('[data-website-urls]')?.getAttribute('data-website-urls');
            if (websiteUrlsAttr) {
                const websiteUrls = JSON.parse(websiteUrlsAttr);
                for (const url of websiteUrls) {
                    const displayUrl = url.replace(/^https?:\/\//, '');
                    const label = websiteUrls.length > 1 ? `Open: ${displayUrl}` : 'Open Website';
                    actions.unshift(cmd('stack', label, `Open ${displayUrl} in browser`, openExternal(url), icons.external_link));
                }
            }

            // Add service-specific commands from data-services and data-containers attributes
            // Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
            const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services');
            const containersAttr = document.querySelector('[data-containers]')?.getAttribute('data-containers');
            if (servicesAttr) {
                const services = servicesAttr.split(',').filter(s => s).sort();
                // Parse container info for shell access: {service: {container, host}}
                const containers = containersAttr ? JSON.parse(containersAttr) : {};

                const svcCmd = (action, service, desc, endpoint, icon) =>
                    cmd('service', `${action}: ${service}`, desc, post(`/api/stack/${stack}/service/${service}/${endpoint}`), icon);
                const svcActions = [
                    ['Logs', 'View logs for service', 'logs', icons.file_text],
                    ['Pull', 'Pull image for service', 'pull', icons.cloud_download],
                    ['Restart', 'Restart service', 'restart', icons.rotate_cw],
                    ['Stop', 'Stop service', 'stop', icons.square],
                    ['Up', 'Start service', 'up', icons.play],
                ];
                for (const [action, desc, endpoint, icon] of svcActions) {
                    for (const service of services) {
                        actions.push(svcCmd(action, service, desc, endpoint, icon));
                    }
                }
                // Add Shell commands if container info is available
                for (const service of services) {
                    const info = containers[service];
                    if (info?.container && info?.host) {
                        actions.push(cmd('service', `Shell: ${service}`, 'Open interactive shell',
                            () => initExecTerminal(stack, info.container, info.host), icons.terminal));
                    }
                }
            }
        }

        // Add nav commands for all stacks from sidebar
        const stacks = [...document.querySelectorAll('#sidebar-stacks li[data-stack] a[href]')].map(a => {
            const name = a.getAttribute('href').replace('/stack/', '');
            return cmd('nav', name, 'Go to stack', nav(`/stack/${name}`), icons.box);
        });

        // Add theme commands with color swatches
        const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
        const themeCommands = THEMES.map(theme =>
            cmd('theme', `theme: ${theme}`, theme === currentTheme ? '(current)' : 'Switch theme', setTheme(theme), themeSwatch(theme), theme)
        );

        commands = [...actions, ...stacks, ...themeCommands];
    }

    function filter() {
        // Fuzzy matching: all query words must match the START of a word in the command name
        // Examples: "r ba" matches "Restart: bazarr" but NOT "Logs: bazarr"
        const q = input.value.toLowerCase().trim();
        // Split query into words and strip non-alphanumeric chars
        const queryWords = q.split(/[^a-z0-9]+/).filter(w => w);

        filtered = commands.filter(c => {
            const name = c.name.toLowerCase();
            // Split command name into words (split on non-alphanumeric)
            const nameWords = name.split(/[^a-z0-9]+/).filter(w => w);
            // Each query word must match the start of some word in the command name
            return queryWords.every(qw =>
                nameWords.some(nw => nw.startsWith(qw))
            );
        });
        selected = Math.max(0, Math.min(selected, filtered.length - 1));
    }

    function render() {
        list.innerHTML = filtered.map((c, i) => `
            <a class="flex justify-between items-center px-3 py-2 rounded-r cursor-pointer hover:bg-base-200 border-l-4 ${i === selected ? 'bg-base-300' : ''}" style="border-left-color: ${colors[c.type] || '#666'}" data-idx="${i}"${c.themeId ? ` data-theme-id="${c.themeId}"` : ''}>
                <span class="flex items-center gap-2">${c.icon || ''}<span>${c.name}</span></span>
                <span class="opacity-40 text-xs">${c.desc}</span>
            </a>
        `).join('') || '<div class="opacity-50 p-2">No matches</div>';
        // Scroll selected item into view
        const sel = list.querySelector(`[data-idx="${selected}"]`);
        if (sel) sel.scrollIntoView({ block: 'nearest' });
        // Preview theme if selected item is a theme command, otherwise restore saved
        const selectedCmd = filtered[selected];
        if (selectedCmd?.themeId) {
            previewTheme(selectedCmd.themeId);
        } else {
            restoreTheme();
        }
    }

    function open(initialFilter = '') {
        buildCommands();
        selected = 0;
        input.value = initialFilter;
        filter();
        // If opening theme picker, select current theme
        if (initialFilter.startsWith('theme:')) {
            const savedTheme = getSavedTheme();
            const currentIdx = filtered.findIndex(c => c.themeId === savedTheme);
            if (currentIdx >= 0) selected = currentIdx;
        }
        render();
        dialog.showModal();
        input.focus();
    }

    function exec() {
        const cmd = filtered[selected];
        if (cmd) {
            dialog.close();
            cmd.action();
        }
    }

    // Keyboard: Cmd+K to open
    document.addEventListener('keydown', e => {
        if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
            e.preventDefault();
            open();
        }
    });

    // Input filtering
    input.addEventListener('input', () => { filter(); render(); });

    // Keyboard nav inside palette
    dialog.addEventListener('keydown', e => {
        if (!dialog.open) return;
        if (e.key === 'ArrowDown') { e.preventDefault(); selected = Math.min(selected + 1, filtered.length - 1); render(); }
        else if (e.key === 'ArrowUp') { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); }
        else if (e.key === 'Enter') { e.preventDefault(); exec(); }
    });

    // Click to execute
    list.addEventListener('click', e => {
        const a = e.target.closest('a[data-idx]');
        if (a) {
            selected = parseInt(a.dataset.idx, 10);
            exec();
        }
    });

    // Hover previews theme without changing selection
    list.addEventListener('mouseover', e => {
        const a = e.target.closest('a[data-theme-id]');
        if (a) previewTheme(a.dataset.themeId);
    });

    // Mouse leaving list restores to selected item's theme (or saved)
    list.addEventListener('mouseleave', () => {
        const cmd = filtered[selected];
        previewTheme(cmd?.themeId || getSavedTheme());
    });

    // Restore theme from localStorage when dialog closes
    dialog.addEventListener('close', restoreTheme);

    // FAB click to open
    if (fab) fab.addEventListener('click', () => open());

    // Theme button opens palette with "theme:" filter
    if (themeBtn) themeBtn.addEventListener('click', () => open('theme:'));
})();

// ============================================================================
// THEME PERSISTENCE
// ============================================================================

// Restore saved theme on load (also handled in inline script to prevent flash)
(function() {
    const saved = localStorage.getItem('cf_theme');
    if (saved) document.documentElement.setAttribute('data-theme', saved);
})();

// ============================================================================
// INITIALIZATION
// ============================================================================

/**
 * Global keyboard shortcut handler
 */
function initKeyboardShortcuts() {
    document.addEventListener('keydown', function(e) {
        // Command+S (Mac) or Ctrl+S (Windows/Linux)
        if ((e.metaKey || e.ctrlKey) && e.key === 's') {
            // Only handle if we have editors and no Monaco editor is focused
            if (Object.keys(editors).length > 0) {
                // Check if any Monaco editor is focused
                const focusedEditor = Object.values(editors).find(ed => ed?.hasTextFocus?.());
                if (!focusedEditor) {
                    e.preventDefault();
                    saveAllEditors();
                }
            }
        }
    });
}

/**
 * Update keyboard shortcut display based on OS
 * Replaces ⌘ with Ctrl on non-Mac platforms
 */
function updateShortcutKeys() {
    // Update elements with class 'shortcut-key' that contain ⌘
    document.querySelectorAll('.shortcut-key').forEach(el => {
        if (el.textContent === '⌘') {
            el.textContent = MOD_KEY;
        }
    });
}

/**
 * Initialize page components
 */
function initPage() {
    initMonacoEditors();
    initSaveButton();
    updateShortcutKeys();
    initLiveStats();
    initSharedActionMenu();
    maybeRunStackAction();
}

function navigateToStack(stack, action = null) {
    const url = action ? `/stack/${stack}?action=${action}` : `/stack/${stack}`;
    window.location.href = url;
}

/**
 * Initialize shared action menu for container rows
 */
function initSharedActionMenu() {
    const menuEl = document.getElementById('shared-action-menu');
    if (!menuEl) return;
    if (menuEl.dataset.bound === '1') return;
    menuEl.dataset.bound = '1';

    let hoverTimeout = null;

    function showMenuForButton(btn, stack) {
        menuEl.dataset.stack = stack;

        // Position menu relative to button
        const rect = btn.getBoundingClientRect();
        menuEl.classList.remove('hidden');
        menuEl.style.visibility = 'hidden';
        const menuRect = menuEl.getBoundingClientRect();

        const left = rect.right - menuRect.width + window.scrollX;
        const top = rect.bottom + window.scrollY;

        menuEl.style.top = `${top}px`;
        menuEl.style.left = `${left}px`;
        menuEl.style.visibility = '';

        if (typeof liveStats !== 'undefined') liveStats.dropdownOpen = true;
    }

    function closeMenu() {
        menuEl.classList.add('hidden');
        if (typeof liveStats !== 'undefined') liveStats.dropdownOpen = false;
        menuEl.dataset.stack = '';
    }

    function scheduleClose() {
        if (hoverTimeout) clearTimeout(hoverTimeout);
        hoverTimeout = setTimeout(closeMenu, 100);
    }

    function cancelClose() {
        if (hoverTimeout) {
            clearTimeout(hoverTimeout);
            hoverTimeout = null;
        }
    }

    // Button hover: show menu (event delegation on tbody)
    const tbody = document.getElementById('container-rows');
    if (tbody) {
        tbody.addEventListener('mouseenter', (e) => {
            const btn = e.target.closest('button[onclick^="openActionMenu"]');
            if (!btn) return;

            // Extract stack from onclick attribute
            const match = btn.getAttribute('onclick')?.match(/openActionMenu\(event,\s*'([^']+)'\)/);
            if (!match) return;

            cancelClose();
            showMenuForButton(btn, match[1]);
        }, true);

        tbody.addEventListener('mouseleave', (e) => {
            const btn = e.target.closest('button[onclick^="openActionMenu"]');
            if (btn) scheduleClose();
        }, true);
    }

    // Keep menu open while hovering over it
    menuEl.addEventListener('mouseenter', cancelClose);
    menuEl.addEventListener('mouseleave', scheduleClose);

    // Click action in menu
    menuEl.addEventListener('click', (e) => {
        const link = e.target.closest('a[data-action]');
        const stack = menuEl.dataset.stack;
        if (!link || !stack) return;

        e.preventDefault();
        navigateToStack(stack, link.dataset.action);
        closeMenu();
    });

    // Also support click on button (for touch/accessibility)
    window.openActionMenu = function(event, stack) {
        event.stopPropagation();
        showMenuForButton(event.currentTarget, stack);
    };

    // Close on outside click
    document.body.addEventListener('click', (e) => {
        if (!menuEl.classList.contains('hidden') &&
            !menuEl.contains(e.target) &&
            !e.target.closest('button[onclick^="openActionMenu"]')) {
            closeMenu();
        }
    });

    // Close on Escape
    document.body.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') closeMenu();
    });
}

/**
 * Attempt to reconnect to an active task from localStorage
 * @param {string} [path] - Optional path to use for task key lookup.
 *                          If not provided, uses current window.location.pathname.
 *                          This is important for HTMX navigation where pushState
 *                          hasn't happened yet when htmx:afterSwap fires.
 */
function tryReconnectToTask(path) {
    const taskKey = TASK_KEY_PREFIX + (path || window.location.pathname);
    const taskId = localStorage.getItem(taskKey);
    if (!taskId) return;

    whenXtermReady(() => {
        expandTerminal();
        initTerminal('terminal-output', taskId);
    });
}

function maybeRunStackAction() {
    const params = new URLSearchParams(window.location.search);
    const stackEl = document.querySelector('[data-stack-name]');
    const stackName = stackEl?.dataset?.stackName;
    if (!stackName) return;

    const action = params.get('action');
    if (!action) return;

    const button = document.querySelector(`button[hx-post="/api/stack/${stackName}/${action}"]`);
    if (!button) return;

    params.delete('action');
    const newQuery = params.toString();
    const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname;
    history.replaceState({}, '', newUrl);

    if (window.htmx) {
        htmx.trigger(button, 'click');
    } else {
        button.click();
    }
}

// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
    initPage();
    initKeyboardShortcuts();
    playFabIntro();

    // Try to reconnect to any active task
    tryReconnectToTask();
});

// Re-initialize after HTMX swaps main content
document.body.addEventListener('htmx:afterSwap', function(evt) {
    if (evt.detail.target.id === 'main-content') {
        initPage();
        // Try to reconnect to task for the TARGET page, not current URL.
        // When using command palette navigation (htmx.ajax + manual pushState),
        // window.location.pathname still reflects the OLD page at this point.
        // Use pathInfo.requestPath to get the correct target path.
        const targetPath = evt.detail.pathInfo?.requestPath?.split('?')[0] || window.location.pathname;
        tryReconnectToTask(targetPath);
    }
});

// Handle action responses (terminal streaming)
document.body.addEventListener('htmx:afterRequest', function(evt) {
    if (!evt.detail.successful || !evt.detail.xhr) return;

    const text = evt.detail.xhr.responseText;
    // Only try to parse if it looks like JSON (starts with {)
    if (!text || !text.trim().startsWith('{')) return;

    try {
        const response = JSON.parse(text);
        if (response.task_id) {
            expandTerminal();
            whenXtermReady(() => initTerminal('terminal-output', response.task_id));
        }
    } catch (e) {
        // Not valid JSON, ignore
    }
});

// ============================================================================
// LIVE STATS PAGE
// ============================================================================

// State persists across SPA navigation (intervals must be cleared on re-init)
let liveStats = {
    sortCol: 9,
    sortAsc: false,
    lastUpdate: 0,
    dropdownOpen: false,
    scrolling: false,
    scrollTimer: null,
    loadingHosts: new Set(),
    eventsBound: false,
    intervals: [],
    updateCheckTimes: new Map(),
    autoRefresh: true
};

const REFRESH_INTERVAL = 5000;
const UPDATE_CHECK_TTL = 120000;
const NUMERIC_COLS = new Set([8, 9, 10, 11]);  // uptime, cpu, mem, net

function filterTable() {
    const textFilter = document.getElementById('filter-input')?.value.toLowerCase() || '';
    const hostFilter = document.getElementById('host-filter')?.value || '';
    const rows = document.querySelectorAll('#container-rows tr');
    let visible = 0;
    let total = 0;

    rows.forEach(row => {
        // Skip loading/empty/error rows (they have colspan)
        if (row.cells[0]?.colSpan > 1) return;
        total++;
        const matchesText = !textFilter || row.textContent.toLowerCase().includes(textFilter);
        const matchesHost = !hostFilter || row.dataset.host === hostFilter;
        const show = matchesText && matchesHost;
        row.style.display = show ? '' : 'none';
        if (show) visible++;
    });

    const countEl = document.getElementById('container-count');
    if (countEl) {
        const isFiltering = textFilter || hostFilter;
        countEl.textContent = total > 0
            ? (isFiltering ? `${visible} of ${total} containers` : `${total} containers`)
            : '';
    }
}
window.filterTable = filterTable;

function sortTable(col) {
    if (liveStats.sortCol === col) {
        liveStats.sortAsc = !liveStats.sortAsc;
    } else {
        liveStats.sortCol = col;
        liveStats.sortAsc = false;
    }
    updateSortIndicators();
    doSort();
}
window.sortTable = sortTable;

function updateSortIndicators() {
    document.querySelectorAll('thead th').forEach((th, i) => {
        const span = th.querySelector('.sort-indicator');
        if (span) {
            span.textContent = (i === liveStats.sortCol) ? (liveStats.sortAsc ? '↑' : '↓') : '';
            span.style.opacity = (i === liveStats.sortCol) ? '1' : '0.3';
        }
    });
}

function doSort() {
    const tbody = document.getElementById('container-rows');
    if (!tbody) return;

    const rows = Array.from(tbody.querySelectorAll('tr'));
    if (rows.length === 0) return;
    if (rows.length === 1 && rows[0].cells[0]?.colSpan > 1) return;  // Empty state row

    const isNumeric = NUMERIC_COLS.has(liveStats.sortCol);
    rows.sort((a, b) => {
        // Pin placeholders/empty rows to the bottom
        const aLoading = a.classList.contains('loading-row') || a.classList.contains('host-empty') || a.cells[0]?.colSpan > 1;
        const bLoading = b.classList.contains('loading-row') || b.classList.contains('host-empty') || b.cells[0]?.colSpan > 1;
        if (aLoading && !bLoading) return 1;
        if (!aLoading && bLoading) return -1;
        if (aLoading && bLoading) return 0;

        const aVal = a.cells[liveStats.sortCol]?.dataset?.sort ?? '';
        const bVal = b.cells[liveStats.sortCol]?.dataset?.sort ?? '';
        const cmp = isNumeric ? aVal - bVal : aVal.localeCompare(bVal);
        return liveStats.sortAsc ? cmp : -cmp;
    });

    let index = 1;
    const fragment = document.createDocumentFragment();
    rows.forEach((row) => {
        if (row.cells.length > 1) {
            row.cells[0].textContent = index++;
        }
        fragment.appendChild(row);
    });
    tbody.appendChild(fragment);
}

function isLoading() {
    return liveStats.loadingHosts.size > 0;
}

function getLiveStatsHosts() {
    const tbody = document.getElementById('container-rows');
    if (!tbody) return [];
    const dataHosts = tbody.dataset.hosts || '';
    return dataHosts.split(',').map(h => h.trim()).filter(Boolean);
}

function buildHostRow(host, message, className) {
    return (
        `<tr class="${className}" data-host="${host}">` +
        `<td colspan="12" class="text-center py-2">` +
        `<span class="text-sm opacity-60">${message}</span>` +
        `</td></tr>`
    );
}

async function checkUpdatesForHost(host) {
    // Update checks always run - they only update small cells, not disruptive
    const last = liveStats.updateCheckTimes.get(host) || 0;
    if (Date.now() - last < UPDATE_CHECK_TTL) return;

    const cells = Array.from(
        document.querySelectorAll(`tr[data-host="${host}"] td.update-cell[data-image][data-tag]`)
    );
    if (cells.length === 0) return;

    const items = [];
    const seen = new Set();
    cells.forEach(cell => {
        const image = decodeURIComponent(cell.dataset.image || '');
        const tag = decodeURIComponent(cell.dataset.tag || '');
        const key = `${image}:${tag}`;
        if (!image || seen.has(key)) return;
        seen.add(key);
        items.push({ image, tag });
    });

    if (items.length === 0) return;

    try {
        const response = await fetch('/api/containers/check-updates', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ items })
        });
        if (!response.ok) return;
        const data = await response.json();
        const results = Array.isArray(data?.results) ? data.results : [];
        const htmlMap = new Map();
        results.forEach(result => {
            const key = `${result.image}:${result.tag}`;
            htmlMap.set(key, result.html);
        });

        cells.forEach(cell => {
            const image = decodeURIComponent(cell.dataset.image || '');
            const tag = decodeURIComponent(cell.dataset.tag || '');
            const key = `${image}:${tag}`;
            const html = htmlMap.get(key);
            if (html && cell.innerHTML !== html) {
                cell.innerHTML = html;
            }
        });

        liveStats.updateCheckTimes.set(host, Date.now());
    } catch (e) {
        console.error('Update check failed:', e);
    }
}

function replaceHostRows(host, html) {
    const tbody = document.getElementById('container-rows');
    if (!tbody) return;

    // Remove loading indicator for this host if present
    const loadingRow = tbody.querySelector(`tr.loading-row[data-host="${host}"]`);
    if (loadingRow) loadingRow.remove();

    const template = document.createElement('template');
    template.innerHTML = html.trim();
    let newRows = Array.from(template.content.children).filter(el => el.tagName === 'TR');

    if (newRows.length === 0) {
        // Only show empty message if we don't have any rows for this host
        const existing = tbody.querySelector(`tr[data-host="${host}"]:not(.loading-row)`);
        if (!existing) {
            template.innerHTML = buildHostRow(host, `No containers on ${host}`, 'host-empty');
            newRows = Array.from(template.content.children);
        }
    }

    // Track which IDs we've seen in this update
    const newIds = new Set();

    newRows.forEach(newRow => {
        const id = newRow.id;
        if (id) newIds.add(id);

        if (id) {
            const existing = document.getElementById(id);
            if (existing) {
                // Morph in place if Idiomorph is available, otherwise replace
                if (typeof Idiomorph !== 'undefined') {
                    Idiomorph.morph(existing, newRow);
                } else {
                    existing.replaceWith(newRow);
                }

                // Re-process HTMX if needed (though inner content usually carries attributes)
                const morphedRow = document.getElementById(id);
                if (window.htmx) htmx.process(morphedRow);

                // Trigger refresh animation
                if (morphedRow) {
                    morphedRow.classList.add('row-updated');
                    setTimeout(() => morphedRow.classList.remove('row-updated'), 500);
                }
            } else {
                // New row - append (will be sorted later)
                tbody.appendChild(newRow);
                if (window.htmx) htmx.process(newRow);
                // Animate new rows too
                newRow.classList.add('row-updated');
                setTimeout(() => newRow.classList.remove('row-updated'), 500);
            }
        } else {
            // Fallback for rows without ID (like error/empty messages)
            // Just append them, cleaning up previous generic rows handled below
            tbody.appendChild(newRow);
        }
    });

    // Remove orphaned rows for this host (rows that exist in DOM but not in new response)
    // Be careful not to remove rows that were just added (if they lack IDs)
    const currentHostRows = Array.from(tbody.querySelectorAll(`tr[data-host="${host}"]`));
    currentHostRows.forEach(row => {
        // Skip if it's one of the new rows we just appended (check presence in newRows?)
        // Actually, if we just appended it, it is in DOM.
        // We rely on ID matching.
        // Error/Empty rows usually don't have ID, but we handle them by clearing old ones?
        // Let's assume data rows have IDs.
        if (row.id && !newIds.has(row.id)) {
            row.remove();
        }
        // Also remove old empty/error messages if we now have data
        if (!row.id && newRows.length > 0 && newRows[0].id) {
             row.remove();
        }
    });

    liveStats.loadingHosts.delete(host);
    checkUpdatesForHost(host);
    scheduleRowUpdate();
}

async function loadHostRows(host) {
    liveStats.loadingHosts.add(host);
    try {
        const response = await fetch(`/api/containers/rows/${encodeURIComponent(host)}`);
        const html = response.ok ? await response.text() : '';
        replaceHostRows(host, html);
    } catch (e) {
        console.error(`Failed to load ${host}:`, e);
        const msg = e.message || String(e);
        // Fallback to simpler error display if replaceHostRows fails (e.g. Idiomorph missing)
        try {
            replaceHostRows(host, buildHostRow(host, `Error: ${msg}`, 'text-error'));
        } catch (err2) {
            // Last resort: find row and force innerHTML
            const tbody = document.getElementById('container-rows');
            const row = tbody?.querySelector(`tr[data-host="${host}"]`);
            if (row) row.innerHTML = `<td colspan="12" class="text-center text-error">Error: ${msg}</td>`;
        }
    } finally {
        liveStats.loadingHosts.delete(host);
    }
}

function refreshLiveStats() {
    if (liveStats.dropdownOpen || liveStats.scrolling) return;
    const hosts = getLiveStatsHosts();
    if (hosts.length === 0) return;
    liveStats.lastUpdate = Date.now();
    hosts.forEach(loadHostRows);
}
window.refreshLiveStats = refreshLiveStats;

function toggleAutoRefresh() {
    liveStats.autoRefresh = !liveStats.autoRefresh;
    const timer = document.getElementById('refresh-timer');
    if (timer) {
        timer.classList.toggle('btn-error', !liveStats.autoRefresh);
        timer.classList.toggle('btn-outline', liveStats.autoRefresh);
    }
    if (liveStats.autoRefresh) {
        // Re-enabling: trigger immediate refresh
        refreshLiveStats();
    } else {
        // Disabling: ensure update checks run for current data
        const hosts = getLiveStatsHosts();
        hosts.forEach(host => checkUpdatesForHost(host));
    }
}
window.toggleAutoRefresh = toggleAutoRefresh;

function initLiveStats() {
    if (!document.getElementById('refresh-timer')) return;

    // Clear previous intervals (important for SPA navigation)
    liveStats.intervals.forEach(clearInterval);
    liveStats.intervals = [];
    liveStats.lastUpdate = Date.now();
    liveStats.dropdownOpen = false;
    liveStats.scrolling = false;
    if (liveStats.scrollTimer) clearTimeout(liveStats.scrollTimer);
    liveStats.scrollTimer = null;
    liveStats.loadingHosts.clear();
    liveStats.updateCheckTimes = new Map();
    liveStats.autoRefresh = true;

    if (!liveStats.eventsBound) {
        liveStats.eventsBound = true;

        // Dropdown pauses refresh
        document.body.addEventListener('click', e => {
            liveStats.dropdownOpen = !!e.target.closest('.dropdown');
        });
        document.body.addEventListener('focusin', e => {
            if (e.target.closest('.dropdown')) liveStats.dropdownOpen = true;
        });
        document.body.addEventListener('focusout', () => {
            setTimeout(() => {
                liveStats.dropdownOpen = !!document.activeElement?.closest('.dropdown');
            }, 150);
        });
        document.body.addEventListener('keydown', e => {
            if (e.key === 'Escape') liveStats.dropdownOpen = false;
        });

        // Pause refresh while scrolling (helps on slow mobile browsers)
        window.addEventListener('scroll', () => {
            liveStats.scrolling = true;
            if (liveStats.scrollTimer) clearTimeout(liveStats.scrollTimer);
            liveStats.scrollTimer = setTimeout(() => {
                liveStats.scrolling = false;
            }, 200);
        }, { passive: true });
    }

    // Auto-refresh every 5 seconds (skip if disabled, loading, or dropdown open)
    liveStats.intervals.push(setInterval(() => {
        if (!liveStats.autoRefresh) return;
        if (liveStats.dropdownOpen || liveStats.scrolling || isLoading()) return;
        refreshLiveStats();
    }, REFRESH_INTERVAL));

    // Timer display (updates every 100ms)
    liveStats.intervals.push(setInterval(() => {
        const timer = document.getElementById('refresh-timer');
        if (!timer) {
            liveStats.intervals.forEach(clearInterval);
            return;
        }

        const loading = isLoading();
        const paused = liveStats.dropdownOpen || liveStats.scrolling;
        const elapsed = Date.now() - liveStats.lastUpdate;
        window.refreshPaused = paused || loading || !liveStats.autoRefresh;

        // Update refresh timer button
        let text;
        if (!liveStats.autoRefresh) {
            text = 'OFF';
        } else if (paused) {
            text = '❚❚';
        } else {
            const remaining = Math.max(0, REFRESH_INTERVAL - elapsed);
            text = loading ? '↻ …' : `↻ ${Math.ceil(remaining / 1000)}s`;
        }
        if (timer.textContent !== text) {
            timer.textContent = text;
        }

        // Update "last updated" display
        const lastUpdatedEl = document.getElementById('last-updated');
        if (lastUpdatedEl) {
            const secs = Math.floor(elapsed / 1000);
            const updatedText = secs < 5 ? 'Updated just now' : `Updated ${secs}s ago`;
            if (lastUpdatedEl.textContent !== updatedText) {
                lastUpdatedEl.textContent = updatedText;
            }
        }
    }, 100));

    updateSortIndicators();
    refreshLiveStats();
}

function scheduleRowUpdate() {
    // Sort and filter immediately to prevent flicker
    doSort();
    filterTable();
}

// ============================================================================
// STACKS BY HOST FILTER
// ============================================================================

function sbhFilter() {
    const query = (document.getElementById('sbh-filter')?.value || '').toLowerCase();
    const hostFilter = document.getElementById('sbh-host-select')?.value || '';

    document.querySelectorAll('.sbh-group').forEach(group => {
        if (hostFilter && group.dataset.h !== hostFilter) {
            group.hidden = true;
            return;
        }

        let visibleCount = 0;
        group.querySelectorAll('li[data-s]').forEach(li => {
            const show = !query || li.dataset.s.includes(query);
            li.hidden = !show;
            if (show) visibleCount++;
        });
        group.hidden = visibleCount === 0;
    });
}
window.sbhFilter = sbhFilter;
