<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>HOBC Configurator</title>
    <script>
        // Set theme ASAP to avoid flash of wrong theme
        (function() {
            try {
                var saved = localStorage.getItem('theme');
                var theme = (saved === 'light' || saved === 'dark')
                    ? saved
                    : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
                document.documentElement.setAttribute('data-theme', theme);
            } catch (_) { /* ignore */ }
        })();
    </script>
    <style>
        :root {
            --bg: #1f2126;          /* dark charcoal */
            --bg-elev: #272a31;     /* elevated surface */
            --bg-elev-2: #2d3139;   /* elevated hover */
            --text: #d7d7d7;        /* light charcoal text */
            --muted: #a6a6a6;
            --accent: #5aa6ff;
            --accent-2: #7cc4ff;
            --ok: #42c97b;
            --err: #ff6b6b;
            --border: #353946;
            --thead: #2a2e36;
            --pill-bg: #343946;
            --row-odd: rgba(255,255,255,0.02);
            --row-hover: rgba(255,255,255,0.04);
        }

        :root[data-theme="light"] {
            --bg: #f3f4f7;
            --bg-elev: #ffffff;
            --bg-elev-2: #f7f9fc;
            --text: #1f2328;
            --muted: #57606a;
            --accent: #2563eb;
            --accent-2: #1d4ed8;
            --ok: #16a34a;
            --err: #dc2626;
            --border: #e2e8f0;
            --thead: #eef2f7;
            --pill-bg: #eef2f7;
            --row-odd: rgba(0,0,0,0.02);
            --row-hover: rgba(0,0,0,0.04);
        }
        * { box-sizing: border-box; }
        html, body {
            margin: 0; padding: 0; background: var(--bg); color: var(--text);
            font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji";
        }
        a { color: var(--accent); text-decoration: none; }
        a:hover { color: var(--accent-2); }

        /* Layout */
        .container { max-width: 1080px; margin: 0 auto; padding: 16px; }
        header {
            background: var(--bg-elev);
            border-bottom: 1px solid var(--border);
            box-shadow: 0 4px 12px rgba(0,0,0,0.25);
        }
        .header-inner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; }
        .brand { display: flex; align-items: center; gap: 16px; }
        .logo { height: 40px; width: auto; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); }
        .title { font-size: 20px; font-weight: 600; letter-spacing: 0.4px; }
        .subtitle { color: var(--muted); font-size: 12px; margin-top: 2px; }

        /* Toggle button */
        .header-actions { display: flex; align-items: center; gap: 8px; }
        .btn { cursor: pointer; padding: 8px 10px; background: var(--bg-elev-2); color: var(--text); border: 1px solid var(--border); border-radius: 8px; font: inherit; }
        .btn:hover { filter: brightness(1.05); }

        /* Tabs */
        .tabs { display: flex; gap: 8px; padding: 10px 16px 0; }
        .tab { cursor: pointer; padding: 10px 14px; border: 1px solid var(--border); border-bottom: none; border-radius: 8px 8px 0 0; background: var(--bg-elev); color: var(--text); }
        .tab.active { background: var(--bg-elev-2); color: var(--accent-2); box-shadow: inset 0 2px 0 var(--accent); }
        .tab:hover { background: var(--bg-elev-2); }

        .card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 6px 16px rgba(0,0,0,0.3); margin-top: 0; padding: 12px; }
        .card-header { display:flex; align-items:center; justify-content: space-between; margin-bottom: 8px; }
        .card-title { font-size: 16px; font-weight: 600; }
        .dim { color: var(--muted); }

        /* Table */
        table { width: 100%; border-collapse: separate; border-spacing: 0; overflow: hidden; border-radius: 8px; }
        thead th { text-align: left; font-weight: 600; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--thead); position: sticky; top: 0; }
        tbody td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
        tbody tr:nth-child(odd) { background: var(--row-odd); }
        tbody tr:hover { background: var(--row-hover); }
        .pill { display: inline-block; padding: 2px 6px; border-radius: 999px; background: var(--pill-bg); color: var(--text); border: 1px solid var(--border); font-size: 12px; }

            /* AO uses layout: vertical pills, controls underneath */
            .uses-list { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
            .uses-list .pill { display: inline-flex; align-items: center; gap: 6px; }
            .ao-add-controls { display: flex; gap: 8px; align-items: center; margin-top: 4px; }

        /* Fancy, theme-aware form controls */
        select, input[type="number"], input.use {
            appearance: none;
            -webkit-appearance: none;
            -moz-appearance: textfield;
            background: var(--bg-elev-2);
            border: 1px solid var(--border);
            color: var(--text);
            border-radius: 8px;
            padding: 8px 12px;
            font: inherit;
            transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, filter 120ms ease;
        }
        /* Custom dropdown arrow using a data URI, one for each theme */
        select {
            padding-right: 32px;
            background-repeat: no-repeat;
            background-position: right 10px center;
            background-size: 12px 12px;
        }
        :root:not([data-theme="light"]) select { /* dark default */
            background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"><path fill="%23a6a6a6" d="M7 10l5 5 5-5z"/></svg>');
        }
        :root[data-theme="light"] select {
            background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"><path fill="%2357606a" d="M7 10l5 5 5-5z"/></svg>');
        }
        /* Remove spinners on number inputs */
        input[type=number]::-webkit-outer-spin-button,
        input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
        /* Widths */
        input.use { width: 12ch; text-align: left; }
        select.sense { min-width: 180px; }
    /* Focus states */
    select:focus, input[type="number"]:focus, input.use:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(90,166,255,0.25); }
        :root[data-theme="light"] select:focus,
    :root[data-theme="light"] input[type="number"]:focus,
    :root[data-theme="light"] input.use:focus { box-shadow: 0 0 0 2px rgba(37,99,235,0.25); }
        /* Disabled button look */
        .btn[disabled], .btn:disabled { opacity: 0.65; cursor: default; filter: grayscale(0.2); }
        /* Primary button */
        .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
        .btn-primary:hover { filter: brightness(1.05); }
        .btn-primary:active { filter: brightness(0.98); }
        /* State badge */
        .state { display: inline-block; min-width: 2.5em; text-align: center; padding: 2px 8px; border-radius: 999px; background: var(--pill-bg); border: 1px solid var(--border); }

    /* Spacing: DO function label vs. bitmask input */
    .use-label { margin-bottom: 6px; }

        /* Status / error */
        .error { color: var(--err); margin-top: 8px; min-height: 1.2em; }
        .status { color: var(--muted); font-size: 12px; }
        .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.15); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; vertical-align: -2px; margin-left: 8px; }
        .hidden { display: none; }
        @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

        /* Sections */
        .section { display: none; }
        .section.active { display: block; }
    </style>
</head>
<body>
    <header>
        <div class="header-inner container">
            <div class="brand">
                <img src="hanover-logo.svg" alt="Hanover" class="logo" />
                <div>
                    <div class="title"><span id="brand">Hanover</span> Configurator</div>
                    <div class="subtitle">Hanover device configuration</div>
                </div>
            </div>
            <div class="header-actions">
                <button id="theme-toggle" class="btn" aria-pressed="false" title="Toggle theme">🌙 Dark</button>
            </div>
        </div>
        <div class="tabs container">
            <div id="tab-system" class="tab">System Info</div>
            <div id="tab-inputs" class="tab">Digital Inputs</div>
            <div id="tab-outputs" class="tab">Digital Outputs</div>
            <div id="tab-audio-outputs" class="tab">Audio Outputs</div>
        </div>
    </header>

    <main class="container">
        <!-- Inputs Section -->
        <section id="section-inputs" class="section">
            <div class="card">
                <div class="card-header">
                    <div class="card-title">Digital Inputs</div>
                    <div class="status">
                        <span id="di-status">Auto-refresh every ~2s</span>
                        <span id="di-spinner" class="spinner hidden" aria-label="Loading"></span>
                    </div>
                </div>
                <table>
                    <thead>
                        <tr><th>ID</th><th>Sense</th><th>Function</th><th>State</th></tr>
                    </thead>
                    <tbody id="di-tbody">
                        <tr><td colspan="4" class="dim">-</td></tr>
                    </tbody>

                </table>
                <div id="di-error" class="error"></div>
            </div>
        </section>
        <!-- Outputs Section -->
        <section id="section-outputs" class="section">
            <div class="card">
                <div class="card-header">
                    <div class="card-title">Digital Outputs</div>
                    <div class="status">
                        <span id="do-status">Auto-refresh every ~2s</span>
                        <span id="do-spinner" class="spinner hidden" aria-label="Loading"></span>
                    </div>
                </div>
                <table>
                    <thead>
                        <tr><th>ID</th><th>Sense</th><th>Function</th><th>State</th></tr>
                    </thead>
                    <tbody id="do-tbody">
                        <tr><td colspan="4" class="dim">-</td></tr>
                    </tbody>
                </table>
                <div id="do-error" class="error"></div>
            </div>
        </section>

        <!-- System Section -->
        <section id="section-system" class="section">
            <div class="card">
                <div class="card-header">
                    <div class="card-title">System</div>
                    <div class="status">
                        <span id="sys-status">Static information</span>
                        <span id="sys-spinner" class="spinner hidden" aria-label="Loading"></span>
                    </div>
                </div>
                <table>
                    <thead>
                        <tr><th>Key</th><th>Value</th></tr>
                    </thead>
                    <tbody id="sys-tbody">
                        <tr><td class="dim">Name</td><td id="sys-name" class="dim">-</td></tr>
                        <tr><td class="dim">Machine</td><td id="sys-machine" class="dim">-</td></tr>
                        <tr><td class="dim">Build mode</td><td id="sys-build-mode" class="dim">-</td></tr>
                        <tr><td class="dim">Version</td><td id="sys-version" class="dim">-</td></tr>
                    </tbody>
                </table>
                <div id="sys-error" class="error"></div>
            </div>
        </section>

        <!-- Audio Outputs Section -->
        <section id="section-audio-outputs" class="section">
            <div class="card">
                <div class="card-header">
                    <div class="card-title">Audio Outputs</div>
                    <div class="status">
                        <span id="ao-status">On-demand</span>
                        <span id="ao-spinner" class="spinner hidden" aria-label="Loading"></span>
                    </div>
                </div>
                <table>
                    <thead>
                        <tr><th>ID</th><th>Name</th><th>Audio Function</th><th>Default Volume</th><th>Playback start delay</th></tr>
                    </thead>
                    <tbody id="ao-tbody">
                        <tr><td colspan="5" class="dim">-</td></tr>
                    </tbody>
                </table>
                <div id="ao-error" class="error"></div>
            </div>
        </section>
    </main>

    <script>
        // Theme toggle
        function currentTheme() { return document.documentElement.getAttribute('data-theme') || 'dark'; }
        function applyTheme(theme) {
            document.documentElement.setAttribute('data-theme', theme);
            try { localStorage.setItem('theme', theme); } catch (_) {}
            updateThemeToggle(theme);
        }
        function updateThemeToggle(theme) {
            var btn = document.getElementById('theme-toggle');
            if (!btn) return;
            if (theme === 'dark') { btn.textContent = '🌙 Dark'; btn.setAttribute('aria-pressed','true'); }
            else { btn.textContent = '☀️ Light'; btn.setAttribute('aria-pressed','false'); }
        }
        (function initTheme() {
            var theme = currentTheme();
            updateThemeToggle(theme);
            // Optional: react to system changes if no manual choice stored
            try {
                if (!localStorage.getItem('theme') && window.matchMedia) {
                    var mq = window.matchMedia('(prefers-color-scheme: dark)');
                    mq.addEventListener('change', function(ev) { applyTheme(ev.matches ? 'dark' : 'light'); });
                }
            } catch (_) {}
        })();
        document.addEventListener('click', function(e) {
            if (e.target && e.target.id === 'theme-toggle') {
                e.preventDefault();
                var next = currentTheme() === 'dark' ? 'light' : 'dark';
                applyTheme(next);
            }
        });

        // Tab state
        let activeTab = 'system';
        let sysLoaded = false;
        let sysInFlight = false;

        // Row caches and build flags
        const diRowById = new Map();
        const doRowById = new Map();
        const aoRowById = new Map();
        let diBuilt = false;
        let doBuilt = false;
        let aoBuilt = false;

        // Inputs polling (gated, no flicker)
        let diInFlight = false, diQueued = false, diTimer = null;
        const diURL = '/cgi-bin/api.cgi?cmd=get_digital_input_state';
        async function fetchDI() {
            const tbody = document.getElementById('di-tbody');
            const err = document.getElementById('di-error');
            const spinner = document.getElementById('di-spinner');
            spinner.classList.remove('hidden');
            err.textContent = '';
            try {
                const res = await fetch(diURL);
                const data = await res.json();
                if (!data || !Array.isArray(data.result)) {
                    err.textContent = (data && data.error) ? data.error : 'No data';
                    return;
                }
                if (!diBuilt) {
                    // Fetch DI use options once for dropdowns
                    let options = [];
                    try {
                        const o = await fetch('/cgi-bin/api.cgi?cmd=get_di_use_options').then(r=>r.json());
                        options = (o && o.result) ? o.result : [];
                    } catch(_) {}
                    // Build rows with editable controls once
                    tbody.innerHTML = data.result.map(it => {
                        const useSelect = '<select class="use-di" aria-label="Input use enum">' + options.map(o => `<option value="${o.value}" ${Number(it.use)===Number(o.value)?'selected':''}>${o.label} (${o.value})</option>`).join('') + '</select>';
                        return `
                        <tr data-id="${it.id}">
                            <td class="id">${it.id}</td>
                            <td>
                                <select class="sense" aria-label="Input sense">
                                    <option value="0" ${it.sense == 0 ? 'selected' : ''}>Active High (0)</option>
                                    <option value="1" ${it.sense == 1 ? 'selected' : ''}>Active Low (1)</option>
                                </select>
                            </td>
                            <td>
                                ${useSelect}
                            </td>
                            <td>
                                <span class="state">${it.state}</span>
                            </td>
                        </tr>`;
                    }).join('');
                    // cache rows
                    [...tbody.querySelectorAll('tr[data-id]')].forEach(tr => diRowById.set(tr.getAttribute('data-id'), tr));
                    diBuilt = true;
                } else {
                    // Only update state cell per row
                    for (const it of data.result) {
                        const row = diRowById.get(String(it.id));
                        if (!row) continue;
                        const st = row.querySelector('.state');
                        if (st) st.textContent = it.state;
                    }
                }
            } catch (e) {
                err.textContent = String(e);
                console.error(e);
            } finally {
                spinner.classList.add('hidden');
            }
        }
        function scheduleNextDI(delayMs = 2000) {
            if (diTimer) clearTimeout(diTimer);
            // Only schedule if this tab remains active
            if (activeTab === 'inputs') {
                diTimer = setTimeout(runDIPollOnce, delayMs);
            }
        }
        async function runDIPollOnce() {
            if (diInFlight) { diQueued = true; return; }
            diInFlight = true;
            try {
                await fetchDI();
            } finally {
                diInFlight = false;
                if (diQueued) { diQueued = false; runDIPollOnce(); }
                else scheduleNextDI();
            }
        }

        // Outputs polling (gated, no flicker)
        let doInFlight = false, doQueued = false, doTimer = null;
        const doURL = '/cgi-bin/api.cgi?cmd=get_digital_output_state';
        // DO bitmask helpers: display in hex (0xNN...) and accept hex or decimal input
        function formatMaskHex(n) { const v = Number(n) >>> 0; return '0x' + v.toString(16).toUpperCase(); }
        function parseMask(val) {
            const s = (val == null ? '' : String(val)).trim();
            if (!s) return 0;
            if (/^0x[0-9a-f]+$/i.test(s)) { const n = parseInt(s, 16); return Number.isFinite(n) ? n : 0; }
            if (/^[0-9]+$/i.test(s)) { const n = parseInt(s, 10); return Number.isFinite(n) ? n : 0; }
            return 0;
        }
        async function fetchDO() {
            const tbody = document.getElementById('do-tbody');
            const err = document.getElementById('do-error');
            const spinner = document.getElementById('do-spinner');
            spinner.classList.remove('hidden');
            err.textContent = '';
            try {
                const res = await fetch(doURL);
                const data = await res.json();
                if (!data || !Array.isArray(data.result)) {
                    err.textContent = (data && data.error) ? data.error : 'No data';
                    return;
                }
                if (!doBuilt) {
                    tbody.innerHTML = data.result.map(it => `
                        <tr data-id="${it.id}">
                            <td class="id">${it.id}</td>
                            <td>
                                <select class="sense" aria-label="Output sense">
                                    <option value="0" ${it.sense == 0 ? 'selected' : ''}>Active High (0)</option>
                                    <option value="1" ${it.sense == 1 ? 'selected' : ''}>Active Low (1)</option>
                                </select>
                            </td>
                            <td>
                                <div class="dim use-label">${it.useLabel || ''}</div>
                                <div>Bit mask: <input type="text" class="use" value="${formatMaskHex(it.use)}" aria-label="Output use mask (hex or decimal)" placeholder="e.g. 0x20 or 32" /></div>
                            </td>
                            <td>
                                <span class="state">${it.state}</span>
                            </td>
                        </tr>`).join('');
                    [...tbody.querySelectorAll('tr[data-id]')].forEach(tr => doRowById.set(tr.getAttribute('data-id'), tr));
                    doBuilt = true;
                } else {
                    for (const it of data.result) {
                        const row = doRowById.get(String(it.id));
                        if (!row) continue;
                        const st = row.querySelector('.state');
                        if (st) st.textContent = it.state;
                    }
                }
            } catch (e) {
                err.textContent = String(e);
                console.error(e);
            } finally {
                spinner.classList.add('hidden');
            }
        }
        function scheduleNextDO(delayMs = 2000) {
            if (doTimer) clearTimeout(doTimer);
            if (activeTab === 'outputs') {
                doTimer = setTimeout(runDOPollOnce, delayMs);
            }
        }
        async function runDOPollOnce() {
            if (doInFlight) { doQueued = true; return; }
            doInFlight = true;
            try {
                await fetchDO();
            } finally {
                doInFlight = false;
                if (doQueued) { doQueued = false; runDOPollOnce(); }
                else scheduleNextDO();
            }
        }

        // Tab switching
        function setActiveTab(tab) {
            activeTab = tab;
            // toggle tabs
            document.getElementById('tab-inputs').classList.toggle('active', tab === 'inputs');
            document.getElementById('tab-outputs').classList.toggle('active', tab === 'outputs');
            document.getElementById('tab-system').classList.toggle('active', tab === 'system');
            document.getElementById('tab-audio-outputs').classList.toggle('active', tab === 'audio-outputs');
            // toggle sections
            document.getElementById('section-inputs').classList.toggle('active', tab === 'inputs');
            document.getElementById('section-outputs').classList.toggle('active', tab === 'outputs');
            document.getElementById('section-system').classList.toggle('active', tab === 'system');
            document.getElementById('section-audio-outputs').classList.toggle('active', tab === 'audio-outputs');
            // cancel any scheduled timers of the other tab
            if (tab === 'inputs') {
                if (doTimer) { clearTimeout(doTimer); doTimer = null; }
                scheduleNextDI(0);
            } else if (tab === 'outputs') {
                if (diTimer) { clearTimeout(diTimer); diTimer = null; }
                scheduleNextDO(0);
            } else if (tab === 'audio-outputs') {
                if (diTimer) { clearTimeout(diTimer); diTimer = null; }
                if (doTimer) { clearTimeout(doTimer); doTimer = null; }
                fetchAO();
            } else {
                if (diTimer) { clearTimeout(diTimer); diTimer = null; }
                if (doTimer) { clearTimeout(doTimer); doTimer = null; }
                // Load system info once on first visit
                if (!sysLoaded && !sysInFlight) fetchSystemInfo();
            }
            // update hash for direct links
            const hash = (tab === 'inputs') ? '#inputs' : (tab === 'outputs' ? '#outputs' : (tab === 'audio-outputs' ? '#audio-outputs' : '#system'));
            if (location.hash !== hash) history.replaceState(null, '', hash);
        }

        // Init
        document.getElementById('tab-inputs').addEventListener('click', () => setActiveTab('inputs'));
        document.getElementById('tab-outputs').addEventListener('click', () => setActiveTab('outputs'));
        document.getElementById('tab-system').addEventListener('click', () => setActiveTab('system'));
        document.getElementById('tab-audio-outputs').addEventListener('click', () => setActiveTab('audio-outputs'));
        window.addEventListener('hashchange', () => {
            const h = (location.hash || '').toLowerCase();
            setActiveTab(h === '#inputs' ? 'inputs' : (h === '#outputs' ? 'outputs' : (h === '#audio-outputs' ? 'audio-outputs' : 'system')));
        });

        // Load System info once and set initial tab/brand on DOM ready
        document.addEventListener('DOMContentLoaded', () => {
            const h = (location.hash || '').toLowerCase();
            setActiveTab(h === '#inputs' ? 'inputs' : (h === '#outputs' ? 'outputs' : (h === '#audio-outputs' ? 'audio-outputs' : 'system')));
            // Fetch OS ID and update branding (non-fatal)
            fetch('/cgi-bin/api.cgi?cmd=get_os_id').then(r => r.json()).then(data => {
                const id = data && data.result && data.result.id ? data.result.id : null;
                if (id) {
                    const brandEl = document.getElementById('brand');
                    brandEl.textContent = id.toUpperCase();
                    document.title = `${id.toUpperCase()} Configurator`;
                }
            }).catch(() => {});
        });

        // Audio Outputs fetch and UI
        // Map AO "uses" numeric codes to human-readable labels (AUDIO_FUNCTION_ENUM)
        const AUDIO_USE_LABELS = {
            0: 'Destination announcement',
            1: 'Information announcement',
            2: 'Stop announcement',
            3: 'Undefined',
            4: 'EG4 internal amplifier',
            5: 'EG4 external amplifier',
            6: 'EG4 internal+external amplifier',
            7: 'Use route definitions',
        };
        function formatAudioUse(u) {
            const n = Number(u);
            const label = Number.isFinite(n) && Object.prototype.hasOwnProperty.call(AUDIO_USE_LABELS, n)
                ? AUDIO_USE_LABELS[n]
                : 'Unknown';
            return `${label} (${u})`;
        }
        async function fetchAO() {
            const tbody = document.getElementById('ao-tbody');
            const err = document.getElementById('ao-error');
            const spinner = document.getElementById('ao-spinner');
            spinner.classList.remove('hidden');
            err.textContent = '';
            try {
                const res = await fetch('/cgi-bin/api.cgi?cmd=get_audio_outputs');
                const data = await res.json();
                if (!data || !Array.isArray(data.result)) {
                    err.textContent = (data && data.error) ? data.error : 'No data';
                    return;
                }
                if (!aoBuilt) {
                        tbody.innerHTML = data.result.map(it => {
                                const usesHtml = `
                                    <div class="uses-list" role="list">
                                        ${(it.uses||[]).map((u, idx) => `<span class="pill" data-index="${idx}" data-value="${u}" title="${formatAudioUse(u)}">${formatAudioUse(u)} <button class="btn btn-sm" data-action="ao-remove-use" data-id="${it.id}" data-index="${idx}" data-value="${u}" title="Remove">✕</button></span>`).join(' ')}
                                        <div class="ao-add-controls">
                                            <input type="number" class="ao-add-index" min="0" step="1" placeholder="index" aria-label="Use index to insert"/>
                                            <button class="btn" data-action="ao-add-use" data-id="${it.id}" title="Add use at index">＋ Add</button>
                                        </div>
                                    </div>`;
                        return `
                        <tr data-id="${it.id}">
                            <td class="id">${it.id}</td>
                            <td class="ao-name">${it.name || ''}</td>
                            <td class="ao-uses">${usesHtml}</td>
                            <td>
                                <input type="range" min="0" max="100" step="1" value="${it.volume}" class="ao-volume" aria-label="Volume slider"/>
                                <span class="pill">${it.volume}</span>
                            </td>
                            <td>
                                <input type="number" class="ao-delay" step="1" value="${it.playbackStartDelay}" aria-label="Playback start delay (ms)"/>
                            </td>
                        </tr>`;
                    }).join('');
                    [...tbody.querySelectorAll('tr[data-id]')].forEach(tr => aoRowById.set(tr.getAttribute('data-id'), tr));
                    aoBuilt = true;
                } else {
                        for (const it of data.result) {
                        const row = aoRowById.get(String(it.id));
                        if (!row) continue;
                        const nameTd = row.querySelector('td.ao-name'); if (nameTd) nameTd.textContent = it.name || '';
                        const vol = row.querySelector('.ao-volume'); if (vol) vol.value = it.volume;
                        const volPill = row.querySelector('.ao-volume + .pill'); if (volPill) volPill.textContent = it.volume;
                        const delay = row.querySelector('.ao-delay'); if (delay) delay.value = it.playbackStartDelay;
                        // Uses list refresh (simple rebuild of the cell)
                        const tdUses = row.querySelector('td.ao-uses');
                        if (tdUses) {
                                const oldIdx = (tdUses.querySelector('.ao-add-index')||{}).value;
                                tdUses.innerHTML = `
                                <div class="uses-list" role="list">
                                    ${(it.uses||[]).map((u, idx) => `<span class="pill" data-index="${idx}" data-value="${u}" title="${formatAudioUse(u)}">${formatAudioUse(u)} <button class="btn btn-sm" data-action="ao-remove-use" data-id="${it.id}" data-index="${idx}" data-value="${u}" title="Remove">✕</button></span>`).join(' ')}
                                    <div class="ao-add-controls">
                                        <input type="number" class="ao-add-index" min="0" step="1" placeholder="index" aria-label="Use index to insert"/>
                                        <button class="btn" data-action="ao-add-use" data-id="${it.id}" title="Add use at index">＋ Add</button>
                                    </div>
                                </div>`;
                                if (oldIdx !== undefined) { const inp = tdUses.querySelector('.ao-add-index'); if (inp) inp.value = oldIdx; }
                        }
                    }
                }
            } catch (e) {
                err.textContent = String(e);
                console.error(e);
            } finally {
                spinner.classList.add('hidden');
            }
        }

        async function aoRefreshSingle(id) {
            const res = await fetch('/cgi-bin/api.cgi?cmd=get_audio_outputs');
            const data = await res.json();
            const it = data && data.result && data.result.find(x => String(x.id) === String(id));
            if (!it) return;
            const row = aoRowById.get(String(id));
            if (!row) return;
            const nameTd = row.querySelector('td.ao-name'); if (nameTd) nameTd.textContent = it.name || '';
            const vol = row.querySelector('.ao-volume'); if (vol) vol.value = it.volume;
            const volPill = row.querySelector('.ao-volume + .pill'); if (volPill) volPill.textContent = it.volume;
            const delay = row.querySelector('.ao-delay'); if (delay) delay.value = it.playbackStartDelay;
            const tdUses = row.querySelector('td.ao-uses');
            if (tdUses) {
                const oldIdx = (tdUses.querySelector('.ao-add-index')||{}).value;
                tdUses.innerHTML = `
                <div class="uses-list" role="list">
                    ${(it.uses||[]).map((u, idx) => `<span class="pill" data-index="${idx}" data-value="${u}" title="${formatAudioUse(u)}">${formatAudioUse(u)} <button class="btn btn-sm" data-action="ao-remove-use" data-id="${it.id}" data-index="${idx}" data-value="${u}" title="Remove">✕</button></span>`).join(' ')}
                    <div class="ao-add-controls">
                        <input type="number" class="ao-add-index" min="0" step="1" placeholder="index" aria-label="Use index to insert"/>
                        <button class="btn" data-action="ao-add-use" data-id="${it.id}" title="Add use at index">＋ Add</button>
                    </div>
                </div>`;
                if (oldIdx !== undefined) { const inp = tdUses.querySelector('.ao-add-index'); if (inp) inp.value = oldIdx; }
            }
        }

        // System info fetcher
        async function fetchSystemInfo() {
            sysInFlight = true;
            const err = document.getElementById('sys-error');
            const spinner = document.getElementById('sys-spinner');
            spinner.classList.remove('hidden');
            err.textContent = '';
            try {
                const res = await fetch('/cgi-bin/api.cgi?cmd=get_os_info');
                const data = await res.json();
                const r = (data && data.result) ? data.result : data;
                if (!r) { err.textContent = (data && data.error) ? data.error : 'No data'; return; }
                document.getElementById('sys-name').textContent = r.name || '';
                document.getElementById('sys-machine').textContent = r.machine || '';
                document.getElementById('sys-build-mode').textContent = r.build_mode || '';
                document.getElementById('sys-version').textContent = r.version || '';
                sysLoaded = true;
            } catch (e) {
                err.textContent = String(e);
                console.error(e);
            } finally {
                spinner.classList.add('hidden');
                sysInFlight = false;
            }
        }

        // Helpers to refresh and save rows
        async function refreshDIRow(id, row) {
            const res = await fetch(diURL);
            const data = await res.json();
            const it = data && data.result && data.result.find(x => String(x.id) === String(id));
            if (it) {
                const useSel = row.querySelector('select.use-di'); if (useSel) useSel.value = String(it.use);
                const ul = row.querySelector('.use-label'); if (ul) ul.textContent = it.useLabel || '';
                row.querySelector('select.sense').value = it.sense;
                const st = row.querySelector('.state'); if (st) st.textContent = it.state;
            }
        }
        async function saveDI(row) {
            const id = row.getAttribute('data-id');
            const sense = parseInt(row.querySelector('select.sense').value, 10) || 0;
            const useSel = row.querySelector('select.use-di');
            const use = useSel ? (parseInt(useSel.value, 10) || 0) : 0;
            const url = `/cgi-bin/api.cgi?cmd=set_digital_input&id=${encodeURIComponent(id)}&sense=${sense}&use=${use}`;
            const r = await fetch(url);
            const j = await r.json();
            if (j && j.error) throw new Error(j.error);
            await refreshDIRow(id, row);
        }

        async function refreshDORow(id, row) {
            const res = await fetch(doURL);
            const data = await res.json();
            const it = data && data.result && data.result.find(x => String(x.id) === String(id));
            if (it) {
                row.querySelector('select.sense').value = it.sense;
                const useNum = row.querySelector('input.use'); if (useNum) useNum.value = formatMaskHex(it.use);
                const ul = row.querySelector('.use-label'); if (ul) ul.textContent = it.useLabel || '';
                const st = row.querySelector('.state'); if (st) st.textContent = it.state;
            }
        }
        async function saveDO(row) {
            const id = row.getAttribute('data-id');
            const sense = parseInt(row.querySelector('select.sense').value, 10) || 0;
            const use = parseMask(row.querySelector('input.use').value);
            const url = `/cgi-bin/api.cgi?cmd=set_digital_output&id=${encodeURIComponent(id)}&sense=${sense}&use=${use}`;
            const r = await fetch(url);
            const j = await r.json();
            if (j && j.error) throw new Error(j.error);
            await refreshDORow(id, row);
        }

        // Delegated change listener for autosave
        document.addEventListener('change', async (e) => {
            const row = e.target && e.target.closest && e.target.closest('tr[data-id]');
            if (!row) return;
            const isDI = row.parentElement && row.parentElement.id === 'di-tbody';
            const isDO = row.parentElement && row.parentElement.id === 'do-tbody';
            const isAO = row.parentElement && row.parentElement.id === 'ao-tbody';
            try {
                if (isDI && (e.target.matches('select.sense') || e.target.matches('select.use-di'))) {
                    await saveDI(row);
                }
                if (isDO && (e.target.matches('select.sense') || e.target.matches('input.use'))) {
                    await saveDO(row);
                }
                if (isAO && e.target.matches('input.ao-delay')) {
                    const id = row.getAttribute('data-id');
                    const delay = parseInt(e.target.value, 10) || 0;
                    const r = await fetch(`/cgi-bin/api.cgi?cmd=set_audio_output_playback_start_delay&id=${encodeURIComponent(id)}&playback_start_delay=${delay}`);
                    const j = await r.json();
                    if (j && j.error) throw new Error(j.error);
                    await aoRefreshSingle(id);
                }
            } catch (err) {
                alert('Failed to save: ' + (err && err.message ? err.message : err));
            }
        });

        // AO volume slider autosave on input (debounced)
        function debounce(fn, ms) { let t; return function(...args){ clearTimeout(t); t=setTimeout(()=>fn.apply(this,args), ms); } }
        const aoSaveVolume = debounce(async (row) => {
            const id = row.getAttribute('data-id');
            const vol = parseInt(row.querySelector('.ao-volume').value, 10) || 0;
            const r = await fetch(`/cgi-bin/api.cgi?cmd=set_audio_output_volume&id=${encodeURIComponent(id)}&volume=${vol}`);
            const j = await r.json();
            if (j && j.error) throw new Error(j.error);
            await aoRefreshSingle(id);
        }, 200);

        document.addEventListener('input', (e) => {
            if (e.target && e.target.matches('.ao-volume')) {
                const row = e.target.closest('tr[data-id]');
                // live update pill
                const pill = e.target.nextElementSibling; if (pill) pill.textContent = e.target.value;
                aoSaveVolume(row);
            }
        });

        // AO uses add/remove
        document.addEventListener('click', async (e) => {
            const rmBtn = e.target.closest && e.target.closest('button[data-action="ao-remove-use"]');
            const addBtn = e.target.closest && e.target.closest('button[data-action="ao-add-use"]');
            if (rmBtn) {
                e.preventDefault();
                const id = rmBtn.getAttribute('data-id');
                const index = parseInt(rmBtn.getAttribute('data-index'), 10) || 0;
                const value = parseInt(rmBtn.getAttribute('data-value'), 10);
                try {
                    const r = await fetch(`/cgi-bin/api.cgi?cmd=aocl_remove_use_node&id=${encodeURIComponent(id)}&index=${index}`);
                    const j = await r.json();
                    if (j && j.error) throw new Error(j.error);
                    await aoRefreshSingle(id);
                } catch (err) {
                    alert('Failed to remove use: ' + (err && err.message ? err.message : err));
                }
            }
            if (addBtn) {
                e.preventDefault();
                const id = addBtn.getAttribute('data-id');
                const row = aoRowById.get(String(id));
                let index;
                if (row) {
                    const inp = row.querySelector('.ao-add-index');
                    index = parseInt(inp && inp.value, 10);
                    if (Number.isNaN(index)) {
                        // Append to end if not specified
                        const pills = row.querySelectorAll('.uses-list .pill');
                        index = pills ? pills.length : 0;
                    }
                }
                try {
                    const r = await fetch(`/cgi-bin/api.cgi?cmd=aocl_insert_use_node&id=${encodeURIComponent(id)}&index=${index}`);
                    const j = await r.json();
                    if (j && j.error) throw new Error(j.error);
                    await aoRefreshSingle(id);
                    if (row) { const inp = row.querySelector('.ao-add-index'); if (inp) inp.value = ''; }
                } catch (err) {
                    alert('Failed to add use: ' + (err && err.message ? err.message : err));
                }
            }
        });
    </script>
</body>
</html>
