(() => {
    const $ = (s) => document.querySelector(s);
    const $$ = (s) => document.querySelectorAll(s);
  /** Fallback slice length from appsettings (overridden by /api/info on load). */
    let _monitorDefaultSliceSec = 20;
    let _monitorDefaultIntervalSec = 20;

    function defaultMonitorSliceSeconds() {
        return _monitorDefaultSliceSec > 0 ? _monitorDefaultSliceSec : 20;
    }

    function defaultMonitorIntervalSeconds() {
        return _monitorDefaultIntervalSec > 0 ? _monitorDefaultIntervalSec : 20;
    }

    const DEFAULT_THRESHOLD_VOTES_UI = 3.5;
    const DEFAULT_MIN_CONFIDENCE = 0.20;
    function defaultThresholdVotesUi() {
        return DEFAULT_THRESHOLD_VOTES_UI;
    }
    function defaultMinConfidence() {
        return DEFAULT_MIN_CONFIDENCE;
    }
    /** UI may use half steps; SoundFingerprinting uses whole votes (rounded). */
    function thresholdVotesFromInput(el) {
        const v = +(el?.value);
        if (!Number.isFinite(v) || v <= 0) return Math.round(DEFAULT_THRESHOLD_VOTES_UI);
        return Math.max(1, Math.min(25, Math.round(v)));
    }

    let _serverProcessorCount = 0;
    const FOLDER_IDENTIFY_MAX_PARALLEL = 128;

    function folderIdentifyAutoParallel(cores) {
        const c = Math.max(1, Math.floor(Number(cores)) || 1);
        return Math.min(FOLDER_IDENTIFY_MAX_PARALLEL, Math.max(2, c * 4));
    }

    function buildIdfConcurrencyOptions(cores) {
        const c = Math.max(1, Math.floor(Number(cores)) || 1);
        const auto = folderIdentifyAutoParallel(c);
        const gentle = Math.max(1, Math.floor(c / 2));
        const maxP = Math.min(FOLDER_IDENTIFY_MAX_PARALLEL, c * 6);
        const items = [];
        const seen = new Set();
        const push = (val, label, recommended) => {
            const key = val === '' || val === 0 ? '' : String(val);
            if (seen.has(key)) return;
            seen.add(key);
            items.push({ value: key, label: recommended ? `${label} ★` : label });
        };
        push('', `Auto — ${auto} parallel files (4× ${c} CPU cores)`, true);
        push(1, 'Light — 1 file at a time', false);
        if (gentle > 1 && gentle !== c) {
            push(gentle, `Gentle — ${gentle} parallel (~½ of ${c} cores)`, false);
        }
        push(c, `Balanced — ${c} parallel (1 worker per core)`, false);
        if (auto > c) push(auto, `Fast — ${auto} parallel (4× cores)`, false);
        if (maxP > auto) push(maxP, `Maximum — ${maxP} parallel (up to 6× cores)`, false);
        return { cores: c, auto, gentle, items };
    }

    const AUDIO_FORMAT_FALLBACK = [
        { extension: '.wav', label: 'WAV (PCM)', decoder: 'NativeWav', fingerprint: true, identify: true, requiresFfmpeg: false },
        { extension: '.mp3', label: 'MP3', decoder: 'BuiltInMp3', fingerprint: true, identify: true, requiresFfmpeg: false },
        { extension: '.flac', label: 'FLAC', decoder: 'Ffmpeg', fingerprint: true, identify: true, requiresFfmpeg: true },
        { extension: '.ogg', label: 'OGG Vorbis', decoder: 'Ffmpeg', fingerprint: true, identify: true, requiresFfmpeg: true },
        { extension: '.m4a', label: 'M4A / AAC', decoder: 'Ffmpeg', fingerprint: true, identify: true, requiresFfmpeg: true },
        { extension: '.aac', label: 'AAC', decoder: 'Ffmpeg', fingerprint: true, identify: true, requiresFfmpeg: true },
    ];
    const COMMON_AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.flac', '.m4a', '.aac', '.ogg']);
    let _audioFormats = [];

    function syncFormatCellState(cell, checked) {
        cell.classList.toggle('is-on', !!checked);
    }

    function updateFormatSummary(containerId) {
        const el = document.getElementById(containerId);
        const summary = document.querySelector(`[data-format-summary="${containerId}"]`);
        if (!el || !summary) return;
        const n = getSelectedAudioExtensions(el).length;
        const total = el.querySelectorAll('input[type="checkbox"][data-ext]').length;
        summary.textContent = n === 0 ? 'None selected' : (n === total ? `All ${n}` : `${n} selected`);
    }

    function renderAudioFormatPanel(containerId, formats, options = {}) {
        const el = document.getElementById(containerId);
        if (!el || !formats?.length) return;
        const prev = new Set(
            Array.from(el.querySelectorAll('input[type="checkbox"][data-ext]:checked')).map(c => c.value));
        const firstPopulate = !el.dataset.populated;
        const order = { NativeWav: 0, BuiltInMp3: 1, Ffmpeg: 2 };
        const sorted = [...formats].sort((a, b) =>
            (order[a.decoder] ?? 9) - (order[b.decoder] ?? 9)
            || a.extension.localeCompare(b.extension));
        el.replaceChildren();
        el.dataset.populated = '1';
        const userPicked = el.dataset.userFormatPick === '1';
        for (const f of sorted) {
            const pickAll = !!options.defaultAll;
            const pickCommon = !pickAll && options.defaultCommon && COMMON_AUDIO_EXTENSIONS.has(f.extension);
            let checked;
            if (userPicked && prev.size > 0) {
                checked = prev.has(f.extension);
            } else if (pickAll) {
                checked = true;
            } else {
                checked = firstPopulate && pickCommon;
            }
            const cell = document.createElement('label');
            cell.className = 'format-cell';
            const decHint = f.decoder === 'Ffmpeg' ? 'ffmpeg' : f.decoder === 'BuiltInMp3' ? 'built-in' : 'native';
            cell.title = `${f.label} (${decHint})`;
            syncFormatCellState(cell, checked);
            const cb = document.createElement('input');
            cb.type = 'checkbox';
            cb.value = f.extension;
            cb.dataset.ext = f.extension;
            cb.checked = checked;
            cb.addEventListener('change', () => {
                markAudioFormatsUserPicked(containerId);
                syncFormatCellState(cell, cb.checked);
                updateFormatSummary(containerId);
            });
            const ext = document.createElement('span');
            ext.className = 'format-cell-ext';
            ext.textContent = f.extension;
            cell.append(cb, ext);
            el.appendChild(cell);
        }
        updateFormatSummary(containerId);
    }

    function getSelectedAudioExtensions(containerOrId) {
        const el = typeof containerOrId === 'string'
            ? document.getElementById(containerOrId)
            : containerOrId;
        if (!el) return [];
        if (el.tagName === 'SELECT') {
            return Array.from(el.selectedOptions).map(o => o.value).filter(Boolean);
        }
        return Array.from(el.querySelectorAll('input[type="checkbox"][data-ext]:checked'))
            .map(c => c.value)
            .filter(Boolean);
    }

    function markAudioFormatsUserPicked(containerId) {
        const el = document.getElementById(containerId);
        if (el) el.dataset.userFormatPick = '1';
    }

    function setAudioFormatSelection(containerId, mode) {
        const el = document.getElementById(containerId);
        if (!el) return;
        markAudioFormatsUserPicked(containerId);
        el.querySelectorAll('input[type="checkbox"][data-ext]').forEach((cb) => {
            if (mode === 'all') cb.checked = true;
            else if (mode === 'clear') cb.checked = false;
            else if (mode === 'common') cb.checked = COMMON_AUDIO_EXTENSIONS.has(cb.value);
            syncFormatCellState(cb.closest('.format-cell'), cb.checked);
        });
        updateFormatSummary(containerId);
    }

    function initAudioFormatSelects(formats) {
        if (formats?.length) _audioFormats = formats;
        const list = _audioFormats.length ? _audioFormats : AUDIO_FORMAT_FALLBACK;
        // Re-apply "All" when the server format list arrives unless the user
        // already chose All/Common/None or toggled a checkbox.
        for (const id of ['folder-ext', 'idf-ext']) {
            const el = document.getElementById(id);
            if (el && el.dataset.userFormatPick !== '1')
                delete el.dataset.populated;
        }
        renderAudioFormatPanel('folder-ext', list, { defaultAll: true });
        renderAudioFormatPanel('idf-ext', list, { defaultAll: true });
        const accept = list.map(f => f.extension).join(',');
        const fpFile = $('#fp-file');
        const idFile = $('#id-file');
        if (fpFile) fpFile.setAttribute('accept', `audio/*,${accept}`);
        if (idFile) idFile.setAttribute('accept', `audio/*,${accept}`);
    }

    function wireAudioFormatActionButtons() {
        document.querySelectorAll('[data-format-action]').forEach((btn) => {
            if (btn.dataset.formatWired) return;
            btn.dataset.formatWired = '1';
            btn.addEventListener('click', () => {
                const target = btn.getAttribute('data-format-target');
                const action = btn.getAttribute('data-format-action');
                if (target && action) setAudioFormatSelection(target, action);
            });
        });
    }

    function renderIdfConcurrencySelect(cores) {
        const sel = $('#idf-concurrency');
        if (!sel) return;
        const prev = sel.value;
        const chunkOn = !!($('#idf-chunk-enable')?.checked);
        const { cores: c, auto, gentle, items } = buildIdfConcurrencyOptions(cores);
        sel.replaceChildren();
        for (const { value, label } of items) {
            const opt = document.createElement('option');
            opt.value = value;
            let text = label;
            if (chunkOn && value === String(gentle)) {
                text = label.replace(/ ★$/, '') + ' — good with chunk mode';
            } else if (chunkOn && value === '' && gentle > 1) {
                text = label.replace(/ ★$/, '') + ` (or Gentle: ${gentle})`;
            }
            opt.textContent = text;
            sel.appendChild(opt);
        }
        const valid = items.some(i => i.value === prev);
        sel.value = valid ? prev : '';
        const wrap = $('#idf-concurrency-wrap');
        if (wrap) {
            wrap.title = `${c} logical CPU core(s). Auto = ${auto} parallel files (max ${FOLDER_IDENTIFY_MAX_PARALLEL}).`;
        }
        _serverProcessorCount = c;
    }

    function streamCaptureSeconds(streamId) {
        if (!streamId) return 0;
        const s = monState.streams.find(x => (x.id || x.Id) === streamId);
        const cap = Number(s?.captureSeconds ?? s?.CaptureSeconds);
        if (cap > 0) return cap;
        const w = monState.workers[streamId];
        const wc = Number(w?.captureSeconds ?? w?.CaptureSeconds);
        return wc > 0 ? wc : 0;
    }

    /** Seconds credited for one monitor_log row (capture config → query → stream → default). */
    function monitorSliceSeconds(e) {
        const cap = Number(e?.captureSeconds ?? e?.CaptureSeconds);
        if (cap > 0) return cap;
        const q = Number(e?.queryLengthSeconds ?? 0);
        if (q > 0) return q;
        const cov = Number(e?.coverageSeconds ?? 0);
        if (cov > 0) return cov;
        const sid = e?.streamId || e?.StreamId;
        const fromStream = streamCaptureSeconds(sid);
        if (fromStream > 0) return fromStream;
        return defaultMonitorSliceSeconds();
    }

    /** Slice length for report charts when row-level data is not loaded yet. */
    function reportChartSliceSeconds() {
        try {
            const qs = typeof reportQuery === 'function' ? reportQuery() : null;
            const sid = qs?.get('streamId');
            if (sid) {
                const sc = streamCaptureSeconds(sid);
                if (sc > 0) return sc;
            }
        } catch { /* report tab not initialised */ }
        if (monState.streams.length === 1) {
            const sc = streamCaptureSeconds(monState.streams[0].id || monState.streams[0].Id);
            if (sc > 0) return sc;
        }
        return defaultMonitorSliceSeconds();
    }

    /** Wall-clock end of one detection (slice start + capture/query length). */
    function airplayEndUtcIso(e) {
        const t = Date.parse(e?.timeUtc || '');
        if (isNaN(t)) return e?.timeUtc || '';
        return new Date(t + monitorSliceSeconds(e) * 1000).toISOString();
    }

    // ── Client identity ────────────────────────────────────────────────
    // The "client" stamped on every export is the folder ONE LEVEL ABOVE
    // the path the user fingerprinted/identified from. e.g. for
    // "D:\\MRT\\03 - KANNADA BASIC PROGRAMMES\\song.wav" the parent of
    // "03 - KANNADA BASIC PROGRAMMES" is "MRT". Drive-root cases fall
    // back to the deepest available folder. We cache the latest value to
    // localStorage so it survives reloads.
    function _splitPath(p) {
        return String(p || '')
            .replace(/^[A-Za-z]:/, '')
            .split(/[\\/]+/)
            .filter(Boolean);
    }
    function deriveClientFromPath(p) {
        if (!p) return null;
        const parts = _splitPath(p);
        if (!parts.length) return null;
        const looksLikeFile = /\.[A-Za-z0-9]{1,8}$/.test(parts[parts.length - 1]);
        const dirParts = looksLikeFile ? parts.slice(0, -1) : parts.slice();
        if (dirParts.length < 2) return dirParts[0] || null;
        return dirParts[dirParts.length - 2];
    }

    function idfFileBaseName(filePath) {
        return String(filePath || '').replace(/^.*[\\/]/, '');
    }

    /** Wall-clock start (ms, local → Date) from filenames like 328_2024-03-28_20-02-29_30.mp4 */
    function parseIdfFileNameStartMs(filePath) {
        const name = idfFileBaseName(filePath);
        if (!name) return null;

        let m = /^[^_]*_(\d{4}-\d{2}-\d{2})_(\d{2})-(\d{2})-(\d{2})(?:_\d+)?\./i.exec(name);
        if (!m) m = /_(\d{4}-\d{2}-\d{2})_(\d{2})-(\d{2})-(\d{2})(?:_|\.)/i.exec(name);
        if (m) {
            const d = new Date(`${m[1]}T${m[2]}:${m[3]}:${m[4]}`);
            if (!isNaN(d.getTime())) return d.getTime();
        }

        m = /(\d{2})-(\d{2})-(\d{4}) (\d{2})-(\d{2})-(\d{2})\./i.exec(name);
        if (m) {
            const d = new Date(`${m[3]}-${m[2]}-${m[1]}T${m[4]}:${m[5]}:${m[6]}`);
            if (!isNaN(d.getTime())) return d.getTime();
        }

        m = /_(\d{2})-(\d{2})-(\d{2})\.[^.]+$/i.exec(name);
        if (m) {
            const today = new Date();
            const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
            const d = new Date(`${ymd}T${m[1]}:${m[2]}:${m[3]}`);
            if (!isNaN(d.getTime())) return d.getTime();
        }
        return null;
    }

    function parseIdfRangeBounds(range) {
        const m = /^([\d.]+)\s*-\s*([\d.]+)\s*s?$/i.exec(String(range || '').trim());
        if (!m) return { start: 0, end: null };
        const start = parseFloat(m[1]);
        const end = parseFloat(m[2]);
        return {
            start: Number.isFinite(start) ? start : 0,
            end: Number.isFinite(end) ? end : null
        };
    }

    /** Date/Time + End from filename, shifted by chunk range within the file (seconds). */
    function idfRowWallTimes(file, rangeStr, match) {
        const baseMs = parseIdfFileNameStartMs(file);
        const nowIso = new Date().toISOString();
        if (baseMs == null) return { identifiedAtIso: nowIso, identifiedEndIso: null };
        const { start: chunkStart, end: chunkEnd } = parseIdfRangeBounds(rangeStr);
        const startMs = baseMs + Math.max(0, chunkStart) * 1000;
        let endMs = chunkEnd != null ? baseMs + chunkEnd * 1000 : null;
        if (endMs == null && match?.queryLengthSeconds > 0) {
            endMs = startMs + match.queryLengthSeconds * 1000;
        }
        return {
            identifiedAtIso: new Date(startMs).toISOString(),
            identifiedEndIso: endMs != null ? new Date(endMs).toISOString() : null
        };
    }
    // Returns the immediate folder of the file pointed to by `r` (or by a
    // raw path string). For "03 - KANNADA BASIC PROGRAMMES\song.wav" the
    // folder is "03 - KANNADA BASIC PROGRAMMES". Used as the "Album /
    // Folder" cell when the track has no artist metadata.
    function folderOf(r) {
        if (!r) return '';
        const p = (typeof r === 'string') ? r
            : (r.file || r.path || r.trackId || r.recording && (r.recording.path || r.recording.file) || '');
        const parts = _splitPath(p);
        if (!parts.length) return '';
        const looksLikeFile = /\.[A-Za-z0-9]{1,8}$/.test(parts[parts.length - 1]);
        const dirs = looksLikeFile ? parts.slice(0, -1) : parts.slice();
        return dirs[dirs.length - 1] || '';
    }

    // ── Continuous-detection consolidation ────────────────────────────
    // Live monitoring fires one detection event per N-second slice while
    // a song is playing, so a single 3-minute song often shows up as 15+
    // identical rows. This helper collapses runs of consecutive
    // same-track matches on the same stream (gap ≤ 90s) into a single
    // row that carries:
    //   .timeUtc          → first detection (start)
    //   .endTimeUtc       → end of last detection slice (start + query/capture)
    //   .consolidatedAirtimeSeconds → wall span first start → last slice end
    //   .durationSeconds  → same as consolidatedAirtimeSeconds
    //   .count            → number of merged sub-events
    //   .confidence       → max confidence over the run
    //   .merged           → array of the original sub-events (timeline use)
    // Non-match (nomatch / error) events are passed through untouched.
    // Input may be in either chronological direction — we sort
    // ascending, group, and return ascending; callers that want newest-
    // first should call .reverse() afterwards.
    function consolidateAirplay(events, opts) {
        const arr = Array.isArray(events) ? events.slice() : [];
        if (!arr.length) return arr;
        const maxGapMs = ((opts && opts.maxGapSeconds) || 90) * 1000;
        const keyOf = (e) => {
            const sid = e.streamId || e.StreamId || e.streamName || '';
            // EPG identity: split runs whenever the program changes so a
            // no-match block doesn't span across program boundaries.
            const ep = e.epg || {};
            const epgKey = (e.epgShowName || ep.programName || ep.showName || '') + '\u241D' + (e.epgStartUtc || ep.startUtc || '');
            // Hour bucket (LOCAL time) so a run never spans an hour change
            // even when EPG metadata is missing or identical across the
            // boundary. Use the local clock so splits align with displayed
            // hh:00 (e.g. IST 23:00, not 17:30 UTC).
            const _t = Date.parse(e.timeUtc);
            let hourKey = '';
            if (!isNaN(_t)) {
                const _d = new Date(_t);
                hourKey = _d.getFullYear() + '-' + _d.getMonth() + '-' + _d.getDate() + '-' + _d.getHours();
            }
            if (e.type !== 'match') return sid + '\u241E__' + (e.type || 'nomatch') + '\u241E' + epgKey + '\u241E' + hourKey;
            const tid = e.trackId || (e.title || '') + '\u241F' + (e.artist || '');
            return sid + '\u241E' + tid + '\u241E' + epgKey + '\u241E' + hourKey;
        };
        // Bucket by stream/track/EPG/hour so interleaved multi-stream logs still merge.
        const buckets = new Map();
        for (const e of arr) {
            const k = keyOf(e);
            if (!buckets.has(k)) buckets.set(k, []);
            buckets.get(k).push(e);
        }
        const out = [];
        for (const bucket of buckets.values()) {
            bucket.sort((a, b) => String(a.timeUtc || '').localeCompare(String(b.timeUtc || '')));
            const bucketOut = [];
            for (const e of bucket) {
                const prev = bucketOut.length ? bucketOut[bucketOut.length - 1] : null;
                if (prev && prev.type === e.type) {
                    const tPrev = Date.parse(prev.endTimeUtc || airplayEndUtcIso(prev));
                    const tCur  = Date.parse(e.timeUtc);
                    if (!isNaN(tPrev) && !isNaN(tCur) && (tCur - tPrev) <= maxGapMs) {
                        prev.endTimeUtc = airplayEndUtcIso(e);
                        const tFirst = Date.parse(prev.timeUtc);
                        const tEnd = Date.parse(prev.endTimeUtc);
                        prev.consolidatedAirtimeSeconds = (isFinite(tFirst) && isFinite(tEnd))
                            ? Math.max(0, (tEnd - tFirst) / 1000) : 0;
                        prev.durationSeconds = prev.consolidatedAirtimeSeconds;
                        prev.count = (prev.count || 1) + 1;
                        if (e.confidence != null && (prev.confidence == null || e.confidence > prev.confidence)) {
                            prev.confidence = e.confidence;
                        }
                        // If the row that became the consolidated header had no
                        // recording attached yet (recording started after the
                        // first detection), promote the first available recording
                        // from a merged event so the ▶ Play / Clip buttons still
                        // work after consolidation.
                        if (!prev.recording && e.recording) {
                            prev.recording = e.recording;
                            if (e.recordingOffsetSeconds != null) prev.recordingOffsetSeconds = e.recordingOffsetSeconds;
                        }
                        if (e.recordingOffsetSeconds != null && Number.isFinite(Number(e.recordingOffsetSeconds))) {
                            const eOff = Number(e.recordingOffsetSeconds);
                            const eEnd = eOff + monitorSliceSeconds(e);
                            if (prev.recordingOffsetSeconds == null) prev.recordingOffsetSeconds = eOff;
                            const prevEnd = Number(prev.recordingEndOffsetSeconds);
                            prev.recordingEndOffsetSeconds = Number.isFinite(prevEnd)
                                ? Math.max(prevEnd, eEnd) : eEnd;
                        }
                        // Inherit playKind from any merged event. Latest non-empty
                        // classification wins — analysts often re-classify a song
                        // mid-run, so the most recent label should propagate.
                        if (e.playKind) prev.playKind = e.playKind;
                        if (e.playKindUpdatedBy) {
                            prev.playKindUpdatedBy = e.playKindUpdatedBy;
                            prev.playKindUpdatedAtUtc = e.playKindUpdatedAtUtc;
                        }
                        const epgVal = mapEventEpgProgram(e);
                        if (epgVal) {
                            prev.epgShowName = epgVal;
                            prev.epg = Object.assign({}, prev.epg || {}, {
                                programName: epgVal,
                                showName: epgVal
                            });
                        }
                        if (e.trackMatchAtSeconds != null) {
                            const qw = monitorSliceSeconds(e);
                            prev.trackMatchEndSeconds = e.trackMatchAtSeconds + qw;
                        }
                        (prev.merged = prev.merged || []).push(e);
                        if (prev.type === 'match') {
                            prev.coverageSeconds = matchCoverageSeconds(prev);
                            prev.durationSeconds = trackMatchDurationSeconds(prev);
                        }
                        continue;
                    }
                }
                // Seed the new consolidated header. `trackMatchEndSeconds` is
                // populated from the single event so the report column has a
                // sensible value even for non-merged rows; it will be overwritten
                // the moment a second detection merges into this header.
                const tmEnd = (e.trackMatchAtSeconds != null)
                    ? (Number(e.trackMatchAtSeconds) + monitorSliceSeconds(e))
                    : null;
                const slice = monitorSliceSeconds(e);
                const endUtc = airplayEndUtcIso(e);
                const recOff = e.recordingOffsetSeconds != null ? Number(e.recordingOffsetSeconds) : null;
                const recEnd = recOff != null ? recOff + slice : null;
                const clone = Object.assign({}, e, {
                    count: 1, endTimeUtc: endUtc,
                    consolidatedAirtimeSeconds: slice, merged: [e],
                    trackMatchEndSeconds: tmEnd,
                    recordingEndOffsetSeconds: recEnd
                });
                clone.durationSeconds = e.type === 'match'
                    ? trackMatchDurationSeconds(clone)
                    : slice;
                bucketOut.push(clone);
            }
            out.push(...bucketOut);
        }
        out.sort((a, b) => String(a.timeUtc || '').localeCompare(String(b.timeUtc || '')));
        return out;
    }
    // User-facing label for an event's internal "type" value. The DB still
    // stores "match" / "nomatch" / "error" — this helper renders the
    // capitalized + spaced variant ("MATCH" / "NO MATCH" / "ERROR") used
    // everywhere in the UI (live log, mapping list, report table, PDF).
    function displayType(t, message) {
        const s = String(t || '').toLowerCase();
        if (s === 'match' || s === 'matched' || s === 'alt') return 'MATCH';
        if (s === 'gap') return 'GAP';
        if (s === 'nomatch' || s === 'unmatched') {
            const msg = (message || '').toString();
            if (msg.includes('Compliance grid gap')) return 'GAP';
            if (msg.includes('Scheduled interval not')) return 'NOT ANALYZED';
            return 'NO MATCH';
        }
        if (s === 'error' || s === 'failed') return 'ERROR';
        return s.toUpperCase();
    }

    function formatIdentifyStatsHtml(matched, unmatched, failed) {
        return `<div class="run-summary-stats">`
            + `<span class="stat-match">Match ${matched}</span>`
            + `<span class="stat-nomatch">No match ${unmatched}</span>`
            + `<span class="stat-error">Error ${failed}</span>`
            + `</div>`;
    }

    function formatIdentifyDoneHtml(lead, matched, unmatched, failed) {
        return `<div class="run-summary">`
            + `<div class="run-summary-lead">${lead}</div>`
            + formatIdentifyStatsHtml(matched, unmatched, failed)
            + `</div>`;
    }

    function idfShortFileName(file) {
        return String(file || '').replace(/^.*[\\/]/, '') || '—';
    }

    function idfCapInProgressPct(pct) {
        return Math.max(0, Math.min(98, Math.round(Number(pct) || 0)));
    }

    function idfPhaseOverallPercent(d, totalFiles) {
        if (!totalFiles || !d) return 0;
        const perFile = 100 / totalFiles;
        const fileBase = (Math.max(1, d.index) - 1) * perFile;
        const phase = d.phase || '';
        let phasePct = Math.max(0, Math.min(100, Number(d.percent) || 0));
        const phaseRanges = {
            decoding: { start: 0, weight: 0.15 },
            preload: { start: 0.15, weight: 0.05 },
            chunks: { start: 0.20, weight: 0.80 },
        };
        // Preload has no granular progress — stay mid-band; never treat 100 as "done".
        if (phase === 'preload') phasePct = 50;
        const r = phaseRanges[phase] || { start: 0, weight: 0 };
        const inFile = (r.start + (r.weight * phasePct / 100)) * perFile;
        return idfCapInProgressPct(fileBase + inFile);
    }

    function idfProgressPercent(d, totalFiles, chunkOn) {
        if (!totalFiles) return 0;
        if (!d) return 0;
        if (chunkOn && d.chunked && d.chunkCount > 0) {
            const perFile = 100 / totalFiles;
            const fileStart = (Math.max(1, d.index) - 1) * perFile;
            const chunkIdx = Math.max(0, d.chunkIndex || 0);
            const inFile = (chunkIdx / d.chunkCount) * perFile;
            return idfCapInProgressPct(fileStart + inFile);
        }
        return idfCapInProgressPct((Math.max(1, d.index) / totalFiles) * 100);
    }

    function setIdfProgressUi(pct, statusHtml, countsHtml) {
        const bar = $('#idf-bar');
        const pctEl = $('#idf-pct');
        const status = $('#idf-status');
        const counts = $('#idf-counts');
        const n = Math.max(0, Math.min(100, Number(pct) || 0));
        if (bar) bar.style.width = n + '%';
        if (pctEl) pctEl.textContent = n + '%';
        if (status && statusHtml != null) {
            status.innerHTML = statusHtml;
            status.className = 'status';
        }
        if (counts && countsHtml != null) counts.innerHTML = countsHtml;
    }

    function formatIdfPhaseProgress(d, totalFiles, matched, unmatched, failed) {
        const name = escapeHtml(idfShortFileName(d.file));
        const filePart = totalFiles > 1 ? `File ${d.index}/${totalFiles} · ${name}` : name;
        let phaseText;
        if (d.phase === 'decoding') {
            phaseText = d.detail || 'Converting audio to WAV';
        } else if (d.phase === 'preload') {
            phaseText = d.detail || 'Loading converted audio';
        } else if (d.phase === 'chunks') {
            phaseText = d.detail || 'Identifying chunks';
        } else {
            phaseText = d.detail || d.phase || 'Working';
        }
        const pct = idfPhaseOverallPercent(d, totalFiles);
        const statusHtml = `<span class="spinner"></span><strong>${pct}%</strong> · ${phaseText} · ${filePart}`;
        const countsHtml = `<div class="run-summary">`
            + `<div class="run-summary-lead">${pct}% · ${phaseText} · ${filePart}</div>`
            + formatIdentifyStatsHtml(matched, unmatched, failed)
            + `</div>`;
        return { pct, statusHtml, countsHtml };
    }

    function formatIdfLiveProgress(d, totalFiles, chunkOn, matched, unmatched, failed) {
        const pct = idfProgressPercent(d, totalFiles, chunkOn);
        const name = escapeHtml(idfShortFileName(d.file));
        let phase;
        if (chunkOn && d.chunked && d.chunkCount) {
            const range = (typeof d.chunkStartSecond === 'number' && typeof d.chunkEndSecond === 'number')
                ? ` · ${Number(d.chunkStartSecond).toFixed(0)}–${Number(d.chunkEndSecond).toFixed(0)}s in file`
                : '';
            phase = totalFiles > 1
                ? `File ${d.index}/${totalFiles} · ${name} · chunk ${d.chunkIndex}/${d.chunkCount}${range}`
                : `${name} · chunk ${d.chunkIndex}/${d.chunkCount}${range}`;
        } else {
            phase = totalFiles > 1
                ? `File ${d.index}/${totalFiles} · ${name}`
                : name;
        }
        const statusHtml = `<span class="spinner"></span><strong>${pct}%</strong> · ${phase}`
            + ` · <span class="muted-tag">${displayType(d.status, d.error)}</span>`;
        const countsHtml = `<div class="run-summary">`
            + `<div class="run-summary-lead">${pct}% · ${phase}</div>`
            + formatIdentifyStatsHtml(matched, unmatched, failed)
            + `</div>`;
        return { pct, statusHtml, countsHtml };
    }

    function formatFingerprintStatsHtml(added, skipped, failed) {
        return `<div class="run-summary-stats">`
            + `<span class="stat-match">Added ${added}</span>`
            + `<span class="stat-nomatch">Skipped ${skipped}</span>`
            + `<span class="stat-error">Failed ${failed}</span>`
            + `</div>`;
    }

    function formatFingerprintDoneHtml(lead, added, skipped, failed) {
        return `<div class="run-summary">`
            + `<div class="run-summary-lead">${lead}</div>`
            + formatFingerprintStatsHtml(added, skipped, failed)
            + `</div>`;
    }

    function formatFingerprintCompleteLead(total) {
        return `Complete · ${total} file${total === 1 ? '' : 's'}`;
    }
    function setClientName(name) {
        if (!name) return;
        try { localStorage.setItem('clientName', String(name)); } catch { /* private mode */ }
        window._clientName = String(name);
    }
    function getClientName(rows) {
        if (rows && rows.length) {
            for (const r of rows) {
                const c = deriveClientFromPath(r && (r.file || r.path));
                if (c) return c;
            }
        }
        if (window._clientName) return window._clientName;
        try {
            const cached = localStorage.getItem('clientName');
            if (cached) return cached;
        } catch { /* ignore */ }
        return window._activeLibrary || '—';
    }

    // ── Date helpers ────────────────────────────────────────────────────
    // Every <input type="date"> in this app represents the user's LOCAL
    // calendar day, not a UTC day. toISOString().slice(0,10) returns a UTC
    // date which flips by ±1 day near midnight in non-UTC zones, so use
    // these helpers wherever we default/pre-fill a date picker or send a
    // date range to the backend.

    /** Local "YYYY-MM-DD" for a Date (defaults to now). */
    function localYmd(d = new Date()) {
        const y = d.getFullYear();
        const m = String(d.getMonth() + 1).padStart(2, '0');
        const dd = String(d.getDate()).padStart(2, '0');
        return `${y}-${m}-${dd}`;
    }

    /** Browser timezone offset in MINUTES EAST of UTC (IST → +330). */
    function tzOffsetMinutes() { return -new Date().getTimezoneOffset(); }

    /**
     * Convert a "YYYY-MM-DD" picker value into precise UTC ISO instants
     * that cover the user's local day. endOfDay=true returns the last
     * millisecond of that local day.
     */
    function localDateToUtcIso(ymd, endOfDay = false) {
        if (!ymd) return null;
        const [yy, mm, dd] = ymd.split('-').map(Number);
        const dt = new Date(yy, (mm || 1) - 1, dd || 1, 0, 0, 0, 0);
        if (endOfDay) {
            dt.setDate(dt.getDate() + 1);
            dt.setMilliseconds(dt.getMilliseconds() - 1);
        }
        return dt.toISOString();
    }

    // ---- Tabs ----
    $$('.tab').forEach(btn => btn.addEventListener('click', () => {
        const tab = String(btn.dataset.tab || '').toLowerCase();
        if (!tabIsAllowed(tab)) return;
        $$('.tab').forEach(b => {
            b.classList.remove('active', 'tab-active');
            b.classList.add('tab-inactive');
        });
        $$('.panel').forEach(p => p.classList.remove('active'));
        btn.classList.add('active', 'tab-active');
        btn.classList.remove('tab-inactive');
        const panel = $(`.panel[data-panel="${btn.dataset.tab}"]`);
        if (panel) {
            $$('.panel').forEach(p => {
                const id = String(p.dataset.panel || '').toLowerCase();
                if (id !== tab) {
                    p.classList.remove('active');
                    p.style.setProperty('display', 'none', 'important');
                    p.hidden = true;
                }
            });
            panel.classList.add('active');
            panel.style.removeProperty('display');
            panel.hidden = false;
        }
        if (typeof stopOverviewPolling === 'function') stopOverviewPolling();
        if (tab === 'overview' && typeof startOverviewPolling === 'function') startOverviewPolling();
        if (btn.dataset.tab === 'library') loadLibrary();
        if (btn.dataset.tab === 'fingerprint' && typeof loadLibraries === 'function') loadLibraries();
        if (btn.dataset.tab === 'fingerprint' && typeof loadStorageBackend === 'function') loadStorageBackend();
        if (btn.dataset.tab === 'report') activateReportTab();
        if (btn.dataset.tab === 'identify') {
            syncIdfOfflineLicenseBanner(_headerLicenseSnapshot);
            refreshLicenseLimits().catch(() => {});
            if (_idfReattachState) _idfReattachState.attempted = false;
            reattachIdentifyJob();
        }
    }));

    // ---- Drop zones ----
    function wireDrop(dropSel, inputSel, filenameSel) {
        const drop = $(dropSel), input = $(inputSel), name = $(filenameSel);
        drop.addEventListener('click', () => input.click());
        drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('dragover'); });
        drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
        drop.addEventListener('drop', (e) => {
            e.preventDefault(); drop.classList.remove('dragover');
            if (e.dataTransfer.files[0]) {
                input.files = e.dataTransfer.files;
                name.textContent = input.files[0].name;
            }
        });
        input.addEventListener('change', () => {
            name.textContent = input.files[0] ? input.files[0].name : '';
        });
    }
    wireDrop('#fp-drop', '#fp-file', '#fp-filename');
    wireDrop('#id-drop', '#id-file', '#id-filename');
    initAudioFormatSelects();
    wireAudioFormatActionButtons();

    // ---- Toast ----
    const toast = $('#toast');
    let toastTimer;
    function showToast(msg, kind = 'ok') {
        clearTimeout(toastTimer);
        toast.className = `toast show ${kind}`;
        toast.textContent = msg;
        toastTimer = setTimeout(() => toast.className = 'toast', 3500);
    }

    // ---- Stats ----
    // Tracks last known index-ready state so we only run the "just became
    // ready" toast once per load.
    let _lastIndexReady = null;

    function parseVariantBandwidthKbps(variantInfo) {
        if (!variantInfo) return 0;
        const m = /@\s*(\d+)\s*kbps/i.exec(String(variantInfo));
        return m ? parseInt(m[1], 10) : 0;
    }

    function formatBitrateMbpsValue(totalKbps) {
        if (!totalKbps) return '—';
        const mbps = totalKbps / 1000;
        const digits = mbps >= 10 ? 1 : 2;
        return mbps.toFixed(digits);
    }

    function updateHeaderRecordingStats(workers) {
        const active = (workers || []).filter(w => w && (w.active || w.Active));
        const totalKbps = active.reduce(
            (sum, w) => sum + parseVariantBandwidthKbps(w.variantInfo || w.VariantInfo),
            0);
        const mbpsEl = $('#stat-rec-mbps');
        const mbpsWrap = $('#stat-rec-mbps-wrap');
        if (mbpsEl) mbpsEl.textContent = formatBitrateMbpsValue(totalKbps);
        if (mbpsWrap) {
            mbpsWrap.title = active.length
                ? `Combined inbound bitrate · ${active.length} recording${active.length === 1 ? '' : 's'} active`
                : 'Combined inbound bitrate of active recorders';
        }
    }

    function updateHeaderStreamLimit(lim) {
        const countEl = $('#stat-rec-workers');
        const wrapEl = $('#stat-rec-workers-wrap');
        if (!countEl) return;
        const stored = readLicenseInfo();
        const merged = mergeLicenseHints(lim, stored);
        const max = Number(merged.maxStreams);
        const cur = Number(merged.currentStreams);
        const m = Number.isFinite(max) && max > 0 ? max : 1;
        const c = Number.isFinite(cur) && cur >= 0 ? cur : 0;
        countEl.textContent = `${c}/${m}`;
        if (wrapEl) {
            wrapEl.title = `${c} configured stream${c === 1 ? '' : 's'} · ${m} licensed`;
            wrapEl.classList.toggle('limit-reached', c >= m);
        }
    }

    function readLicenseOrganization() {
        try {
            const raw = localStorage.getItem('audioboxx_license_info');
            if (raw) {
                const data = JSON.parse(raw);
                const org = String(data.organization || '').trim();
                if (org) return org;
            }
        } catch { /* ignore */ }
        const client = getClientName();
        return client && client !== '—' ? String(client).trim() : '';
    }

    const HEADER_TAGLINE = 'AUDIOBoxx is your audio fingerprinting and broadcast monitoring platform';

    function readLicenseInfo() {
        try {
            const raw = localStorage.getItem('audioboxx_license_info');
            if (raw) return JSON.parse(raw);
        } catch { /* ignore */ }
        return null;
    }

    function mergeLicenseHints(apiLim, stored) {
        const a = apiLim && typeof apiLim === 'object' ? apiLim : {};
        const s = stored && typeof stored === 'object' ? stored : {};
        return {
            organization: a.organization || s.organization || '',
            maxStreams: a.maxStreams ?? a.max_streams ?? s.max_streams,
            currentStreams: a.currentStreams ?? a.current_streams,
            hasValidLicense: a.hasValidLicense ?? a.valid ?? s.valid,
            lifetime: a.lifetime ?? s.lifetime,
            expires: a.expires ?? s.expires,
            daysLeft: a.daysLeft ?? a.days_left ?? s.days_left,
            daysLeftUnlimited: a.daysLeftUnlimited ?? (
                s.days_left === 'unlimited' || s.expires === 'lifetime' || s.lifetime === true
            ),
            maxOfflineHours: a.maxOfflineHours ?? a.max_offline_hours ?? s.max_offline_hours,
            usedOfflineHours: a.usedOfflineHours ?? a.used_offline_hours ?? s.used_offline_hours,
            offlineHoursRemaining: a.offlineHoursRemaining ?? a.offline_hours_remaining,
            offlineIdentifyAtLimit: a.offlineIdentifyAtLimit ?? a.offline_identify_at_limit,
            offlineIdentifyLimited: a.offlineIdentifyLimited ?? a.offline_identify_limited,
        };
    }

    function formatOfflineHoursLabel(lim) {
        if (!lim) return '—';
        const max = Number(lim.maxOfflineHours ?? lim.max_offline_hours ?? 0);
        const used = Number(lim.usedOfflineHours ?? lim.used_offline_hours ?? 0);
        if (!(max > 0)) return 'Unlimited';
        const rem = Number(lim.offlineHoursRemaining ?? lim.offline_hours_remaining);
        const remStr = Number.isFinite(rem) ? fmtOfflineHours(rem) : fmtOfflineHours(Math.max(0, max - used));
        return `${fmtOfflineHours(used)} / ${fmtOfflineHours(max)} h (${remStr} h left)`;
    }

    function fmtOfflineHours(n) {
        const v = Number(n);
        if (!Number.isFinite(v)) return '0';
        if (Math.abs(v - Math.round(v)) < 1e-6) return String(Math.round(v));
        const s = v.toFixed(4).replace(/\.?0+$/, '');
        return s || '0';
    }

    function syncIdfOfflineLicenseBanner(lim) {
        const quota = $('#idf-offline-quota');
        const submit = $('#idf-submit');
        if (!quota) return;

        const merged = mergeLicenseHints(
            lim && typeof lim === 'object' ? lim : _headerLicenseSnapshot,
            readLicenseInfo()
        );

        if (!merged || (merged.hasValidLicense === false && !lim && !_headerLicenseSnapshot)) {
            quota.textContent = 'Offline: activate license';
            quota.className = 'idf-offline-quota';
            quota.title = 'Activate your license to use offline folder identify.';
            if (submit && !idfAbort) submit.disabled = true;
            return;
        }

        const max = Number(merged.maxOfflineHours ?? merged.max_offline_hours ?? 0);
        const used = Number(merged.usedOfflineHours ?? merged.used_offline_hours ?? 0);
        const usedStr = fmtOfflineHours(used);
        const maxStr = fmtOfflineHours(max);

        if (!(max > 0)) {
            quota.textContent = used > 0
                ? `Offline: Unlimited · ${usedStr} h used`
                : 'Offline: Unlimited';
            quota.className = 'idf-offline-quota is-unlimited';
            quota.title = 'No offline media-hour cap on this license.';
            if (submit && !idfAbort) submit.disabled = false;
            return;
        }

        const atLimit = merged.offlineIdentifyAtLimit === true || used >= max;
        const rem = fmtOfflineHours(Math.max(0, max - used));
        quota.textContent = atLimit
            ? `Offline: ${usedStr} / ${maxStr} h · limit reached`
            : `Offline: ${usedStr} / ${maxStr} h · ${rem} h left`;
        quota.className = 'idf-offline-quota' + (atLimit ? ' is-blocked' : '');
        quota.title = atLimit
            ? 'Offline identify quota reached. Upgrade your license for more processing.'
            : 'Licensed offline media hours — each scanned file counts once by its duration.';
        if (submit && !idfAbort) submit.disabled = atLimit;
    }

    function licenseExpirySentence(lim) {
        if (!lim) return 'License status is unavailable at this time.';
        const lifetime = lim.lifetime === true
            || lim.expires === 'lifetime'
            || lim.daysLeftUnlimited === true
            || lim.daysLeft === 'unlimited';
        if (lifetime) return 'Your license is lifetime and does not expire.';
        if (lim.hasValidLicense === false) return 'Please activate your license to continue.';

        const days = Number(lim.daysLeft);
        if (Number.isFinite(days)) {
            if (days <= 0) return 'Your license has expired.';
            if (days === 1) return 'Your license expires in 1 day.';
            return `Your license expires in ${days} days.`;
        }
        const exp = lim.expires ? String(lim.expires).split(' ')[0] : '';
        if (exp) return `Your license is valid until ${exp}.`;
        return 'Your license is active.';
    }

    function offlineIdentifySentence(lim) {
        if (!lim || typeof lim !== 'object') return '';
        if (lim.hasValidLicense === false) return '';

        const max = Number(lim.maxOfflineHours ?? lim.max_offline_hours ?? 0);
        const used = Number(lim.usedOfflineHours ?? lim.used_offline_hours ?? 0);
        const usedStr = fmtOfflineHours(used);
        const maxStr = fmtOfflineHours(max);

        if (!(max > 0)) {
            return used > 0
                ? `Offline identify is unlimited on your license · ${usedStr} media hours used.`
                : 'Offline folder identify is unlimited on your license.';
        }

        const atLimit = lim.offlineIdentifyAtLimit === true || used >= max;
        const rem = fmtOfflineHours(Math.max(0, max - used));
        if (atLimit) {
            return `Offline identify quota reached · ${usedStr} of ${maxStr} licensed media hours used.`;
        }
        return `Offline identify · ${usedStr} of ${maxStr} licensed media hours used · ${rem} hours remaining.`;
    }

    function fmtHeaderCount(n) {
        const v = Number(n);
        if (!Number.isFinite(v) || v < 0) return '0';
        return Math.floor(v).toLocaleString('en-US');
    }

    function buildHeaderVitalsSnapshot(info) {
        return {
            totalTracks: Number(info?.totalTracks ?? 0),
            totalSubFingerprints: Number(info?.totalSubFingerprints ?? 0),
            indexReady: info?.indexReady !== false,
        };
    }

    function librarySentence(v) {
        if (!v) return '';
        const tracks = fmtHeaderCount(v.totalTracks);
        const hashes = fmtHeaderCount(v.totalSubFingerprints);
        if (v.indexReady === false) {
            return 'Your fingerprint index is still loading.';
        }
        if (v.totalTracks <= 0 && v.totalSubFingerprints <= 0) {
            return 'Your fingerprint library is ready for new tracks.';
        }
        if (v.totalSubFingerprints > 0) {
            return `Your library has ${tracks} fingerprinted tracks and ${hashes} sub-fingerprints indexed.`;
        }
        return `Your library has ${tracks} fingerprinted tracks indexed.`;
    }

    function getTimeOfDayGreeting() {
        const h = new Date().getHours();
        if (h >= 5 && h < 12) return 'Good Morning';
        if (h >= 12 && h < 17) return 'Good Afternoon';
        if (h >= 17 && h < 21) return 'Good Evening';
        return 'Good Night';
    }

    function buildHeaderTickerMessage(org, lim, vitals) {
        const greeting = getTimeOfDayGreeting();
        const name = String(org ?? '').trim();
        const parts = [
            name ? `${greeting}, ${name}.` : `${greeting}.`,
            licenseExpirySentence(lim),
        ];
        const offline = offlineIdentifySentence(lim);
        if (offline) parts.push(offline);
        const lib = librarySentence(vitals);
        if (lib) parts.push(lib);
        parts.push(`${HEADER_TAGLINE}.`);
        return parts.join(' ');
    }

    let lastBrandingWelcome = '';
    let headerGreetingTimer = null;
    let _headerLicenseSnapshot = null;
    let _headerVitalsSnapshot = null;

    function scheduleHeaderGreetingRefresh() {
        if (headerGreetingTimer) clearInterval(headerGreetingTimer);
        headerGreetingTimer = setInterval(() => {
            const next = buildHeaderTickerMessage(
                readLicenseOrganization(),
                mergeLicenseHints(_headerLicenseSnapshot, readLicenseInfo()),
                _headerVitalsSnapshot
            );
            if (next !== lastBrandingWelcome) {
                lastBrandingWelcome = '';
                updateHeaderBranding();
            }
        }, 60_000);
    }

    function updateHeaderBranding(orgHint, limHint, vitalsHint) {
        const stored = readLicenseInfo();
        const apiLim = limHint ?? _headerLicenseSnapshot;
        const lim = mergeLicenseHints(apiLim, stored);
        const vitals = vitalsHint ?? _headerVitalsSnapshot;
        const org = String(orgHint ?? lim.organization ?? readLicenseOrganization() ?? '').trim();
        const message = buildHeaderTickerMessage(org, lim, vitals);
        if (message === lastBrandingWelcome) return;
        lastBrandingWelcome = message;
        if (window.HeaderLedMatrix) {
            window.HeaderLedMatrix.setMessage(message);
        } else {
            window._headerBrandingPending = message;
        }
    }

    async function loadStats() {
        try {
            const [infoR, recR, limR] = await Promise.all([
                fetch('/api/info'),
                fetch('/api/recording/status'),
                fetch('/api/license/limits').catch(() => null),
            ]);
            if (!infoR.ok) throw new Error('server error');
            const data = await infoR.json();
            const rec = recR.ok ? await recR.json() : null;
            _headerVitalsSnapshot = buildHeaderVitalsSnapshot(data);
            const cap = Number(data?.monitor?.defaultCaptureSeconds);
            const iv = Number(data?.monitor?.defaultIntervalSeconds);
            if (cap > 0) _monitorDefaultSliceSec = cap;
            if (iv > 0) _monitorDefaultIntervalSec = iv;
            $('#stat-tracks').textContent = data.totalTracks ?? 0;
            $('#stat-server').textContent = '●';
            $('#stat-server').style.color = 'var(--success)';
            if (rec) {
                updateHeaderRecordingStats(rec.workers || []);
            } else {
                updateHeaderRecordingStats([]);
            }
            if (limR && limR.ok) {
                const lim = await limR.json();
                _headerLicenseSnapshot = lim;
                updateHeaderStreamLimit(lim);
                updateHeaderBranding(lim.organization || '', lim, _headerVitalsSnapshot);
                syncLicenseCardFromLimits(lim);
            } else {
                updateHeaderStreamLimit(null);
                updateHeaderBranding(undefined, undefined, _headerVitalsSnapshot);
            }
            // System gauges — server returns `system` once that build is
            // deployed. Falls back to "—" on older servers so the page
            // doesn't break against a stale backend.
            if (data.system) {
                updateSystemGauges(data.system);
                const used = Number(data.system.usedRamBytes ?? 0);
                const total = Number(data.system.totalSystemRamBytes ?? 0);
                const ramPct = total > 0 ? (used / total) * 100 : 0;
                aboxBuddy('system', {
                    cpu: Number(data.system.cpuPercent ?? 0),
                    ram: ramPct
                });
            }
            const pc = Number(data.system?.processorCount);
            if (pc > 0) renderIdfConcurrencySelect(pc);
            if (Array.isArray(data.audioFormats) && data.audioFormats.length) {
                initAudioFormatSelects(data.audioFormats);
            }
            updateIndexReady(data);
        } catch {
            $('#stat-server').textContent = '●';
            $('#stat-server').style.color = 'var(--danger)';
            updateHeaderRecordingStats([]);
        }
    }

    // ── Header CPU/RAM gauges ─────────────────────────────────────────
    // Format bytes into a compact GB/MB string so the gauge fits a
    // single header cell. We use binary units (1 GB = 1024 MB) to match
    // what Task Manager shows on Windows.
    function fmtBytesShort(b) {
        const n = Number(b);
        if (!Number.isFinite(n) || n <= 0) return '—';
        if (n >= 1024 * 1024 * 1024) return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
        if (n >= 1024 * 1024) return Math.round(n / (1024 * 1024)) + ' MB';
        return Math.round(n / 1024) + ' KB';
    }
    function gaugeColor(pct) {
        // Cyan ≤60%, amber ≤85%, red above. Same ramp for CPU and RAM
        // so the header visually grades load consistently.
        if (pct >= 85) return '#f87171';   // red-400
        if (pct >= 60) return '#fbbf24';   // amber-400
        return '#67e8f9';                  // cyan-300
    }
    function updateSystemGauges(sys) {
        const cpuEl = $('#stat-cpu');
        const ramEl = $('#stat-ram');
        const ramLabel = $('#stat-ram-label');
        if (cpuEl) {
            // System-wide CPU% (whole machine, not just the AUDIOBoxx
            // process). Matches Task Manager's "CPU" column.
            const cpu = Number(sys.cpuPercent ?? 0);
            cpuEl.textContent = cpu.toFixed(cpu >= 10 ? 0 : 1) + '%';
            cpuEl.style.color = gaugeColor(cpu);
            const wrap = $('#stat-cpu-wrap');
            if (wrap) wrap.title =
                `System CPU: ${cpu.toFixed(1)}% across ${sys.processorCount || '?'} logical core(s)`;
        }
        if (ramEl) {
            // System-wide used RAM (Total - Available), matching Task
            // Manager's "In use" reading.
            const used  = Number(sys.usedRamBytes ?? 0);
            const total = Number(sys.totalSystemRamBytes ?? 0);
            ramEl.textContent = fmtBytesShort(used);
            if (total > 0) {
                const pct = (used / total) * 100;
                ramEl.style.color = gaugeColor(pct);
                if (ramLabel) ramLabel.textContent = `ram · ${pct.toFixed(0)}%`;
                const wrap = $('#stat-ram-wrap');
                if (wrap) wrap.title =
                    `System RAM in use: ${fmtBytesShort(used)} of ${fmtBytesShort(total)} (${pct.toFixed(1)}%)`;
            } else {
                ramEl.style.color = '';
                if (ramLabel) ramLabel.textContent = 'ram';
            }
        }
    }

    // Show/hide the "Loading fingerprint library…" banner and disable
    // fingerprint / identify buttons until the in-memory index is ready.
    function updateIndexReady(info) {
        const ready = !!info.indexReady;
        const banner = $('#index-loading');
        const detail = $('#index-loading-detail');
        const pctEl = $('#index-loading-pct');
        const fill = $('#index-loading-fill');

        if (!ready) {
            banner.hidden = false;
            const total = Number(info.indexLoadTotal ?? info.totalTracks ?? 0);
            const done = Number(info.indexLoadDone ?? 0);
            const stage = String(info.indexLoadStage || 'preparing');

            // Compute percentage. Snapshot stage now reports an elapsed-time
            // based estimate via indexLoadDone, so we can show a moving % for
            // all progressive stages.
            let pct = null;
            const progressiveStages = ['snapshot', 'rehydrating', 'snapshotting', 'loading', 'migrating'];
            if (total > 0 && progressiveStages.includes(stage)) {
                pct = Math.min(100, Math.max(0, Math.floor((done / total) * 100)));
            }

            if (pct !== null) {
                pctEl.textContent = ` (${pct}%)`;
                fill.style.width = pct + '%';
            } else {
                pctEl.textContent = '';
                fill.style.width = '0%';
            }

            const stageLabel = {
                'starting': 'Preparing in-memory index.',
                'snapshot': total > 0
                    ? `Loading saved snapshot from disk: ~${done.toLocaleString()} / ${total.toLocaleString()} tracks.`
                    : 'Loading saved snapshot from disk…',
                'rehydrating': total > 0
                    ? `Rebuilding in-memory index: ${done.toLocaleString()} / ${total.toLocaleString()} tracks.`
                    : 'Rebuilding in-memory index from database…',
                'snapshotting': 'Saving snapshot for faster next startup…',
                'loading': total > 0
                    ? `Loading library into RAM: ${done.toLocaleString()} / ${total.toLocaleString()} tracks.`
                    : 'Loading library into RAM…',
                'migrating': total > 0
                    ? `Migrating library to RocksDB: ${done.toLocaleString()} / ${total.toLocaleString()} tracks.`
                    : 'Migrating library to RocksDB…',
                'failed': 'Index load failed — check server logs.'
            }[stage] || 'Preparing in-memory index.';

            detail.textContent = stageLabel + ' Fingerprint & Identify will be enabled when ready.';

            // Mirror to the compact header pill so the operator sees the
            // migration / RAM-index progress from any tab. A folder scan
            // (when it starts) will simply overwrite these writes — folder
            // scans can only run once the index is ready, so there is no
            // real conflict.
            if (typeof hdrProg !== 'undefined' && hdrProg.wrap) {
                const shortLabel = {
                    'starting':     'Preparing index…',
                    'snapshot':     'Loading snapshot…',
                    'rehydrating':  'Rebuilding index…',
                    'snapshotting': 'Saving snapshot…',
                    'loading':      'Loading library to RAM…',
                    'migrating':    'Migrating to RocksDB…',
                    'failed':       'Index load failed'
                }[stage] || 'Preparing index…';
                const countsLabel = total > 0
                    ? `${done.toLocaleString()} / ${total.toLocaleString()}`
                    : '';
                hdrProg.show(shortLabel, countsLabel, pct ?? 0);
                hdrProg._indexOwned = true;
            }
        } else {
            banner.hidden = true;
            pctEl.textContent = '';
            fill.style.width = '100%';
            if (typeof hdrProg !== 'undefined' && hdrProg.wrap && hdrProg._indexOwned) {
                hdrProg.show('Index ready', '', 100);
                hdrProg.hide(1500);
                hdrProg._indexOwned = false;
            }
        }

        // Toggle the wait-state on action buttons. Any button that should
        // wait for the index gets data-requires-index="true" in HTML.
        document.querySelectorAll('button[data-requires-index]').forEach(btn => {
            btn.classList.toggle('is-waiting', !ready);
            if (!ready) {
                if (!btn.dataset._origTitle) btn.dataset._origTitle = btn.title || '';
                btn.title = 'Waiting for the fingerprint library to finish loading…';
            } else {
                if (btn.dataset._origTitle !== undefined) btn.title = btn.dataset._origTitle;
            }
        });

        if (_lastIndexReady === false && ready) {
            showToast('Fingerprint library is ready.', 'ok');
        }
        _lastIndexReady = ready;
    }
    loadStats();
    updateHeaderBranding();
    scheduleHeaderGreetingRefresh();
    // Adaptive polling: fast (400ms) while the index is still loading so the
    // progress bar tracks real-time per-sub-fingerprint progress; relax to 2s
    // once the library is ready.
    let _statsTimer = null;
    function scheduleNextStats() {
        const delay = _lastIndexReady === true ? 2000 : 400;
        _statsTimer = setTimeout(async () => {
            await loadStats();
            scheduleNextStats();
        }, delay);
    }
    scheduleNextStats();

    // Overview polling state — must exist before auth/login (refreshAuthStatus
    // runs mid-script and calls stopOverviewPolling for data_entry).
    const ovState = {
        cpu: Array(60).fill(0),
        processCpu: Array(60).fill(0),
        processRam: Array(60).fill(0),
        ram: Array(60).fill(0),
        cpuChart: null,
        ramChart: null,
        pollTimer: null
    };

    function stopOverviewPolling() {
        if (ovState.pollTimer) {
            clearInterval(ovState.pollTimer);
            ovState.pollTimer = null;
        }
    }

    // ── LAN access gate (admin vs lan-public) ─────────────────────────
    // Fetch auth status on page load. If we're on the host (loopback) we
    // always get isAdmin=true and there's nothing to hide. If we're a
    // LAN visitor, hide the tabs that aren't in publicTabs and disable
    // any "primary" form-submit button so the user can't even try a
    // mutating action without unlocking first.
    let _authState = null;
    let _authStatusError = null;

    function normalizeAuthStatus(raw) {
        if (!raw || typeof raw !== 'object') return null;
        const pick = (camel, pascal) => {
            if (raw[camel] !== undefined && raw[camel] !== null) return raw[camel];
            return raw[pascal];
        };
        const tabs = pick('publicTabs', 'PublicTabs');
        const allowed = pick('allowedTabs', 'AllowedTabs');
        const perms = pick('permissions', 'Permissions');
        const ips = pick('hostLanAddresses', 'HostLanAddresses');
        const user = pick('user', 'User');
        const isLoopback = pick('isLoopback', 'IsLoopback') === true;
        const isHost = pick('isHost', 'IsHost') === true;
        const isAdmin = pick('isAdmin', 'IsAdmin') === true;
        const isAuthenticated = pick('isAuthenticated', 'IsAuthenticated') === true
            || isAdmin || isLoopback || !!user;
        const role = String((user && user.role) || pick('role', 'Role') || (isAdmin ? 'admin' : 'viewer')).toLowerCase();
        const allowedFromServer = Array.isArray(allowed) ? allowed.map(t => String(t).toLowerCase()) : [];
        return finalizeAuthState({
            isAdmin,
            isAuthenticated,
            isLoopback,
            isHost,
            lanEnabled: pick('lanEnabled', 'LanEnabled') !== false,
            breakGlassUsername: pick('breakGlassUsername', 'BreakGlassUsername')
                || pick('username', 'Username') || 'admin',
            role,
            publicTabs: Array.isArray(tabs) ? tabs : [],
            allowedTabs: allowedFromServer.length ? allowedFromServer : clientRoleTabsList(role),
            permissions: Array.isArray(perms) ? perms : [],
            user: user && typeof user === 'object' ? user : null,
            roles: Array.isArray(pick('roles', 'Roles')) ? pick('roles', 'Roles') : [],
            canManageUsers: pick('canManageUsers', 'CanManageUsers') === true,
            canWritePlayKind: pick('canWritePlayKind', 'CanWritePlayKind') === true,
            canExportReports: pick('canExportReports', 'CanExportReports') === true,
            clientIp: pick('clientIp', 'ClientIp') || '',
            hostLanAddresses: Array.isArray(ips) ? ips : [],
            listenUrl: pick('listenUrl', 'ListenUrl') || '',
            lanListeningOnAllInterfaces: pick('lanListeningOnAllInterfaces', 'LanListeningOnAllInterfaces') !== false
        });
    }

    /** Display name for report "Classified by" (from signed-in LAN user). */
    function playKindClassifiedByLabel() {
        const u = _authState && _authState.user;
        if (!u || typeof u !== 'object') return '';
        const dn = String(u.displayName || u.DisplayName || '').trim();
        if (dn) return dn;
        return String(u.username || u.Username || '').trim();
    }

    /** Numeric DB log id for play-kind / EPG save (monitor or offline). */
    function playKindDbId(ev) {
        if (!ev) return null;
        const n = Number(ev.logId != null ? ev.logId : ev.id);
        return Number.isFinite(n) && n > 0 ? n : null;
    }

    /** Ensure role flags match permissions (host / data_entry / admin). */
    function finalizeAuthState(state) {
        if (!state) return state;
        const r = String((state.user && state.user.role) || state.role || '').toLowerCase();
        const permsEarly = Array.isArray(state.permissions) ? state.permissions : [];
        if (permsEarly.includes('write:playkind')) state.canWritePlayKind = true;
        if (r === 'data_entry') {
            state.role = 'data_entry';
            state.isLoopback = false;
            state.isAdmin = false;
            state.allowedTabs = ['report', 'results'];
            if (state.isAuthenticated) {
                state.canWritePlayKind = true;
            }
        }
        const canPk = state.canWritePlayKind === true
            || state.isLoopback
            || state.isAdmin
            || (state.isAuthenticated && roleGrantsPermission(r, 'write:playkind'));
        state.canWritePlayKind = !!canPk;
        const p = Array.isArray(state.permissions) ? state.permissions.slice() : [];
        if (canPk && !p.includes('write:playkind')) p.push('write:playkind');
        if (lanPlayKindAllowed(state)) {
            state.canWritePlayKind = true;
            if (!p.includes('write:playkind')) p.push('write:playkind');
        }
        if (state.isLoopback || state.isAdmin) {
            state.isAdmin = true;
            state.isAuthenticated = true;
            state.canManageUsers = true;
            ['manage:users', 'manage:settings', 'export:reports'].forEach(perm => {
                if (!p.includes(perm)) p.push(perm);
            });
        }
        if (state.canManageUsers && !p.includes('manage:users')) p.push('manage:users');
        state.permissions = p;
        return state;
    }

    function canManageLanUsers() {
        if (isLikelyLoopbackClient()) return true;
        if (!_authState) return false;
        if (_authState.canManageUsers) return true;
        if (_authState.isLoopback || _authState.isAdmin) return true;
        const h = (window.location.hostname || '').toLowerCase();
        const ips = Array.isArray(_authState.hostLanAddresses) ? _authState.hostLanAddresses : [];
        if (h && ips.some(ip => String(ip).toLowerCase() === h)) return true;
        return authCan('manage:users');
    }

    /** True when this session may edit/save play kind on the Results panel. */
    function canEditPlayKind() {
        if (lanPlayKindAllowed(_authState)) return true;
        if (!_authState) return isLikelyLoopbackClient();
        const role = authRoleKey(_authState);
        if (isDataEntrySession(_authState)) return true;
        if (_authState.isLoopback || _authState.isAdmin) return true;
        if (_authState.canWritePlayKind === true) return true;
        const perms = _authState.permissions || [];
        if (perms.includes('write:playkind')) return true;
        return roleGrantsPermission(role, 'write:playkind');
    }

    function authRole() {
        return authRoleKey(_authState);
    }

    function roleGrantsPermission(role, permission) {
        if (!role) return false;
        if (role === 'admin' || role === 'operator' || role === 'supervisor' || role === 'analyst'
            || role === 'data_entry' || role === 'viewer') {
            if (permission === 'write:playkind') return true;
        }
        if (role === 'admin' || role === 'operator' || role === 'supervisor') {
            if (permission === 'export:reports') return true;
        }
        if (role === 'admin') return true;
        return false;
    }

    function authCan(permission) {
        if (!_authState) return isLikelyLoopbackClient();
        if (_authState.isLoopback) return true;
        if (!_authState.isAuthenticated) return false;
        if (_authState.isAdmin) return true;
        if (permission === 'write:playkind' && _authState.canWritePlayKind) return true;
        if (permission === 'export:reports' && _authState.canExportReports) return true;
        const perms = _authState.permissions || [];
        if (perms.includes(permission)) return true;
        return roleGrantsPermission(authRole(), permission);
    }

    function isLikelyLoopbackClient() {
        const h = (window.location.hostname || '').toLowerCase();
        return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
    }

    /** Browser opened via server LAN hostname/IP (not localhost). */
    function isRemoteLanBrowser() {
        return !isLikelyLoopbackClient();
    }

    /** Play kind editing is allowed for every LAN user (all roles). */
    function lanPlayKindAllowed(state) {
        if (isLikelyLoopbackClient()) return true;
        if (state && state.lanEnabled === false) return false;
        return isRemoteLanBrowser();
    }

    function syncLanLoginGate(state) {
        const api = window.AUDIOBoxxLanGate;
        if (!api || typeof api.sync !== 'function') return;
        if (!state) return;
        api.sync({
            isLoopback: state.isLoopback,
            isAuthenticated: state.isAuthenticated,
            lanEnabled: state.lanEnabled,
        });
    }

    async function refreshAuthStatus() {
        const delays = [0, 400, 1200];
        let lastErr = null;
        if (window.__initialAuthStatus) {
            const boot = normalizeAuthStatus(window.__initialAuthStatus);
            delete window.__initialAuthStatus;
            if (boot) {
                _authState = boot;
                _authStatusError = null;
                applyAuthGate();
                applyPermissionUi();
                syncLanLoginGate(_authState);
                document.dispatchEvent(new CustomEvent('audioboxx-auth-ready', { detail: _authState }));
            }
        }
        for (let i = 0; i < delays.length; i++) {
            if (delays[i]) await new Promise(r => setTimeout(r, delays[i]));
            try {
                const r = await fetch('/api/auth/status', { cache: 'no-store' });
                if (!r.ok) throw new Error('HTTP ' + r.status);
                const normalized = normalizeAuthStatus(await r.json());
                if (!normalized) throw new Error('invalid response');
                _authState = normalized;
                _authStatusError = null;
                applyAuthGate();
                applyPermissionUi();
                syncLanLoginGate(_authState);
                document.dispatchEvent(new CustomEvent('audioboxx-auth-ready', { detail: _authState }));
                return;
            } catch (e) {
                lastErr = e;
            }
        }
        _authStatusError = (lastErr && (lastErr.message || String(lastErr))) || 'unavailable';
        if (!_authState) {
            if (isLikelyLoopbackClient()) {
                _authState = finalizeAuthState({
                    isAdmin: true, isLoopback: true, isAuthenticated: true, canManageUsers: true,
                    publicTabs: [], allowedTabs: [],
                    permissions: ['manage:users', 'manage:settings'], hostLanAddresses: [],
                    lanEnabled: true, lanListeningOnAllInterfaces: true
                });
            } else {
                _authState = {
                    isAdmin: false, isLoopback: false, isAuthenticated: false, publicTabs: [], allowedTabs: [],
                    permissions: [], hostLanAddresses: [], lanEnabled: true, lanListeningOnAllInterfaces: true
                };
            }
        }
        applyAuthGate();
        applyPermissionUi();
        syncLanLoginGate(_authState);
        document.dispatchEvent(new CustomEvent('audioboxx-auth-ready', { detail: _authState }));
    }

    window.__audioboxxRefreshAuth = refreshAuthStatus;

    function applyPermissionUi() {
        const canPlayKind = canEditPlayKind();
        const canExport = authCan('export:reports');
        ['pd-kind', 'pd-custom', 'pd-epg', 'pd-save'].forEach(id => {
            const el = document.getElementById(id);
            if (el) el.disabled = !canPlayKind;
        });
        const pdManage = document.getElementById('pd-manage');
        if (pdManage) pdManage.disabled = !canPlayKind;
        const pdHint = document.querySelector('#play-details .pd-hint');
        if (pdHint) {
            pdHint.textContent = 'Set play kind and EPG program for this event. Values are saved to the database and appear in CSV, Excel, and PDF reports.';
        }
        ['rpt-export-xlsx', 'rpt-export-xlsx-raw', 'rpt-export-csv', 'rpt-export-csv-raw',
            'rpt-export-pdf', 'rpt-export-pdf-raw', 'mon-export-xlsx'].forEach(id => {
            const el = document.getElementById(id);
            if (el) {
                el.disabled = !canExport;
                el.title = canExport ? (el.title || '') : 'Requires supervisor role or higher';
            }
        });
    }

    function applyAuthGate() {
        const {
            isAdmin, isAuthenticated, isLoopback, clientIp,
            lanEnabled = true, breakGlassUsername = 'admin', role = 'viewer', user,
            publicTabs = [],
            hostLanAddresses = [],
            lanListeningOnAllInterfaces = true
        } = _authState || {};
        const tabSet = effectiveTabSet(_authState);

        // ── Overview-tab LAN access card ────────────────────────────
        // This is the user-facing place to see and change the session's
        // access level. Three distinct states are surfaced:
        //   • Host (loopback) — full admin, nothing to unlock.
        //   • LAN visitor — read-only view, button offers "Unlock".
        //   • LAN admin   — promoted via cookie, button offers "Lock".
        const icon = document.getElementById('ov-auth-icon');
        const state = document.getElementById('ov-auth-state');
        const ipEl = document.getElementById('ov-auth-ip');
        const modeEl = document.getElementById('ov-auth-mode');
        const pubEl = document.getElementById('ov-auth-public');
        const noteEl = document.getElementById('ov-auth-note');
        const userEl = document.getElementById('ov-auth-user');
        const btn = document.getElementById('ov-auth-toggle');

        const roleLabel = (r) => lanRoleLabel(r);
        let modeText, stateText, noteText, iconText, btnText, btnVisible;
        if (isLoopback) {
            modeText  = 'Host (loopback) — full admin access';
            stateText = 'Host';
            noteText  = 'You are on the host machine. Every tab is available — expand this card to add LAN users and roles.';
            iconText  = '🟢';
            btnText   = '';
            btnVisible = false;
        } else if (isAuthenticated) {
            modeText  = `LAN — signed in as ${roleLabel(role)}`;
            stateText = roleLabel(role);
            noteText  = isAdmin
                ? 'Full admin session. Sign out to return to the public LAN view.'
                : 'Your role controls tabs and actions (play kind, exports, streams). Sign out when finished.';
            iconText  = '🔓';
            btnText   = 'Sign out';
            btnVisible = true;
        } else {
            modeText  = 'LAN — not signed in';
            stateText = 'Guest';
            noteText  = 'Sign in at the LAN gateway to access your role-based tabs and actions.';
            iconText  = '🔒';
            btnText   = 'Sign in';
            btnVisible = true;
        }
        if (userEl) {
            if (isLoopback) userEl.textContent = '—';
            else if (user && user.username) {
                const dn = user.displayName && user.displayName !== user.username
                    ? `${user.displayName} (${user.username})` : user.username;
                userEl.textContent = `${dn} · ${roleLabel(user.role || role)}`;
            } else userEl.textContent = '—';
        }
        if (icon) icon.textContent = iconText;
        if (state) state.textContent = stateText;
        if (modeEl) modeEl.textContent = modeText;
        if (ipEl) ipEl.textContent = clientIp || (isLoopback ? '127.0.0.1' : '—');
        if (pubEl) {
            const arr = Array.isArray(publicTabs) ? publicTabs : [];
            if (_authStatusError && !arr.length) {
                pubEl.innerHTML = '<span style="color:var(--warn);">Could not load — use Retry under Share with LAN</span>';
            } else {
                pubEl.textContent = arr.length ? arr.join(', ') : '(none configured)';
            }
        }
        if (noteEl) noteEl.textContent = noteText;
        if (btn) {
            btn.textContent = btnText;
            btn.style.display = btnVisible ? '' : 'none';
        }

        // One-line summary visible when the card is collapsed (Host / LAN).
        const headSummary = document.getElementById('ov-auth-head-summary');
        if (headSummary) {
            const lanTag = lanEnabled ? 'LAN on' : 'LAN off';
            if (isLoopback) {
                headSummary.textContent = lanTag;
                headSummary.title = 'Host machine — click the card header to expand LAN details';
            } else {
                const ips = Array.isArray(hostLanAddresses) ? hostLanAddresses : [];
                const port = (window.location.port || (window.location.protocol === 'https:' ? '443' : '80'));
                const proto = window.location.protocol || 'http:';
                const firstUrl = ips.length ? `${proto}//${ips[0]}:${port}` : '';
                headSummary.textContent = [stateText, lanTag, firstUrl].filter(Boolean).join(' · ');
                headSummary.title = firstUrl || headSummary.textContent;
            }
        }
        updateLanCardCollapseUi();

        // ── Share-with-LAN URL list ──────────────────────────────────
        // Build the list of "http://<host-ip>:<port>" URLs the host can
        // share with LAN devices. Falls back to a helpful note when no
        // non-loopback NIC is up or LAN is currently disabled.
        const shareEl = document.getElementById('ov-auth-share');
        if (shareEl) {
            const port = (window.location.port || (window.location.protocol === 'https:' ? '443' : '80'));
            const proto = window.location.protocol || 'http:';
            const ips = Array.isArray(hostLanAddresses) ? hostLanAddresses : [];
            const retryBtn = '<button type="button" class="secondary" id="ov-auth-retry" style="font-size:10px;padding:2px 8px;margin-left:6px;">Retry</button>';
            if (_authStatusError) {
                shareEl.innerHTML = `<span style="color:var(--warn);">Could not load LAN settings (${_authStatusError}).${retryBtn}</span>`;
                document.getElementById('ov-auth-retry')?.addEventListener('click', () => refreshAuthStatus());
            } else if (!lanEnabled) {
                shareEl.innerHTML = '<span style="color:var(--warn);">LAN access is OFF — toggle it on to share.</span>';
            } else if (!lanListeningOnAllInterfaces) {
                shareEl.innerHTML = '<span style="color:var(--warn);">Server is bound to localhost only — set Kestrel to <code>http://0.0.0.0:5050</code> in appsettings so LAN devices can connect.</span>';
            } else if (ips.length === 0) {
                const hostHint = window.location.hostname && window.location.hostname !== 'localhost'
                    ? `${proto}//${window.location.hostname}:${port}`
                    : '';
                shareEl.innerHTML = '<span style="color:var(--muted);">No LAN IPv4 detected on this PC.'
                    + (hostHint ? ` Try <code>${hostHint}</code> if your network resolves this machine by name.` : ' Connect Wi‑Fi/Ethernet or add <code>LanAccess:AdvertiseAddresses</code> in appsettings.')
                    + '</span>';
            } else {
                shareEl.innerHTML = ips.map(ip =>
                    `<div style="display:flex;gap:6px;align-items:center;">`
                    + `<code>${proto}//${ip}:${port}</code>`
                    + ` <button type="button" class="secondary" style="font-size:10px;padding:2px 6px;" data-copy="${proto}//${ip}:${port}">Copy</button>`
                    + `</div>`
                ).join('');
                // Hook copy buttons (delegated would be simpler but
                // this list is tiny).
                shareEl.querySelectorAll('button[data-copy]').forEach(b => {
                    b.addEventListener('click', () => {
                        try {
                            navigator.clipboard.writeText(b.dataset.copy);
                            showToast('URL copied to clipboard.', 'ok');
                        } catch { /* clipboard blocked — silently ignore */ }
                    });
                });
            }
        }

        // ── LAN on/off toggle ────────────────────────────────────────
        // Admin-only; we hide the entire control for non-admin
        // sessions because they can't change it and the visible
        // checkbox state would just be misleading.
        const lanWrap = document.getElementById('ov-lan-toggle-wrap');
        const lanCb   = document.getElementById('ov-lan-toggle');
        const lanLbl  = document.getElementById('ov-lan-toggle-label');
        if (lanWrap) lanWrap.style.display = (isLoopback || authCan('manage:settings')) ? 'inline-flex' : 'none';
        if (lanCb)   lanCb.checked = !!lanEnabled;
        if (lanLbl)  lanLbl.textContent = lanEnabled ? 'On' : 'Off';

        const credsWrap = document.getElementById('ov-auth-creds-wrap');
        if (credsWrap) credsWrap.style.display = (isLoopback || authCan('manage:settings')) ? '' : 'none';
        const lanUsersWrap = document.getElementById('ov-lan-users-wrap');
        const canManageUsers = canManageLanUsers();
        const authCard = document.getElementById('ov-auth-card');
        if (authCard) authCard.classList.toggle('host-manage-lan', !!canManageUsers);
        if (lanUsersWrap) {
            lanUsersWrap.hidden = false;
            lanUsersWrap.classList.toggle('is-visible', !!canManageUsers);
            lanUsersWrap.classList.toggle('host-force-show', !!canManageUsers);
            lanUsersWrap.style.display = '';
        }
        if (canManageUsers && typeof window.__refreshLanUsers === 'function') {
            window.__refreshLanUsers();
        }
        const credsUser = document.getElementById('ov-auth-creds-user');
        if (credsUser) credsUser.placeholder = breakGlassUsername || 'admin';

        const isDataEntry = isDataEntrySession(_authState);
        document.body.classList.toggle('abx-role-data-entry', isDataEntry);

        syncRoleTabNav(tabSet);
        applyRolePanels(tabSet);
        if (isDataEntry) {
            if (typeof stopOverviewPolling === 'function') stopOverviewPolling();
            switchToAppTab('results');
        } else {
            let stillActive = document.querySelector('.tab.tab-active[data-tab]');
            const activeTab = stillActive ? String(stillActive.dataset.tab || '').toLowerCase() : '';
            if (!stillActive || !tabSet.has(activeTab) || stillActive.hidden) {
                const firstAllowed = [...document.querySelectorAll('.tab[data-tab]')]
                    .map(b => String(b.dataset.tab || '').toLowerCase())
                    .find(t => tabSet.has(t));
                if (firstAllowed) switchToAppTab(firstAllowed);
            } else if (activeTab === 'overview' && typeof startOverviewPolling === 'function') {
                startOverviewPolling();
            } else if (typeof stopOverviewPolling === 'function') {
                stopOverviewPolling();
            }
        }

    }

    // Unlock dialog wiring — the trigger button now lives on the
    // Overview tab (`#ov-auth-toggle`). The modal markup stays at the
    // body level so it floats above any tab.
    (function _wireAuthSessionToggle() {
        const toggle = document.getElementById('ov-auth-toggle');
        if (!toggle) return;

        toggle.addEventListener('click', async () => {
            if (_authState.isAuthenticated && !_authState.isLoopback) {
                try { await fetch('/api/auth/logout', { method: 'POST' }); }
                catch { /* ignore */ }
                await refreshAuthStatus();
                showToast('Signed out.', 'ok');
                return;
            }
            if (window.AUDIOBoxxLanGate && typeof window.AUDIOBoxxLanGate.showLogin === 'function') {
                window.AUDIOBoxxLanGate.showLogin();
            }
        });
    })();

    // ── LAN on/off toggle (admin-only) ────────────────────────────────
    // Posts to /api/auth/lan-enabled. We optimistically flip the
    // checkbox state when the call returns OK; on failure we refresh
    // auth status so the UI snaps back to whatever the server reports.
    (function _wireLanToggle() {
        const cb = document.getElementById('ov-lan-toggle');
        if (!cb) return;
        cb.addEventListener('change', async () => {
            const desired = cb.checked;
            try {
                const r = await fetch('/api/auth/lan-enabled', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ enabled: desired })
                });
                if (!r.ok) throw new Error('HTTP ' + r.status);
                showToast(desired ? 'LAN access enabled.' : 'LAN access disabled.', 'ok');
            } catch (err) {
                showToast('Could not change LAN state: ' + (err?.message || err), 'err');
            }
            await refreshAuthStatus();
        });
    })();

    // ── Change-credentials form (admin-only) ──────────────────────────
    (function _wireCredsForm() {
        const editBtn = document.getElementById('ov-auth-creds-edit');
        const form    = document.getElementById('ov-auth-creds-form');
        const userEl  = document.getElementById('ov-auth-creds-user');
        const passEl  = document.getElementById('ov-auth-creds-pass');
        const statusEl= document.getElementById('ov-auth-creds-status');
        const cancel  = document.getElementById('ov-auth-creds-cancel');
        const save    = document.getElementById('ov-auth-creds-save');
        if (!editBtn || !form || !save) return;

        function setStatus(msg, kind) {
            if (!statusEl) return;
            statusEl.textContent = msg || '';
            statusEl.className = 'status' + (kind ? ' ' + kind : '');
        }
        function open() {
            setStatus('');
            if (userEl) userEl.value = (_authState && _authState.breakGlassUsername) || 'admin';
            if (passEl) passEl.value = '';
            form.style.display = 'flex';
            editBtn.style.display = 'none';
        }
        function close() {
            form.style.display = 'none';
            editBtn.style.display = '';
        }
        editBtn.addEventListener('click', open);
        cancel && cancel.addEventListener('click', close);
        save.addEventListener('click', async () => {
            const u = (userEl && userEl.value || '').trim();
            const p = (passEl && passEl.value) || '';
            if (!u || !p) { setStatus('Username and password are required.', 'err'); return; }
            save.disabled = true;
            try {
                const r = await fetch('/api/auth/credentials', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ username: u, newPassword: p })
                });
                if (!r.ok) {
                    const e = await r.json().catch(() => ({}));
                    throw new Error(e.error || ('HTTP ' + r.status));
                }
                setStatus('Saved. Existing LAN sessions stay valid until they expire.', 'ok');
                await refreshAuthStatus();
                setTimeout(close, 1200);
            } catch (err) {
                setStatus('Save failed: ' + (err?.message || err), 'err');
            } finally {
                save.disabled = false;
            }
        });
    })();

    const ALL_TAB_IDS = ['overview', 'fingerprint', 'identify', 'library', 'streams', 'monitor', 'recording', 'results', 'report', 'about'];

    /** Client mirror of server LanPermissions.RoleTabs (+ data_entry exclusive). */
    function clientRoleTabsList(role) {
        const r = String(role || 'viewer').toLowerCase();
        if (r === 'admin') return ALL_TAB_IDS.slice();
        if (r === 'data_entry') return ['report', 'results'];
        if (r === 'operator') return ['report', 'results', 'about', 'streams', 'monitor', 'identify'];
        if (r === 'analyst' || r === 'supervisor' || r === 'viewer') {
            return ['report', 'results', 'about'];
        }
        return ['report', 'results', 'about'];
    }

    function authRoleKey(state) {
        if (!state) return 'viewer';
        const u = state.user;
        return String((u && u.role) || state.role || 'viewer').toLowerCase();
    }

    function isDataEntrySession(state) {
        if (!state || !state.isAuthenticated) return false;
        if (state.isAdmin || state.isLoopback) return false;
        if (authRoleKey(state) === 'data_entry') return true;
        const tabs = Array.isArray(state.allowedTabs)
            ? state.allowedTabs.map(t => String(t).toLowerCase()).filter(Boolean)
            : [];
        const only = new Set(tabs);
        return only.size > 0 && [...only].every(t => t === 'results' || t === 'report')
            && only.has('results');
    }

    function effectiveTabSet(state) {
        if (!state) return new Set();
        const role = authRoleKey(state);
        if (role === 'data_entry') return new Set(['report', 'results']);
        if (state.isLoopback || state.isAdmin) return new Set(ALL_TAB_IDS);
        let set;
        if (state.isAuthenticated) {
            const fromServer = Array.isArray(state.allowedTabs) ? state.allowedTabs : [];
            if (fromServer.length) {
                set = new Set(fromServer.map(t => String(t).toLowerCase()));
            } else {
                set = new Set(clientRoleTabsList(role));
            }
        } else {
            set = new Set((state.publicTabs || []).map(t => String(t).toLowerCase()).filter(Boolean));
        }
        if (role === 'data_entry') return new Set(['report', 'results']);
        // Host/admin only on LAN — hide like fingerprint / identify.
        set.delete('overview');
        set.delete('recording');
        return set;
    }

    const _tabButtonStash = new Map();

    function tabIsAllowed(tabId) {
        const tab = String(tabId || '').toLowerCase();
        if (!tab) return false;
        if (isDataEntrySession(_authState)) return tab === 'results' || tab === 'report';
        return effectiveTabSet(_authState).has(tab);
    }

    function applyRolePanels(tabSet) {
        const isDataEntry = isDataEntrySession(_authState);
        const activeTabBtn = document.querySelector('.tab.tab-active[data-tab], .tab.active[data-tab]');
        const activeTab = isDataEntry
            ? 'results'
            : String(activeTabBtn?.dataset?.tab || '').toLowerCase();
        const resolvedActive = tabSet.has(activeTab)
            ? activeTab
            : ([...tabSet][0] || 'results');

        document.querySelectorAll('.panel[data-panel]').forEach(p => {
            const tab = String(p.dataset.panel || '').toLowerCase();
            const allowed = tabSet.has(tab);
            if (!allowed) {
                p.classList.remove('active');
                p.style.setProperty('display', 'none', 'important');
                p.hidden = true;
                p.setAttribute('aria-hidden', 'true');
                return;
            }
            const show = tab === resolvedActive;
            p.hidden = !show;
            p.setAttribute('aria-hidden', show ? 'false' : 'true');
            if (show) {
                p.classList.add('active');
                p.style.removeProperty('display');
            } else {
                p.classList.remove('active');
                p.style.setProperty('display', 'none', 'important');
            }
        });
    }

    /** Remove disallowed tab buttons from the nav (restore when role changes). */
    function syncRoleTabNav(tabSet) {
        const nav = document.querySelector('nav.tabs-static');
        const isDataEntry = isDataEntrySession(_authState);
        if (nav) {
            nav.hidden = false;
            nav.setAttribute('aria-hidden', 'false');
            nav.classList.toggle('data-entry-nav-hidden', false);
        }

        document.querySelectorAll('.tab[data-tab]').forEach(btn => {
            const tab = String(btn.dataset.tab || '').toLowerCase();
            if (tabSet.has(tab)) return;
            if (!btn.isConnected) return;
            _tabButtonStash.set(tab, { el: btn, parent: btn.parentNode, next: btn.nextSibling });
            btn.remove();
        });

        for (const [tab, rec] of [..._tabButtonStash.entries()]) {
            if (!tabSet.has(tab)) continue;
            if (document.querySelector(`.tab[data-tab="${tab}"]`)) {
                _tabButtonStash.delete(tab);
                continue;
            }
            const parent = rec.parent;
            const el = rec.el;
            if (!parent || !el) continue;
            if (rec.next && rec.next.parentNode === parent) parent.insertBefore(el, rec.next);
            else parent.appendChild(el);
            _tabButtonStash.delete(tab);
        }
    }

    function switchToAppTab(tabId) {
        const tab = String(tabId || '').toLowerCase();
        if (!tabIsAllowed(tab)) return false;
        const btn = document.querySelector(`.tab[data-tab="${tab}"]`);
        const panel = document.querySelector(`.panel[data-panel="${tab}"]`);
        if (!panel) return false;
        if (btn) {
            $$('.tab').forEach(b => {
                b.classList.remove('active', 'tab-active');
                b.classList.add('tab-inactive');
            });
            btn.classList.remove('tab-inactive');
            btn.classList.add('active', 'tab-active');
        }
        $$('.panel').forEach(p => {
            const id = String(p.dataset.panel || '').toLowerCase();
            p.classList.remove('active');
            if (id !== tab) {
                p.style.setProperty('display', 'none', 'important');
                p.hidden = true;
                p.setAttribute('aria-hidden', 'true');
            }
        });
        panel.classList.add('active');
        panel.style.removeProperty('display');
        panel.hidden = false;
        panel.setAttribute('aria-hidden', 'false');
        if (typeof stopOverviewPolling === 'function') stopOverviewPolling();
        if (tab === 'overview' && typeof startOverviewPolling === 'function') startOverviewPolling();
        if (tab === 'library' && typeof loadLibrary === 'function') loadLibrary();
        if (tab === 'fingerprint' && typeof loadLibraries === 'function') loadLibraries();
        if (tab === 'fingerprint' && typeof loadStorageBackend === 'function') loadStorageBackend();
        if (tab === 'report' && typeof activateReportTab === 'function') activateReportTab();
        if (tab === 'identify') {
            syncIdfOfflineLicenseBanner(_headerLicenseSnapshot);
            refreshLicenseLimits().catch(() => {});
            if (_idfReattachState) _idfReattachState.attempted = false;
            reattachIdentifyJob();
        }
        return true;
    }

    const LAN_ROLE_OPTIONS = [
        { id: 'viewer', label: 'Viewer — report, results, about (play kind)' },
        { id: 'data_entry', label: 'Data entry — Report + Results (play kind)' },
        { id: 'analyst', label: 'Analyst — classify + public tabs' },
        { id: 'supervisor', label: 'Supervisor — analyst + exports' },
        { id: 'operator', label: 'Operator — streams, monitor, offline scan' },
        { id: 'admin', label: 'Admin — full access' },
    ];

    function lanRoleLabel(roleId) {
        const id = String(roleId || '').toLowerCase();
        const hit = LAN_ROLE_OPTIONS.find(r => r.id === id);
        if (hit) return hit.label.split(' — ')[0];
        return id ? id.charAt(0).toUpperCase() + id.slice(1) : 'Viewer';
    }

    function buildLanRoleSelectHtml(selectedId) {
        const sel = String(selectedId || 'analyst').toLowerCase();
        return LAN_ROLE_OPTIONS.map(r =>
            `<option value="${r.id}"${r.id === sel ? ' selected' : ''}>${escapeHtml(r.label)}</option>`
        ).join('');
    }

    (function _wireLanUsers() {
        const tbody = document.getElementById('ov-lan-users-tbody');
        const statusEl = document.getElementById('ov-lan-users-status');
        const auditEl = document.getElementById('ov-lan-audit');
        const addBtn = document.getElementById('ov-lan-users-add');
        const addForm = document.getElementById('ov-lan-users-add-form');
        const addUser = document.getElementById('ov-lan-add-user');
        const addPass = document.getElementById('ov-lan-add-pass');
        const addDisplay = document.getElementById('ov-lan-add-display');
        const addRole = document.getElementById('ov-lan-add-role');
        const addSave = document.getElementById('ov-lan-add-save');
        const addCancel = document.getElementById('ov-lan-add-cancel');
        if (!tbody || !addBtn) return;

        if (addRole) addRole.innerHTML = buildLanRoleSelectHtml('data_entry');

        function setStatus(msg, kind) {
            if (!statusEl) return;
            statusEl.textContent = msg || '';
            statusEl.className = 'status' + (kind ? ' ' + kind : '');
        }

        async function loadAudit() {
            if (!auditEl || !canManageLanUsers()) return;
            try {
                const r = await fetch('/api/auth/audit?limit=40', { cache: 'no-store' });
                if (!r.ok) return;
                const rows = await r.json();
                auditEl.textContent = (rows || []).map(e =>
                    `${e.atUtc || ''}  ${e.username || '?'}  ${e.action || ''}  ${e.target || ''}`
                ).join('\n') || '(no entries)';
            } catch { /* ignore */ }
        }

        async function refreshUsers() {
            if (!canManageLanUsers()) return;
            try {
                const r = await fetch('/api/auth/users', { cache: 'no-store' });
                if (!r.ok) throw new Error('HTTP ' + r.status);
                const users = await r.json();
                const adminCount = (users || []).filter(x =>
                    x.enabled && String(x.role || '').toLowerCase() === 'admin').length;
                tbody.innerHTML = '';
                (users || []).forEach(u => {
                    const tr = document.createElement('tr');
                    const isLastAdmin = u.enabled
                        && String(u.role || '').toLowerCase() === 'admin'
                        && adminCount <= 1;
                    const roleOpts = buildLanRoleSelectHtml(u.role);
                    tr.innerHTML = `
                        <td>${escapeHtml(u.username)}</td>
                        <td><input type="text" data-f="display" value="${escapeHtml(u.displayName || '')}" style="width:100%;max-width:140px;font-size:11px;padding:2px 6px;" /></td>
                        <td><select data-f="role" style="font-size:11px;"${isLastAdmin ? ' disabled title="Last enabled admin"' : ''}>${roleOpts}</select></td>
                        <td><input type="checkbox" data-f="enabled" ${u.enabled ? 'checked' : ''}${isLastAdmin ? ' disabled title="Last enabled admin"' : ''} /></td>
                        <td style="white-space:nowrap;">
                            <button type="button" class="secondary btn-tiny" data-act="save">Save</button>
                            <button type="button" class="secondary btn-tiny" data-act="pwd" title="Set new password">Pwd</button>
                            <button type="button" class="danger btn-tiny" data-act="del"${isLastAdmin ? ' disabled title="Cannot delete the last enabled admin"' : ''}>Del</button>
                        </td>`;
                    tr.dataset.id = u.id;
                    if (isLastAdmin) tr.dataset.lastAdmin = '1';
                    tbody.appendChild(tr);
                });
                await loadAudit();
            } catch (e) {
                setStatus('Could not load users: ' + (e?.message || e), 'err');
            }
        }

        tbody.addEventListener('click', async (ev) => {
            const btn = ev.target.closest('button[data-act]');
            if (!btn) return;
            const tr = btn.closest('tr');
            if (!tr || !tr.dataset.id) return;
            const id = tr.dataset.id;
            const act = btn.dataset.act;
            if (act === 'del') {
                if (tr.dataset.lastAdmin === '1') {
                    setStatus('Cannot delete the last enabled admin account.', 'err');
                    return;
                }
                if (!confirm('Delete this LAN user?')) return;
                const r = await fetch('/api/auth/users/' + encodeURIComponent(id), { method: 'DELETE' });
                if (!r.ok) {
                    const e = await r.json().catch(() => ({}));
                    setStatus(e.error || 'Delete failed', 'err');
                    return;
                }
                setStatus('User deleted.', 'ok');
                await refreshUsers();
                return;
            }
            if (act === 'pwd') {
                const p = prompt('New password for this user:');
                if (!p) return;
                const r = await fetch('/api/auth/users/' + encodeURIComponent(id), {
                    method: 'PUT',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ password: p })
                });
                if (!r.ok) { setStatus('Password update failed', 'err'); return; }
                setStatus('Password updated.', 'ok');
                return;
            }
            if (act === 'save') {
                const display = tr.querySelector('[data-f="display"]')?.value?.trim();
                const role = tr.querySelector('[data-f="role"]')?.value;
                const enabled = !!tr.querySelector('[data-f="enabled"]')?.checked;
                const r = await fetch('/api/auth/users/' + encodeURIComponent(id), {
                    method: 'PUT',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ displayName: display, role, enabled })
                });
                if (!r.ok) {
                    const e = await r.json().catch(() => ({}));
                    setStatus(e.error || 'Save failed', 'err');
                    return;
                }
                setStatus('User saved.', 'ok');
                await refreshUsers();
            }
        });

        function showAddForm(show) {
            if (!addForm) return;
            addForm.hidden = !show;
            addBtn.style.display = show ? 'none' : '';
            if (show) {
                if (addUser) addUser.value = '';
                if (addPass) addPass.value = '';
                if (addDisplay) addDisplay.value = '';
                if (addRole) addRole.innerHTML = buildLanRoleSelectHtml('data_entry');
                setTimeout(() => addUser?.focus(), 40);
            }
        }

        addBtn.addEventListener('click', () => showAddForm(true));
        addCancel && addCancel.addEventListener('click', () => showAddForm(false));
        addSave && addSave.addEventListener('click', async () => {
            const username = (addUser?.value || '').trim();
            const password = addPass?.value || '';
            const role = addRole?.value || 'data_entry';
            const displayName = (addDisplay?.value || '').trim() || username;
            if (!username || !password) {
                setStatus('Username and password are required.', 'err');
                return;
            }
            addSave.disabled = true;
            try {
                const r = await fetch('/api/auth/users', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ username, password, role, displayName })
                });
                if (!r.ok) {
                    const e = await r.json().catch(() => ({}));
                    setStatus(e.error || 'Create failed', 'err');
                    return;
                }
                setStatus('User created.', 'ok');
                showAddForm(false);
                await refreshUsers();
            } finally {
                addSave.disabled = false;
            }
        });

        const origApply = applyAuthGate;
        window.__refreshLanUsers = refreshUsers;
        document.addEventListener('audioboxx-auth-ready', () => refreshUsers());
        setInterval(() => { if (canManageLanUsers()) refreshUsers(); }, 60000);
    })();

    // ── JioTV management card (admin-only) ────────────────────────────
    // Surfaces live status (running flag, port, exe path, hint) and
    // provides Start / Stop / Restart / Update buttons. Update calls
    // the bundled binary's self-updater which pulls the latest release
    // from github.com/JioTV-Go/jiotv_go and overwrites the .exe; the
    // server then relaunches with the existing PickFreePort failover
    // so a Hyper-V-blocked port doesn't break the post-update restart.
    let _jioPollTimer = null;
    async function refreshJioStatus() {
        try {
            const r = await fetch('/api/jiotv/status', { cache: 'no-store' });
            if (!r.ok) return;
            const s = await r.json();
            const card = document.getElementById('ov-jio-card');
            if (card) card.style.display = _authState.isAdmin ? '' : 'none';
            const state = document.getElementById('ov-jio-state');
            const url   = document.getElementById('ov-jio-url');
            const port  = document.getElementById('ov-jio-port');
            const exe   = document.getElementById('ov-jio-exe');
            const exit  = document.getElementById('ov-jio-exit');
            const hint  = document.getElementById('ov-jio-hint');
            if (state) state.textContent = s.running ? 'Running' : (s.exeAvailable ? 'Stopped' : 'Missing');
            if (state) state.style.color = s.running ? 'var(--success)' : (s.exeAvailable ? 'var(--warn)' : 'var(--danger)');
            if (url)   url.textContent = s.url || '—';
            const wantPort = s.configuredPort ?? 5001;
            if (port) {
                port.textContent = s.portFallbackActive
                    ? `${s.port} (wanted ${wantPort} — run AUDIOBoxx as Administrator once to reserve it)`
                    : String(s.port);
            }
            const urlHint = document.getElementById('jiotv-url-hint');
            if (urlHint && s.port) {
                urlHint.textContent = `http://127.0.0.1:${s.port}/live/<channelId>.m3u8`;
            }
            if (exe)   exe.textContent = s.exePath || '—';
            if (exit) {
                if (s.lastExitCode == null) exit.textContent = '—';
                else if (s.lastExitCode === 0) exit.textContent = '0 (clean)';
                else exit.textContent = `${s.lastExitCode} (failure)`;
            }
            if (hint) {
                if (s.hint && s.hint.length > 0) {
                    hint.textContent = s.hint;
                    hint.className = 'status err';
                    hint.style.display = '';
                } else {
                    hint.style.display = 'none';
                }
            }
        } catch { /* ignore — poll will retry */ }
    }
    function startJioPolling() {
        if (_jioPollTimer) return;
        _jioPollTimer = setInterval(refreshJioStatus, 5000);
        refreshJioStatus();
    }
    function stopJioPolling() {
        if (_jioPollTimer) { clearInterval(_jioPollTimer); _jioPollTimer = null; }
    }

    (function _wireJioControls() {
        const btnUpdate  = document.getElementById('ov-jio-update');
        const btnRestart = document.getElementById('ov-jio-restart');
        const btnStart   = document.getElementById('ov-jio-start');
        const btnStop    = document.getElementById('ov-jio-stop');
        if (!btnUpdate) return;
        async function call(path, opts = {}) {
            try {
                const r = await fetch(path, { method: 'POST', ...opts });
                const j = await r.json().catch(() => ({}));
                return { ok: r.ok, body: j };
            } catch (err) {
                return { ok: false, body: { error: err.message || String(err) } };
            }
        }
        btnRestart && btnRestart.addEventListener('click', async () => {
            btnRestart.disabled = true;
            try { showToast('Restarting JioTV…', 'ok'); }
            catch { /* ignore */ }
            const r = await call('/api/jiotv/restart');
            try { showToast(r.ok ? 'JioTV restart triggered' : 'Restart failed', r.ok ? 'ok' : 'err'); } catch { /* ignore */ }
            btnRestart.disabled = false;
            refreshJioStatus();
        });
        btnStart && btnStart.addEventListener('click', async () => {
            btnStart.disabled = true;
            const r = await call('/api/jiotv/start');
            try { showToast(r.ok ? 'JioTV start triggered' : 'Start failed', r.ok ? 'ok' : 'err'); } catch { /* ignore */ }
            btnStart.disabled = false;
            refreshJioStatus();
        });
        btnStop && btnStop.addEventListener('click', async () => {
            btnStop.disabled = true;
            const r = await call('/api/jiotv/stop');
            try { showToast(r.ok ? 'JioTV stopped' : 'Stop failed', r.ok ? 'ok' : 'err'); } catch { /* ignore */ }
            btnStop.disabled = false;
            refreshJioStatus();
        });
        btnUpdate && btnUpdate.addEventListener('click', async () => {
            if (!confirm('Download the latest jiotv_go release from GitHub and replace the binary? JioTV will be briefly unavailable.')) return;
            btnUpdate.disabled = true;
            const prog = document.getElementById('ov-jio-update-progress');
            const log  = document.getElementById('ov-jio-update-log');
            const stat = document.getElementById('ov-jio-update-status');
            if (prog) prog.style.display = '';
            if (log)  log.textContent = '';
            if (stat) stat.textContent = 'Stopping JioTV and downloading latest release…';
            const r = await call('/api/jiotv/update');
            if (stat) stat.textContent = (r.body && r.body.summary) || (r.ok ? 'Update finished.' : 'Update failed.');
            if (log)  log.textContent = ((r.body && r.body.output) || []).join('\n');
            btnUpdate.disabled = false;
            try { showToast(r.ok ? 'JioTV updated' : 'JioTV update failed', r.ok ? 'ok' : 'err'); } catch { /* ignore */ }
            refreshJioStatus();
        });
    })();

    // Start polling once the overview tab is reachable. Polling is gated
    // on admin status — the card itself hides for non-admin sessions.
    startJioPolling();

    // ── LAN access card — expand / collapse on Overview ───────────────
    const OV_LAN_CARD_EXPANDED_KEY = 'ovLanCardExpanded';
    let _ovLanCardExpandedInit = false;

    function updateLanCardCollapseUi() {
        const card = document.getElementById('ov-auth-card');
        const collapseBtn = document.getElementById('ov-auth-collapse');
        if (!card) return;
        const expanded = !card.classList.contains('collapsed');
        if (collapseBtn) {
            collapseBtn.textContent = expanded ? '▼' : '▶';
            collapseBtn.setAttribute('aria-expanded', String(expanded));
            collapseBtn.title = expanded ? 'Collapse LAN details' : 'Expand LAN details';
        }
    }

    function setLanCardExpanded(expanded, persist) {
        const card = document.getElementById('ov-auth-card');
        if (!card) return;
        card.classList.toggle('collapsed', !expanded);
        updateLanCardCollapseUi();
        if (persist) {
            try { localStorage.setItem(OV_LAN_CARD_EXPANDED_KEY, expanded ? '1' : '0'); } catch { /* private mode */ }
        }
    }

    function initLanCardCollapse() {
        if (_ovLanCardExpandedInit) return;
        _ovLanCardExpandedInit = true;
        const card = document.getElementById('ov-auth-card');
        const head = document.getElementById('ov-auth-head');
        const collapseBtn = document.getElementById('ov-auth-collapse');
        if (!card) return;

        let expanded = true;
        const hostOrAdmin = canManageLanUsers();
        if (hostOrAdmin) {
            expanded = true;
            try { localStorage.setItem(OV_LAN_CARD_EXPANDED_KEY, '1'); } catch { /* ignore */ }
        } else {
            try {
                const saved = localStorage.getItem(OV_LAN_CARD_EXPANDED_KEY);
                if (saved === '0' || saved === '1') expanded = saved === '1';
            } catch { /* ignore */ }
        }
        setLanCardExpanded(expanded, false);

        const toggleExpand = (e) => {
            if (e) {
                e.preventDefault();
                e.stopPropagation();
            }
            const willExpand = card.classList.contains('collapsed');
            setLanCardExpanded(willExpand, true);
        };
        if (collapseBtn) {
            collapseBtn.addEventListener('click', toggleExpand);
        }
        if (head) {
            head.addEventListener('click', (e) => {
                if (e.target.closest('#ov-auth-collapse, button, input, label, a, select, textarea')) return;
                toggleExpand(e);
            });
        }
    }

    refreshAuthStatus().then(() => {
        initLanCardCollapse();
        updateLanCardCollapseUi();
    });

    // ---- Fingerprint form ----
    const fpChunkEnable = $('#fp-chunk-enable');
    if (fpChunkEnable) {
        fpChunkEnable.addEventListener('change', () => {
            const on = fpChunkEnable.checked;
            const cs = $('#fp-chunk-size'); if (cs) cs.disabled = !on;
            const co = $('#fp-chunk-overlap'); if (co) co.disabled = !on;
        });
    }
    $('#fp-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const file = $('#fp-file').files[0];
        const status = $('#fp-status');
        if (!file) { status.textContent = 'Please select an audio file.'; status.className = 'status err'; return; }

        const btn = $('#fp-submit');
        btn.disabled = true;
        aboxBuddy('start', { kind: 'fingerprint', label: 'Fingerprinting…' });
        status.innerHTML = '<span class="spinner"></span>Computing fingerprint…';
        status.className = 'status';

        const fd = new FormData();
        fd.append('file', file);
        fd.append('id', $('#fp-id').value);
        fd.append('title', $('#fp-title').value);
        fd.append('artist', $('#fp-artist').value);
        const fpChunkOn = !!(fpChunkEnable && fpChunkEnable.checked);
        if (fpChunkOn) {
            const cs = Math.max(1, parseFloat($('#fp-chunk-size').value) || 10);
            const co = Math.max(0, parseFloat($('#fp-chunk-overlap').value) || 0);
            if (co >= cs) {
                status.textContent = 'Chunk overlap must be smaller than chunk length.';
                status.className = 'status err';
                btn.disabled = false;
                return;
            }
            fd.append('chunkSeconds', String(cs));
            fd.append('overlapSeconds', String(co));
        }

        try {
            const r = await fetch('/api/fingerprint', { method: 'POST', body: fd });
            const data = await r.json();
            if (!r.ok) throw new Error(data.error || data.detail || 'Failed');
            if (data.chunked) {
                status.textContent = `Stored ${data.chunks.length} chunk track${data.chunks.length === 1 ? '' : 's'} for "${data.track.title}" (base id ${data.track.id}, duration ${Number(data.durationSeconds).toFixed(1)}s).`;
                showToast(`Added ${data.chunks.length} chunk tracks`, 'ok');
            } else {
                status.textContent = `Stored "${data.track.title}" (${data.track.id}).`;
                showToast(`Added "${data.track.title}" to library`, 'ok');
            }
            status.className = 'status ok';
            $('#fp-form').reset();
            $('#fp-filename').textContent = '';
            if (fpChunkEnable) {
                fpChunkEnable.checked = false;
                $('#fp-chunk-size').disabled = true;
                $('#fp-chunk-overlap').disabled = true;
            }
            loadStats();
        } catch (err) {
            status.textContent = `Error: ${err.message}`;
            status.className = 'status err';
            showToast(err.message, 'err');
            aboxBuddy('end', { kind: 'fingerprint', outcome: 'error' });
        } finally {
            btn.disabled = false;
            if (status.className === 'status ok') aboxBuddy('end', { kind: 'fingerprint', outcome: 'done' });
        }
    });

    // ---- Folder scan ----
    let folderAbort = null;
    let folderRows = []; // collected for Excel export

    // Header-attached mirror of the folder-scan progress so the operator
    // sees "Fingerprinting N files… · X / N · added, skipped, failed" no
    // matter which tab they're viewing.
    const hdrProg = {
        wrap:   document.getElementById('header-progress'),
        label:  document.getElementById('header-progress-label'),
        counts: document.getElementById('header-progress-counts'),
        fill:   document.getElementById('header-progress-fill'),
        show(label, counts, pct) {
            if (!this.wrap) return;
            this.wrap.hidden = false;
            if (label  != null) this.label.textContent  = truncateDisplayText(label, 48);
            if (counts != null) {
                if (String(counts).includes('<')) this.counts.innerHTML = counts;
                else {
                    const full = String(counts);
                    this.counts.textContent = truncateDisplayText(full, 72);
                    this.counts.title = full.length > 72 ? full : '';
                }
            }
            if (pct    != null) this.fill.style.width   = pct + '%';
        },
        hide(delayMs = 0) {
            if (!this.wrap) return;
            const w = this.wrap;
            if (delayMs > 0) setTimeout(() => { w.hidden = true; }, delayMs);
            else w.hidden = true;
        }
    };

    /** Draggable help buddy — tasks, system stress, match celebrations. */
    function aboxBuddy(op, data) {
        try {
            const b = window.AboxBuddy;
            if (!b) return;
            const d = data || {};
            if (op === 'start') b.startTask(d);
            else if (op === 'progress') b.updateTask(d);
            else if (op === 'end') b.endTask(d);
            else if (op === 'system') b.reactSystem?.(d.cpu, d.ram);
            else if (op === 'match') b.reactMatch?.(d);
            else if (op === 'nomatch') b.reactNomatch?.(d);
        } catch { /* optional UI */ }
    }

    // Header mirror for folder identify (same pill as fingerprint folder scan).
    let idfHeaderActive = false;

    function formatIdentifyStatsPlain(matched, unmatched, failed) {
        return `match ${matched}, no match ${unmatched}, error ${failed}`;
    }

    function startIdfHeaderProgress(label) {
        idfHeaderActive = true;
        hdrProg.show(label || 'Scanning folder…', '', 0);
        aboxBuddy('start', { kind: 'identify', label: label || 'Identifying…' });
    }

    function paintIdfHeaderProgress(pct, label, countsPlain) {
        if (!idfHeaderActive) return;
        hdrProg.show(label, countsPlain, pct);
        aboxBuddy('progress', {
            kind: 'identify',
            pct: typeof pct === 'number' ? pct : undefined,
            label: countsPlain || label || 'Searching…'
        });
    }

    function finishIdfHeaderProgress(label, countsPlain, delayHide = 2500) {
        if (!idfHeaderActive) return;
        hdrProg.show(label, countsPlain, 100);
        hdrProg.hide(delayHide);
        idfHeaderActive = false;
        const outcome = label === 'Cancelled' ? 'cancel' : 'done';
        aboxBuddy('end', { kind: 'identify', outcome });
    }

    function stopIdfHeaderProgress(delayHide = 1500) {
        if (!idfHeaderActive) return;
        hdrProg.hide(delayHide);
        idfHeaderActive = false;
        aboxBuddy('end', { kind: 'identify', outcome: 'error' });
    }

    function idfHeaderLabel(total, chunkOn) {
        if (!total) return 'Identifying…';
        const files = `${total} file${total === 1 ? '' : 's'}`;
        return chunkOn ? `Identifying ${files} (chunk mode)…` : `Identifying ${files}…`;
    }

    const folderChunkEnable = $('#folder-chunk-enable');
    if (folderChunkEnable) {
        folderChunkEnable.addEventListener('change', () => {
            const on = folderChunkEnable.checked;
            const cs = $('#folder-chunk-size'); if (cs) cs.disabled = !on;
            const co = $('#folder-chunk-overlap'); if (co) co.disabled = !on;
        });
    }

    $('#folder-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const path = $('#folder-path').value.trim();
        if (!path) return;
        const derivedClient = deriveClientFromPath(path);
        if (derivedClient) setClientName(derivedClient);

        const status = $('#folder-status');
        const wrap = $('#folder-progress-wrap');
        const bar = $('#folder-bar');
        const counts = $('#folder-counts');
        const log = $('#folder-log');
        const submit = $('#folder-submit');
        const cancel = $('#folder-cancel');
        const exportBtn = $('#folder-export');

        log.innerHTML = '';
        wrap.style.display = 'block';
        bar.style.width = '0%';
        counts.textContent = '';
        status.innerHTML = '<span class="spinner"></span>Scanning folder…';
        status.className = 'status';
        submit.disabled = true;
        cancel.style.display = 'inline-block';
        exportBtn.style.display = 'none';
        folderRows = [];
        hdrProg.show('Scanning folder…', '', 0);
        aboxBuddy('start', { kind: 'fingerprint', label: 'Scanning folder…' });

        folderAbort = new AbortController();
        let added = 0, skipped = 0, failed = 0, total = 0, processed = 0;

        function paintFolderFingerprintProgress(d) {
            if (d?.total) total = d.total;
            if (typeof d?.added === 'number') added = d.added;
            if (typeof d?.skipped === 'number') skipped = d.skipped;
            if (typeof d?.failed === 'number') failed = d.failed;
            const sum = added + skipped + failed;
            let done = typeof d?.completed === 'number' ? d.completed : sum;
            if (total) done = Math.min(total, Math.max(0, done));
            processed = done;
            const pct = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
            bar.style.width = pct + '%';
            const phaseHint = d?.phase === 'decoding' ? ' · converting'
                : d?.phase === 'fingerprinting' ? ' · fingerprinting' : '';
            const curName = d?.currentFile ? fileBaseName(d.currentFile) : '';
            const cur = curName ? ` · ${curName}` : '';
            const txt = `${done} / ${total} · added ${added}, skipped ${skipped}, failed ${failed}${phaseHint}${cur}`;
            counts.textContent = txt;
            counts.title = d?.currentFile && d.currentFile !== curName ? d.currentFile : '';
            hdrProg.show(`Fingerprinting ${total} file${total === 1 ? '' : 's'}…`, txt, pct);
            aboxBuddy('progress', {
                kind: 'fingerprint',
                pct,
                phase: d?.phase,
                total,
                done,
                label: d?.phase === 'decoding' ? 'Converting…' : (d?.phase === 'fingerprinting' ? 'Hashing…' : (pct + '%'))
            });
            if (d?.currentFile && (d.phase === 'decoding' || d.phase === 'fingerprinting')) {
                const verb = d.phase === 'decoding' ? 'Converting' : 'Fingerprinting';
                status.innerHTML = `<span class="spinner"></span>${verb} ${escapeHtml(curName || d.currentFile)} (${done}/${total})…`;
                status.title = d.currentFile;
                status.className = 'status';
            }
        }

        const extensions = getSelectedAudioExtensions($('#folder-ext'));
        if (!extensions.length) {
            status.textContent = 'Select at least one audio format.';
            status.className = 'status err';
            submit.disabled = false;
            cancel.style.display = 'none';
            return;
        }
        const folderChunkOn = !!($('#folder-chunk-enable') && $('#folder-chunk-enable').checked);
        const fcs = Math.max(1, parseFloat($('#folder-chunk-size')?.value) || 10);
        const fco = Math.max(0, parseFloat($('#folder-chunk-overlap')?.value) || 0);
        if (folderChunkOn && fco >= fcs) {
            status.textContent = 'Chunk overlap must be smaller than chunk length.';
            status.className = 'status err';
            submit.disabled = false;
            cancel.style.display = 'none';
            return;
        }
        const body = {
            path,
            extensions: extensions.length ? extensions : null,
            overwrite: $('#folder-overwrite').checked,
            idPrefix: $('#folder-prefix').value.trim() || null,
            chunkSeconds: folderChunkOn ? fcs : null,
            overlapSeconds: folderChunkOn ? fco : null
        };

        try {
            const r = await fetch('/api/fingerprint-folder', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(body),
                signal: folderAbort.signal
            });
            if (!r.ok) {
                // 409 Conflict ⇒ the server already has a fingerprint job
                // running (e.g. one survived a previous page refresh).
                // Auto-reattach to its event stream instead of yelling at
                // the user — they get the live progress UI back without
                // having to figure out how to cancel and retry.
                if (r.status === 409) {
                    status.innerHTML = '<span class="spinner"></span>Reconnecting to running fingerprint job…';
                    status.className = 'status';
                    submit.disabled = false;
                    cancel.style.display = 'none';
                    // Force a fresh reattach attempt (reset the one-shot guard).
                    if (_fpReattachState) _fpReattachState.attempted = false;
                    reattachFingerprintJob();
                    return;
                }
                const err = await r.json().catch(() => ({ error: 'Request failed' }));
                throw new Error(err.error || `HTTP ${r.status}`);
            }

            // ── Item batcher ────────────────────────────────────────────
            // SSE `item` events arrive faster than the browser can repaint
            // (especially during the skip-heavy phase where each file is
            // just a DB lookup). Without batching, every event triggers
            // ~4 forced layouts and a DOM append — at ~1000 events/sec
            // that's enough to freeze the page. Batcher queues incoming
            // items and flushes via requestAnimationFrame so the DOM gets
            // ONE update per frame (~60Hz), regardless of inbound rate.
            let _pendingItems = [];
            let _flushScheduled = false;
            const MAX_VISIBLE_ROWS = 500;
            function flushFolderItems(force) {
                if (!_pendingItems.length) return;
                const items = _pendingItems;
                _pendingItems = [];
                const frag = document.createDocumentFragment();
                for (const d of items) {
                    if (d.status === 'added') added++;
                    else if (d.status === 'skipped') skipped++;
                    else if (d.status === 'failed') failed++;
                    folderRows.push({
                        index: d.index, file: d.file || '', trackId: d.id || '',
                        title: d.title || '', artist: d.artist || '', status: d.status || '',
                        error: d.error || '', timestamp: new Date().toISOString()
                    });
                    const row = document.createElement('div');
                    row.className = 'row ' + d.status;
                    row.innerHTML = `<span class="tag">${d.status}</span>
                        <span class="path">${escapeHtml(d.file)}${d.error ? ` <span class="err">— ${escapeHtml(d.error)}</span>` : ''}</span>`;
                    frag.appendChild(row);
                }
                log.appendChild(frag);
                // Cap the in-DOM log to the last MAX_VISIBLE_ROWS rows.
                // folderRows (for export) still holds the complete history.
                while (log.childElementCount > MAX_VISIBLE_ROWS) {
                    log.removeChild(log.firstChild);
                }
                log.scrollTop = log.scrollHeight;
                paintFolderFingerprintProgress({
                    total,
                    added,
                    skipped,
                    failed,
                    completed: added + skipped + failed
                });
            }
            function enqueueFolderItem(d) {
                _pendingItems.push(d);
                if (!_flushScheduled) {
                    _flushScheduled = true;
                    requestAnimationFrame(() => {
                        _flushScheduled = false;
                        try { flushFolderItems(false); } catch {}
                    });
                }
            }

            const reader = r.body.getReader();
            const decoder = new TextDecoder();
            let buf = '';
            while (true) {
                const { value, done } = await reader.read();
                if (done) break;
                buf += decoder.decode(value, { stream: true });
                let idx;
                while ((idx = buf.indexOf('\n\n')) !== -1) {
                    const chunk = buf.slice(0, idx);
                    buf = buf.slice(idx + 2);
                    const evt = parseSseEvent(chunk);
                    if (!evt) continue;

                    if (evt.event === 'start') {
                        total = evt.data.total;
                        status.innerHTML = `<span class="spinner"></span>Fingerprinting ${total} file${total === 1 ? '' : 's'}…`;
                        counts.textContent = `0 / ${total}`;
                        hdrProg.show(
                            `Fingerprinting ${total} file${total === 1 ? '' : 's'}…`,
                            `0 / ${total}`,
                            0);
                        if (total === 0) {
                            status.textContent = 'No matching audio files found.';
                            status.className = 'status err';
                            hdrProg.hide(1200);
                        }
                    } else if (evt.event === 'progress') {
                        paintFolderFingerprintProgress(evt.data);
                    } else if (evt.event === 'item') {
                        // Enqueue the item — actual DOM/counter updates run
                        // on the next animation frame so a flood of skipped-
                        // file events (which arrive faster than the browser
                        // can repaint) can't lock the main thread.
                        enqueueFolderItem(evt.data);
                    } else if (evt.event === 'done') {
                        // Flush any straggler items synchronously so the
                        // final counts shown match the `done` summary.
                        flushFolderItems(true);
                        const d = evt.data;
                        bar.style.width = '100%';
                        const fpLead = formatFingerprintCompleteLead(d.total);
                        const fpDone = formatFingerprintDoneHtml(fpLead, d.added, d.skipped, d.failed);
                        status.innerHTML = fpDone;
                        status.className = 'status';
                        counts.innerHTML = fpDone;
                        hdrProg.show('Complete', formatFingerprintStatsHtml(d.added, d.skipped, d.failed), 100);
                        hdrProg.hide(2500);
                        if (d.added > 0) showToast(`Added ${d.added} track${d.added === 1 ? '' : 's'} to library`, 'ok');
                        if (folderRows.length > 0) exportBtn.style.display = 'inline-block';
                        loadStats();
                        aboxBuddy('end', { kind: 'fingerprint', outcome: 'done' });
                    } else if (evt.event === 'error') {
                        status.textContent = `Error: ${evt.data.error}`;
                        status.className = 'status err';
                        hdrProg.hide(2000);
                        aboxBuddy('end', { kind: 'fingerprint', outcome: 'error' });
                    }
                }
            }
        } catch (err) {
            if (err.name === 'AbortError') {
                status.innerHTML = formatFingerprintDoneHtml('Cancelled', added, skipped, failed);
                status.className = 'status';
                if (total) counts.innerHTML = formatFingerprintDoneHtml('Cancelled', added, skipped, failed);
                aboxBuddy('end', { kind: 'fingerprint', outcome: 'cancel' });
            } else {
                status.textContent = `Error: ${err.message}`;
                status.className = 'status err';
                showToast(err.message, 'err');
                aboxBuddy('end', { kind: 'fingerprint', outcome: 'error' });
            }
            hdrProg.hide(1500);
        } finally {
            submit.disabled = false;
            cancel.style.display = 'none';
            if (folderRows.length > 0) exportBtn.style.display = 'inline-block';
            folderAbort = null;
        }
    });
    $('#folder-cancel').addEventListener('click', async () => {
        // Tell the server to cancel — survives browser refresh because the
        // worker now runs on its own CTS in FingerprintJobManager. Also
        // abort the local fetch so the SSE reader stops waiting for events.
        try { await fetch('/api/fingerprint-folder/cancel', { method: 'POST' }); }
        catch { /* best effort */ }
        if (folderAbort) folderAbort.abort();
    });

    // ── Reattach in-flight fingerprint job ──────────────────────────────
    // The server keeps the worker running across browser refreshes via
    // FingerprintJobManager. We probe /status; if a job is running, open
    // an SSE stream against /stream and rebuild the UI. Guards:
    //   • Re-entrancy: a single in-flight EventSource at a time.
    //   • EventSource auto-reconnect disabled — on any error or `done`
    //     event we explicitly close() so the browser doesn't enter a
    //     reconnect-replay-reconnect loop after the job completes (the
    //     server happily replays the full event log to each new
    //     subscriber, which on a 10k-file scan would flood the main
    //     thread enough to feel like a page hang).
    //   • Runs LAZILY on first Library-tab activation, not at page load,
    //     so a user who never opens that tab pays zero cost.
    let _fpReattachState = { es: null, attempted: false };
    async function reattachFingerprintJob() {
        if (_fpReattachState.attempted) return;
        _fpReattachState.attempted = true;

        let st;
        try {
            const r = await fetch('/api/fingerprint-folder/status');
            if (!r.ok) return;
            st = await r.json();
        } catch { return; }
        if (!st || (!st.running && !st.completedUtc)) return;
        if (!st.running) {
            const completed = new Date(st.completedUtc).getTime();
            if (Date.now() - completed > 60_000) return;
        }

        const status = $('#folder-status');
        const wrap = $('#folder-progress-wrap');
        const bar = $('#folder-bar');
        const counts = $('#folder-counts');
        const log = $('#folder-log');
        const submit = $('#folder-submit');
        const cancel = $('#folder-cancel');
        if (!wrap || !status || !log) return;

        log.innerHTML = '';
        wrap.style.display = 'block';
        status.innerHTML = '<span class="spinner"></span>Resuming fingerprint job…';
        status.className = 'status';
        submit.disabled = !!st.running;
        cancel.style.display = st.running ? 'inline-block' : 'none';
        folderRows = [];

        let added = st.added || 0, skipped = st.skipped || 0, failed = st.failed || 0, total = st.total || 0;
        let processed = st.processed ?? (added + skipped + failed);
        const skipItemCounterBump = true;
        const pct = total ? Math.round((processed / total) * 100) : 0;
        bar.style.width = pct + '%';
        counts.textContent = `${processed} / ${total} · added ${added}, skipped ${skipped}, failed ${failed}`;
        hdrProg.show(`Fingerprinting ${total} file${total === 1 ? '' : 's'}…`, counts.textContent, pct);

        // Don't open an SSE stream if the job is already complete — the
        // /status response was enough to repaint the final summary.
        if (!st.running) {
            const fpDone = formatFingerprintDoneHtml(formatFingerprintCompleteLead(total), added, skipped, failed);
            status.innerHTML = fpDone;
            status.className = 'status';
            counts.innerHTML = fpDone;
            submit.disabled = false;
            hdrProg.hide(2500);
            return;
        }

        aboxBuddy('start', { kind: 'fingerprint', label: 'Fingerprinting…' });

        const es = new EventSource('/api/fingerprint-folder/stream');
        _fpReattachState.es = es;
        let finished = false;
        const finish = () => {
            if (finished) return;
            finished = true;
            try { es.close(); } catch {}
            _fpReattachState.es = null;
        };
        // Same batching pattern as the submit handler — see the comment
        // above flushFolderItems there for the why.
        let _pendingItems = [];
        let _flushScheduled = false;
        const MAX_VISIBLE_ROWS = 500;
        function paintReattachProgress(d) {
            if (d?.total) total = d.total;
            if (typeof d?.added === 'number') added = d.added;
            if (typeof d?.skipped === 'number') skipped = d.skipped;
            if (typeof d?.failed === 'number') failed = d.failed;
            const sum = added + skipped + failed;
            let done = typeof d?.completed === 'number' ? d.completed : sum;
            if (total) done = Math.min(total, Math.max(0, done));
            processed = done;
            const pct = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
            bar.style.width = pct + '%';
            const phaseHint = d?.phase === 'decoding' ? ' · converting'
                : d?.phase === 'fingerprinting' ? ' · fingerprinting' : '';
            const curName = d?.currentFile ? fileBaseName(d.currentFile) : '';
            const cur = curName ? ` · ${curName}` : '';
            counts.textContent = `${done} / ${total} · added ${added}, skipped ${skipped}, failed ${failed}${phaseHint}${cur}`;
            counts.title = d?.currentFile && d.currentFile !== curName ? d.currentFile : '';
            hdrProg.show(`Fingerprinting ${total} file${total === 1 ? '' : 's'}…`, counts.textContent, pct);
            aboxBuddy('progress', {
                kind: 'fingerprint',
                pct,
                phase: d?.phase,
                total,
                done,
                label: d?.phase === 'decoding' ? 'Converting…' : (d?.phase === 'fingerprinting' ? 'Hashing…' : (pct + '%'))
            });
            if (d?.currentFile && (d.phase === 'decoding' || d.phase === 'fingerprinting')) {
                const verb = d.phase === 'decoding' ? 'Converting' : 'Fingerprinting';
                status.innerHTML = `<span class="spinner"></span>${verb} ${escapeHtml(curName || d.currentFile)} (${done}/${total})…`;
                status.title = d.currentFile;
            }
        }
        function flushItems() {
            if (!_pendingItems.length) return;
            const items = _pendingItems;
            _pendingItems = [];
            const frag = document.createDocumentFragment();
            for (const d of items) {
                if (!skipItemCounterBump) {
                    if (d.status === 'added') added++;
                    else if (d.status === 'skipped') skipped++;
                    else if (d.status === 'failed') failed++;
                }
                folderRows.push({
                    index: d.index, file: d.file || '', trackId: d.id || '',
                    title: d.title || '', artist: d.artist || '', status: d.status || '',
                    error: d.error || '', timestamp: new Date().toISOString()
                });
                const row = document.createElement('div');
                row.className = 'row ' + d.status;
                row.innerHTML = `<span class="tag">${d.status}</span>
                    <span class="path">${escapeHtml(d.file || '')}${d.error ? ` <span class="err">— ${escapeHtml(d.error)}</span>` : ''}</span>`;
                frag.appendChild(row);
            }
            log.appendChild(frag);
            while (log.childElementCount > MAX_VISIBLE_ROWS) log.removeChild(log.firstChild);
            log.scrollTop = log.scrollHeight;
            paintReattachProgress({
                total,
                added,
                skipped,
                failed,
                completed: added + skipped + failed
            });
        }
        function enqueueItem(d) {
            _pendingItems.push(d);
            if (!_flushScheduled) {
                _flushScheduled = true;
                requestAnimationFrame(() => { _flushScheduled = false; try { flushItems(); } catch {} });
            }
        }
        es.addEventListener('start', e => {
            try { const d = JSON.parse(e.data); total = d.total; } catch {}
        });
        es.addEventListener('progress', e => {
            try { paintReattachProgress(JSON.parse(e.data)); } catch {}
        });
        es.addEventListener('item', e => {
            try { enqueueItem(JSON.parse(e.data)); } catch {}
        });
        es.addEventListener('done', e => {
            try {
                // Drain any unflushed batched items before painting the
                // final summary so the counters shown can't lag.
                try { flushItems(); } catch {}
                const d = JSON.parse(e.data);
                bar.style.width = '100%';
                const fpLead = d.cancelled ? 'Cancelled' : formatFingerprintCompleteLead(d.total);
                const fpDone = formatFingerprintDoneHtml(fpLead, d.added, d.skipped, d.failed);
                status.innerHTML = fpDone;
                status.className = 'status';
                counts.innerHTML = fpDone;
                hdrProg.show(d.cancelled ? 'Cancelled' : 'Complete', formatFingerprintStatsHtml(d.added, d.skipped, d.failed), 100);
                hdrProg.hide(2500);
                submit.disabled = false;
                cancel.style.display = 'none';
                const exportBtn = $('#folder-export');
                if (folderRows.length > 0 && exportBtn) exportBtn.style.display = 'inline-block';
                loadStats();
                aboxBuddy('end', { kind: 'fingerprint', outcome: d.cancelled ? 'cancel' : 'done' });
            } catch {}
            finish();
        });
        es.addEventListener('error', e => {
            try {
                const d = JSON.parse(e.data || '{}');
                if (d.error) {
                    status.textContent = `Error: ${d.error}`;
                    status.className = 'status err';
                    aboxBuddy('end', { kind: 'fingerprint', outcome: 'error' });
                }
            } catch {}
        });
        // CRITICAL: EventSource auto-reconnects on socket-level errors
        // (including the natural close after the server sends `done`). On
        // reconnect, the server happily replays its entire event log,
        // creating a "replay → close → reconnect → replay" loop that
        // looked exactly like a page freeze. Close explicitly here so the
        // browser doesn't enter that loop.
        es.onerror = () => { finish(); };
    }
    // Reattach is one-shot (guarded by _fpReattachState.attempted) so we
    // can wire it everywhere without paying double cost. Fire on:
    //   • Page load — covers the most common case where the user just
    //     refreshed mid-scan and the Fingerprint tab is already active.
    //   • Clicks on any tab — handles the case where the user is on
    //     Overview at first and only later opens Fingerprint / Library.
    // The guard ensures only one /status probe + at most one SSE stream
    // is ever opened regardless of how many times we call it.
    setTimeout(() => { reattachFingerprintJob(); reattachIdentifyJob(); }, 200);
    document.querySelectorAll('.tab').forEach(t => {
        t.addEventListener('click', () => {
            reattachFingerprintJob();
            reattachIdentifyJob();
        });
    });
    $('#folder-export').addEventListener('click', async () => {
        const btn = $('#folder-export');
        if (!folderRows.length) {
            showToast('No fingerprint results to export.', 'err');
            return;
        }
        const prev = btn.textContent;
        btn.disabled = true;
        btn.textContent = 'Building Excel…';
        try {
            await downloadFingerprintExcel(folderRows);
        } catch (err) {
            showToast(err.message || 'Excel export failed', 'err');
        } finally {
            btn.disabled = false;
            btn.textContent = prev;
        }
    });

    async function downloadFingerprintExcel(rows) {
        if (!rows || rows.length === 0) {
            showToast('No fingerprint results to export.', 'err');
            return;
        }
        const payload = {
            rows: rows.map(r => ({
                index: r.index,
                file: r.file || '',
                trackId: r.trackId || '',
                title: r.title || '',
                artist: r.artist || '',
                status: r.status || '',
                error: r.error || '',
                timestampIso: r.timestamp || null
            }))
        };
        const r = await fetch('/api/fingerprint-export.xlsx', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
        });
        if (!r.ok) {
            const err = await r.json().catch(() => ({ error: 'Export failed' }));
            throw new Error(err.error || `HTTP ${r.status}`);
        }
        const blob = await r.blob();
        const disp = r.headers.get('Content-Disposition') || '';
        const m = /filename="?([^";\n]+)"?/i.exec(disp);
        const filename = m ? m[1] : `AUDIOBoxx-fingerprint-${fmtStampLocal(new Date())}.xlsx`;
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    }

    function idfAutoPlayKind(playKind, type, queryLenSec) {
        const saved = (playKind || '').trim();
        if (saved) return saved;
        const ql = Number(queryLenSec) || 0;
        if (type === 'match' && ql >= 60) return 'Full Song';
        return '';
    }

    function idfRowFromServerRow(r) {
        return {
            logId: r.logId,
            file: r.file || '',
            range: r.range || '',
            status: r.status || '',
            error: r.error || '',
            playKind: r.playKind || null,
            epgShowName: r.epgShowName || null,
            identifiedAtIso: r.identifiedAtIso || null,
            match: r.match ? {
                trackId: r.match.trackId || '',
                title: r.match.title || '',
                artist: r.match.artist || '',
                confidence: r.match.confidence,
                score: r.match.score,
                coverageSeconds: r.match.coverageSeconds,
                queryLengthSeconds: r.match.queryLengthSeconds,
                trackMatchAtSeconds: r.match.trackMatchAtSeconds,
                trackMatchEndSeconds: r.match.trackMatchEndSeconds ?? null
            } : null
        };
    }

    async function loadIdfSavedScanRoots() {
        const sel = $('#idf-saved-scans');
        if (!sel) return;
        try {
            const r = await fetch('/api/offline/scan-roots');
            const rows = r.ok ? await r.json() : [];
            const cur = sel.value;
            sel.innerHTML = '<option value="">— pick a folder —</option>' +
                rows.map(x => `<option value="${escapeAttr(x.scanRoot)}">${escapeHtml(x.label)}</option>`).join('');
            if (cur) sel.value = cur;
        } catch {
            sel.innerHTML = '<option value="">— pick a folder —</option>';
        }
    }

    async function loadIdfOfflineHistory(scanRoot) {
        const path = (scanRoot || $('#idf-path')?.value || '').trim();
        if (!path) {
            showToast('Enter a folder path or pick a recent scan', 'err');
            return;
        }
        const status = $('#idf-status');
        if (status) {
            status.innerHTML = '<span class="spinner"></span>Loading saved results…';
            status.className = 'status';
        }
        try {
            const r = await fetch('/api/offline/logs?scanRoot=' + encodeURIComponent(path));
            if (!r.ok) {
                const err = await r.json().catch(() => ({ error: 'Load failed' }));
                throw new Error(err.error || `HTTP ${r.status}`);
            }
            const data = await r.json();
            idfRows = (data.rows || []).map(idfRowFromServerRow);
            if ($('#idf-path') && data.scanRoot) $('#idf-path').value = data.scanRoot;
            refreshIdfReport();
            syncIdfExportToolbar();
            if (status) {
                status.textContent = idfRows.length
                    ? `Loaded ${idfRows.length} saved result${idfRows.length === 1 ? '' : 's'} from offline database.`
                    : 'No saved results for this folder yet — run Scan & Identify first.';
                status.className = idfRows.length ? 'status ok' : 'status';
            }
            if (idfRows.length) showToast(`Loaded ${idfRows.length} saved rows`, 'ok');
        } catch (err) {
            if (status) {
                status.textContent = `Error: ${err.message}`;
                status.className = 'status err';
            }
            showToast(err.message || 'Could not load saved results', 'err');
        }
    }

    function applyPlayKindToIdfRows(logId, value) {
        if (!logId || !idfRows.length) return;
        let touched = false;
        for (const r of idfRows) {
            if (Number(r.logId) === Number(logId)) {
                r.playKind = value || null;
                touched = true;
            }
        }
        if (touched) refreshIdfReport();
    }

    // ---- Folder identify report (consolidated / raw, same concept as Reports) ----
    let idfViewMode = 'consolidated';

    function idfStatusToMonitorType(status) {
        const s = String(status || '').toLowerCase();
        if (s === 'matched' || s === 'alt') return 'match';
        if (s === 'failed' || s === 'error') return 'error';
        return 'nomatch';
    }

    function parseIdfRangeSeconds(range) {
        if (!range) return null;
        const m = /^([\d.]+)\s*-\s*([\d.]+)\s*s?$/i.exec(String(range).trim());
        if (!m) return null;
        const a = parseFloat(m[1]);
        const b = parseFloat(m[2]);
        if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
        return Math.max(0, b - a);
    }

    /** Chunk column: "1780.00-1799.13s" → "00:29:40 → 00:29:59" */
    function fmtIdfChunkRange(range) {
        const raw = String(range || '').trim();
        if (!raw) return '';
        const { start, end } = parseIdfRangeBounds(raw);
        if (end == null || !Number.isFinite(start)) return raw;
        return `${fmtHms(start)} → ${fmtHms(end)}`;
    }

    /** File + in-track spans across merged identify chunks (consolidated export/report). */
    function idfMergedSpans(e) {
        const parts = (e.merged && e.merged.length) ? e.merged : [e];
        let rangeStart = null;
        let rangeEnd = null;
        let trackStart = null;
        let trackEnd = null;
        let querySum = 0;
        let coverageSum = 0;
        for (const p of parts) {
            const src = p._idfSource || {};
            const parsed = /^([\d.]+)\s*-\s*([\d.]+)\s*s?$/i.exec(String(src.range || '').trim());
            if (parsed) {
                const a = parseFloat(parsed[1]);
                const b = parseFloat(parsed[2]);
                if (Number.isFinite(a) && Number.isFinite(b)) {
                    if (rangeStart == null || a < rangeStart) rangeStart = a;
                    if (rangeEnd == null || b > rangeEnd) rangeEnd = b;
                }
            }
            const tm = p.trackMatchAtSeconds;
            const ql = Number(p.queryLengthSeconds) || 0;
            if (tm != null && Number.isFinite(tm)) {
                const te = p.trackMatchEndSeconds != null
                    ? Number(p.trackMatchEndSeconds)
                    : tm + ql;
                if (trackStart == null || tm < trackStart) trackStart = tm;
                if (trackEnd == null || te > trackEnd) trackEnd = te;
            }
            querySum += ql;
            coverageSum += Number(p.coverageSeconds) || 0;
        }
        const rangeStr = (rangeStart != null && rangeEnd != null)
            ? `${rangeStart.toFixed(2)}-${rangeEnd.toFixed(2)}s`
            : '';
        const fileSpanSec = (rangeStart != null && rangeEnd != null) ? Math.max(0, rangeEnd - rangeStart) : null;
        const trackSpanSec = (trackStart != null && trackEnd != null) ? Math.max(0, trackEnd - trackStart) : null;
        return {
            rangeStr, fileSpanSec, trackStart, trackEnd, trackSpanSec, querySum, coverageSum,
            rangeStart, rangeEnd
        };
    }

    function idfRowsToMonitorEvents(rows) {
        const path = ($('#idf-path')?.value || '').trim();
        const folderLabel = deriveClientFromPath(path) || 'Identify folder';
        return rows.map((r, idx) => {
            const m = r.match;
            const type = idfStatusToMonitorType(r.status);
            const rangeSec = parseIdfRangeSeconds(r.range);
            const ql = m?.queryLengthSeconds ?? rangeSec ?? 0;
            const file = r.file || folderLabel;
            return {
                id: r.logId != null ? r.logId : idx,
                logId: r.logId,
                playKind: r.playKind || null,
                timeUtc: r.identifiedAtIso || new Date().toISOString(),
                endTimeUtc: r.identifiedEndIso || undefined,
                streamId: file,
                streamName: file.replace(/^.*[\\/]/, '') || folderLabel,
                type,
                title: m?.title || (type === 'error' ? (r.error || '') : ''),
                artist: m?.artist || '',
                trackId: m?.trackId,
                confidence: m?.confidence,
                coverageSeconds: m?.coverageSeconds,
                queryLengthSeconds: ql,
                trackMatchAtSeconds: m?.trackMatchAtSeconds,
                score: m?.score,
                message: r.error || '',
                _idfSource: { file: r.file, range: r.range, status: r.status }
            };
        });
    }

    function buildIdfExportRows(rows, consolidate) {
        const mapMatch = (m) => m ? {
            trackId: m.trackId || '',
            title: m.title || '',
            artist: m.artist || '',
            confidence: m.confidence,
            score: m.score,
            coverageSeconds: m.coverageSeconds,
            queryLengthSeconds: m.queryLengthSeconds,
            trackMatchAtSeconds: m.trackMatchAtSeconds,
            trackMatchEndSeconds: m.trackMatchEndSeconds
        } : null;
        const mapOne = (r, extras) => ({
            file: r.file || '',
            range: r.range || '',
            status: r.status || '',
            error: r.error || '',
            logId: r.logId ?? null,
            playKind: r.playKind ?? null,
            identifiedAtIso: r.identifiedAtIso || null,
            identifiedEndIso: extras?.identifiedEndIso || r.identifiedEndIso || null,
            plays: extras?.plays ?? r.plays ?? 1,
            match: mapMatch(r.match)
        });
        if (consolidate === false) return rows.map(r => mapOne(r));
        const events = consolidateAirplay(idfRowsToMonitorEvents(rows));
        return events.map(e => {
            const first = (e.merged && e.merged.length) ? e.merged[0] : e;
            const src = first._idfSource || e._idfSource || {};
            const spans = idfMergedSpans(e);
            const status = e.type === 'match' ? 'matched' : e.type === 'error' ? 'failed' : 'unmatched';
            const trackStart = spans.trackStart ?? e.trackMatchAtSeconds ?? first.trackMatchAtSeconds;
            const trackEnd = spans.trackEnd
                ?? e.trackMatchEndSeconds
                ?? (trackStart != null && e.queryLengthSeconds != null ? trackStart + e.queryLengthSeconds : null);
            const trackSpan = spans.trackSpanSec
                ?? (trackStart != null && trackEnd != null ? Math.max(0, trackEnd - trackStart) : null);
            const fileSpan = spans.fileSpanSec ?? trackSpan ?? spans.querySum;
            const mergedMatch = e.type === 'match' ? {
                trackId: e.trackId,
                title: e.title || '',
                artist: e.artist || '',
                confidence: e.confidence,
                score: e.score ?? first.score ?? 0,
                coverageSeconds: spans.coverageSum || e.coverageSeconds || first.coverageSeconds,
                queryLengthSeconds: fileSpan ?? e.queryLengthSeconds ?? first.queryLengthSeconds,
                trackMatchAtSeconds: trackStart,
                trackMatchEndSeconds: trackEnd
            } : null;
            const wall = idfRowWallTimes(
                src.file || e.streamName || '',
                spans.rangeStr || src.range || '',
                mergedMatch);
            return mapOne({
                file: src.file || e.streamName || '',
                range: spans.rangeStr || src.range || '',
                status,
                error: e.type === 'error' ? (e.message || '') : '',
                logId: e.logId ?? first.logId ?? null,
                playKind: e.playKind ?? first.playKind ?? null,
                identifiedAtIso: wall.identifiedAtIso || e.timeUtc,
                match: mergedMatch
            }, {
                identifiedEndIso: wall.identifiedEndIso || e.endTimeUtc || airplayEndUtcIso(e),
                plays: e.count || 1
            });
        });
    }

    function renderIdfReportRow(row, opts) {
        const raw = opts && opts.raw;
        let timeUtc, timeEnd, file, range, type, title, artist, confidence, tStart, tEnd, plays, typeMsg;
        if (raw) {
            const m = row.match;
            timeUtc = row.identifiedAtIso;
            timeEnd = null;
            file = row.file || '';
            range = row.range || '';
            type = idfStatusToMonitorType(row.status);
            typeMsg = row.error || '';
            title = m?.title || (type === 'error' ? (row.error || '') : '');
            artist = m?.artist || '';
            confidence = m?.confidence;
            tStart = m?.trackMatchAtSeconds;
            tEnd = (tStart != null && m?.queryLengthSeconds != null) ? tStart + m.queryLengthSeconds : null;
            plays = 1;
        } else {
            const first = (row.merged && row.merged.length) ? row.merged[0] : row;
            const src = first._idfSource || row._idfSource || {};
            const spans = idfMergedSpans(row);
            timeUtc = row.timeUtc;
            timeEnd = row.endTimeUtc || airplayEndUtcIso(row);
            file = src.file || row.streamName || '';
            range = spans.rangeStr || src.range || '';
            type = row.type;
            typeMsg = row.message || '';
            title = row.title || '';
            artist = row.artist || '';
            confidence = row.confidence;
            tStart = spans.trackStart ?? row.trackMatchAtSeconds;
            tEnd = spans.trackEnd ?? row.trackMatchEndSeconds
                ?? (tStart != null && row.queryLengthSeconds != null ? tStart + row.queryLengthSeconds : null);
            plays = row.count || 1;
        }
        const time = (timeEnd && timeEnd !== timeUtc)
            ? `${fmtTime(timeUtc)} → ${fmtTime(timeEnd)}`
            : fmtTime(timeUtc);
        const conf = confidence != null ? (confidence * 100).toFixed(1) + '%' : '—';
        const tStartS = tStart != null ? fmtHms(tStart) : '—';
        const tEndS = tEnd != null ? fmtHms(tEnd) : '—';
        const typeClass = type === 'match' ? 'tag tag-added' : type === 'error' ? 'tag tag-failed' : 'tag tag-skipped';
        const totalDur = raw
            ? (parseIdfRangeSeconds(range) != null ? fmtHms(parseIdfRangeSeconds(range)) : (row.match?.queryLengthSeconds != null ? fmtHms(row.match.queryLengthSeconds) : '—'))
            : airplayTotalDurationHms(row);
        const playsCell = (!raw && plays > 1) ? String(plays) : (raw ? '1' : (type === 'match' ? '1' : '—'));
        const fileShort = file.replace(/^.*[\\/]/, '') || file;
        const qlForPk = raw
            ? (row.match?.queryLengthSeconds ?? parseIdfRangeSeconds(range))
            : (row.queryLengthSeconds ?? idfMergedSpans(row).querySum);
        const playKind = idfAutoPlayKind(row.playKind, type, qlForPk);
        const pkCell = playKind
            ? escapeHtml(playKind)
            : '<span class="muted-tag">—</span>';
        return `<tr>
            <td>${time}</td>
            <td title="${escapeAttr(file)}">${escapeHtml(fileShort)}</td>
            <td class="c">${range ? escapeHtml(fmtIdfChunkRange(range)) : '<span class="muted-tag">—</span>'}</td>
            <td class="c">${escapeHtml(totalDur)}</td>
            <td><span class="${typeClass}">${displayType(type, typeMsg)}</span></td>
            <td>${pkCell}</td>
            <td>${escapeHtml(title || '')}</td>
            <td>${escapeHtml(artist || '')}</td>
            <td>${conf}</td>
            <td>${tStartS}</td>
            <td>${tEndS}</td>
            <td class="c">${escapeHtml(playsCell)}</td>
        </tr>`;
    }

    function syncIdfExportToolbar() {
        const bar = $('#idf-export-toolbar');
        if (!bar) return;
        const show = idfRows.length > 0;
        bar.style.display = show ? 'inline-flex' : 'none';
        bar.setAttribute('aria-hidden', show ? 'false' : 'true');
    }

    function refreshIdfReport() {
        const wrap = $('#idf-report-wrap');
        syncIdfExportToolbar();
        if (!wrap) return;
        if (!idfRows.length) {
            wrap.style.display = 'none';
            return;
        }
        wrap.style.display = 'block';
        const raw = idfViewMode === 'raw';
        document.querySelectorAll('[data-idf-view]').forEach(btn => {
            btn.classList.toggle('active', btn.getAttribute('data-idf-view') === idfViewMode);
        });
        const rows = raw
            ? idfRows.slice()
            : consolidateAirplay(idfRowsToMonitorEvents(idfRows));
        const body = $('#idf-report-body');
        const countEl = $('#idf-report-count');
        if (!body) return;
        const displayRows = rows.slice().reverse().slice(0, 500);
        if (countEl) {
            const label = raw ? 'raw' : 'consolidated';
            const shown = displayRows.length;
            const total = rows.length;
            countEl.textContent = shown < total
                ? `${shown} of ${total} ${label} rows (newest)`
                : `${total} ${label} row${total === 1 ? '' : 's'}`;
        }
        body.innerHTML = displayRows.length
            ? displayRows.map(r => renderIdfReportRow(r, { raw })).join('')
            : '<tr><td colspan="12" class="empty">No rows to display.</td></tr>';
    }

    document.querySelectorAll('[data-idf-view]').forEach(btn => {
        btn.addEventListener('click', () => {
            const mode = btn.getAttribute('data-idf-view');
            if (!mode || mode === idfViewMode) return;
            idfViewMode = mode;
            refreshIdfReport();
        });
    });

    // ---- Folder identify ----
    const idfChunkEnable = $('#idf-chunk-enable');
    if (idfChunkEnable) {
        const syncIdfChunkFields = () => {
            const on = idfChunkEnable.checked;
            const cs = $('#idf-chunk-size'); if (cs) cs.disabled = !on;
            const co = $('#idf-chunk-overlap'); if (co) co.disabled = !on;
            renderIdfConcurrencySelect(_serverProcessorCount || navigator.hardwareConcurrency || 4);
        };
        idfChunkEnable.addEventListener('change', syncIdfChunkFields);
        syncIdfChunkFields();
    } else if ($('#idf-concurrency')) {
        renderIdfConcurrencySelect(_serverProcessorCount || navigator.hardwareConcurrency || 4);
    }
    let idfAbort = null;
    let idfUserCancelled = false;
    let idfRows = []; // collected for CSV

    let _idfReattachState = { es: null, attempted: false };
    async function reattachIdentifyJob() {
        if (_idfReattachState.attempted) return;
        _idfReattachState.attempted = true;

        let st;
        try {
            const r = await fetch('/api/identify-folder/status');
            if (!r.ok) return;
            st = await r.json();
        } catch { return; }
        if (!st || (!st.running && !st.completedUtc)) return;
        if (!st.running) {
            const completed = new Date(st.completedUtc).getTime();
            if (Date.now() - completed > 60_000) return;
        }

        const status = $('#idf-status');
        const wrap = $('#idf-progress-wrap');
        const bar = $('#idf-bar');
        const counts = $('#idf-counts');
        const log = $('#idf-log');
        const submit = $('#idf-submit');
        const cancel = $('#idf-cancel');
        const reportWrap = $('#idf-report-wrap');
        if (!wrap || !status || !log) return;

        const chunkOn = st.chunkMode === true;
        log.innerHTML = '';
        wrap.style.display = 'block';
        status.innerHTML = '<span class="spinner"></span>Resuming identify job…';
        status.className = 'status';
        submit.disabled = !!st.running;
        cancel.style.display = st.running ? 'inline-block' : 'none';
        if (reportWrap) reportWrap.style.display = 'none';
        idfRows = [];
        startIdfHeaderProgress('Resuming identify job…');

        let matched = st.matched || 0, unmatched = st.unmatched || 0, failed = st.failed || 0;
        let total = st.total || 0, chunksDone = 0;
        const idfSeenFiles = new Set();

        if (!st.running) {
            const lead = chunkOn
                ? `Complete · ${st.total || 0} file${(st.total || 0) === 1 ? '' : 's'}`
                : `Complete · ${st.total || 0} file${(st.total || 0) === 1 ? '' : 's'}`;
            setIdfProgressUi(100, formatIdentifyDoneHtml(lead, matched, unmatched, failed), null);
            finishIdfHeaderProgress('Complete', formatIdentifyStatsPlain(matched, unmatched, failed));
            refreshIdfReport();
            submit.disabled = false;
            return;
        }

        const es = new EventSource('/api/identify-folder/stream');
        _idfReattachState.es = es;
        let finished = false;
        const finish = () => {
            if (finished) return;
            finished = true;
            try { es.close(); } catch {}
            _idfReattachState.es = null;
        };

        const appendIdfItemRow = (d) => {
            if (d.chunked) {
                chunksDone++;
                if (d.status === 'matched') matched++;
                else if (d.status === 'unmatched') unmatched++;
                else if (d.status === 'failed') failed++;
            } else if (!idfSeenFiles.has(d.index)) {
                idfSeenFiles.add(d.index);
                if (d.status === 'matched') matched++;
                else if (d.status === 'unmatched') unmatched++;
                else if (d.status === 'failed') failed++;
            }
            const row = document.createElement('div');
            row.className = 'row ' + d.status;
            row.innerHTML = `<span class="tag">${displayType(d.status)}</span> <span class="path">${escapeHtml(d.file || '')}</span>`;
            log.appendChild(row);
            while (log.childElementCount > 500) log.removeChild(log.firstChild);
            log.scrollTop = log.scrollHeight;
            if (d.status === 'matched') {
                aboxBuddy('match', { source: 'identify', title: d.file, confidence: d.confidence });
            } else if (d.status === 'unmatched') {
                aboxBuddy('nomatch', { source: 'identify' });
            }
            const live = formatIdfLiveProgress(d, total, chunkOn, matched, unmatched, failed);
            setIdfProgressUi(live.pct, live.statusHtml, live.countsHtml);
            paintIdfHeaderProgress(live.pct, idfHeaderLabel(total, chunkOn), `${live.pct}% · ${formatIdentifyStatsPlain(matched, unmatched, failed)}`);
        };

        es.addEventListener('start', e => {
            try { const d = JSON.parse(e.data); total = d.total || total; if (d.root && $('#idf-path')) $('#idf-path').value = d.root; } catch {}
        });
        es.addEventListener('progress', e => {
            try {
                const d = JSON.parse(e.data);
                const live = formatIdfPhaseProgress(d, total, matched, unmatched, failed);
                setIdfProgressUi(live.pct, live.statusHtml, live.countsHtml);
            } catch {}
        });
        es.addEventListener('item', e => {
            try { appendIdfItemRow(JSON.parse(e.data)); } catch {}
        });
        es.addEventListener('quota', e => {
            try {
                const q = JSON.parse(e.data);
                status.textContent = q.message || 'Offline identify quota reached.';
                status.className = 'status err';
                refreshLicenseLimits().catch(() => {});
            } catch {}
        });
        es.addEventListener('done', e => {
            try {
                const d = JSON.parse(e.data);
                const lead = d.cancelled ? 'Cancelled' : (chunkOn
                    ? `100% · Complete · ${chunksDone} chunk${chunksDone === 1 ? '' : 's'} · ${d.total} file${d.total === 1 ? '' : 's'}`
                    : `100% · Complete · ${d.total} file${d.total === 1 ? '' : 's'}`);
                setIdfProgressUi(100, formatIdentifyDoneHtml(lead, d.matched, d.unmatched, d.failed), null);
                finishIdfHeaderProgress(d.cancelled ? 'Cancelled' : 'Complete', formatIdentifyStatsPlain(d.matched, d.unmatched, d.failed));
                refreshIdfReport();
                refreshLicenseLimits().catch(() => {});
                submit.disabled = false;
                cancel.style.display = 'none';
                syncIdfExportToolbar();
            } catch {}
            finish();
        });
        es.addEventListener('error', () => finish());
    }

    $('#idf-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const path = $('#idf-path').value.trim();
        if (!path) return;
        // Capture the source folder's root segment as the report client.
        const derivedClient = deriveClientFromPath(path);
        if (derivedClient) setClientName(derivedClient);

        const status = $('#idf-status');
        const wrap = $('#idf-progress-wrap');
        const bar = $('#idf-bar');
        const counts = $('#idf-counts');
        const log = $('#idf-log');
        const submit = $('#idf-submit');
        const cancel = $('#idf-cancel');
        const reportWrap = $('#idf-report-wrap');

        log.innerHTML = '';
        wrap.style.display = 'block';
        bar.style.width = '0%';
        counts.textContent = '';
        status.innerHTML = '<span class="spinner"></span>Scanning folder…';
        status.className = 'status';
        setIdfProgressUi(0, '<span class="spinner"></span><strong>0%</strong> · Scanning folder…', '0% · preparing');
        submit.disabled = true;
        cancel.style.display = 'inline-block';
        if (reportWrap) reportWrap.style.display = 'none';
        syncIdfExportToolbar();
        idfRows = [];

        idfAbort = new AbortController();
        idfUserCancelled = false;
        let matched = 0, unmatched = 0, failed = 0, total = 0, chunksDone = 0;
        const idfSeenFiles = new Set();

        const extensions = getSelectedAudioExtensions($('#idf-ext'));
        if (!extensions.length) {
            status.textContent = 'Select at least one audio format.';
            status.className = 'status err';
            submit.disabled = false;
            cancel.style.display = 'none';
            return;
        }
        const idfLib = ($('#idf-library')?.value || '').trim();
        const chunkOn = !!(idfChunkEnable && idfChunkEnable.checked);
        const minConfidence = +($('#idf-minconf')?.value);
        const thresholdVotes = thresholdVotesFromInput($('#idf-threshold'));
        const concVal = ($('#idf-concurrency')?.value ?? '').trim();
        const concRaw = concVal === '' ? 0 : parseInt(concVal, 10);
        const body = {
            path,
            extensions: extensions.length ? extensions : null,
            topN: Math.max(1, parseInt($('#idf-topn').value, 10) || 1),
            libraryName: idfLib || null,
            minConfidence: Number.isFinite(minConfidence) ? minConfidence : defaultMinConfidence(),
            thresholdVotes,
            concurrency: Number.isFinite(concRaw) && concRaw > 0 ? concRaw : null,
            chunkSeconds: chunkOn ? Math.max(1, parseFloat($('#idf-chunk-size').value) || 20) : null,
            overlapSeconds: chunkOn ? Math.max(0, parseFloat($('#idf-chunk-overlap').value) || 0) : null
        };
        if (chunkOn && body.overlapSeconds >= body.chunkSeconds) {
            status.textContent = 'Chunk overlap must be smaller than chunk length.';
            status.className = 'status err';
            submit.disabled = false;
            cancel.style.display = 'none';
            return;
        }

        startIdfHeaderProgress('Scanning folder…');

        try {
            const r = await fetch('/api/identify-folder', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(body),
                signal: idfAbort.signal
            });
            if (!r.ok) {
                if (r.status === 409) {
                    status.innerHTML = '<span class="spinner"></span>Reconnecting to running identify job…';
                    status.className = 'status';
                    submit.disabled = false;
                    cancel.style.display = 'none';
                    _idfReattachState.attempted = false;
                    reattachIdentifyJob();
                    return;
                }
                const err = await r.json().catch(() => ({ error: 'Request failed' }));
                throw new Error(err.message || err.error || `HTTP ${r.status}`);
            }

            const reader = r.body.getReader();
            const decoder = new TextDecoder();
            let buf = '';
            while (true) {
                const { value, done } = await reader.read();
                if (done) break;
                buf += decoder.decode(value, { stream: true });
                let idx;
                while ((idx = buf.indexOf('\n\n')) !== -1) {
                    const chunk = buf.slice(0, idx);
                    buf = buf.slice(idx + 2);
                    const evt = parseSseEvent(chunk);
                    if (!evt) continue;

                    if (evt.event === 'quota') {
                        const q = evt.data || {};
                        status.textContent = q.message || 'Offline identify quota reached.';
                        status.className = 'status err';
                        showToast(q.message || 'Offline identify quota reached', 'err');
                        refreshLicenseLimits().catch(() => {});
                    } else if (evt.event === 'start') {
                        total = evt.data.total;
                        const startLabel = total === 1
                            ? '1 file'
                            : `${total} files`;
                        const chunkHint = chunkOn ? ' · chunk mode' : '';
                        setIdfProgressUi(
                            0,
                            `<span class="spinner"></span><strong>0%</strong> · Identifying ${startLabel}${chunkHint}…`,
                            `0% · ${startLabel} queued`);
                        paintIdfHeaderProgress(0, idfHeaderLabel(total, chunkOn), '');
                        if (total === 0) {
                            status.textContent = 'No matching audio files found.';
                            status.className = 'status err';
                            setIdfProgressUi(0, null, '');
                            stopIdfHeaderProgress(2000);
                        }
                    } else if (evt.event === 'progress') {
                        const d = evt.data;
                        const live = formatIdfPhaseProgress(d, total, matched, unmatched, failed);
                        setIdfProgressUi(live.pct, live.statusHtml, live.countsHtml);
                        const cur = idfShortFileName(d.file);
                        const suffix = cur ? ` · ${cur}` : '';
                        paintIdfHeaderProgress(
                            live.pct,
                            idfHeaderLabel(total, chunkOn),
                            `${live.pct}% · ${formatIdentifyStatsPlain(matched, unmatched, failed)}${suffix}`);
                    } else if (evt.event === 'item') {
                        const d = evt.data;
                        if (d.chunked) {
                            chunksDone++;
                            if (d.status === 'matched') matched++;
                            else if (d.status === 'unmatched') unmatched++;
                            else if (d.status === 'failed') failed++;
                        } else if (!idfSeenFiles.has(d.index)) {
                            idfSeenFiles.add(d.index);
                            if (d.status === 'matched') matched++;
                            else if (d.status === 'unmatched') unmatched++;
                            else if (d.status === 'failed') failed++;
                        }

                        const row = document.createElement('div');
                        row.className = 'row ' + d.status;
                        const hasRange = d.chunked && typeof d.chunkStartSecond === 'number';
                        const rangeTag = hasRange
                            ? ` <span class="tag" style="background:#123; color:#7df;">${Number(d.chunkStartSecond).toFixed(1)}s–${Number(d.chunkEndSecond).toFixed(1)}s</span>`
                            : '';
                        const chunkInfo = d.chunked
                            ? ` <span class="tag" style="background:#223; color:#9cf;">chunk ${d.chunkIndex || ''}${d.chunkCount ? '/' + d.chunkCount : ''}${d.durationSeconds ? ` · file ${Number(d.durationSeconds).toFixed(1)}s` : ''}</span>`
                            : '';
                        const chunkErr = d.chunked && d.error && d.status === 'failed'
                            ? ` <span class="err">— ${escapeHtml(d.error)}</span>` : '';
                        const head = `
                            <span class="tag">${displayType(d.status)}</span>${rangeTag}${chunkInfo}
                            <span class="path">${escapeHtml(d.file)}${d.error ? ` <span class="err">— ${escapeHtml(d.error)}</span>` : ''}${chunkErr}</span>`;
                        row.innerHTML = head;
                        if (d.matches && d.matches.length) {
                            d.matches.forEach((m, i) => {
                                const m1 = document.createElement('span');
                                m1.className = 'match';
                                m1.innerHTML = `${i === 0 ? '→' : ' '} <strong>${(m.confidence * 100).toFixed(1)}%</strong> — ${escapeHtml(m.title || m.trackId)}${m.artist ? ' · ' + escapeHtml(m.artist) : ''}`;
                                row.appendChild(m1);

                                const meta = document.createElement('span');
                                meta.className = 'match match-meta';
                                meta.innerHTML =
                                    `score ${m.score.toFixed(1)}` +
                                    ` · coverage ${m.trackCoverageSeconds.toFixed(2)}s` +
                                    ` · query length ${m.queryLengthSeconds.toFixed(2)}s` +
                                    ` · track match @ ${m.trackMatchStartsAt.toFixed(2)}s`;
                                row.appendChild(meta);
                            });
                        }
                        log.appendChild(row);
                        log.scrollTop = log.scrollHeight;
                        if (d.status === 'matched' || (d.matches && d.matches.length)) {
                            const m0 = d.matches && d.matches[0];
                            aboxBuddy('match', {
                                source: 'identify',
                                title: (m0 && (m0.title || m0.trackId)) || d.file,
                                confidence: m0 && m0.confidence
                            });
                        } else if (d.status === 'unmatched') {
                            aboxBuddy('nomatch', { source: 'identify' });
                        }

                        // CSV row(s). In chunked mode, include the chunk
                        // time range so the user can tell which segment of
                        // a file produced each match. Rows are now structured
                        // objects so downloadCsv() can map them onto the
                        // full airplay-CSV schema (Client, Date/Time, MATCH /
                        // NO MATCH / ERROR labels, Play Kind, Track match
                        // start/end, HH:MM:SS duration, etc).
                        const rangeStr = hasRange
                            ? `${Number(d.chunkStartSecond).toFixed(2)}-${Number(d.chunkEndSecond).toFixed(2)}s`
                            : '';
                        const pushIdfRow = (row) => {
                            const m = row.match;
                            const wall = idfRowWallTimes(row.file, row.range, m);
                            idfRows.push(Object.assign({}, row, {
                                logId: row.logId ?? d.logId ?? null,
                                playKind: row.playKind ?? null,
                                identifiedAtIso: wall.identifiedAtIso,
                                identifiedEndIso: wall.identifiedEndIso
                            }));
                        };
                        if (d.status === 'failed') {
                            pushIdfRow({
                                logId: d.logId,
                                file: d.file, range: rangeStr, status: 'failed',
                                error: d.error || ''
                            });
                        } else if (!d.matches || d.matches.length === 0) {
                            pushIdfRow({
                                logId: d.logId,
                                file: d.file, range: rangeStr, status: d.status,
                                error: ''
                            });
                        } else {
                            d.matches.forEach((m, i) => pushIdfRow({
                                logId: i === 0 ? d.logId : null,
                                file: d.file,
                                range: rangeStr,
                                status: i === 0 ? d.status : 'alt',
                                error: '',
                                match: {
                                    trackId: m.trackId,
                                    title: m.title || '',
                                    artist: m.artist || '',
                                    confidence: m.confidence,
                                    score: m.score,
                                    coverageSeconds: m.trackCoverageSeconds,
                                    queryLengthSeconds: m.queryLengthSeconds,
                                    trackMatchAtSeconds: m.trackMatchStartsAt
                                }
                            }));
                        }

                        const live = formatIdfLiveProgress(d, total, chunkOn, matched, unmatched, failed);
                        setIdfProgressUi(live.pct, live.statusHtml, live.countsHtml);
                        const processed = matched + unmatched + failed;
                        const cur = idfShortFileName(d.file);
                        paintIdfHeaderProgress(
                            live.pct,
                            idfHeaderLabel(total, chunkOn),
                            total
                                ? `${processed} / ${total} · ${formatIdentifyStatsPlain(matched, unmatched, failed)} · ${cur}`
                                : `${formatIdentifyStatsPlain(matched, unmatched, failed)} · ${cur}`);
                    } else if (evt.event === 'done') {
                        const d = evt.data;
                        const lead = chunkOn
                            ? `100% · Complete · ${chunksDone} chunk${chunksDone === 1 ? '' : 's'} · ${d.total} file${d.total === 1 ? '' : 's'}`
                            : `100% · Complete · ${d.total} file${d.total === 1 ? '' : 's'}`;
                        setIdfProgressUi(100, formatIdentifyDoneHtml(lead, d.matched, d.unmatched, d.failed), null);
                        finishIdfHeaderProgress('Complete', formatIdentifyStatsPlain(d.matched, d.unmatched, d.failed));
                        refreshIdfReport();
                        if (d.quotaExceeded) {
                            status.textContent = `Stopped: offline identify quota reached (${d.usedOfflineHours ?? '?'} / ${d.maxOfflineHours ?? '?'} h).`;
                            status.className = 'status err';
                        }
                        refreshLicenseLimits().catch(() => {});
                    } else if (evt.event === 'error') {
                        status.textContent = `Error: ${evt.data.error}`;
                        status.className = 'status err';
                        stopIdfHeaderProgress(2000);
                    }
                }
            }
        } catch (err) {
            if (err.name === 'AbortError') {
                if (!idfUserCancelled) return;
                status.innerHTML = formatIdentifyDoneHtml('Cancelled', matched, unmatched, failed);
                status.className = 'status';
                finishIdfHeaderProgress('Cancelled', formatIdentifyStatsPlain(matched, unmatched, failed));
                refreshIdfReport();
            } else {
                status.textContent = `Error: ${err.message}`;
                status.className = 'status err';
                showToast(err.message, 'err');
                stopIdfHeaderProgress(2000);
            }
        } finally {
            submit.disabled = false;
            cancel.style.display = 'none';
            idfAbort = null;
        }
    });
    $('#idf-cancel').addEventListener('click', async () => {
        idfUserCancelled = true;
        try { await fetch('/api/identify-folder/cancel', { method: 'POST' }); } catch { /* best effort */ }
        if (idfAbort) idfAbort.abort();
    });
    $('#idf-load-saved')?.addEventListener('click', () => loadIdfOfflineHistory());
    $('#idf-saved-scans')?.addEventListener('change', () => {
        const v = $('#idf-saved-scans')?.value;
        if (!v) return;
        if ($('#idf-path')) $('#idf-path').value = v;
        loadIdfOfflineHistory(v);
    });
    document.querySelector('.tab[data-tab="identify"]')?.addEventListener('click', () => {
        loadIdfSavedScanRoots();
    });
    async function downloadIdfCsv(consolidate) {
        const btnId = consolidate === false ? 'idf-export-csv-raw' : 'idf-export-csv';
        const btn = $('#' + btnId);
        if (!btn || !idfRows.length) return;
        const prev = btn.textContent;
        btn.disabled = true;
        btn.textContent = 'Building CSV…';
        try {
            const exportRows = buildIdfExportRows(idfRows, consolidate);
            const tag = consolidate === false ? 'raw' : 'consolidated';
            await downloadCsv(exportRows, { filenameTag: tag });
            showToast(consolidate === false ? 'Raw CSV exported' : 'Consolidated CSV exported', 'ok');
        } catch (err) {
            showToast(err.message || 'CSV export failed', 'err');
        } finally {
            btn.disabled = false;
            btn.textContent = prev;
        }
    }
    $('#idf-export-csv')?.addEventListener('click', () => downloadIdfCsv(true));
    $('#idf-export-csv-raw')?.addEventListener('click', () => downloadIdfCsv(false));

    async function downloadCsv(rows, opts) {
        // Server export mirrors airplay CSV and appends Meta: columns from the
        // track metadata catalogue (Overview upload) when loaded.
        const payload = {
            rows: rows.map(r => ({
                file: r.file || '',
                range: r.range || '',
                status: r.status || '',
                error: r.error || '',
                logId: r.logId != null ? r.logId : null,
                playKind: r.playKind || null,
                identifiedAtIso: r.identifiedAtIso || null,
                identifiedEndIso: r.identifiedEndIso || null,
                plays: r.plays != null ? r.plays : null,
                match: r.match ? {
                    trackId: r.match.trackId || '',
                    title: r.match.title || '',
                    artist: r.match.artist || '',
                    confidence: r.match.confidence,
                    score: r.match.score,
                    coverageSeconds: r.match.coverageSeconds,
                    queryLengthSeconds: r.match.queryLengthSeconds,
                    trackMatchAtSeconds: r.match.trackMatchAtSeconds,
                    trackMatchEndSeconds: r.match.trackMatchEndSeconds ?? null
                } : null
            }))
        };
        const r = await fetch('/api/identify-export.csv', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
        });
        if (!r.ok) {
            const err = await r.json().catch(() => ({ error: 'Export failed' }));
            throw new Error(err.error || `HTTP ${r.status}`);
        }
        const blob = await r.blob();
        const disp = r.headers.get('Content-Disposition') || '';
        const m = /filename="?([^";\n]+)"?/i.exec(disp);
        const tag = (opts && opts.filenameTag) ? `-${opts.filenameTag}` : '';
        const filename = m ? m[1].replace(/\.csv$/i, `${tag}.csv`) : `AUDIOBoxx-identify${tag}-${fmtStampLocal(new Date())}.csv`;
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    }
    function numOrNull(s) { const n = parseFloat(s); return Number.isFinite(n) ? n : null; }

    function parseSseEvent(chunk) {
        let event = 'message', data = '';
        for (const line of chunk.split('\n')) {
            if (line.startsWith('event:')) event = line.slice(6).trim();
            else if (line.startsWith('data:')) data += line.slice(5).trim();
        }
        if (!data) return null;
        try { return { event, data: JSON.parse(data) }; }
        catch { return null; }
    }

    // ---- Identify form ----
    // Enable/disable chunk inputs based on checkbox
    const chunkEnable = $('#id-chunk-enable');
    if (chunkEnable) {
        chunkEnable.addEventListener('change', () => {
            const on = chunkEnable.checked;
            const cs = $('#id-chunk-size'); if (cs) cs.disabled = !on;
            const co = $('#id-chunk-overlap'); if (co) co.disabled = !on;
        });
    }

    $('#id-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const file = $('#id-file').files[0];
        const status = $('#id-status');
        const results = $('#id-results');
        if (!file) { status.textContent = 'Please select an audio file.'; status.className = 'status err'; return; }

        const btn = $('#id-submit');
        btn.disabled = true;
        aboxBuddy('start', { kind: 'identify', label: 'Listening…' });
        status.innerHTML = '<span class="spinner"></span>Querying reference library…';
        status.className = 'status';
        results.innerHTML = '';

        const idLib = ($('#id-library')?.value || '').trim();
        const chunkOn = !!(chunkEnable && chunkEnable.checked);

        try {
            if (chunkOn) {
                await runChunkedIdentify(file, idLib, status, results);
            } else {
                const fd = new FormData();
                fd.append('file', file);
                if (idLib) fd.append('libraryName', idLib);

                const r = await fetch('/api/identify', { method: 'POST', body: fd });
                const data = await r.json();
                if (!r.ok) throw new Error(data.error || data.detail || 'Failed');
                renderMatches(data);
                status.textContent = data.containsMatches
                    ? `Found ${data.matches.length} match${data.matches.length === 1 ? '' : 'es'}.`
                    : 'No matches found.';
                status.className = 'status ok';
            }
        } catch (err) {
            status.textContent = `Error: ${err.message}`;
            status.className = 'status err';
            aboxBuddy('end', { kind: 'identify', outcome: 'error' });
        } finally {
            btn.disabled = false;
            if (status.className === 'status ok') aboxBuddy('end', { kind: 'identify', outcome: 'done' });
        }
    });

    async function runChunkedIdentify(file, libraryName, status, results) {
        const chunkSize = Math.max(1, parseFloat($('#id-chunk-size').value) || 10);
        const overlap = Math.max(0, parseFloat($('#id-chunk-overlap').value) || 0);
        if (overlap >= chunkSize) {
            throw new Error('Chunk overlap must be smaller than chunk length.');
        }

        const progWrap = $('#id-progress-wrap');
        const bar = $('#id-bar');
        const meta = $('#id-progress-meta');
        if (progWrap) progWrap.style.display = '';
        if (bar) bar.style.width = '0%';
        if (meta) meta.textContent = 'Uploading & splitting on server…';

        aboxBuddy('progress', { kind: 'identify', pct: 5, label: 'Splitting…' });
        status.innerHTML = '<span class="spinner"></span>Server is splitting the audio into chunks and identifying each…';

        const fd = new FormData();
        fd.append('file', file);
        fd.append('chunkSeconds', String(chunkSize));
        fd.append('overlapSeconds', String(overlap));
        if (libraryName) fd.append('libraryName', libraryName);

        const r = await fetch('/api/identify-chunked', { method: 'POST', body: fd });
        const data = await r.json();
        if (!r.ok) throw new Error(data.error || data.detail || 'Failed');

        const chunks = data.chunks || [];
        const total = chunks.length;
        let matchedChunks = 0;

        results.innerHTML = '';
        const header = document.createElement('div');
        header.className = 'result';
        header.innerHTML = `<div class="badge">Chunks</div><div><div class="title">Chunked identify</div><div class="artist">${total} chunks · ${chunkSize}s each${overlap ? ` · ${overlap}s overlap` : ''} · duration ${Number(data.durationSeconds).toFixed(1)}s</div></div>`;
        results.appendChild(header);

        chunks.forEach((c) => {
            renderChunkResult(results, Number(c.startSecond), Number(c.endSecond), {
                containsMatches: !!c.containsMatches,
                matches: c.matches || [],
                error: c.error || null,
            });
            if (c.containsMatches && c.matches && c.matches.length) matchedChunks++;
        });

        if (bar) bar.style.width = '100%';
        if (meta) meta.textContent = `${total} / ${total} chunks · ${matchedChunks} MATCH`;

        status.textContent = `Chunked identify complete — ${matchedChunks} / ${total} chunks MATCH.`;
        status.className = 'status ok';
        aboxBuddy('progress', { kind: 'identify', pct: 100, label: 'Done! ✨' });
    }

    function renderChunkResult(container, startAt, endAt, data) {
        const range = `${startAt.toFixed(1)}s–${endAt.toFixed(1)}s`;
        const el = document.createElement('div');
        if (data.error) {
            el.className = 'result';
            el.innerHTML = `<div class="badge">${range}</div><div><div class="title">Error</div><div class="artist">${escapeHtml(data.error)}</div></div>`;
            container.appendChild(el);
            return;
        }
        if (!data.containsMatches || !data.matches || !data.matches.length) {
            el.className = 'result empty';
            el.innerHTML = `<div class="badge">${range}</div><div><div class="title">NO MATCH</div><div class="artist">Not in reference library</div></div>`;
            container.appendChild(el);
            return;
        }
        const m = data.matches[0];
        el.className = 'result';
        el.innerHTML = `
            <div class="badge">${range}</div>
            <div>
                <div class="title">${escapeHtml(m.title || m.trackId)}</div>
                <div class="artist">${escapeHtml(m.artist || 'Unknown artist')} · <code>${escapeHtml(m.trackId)}</code></div>
            </div>
            <div class="conf">
                <div class="val">${(m.confidence * 100).toFixed(1)}%</div>
                <div class="sub">confidence</div>
            </div>
            <div class="meta">
                <span>score ${m.score.toFixed(1)}</span>
                <span>coverage ${m.trackCoverageSeconds.toFixed(2)}s</span>
                <span>track @ ${m.trackMatchStartsAt.toFixed(2)}s</span>
            </div>`;
        container.appendChild(el);
    }

    function renderMatches(data) {
        const results = $('#id-results');
        if (!data.containsMatches || !data.matches.length) {
            results.innerHTML = '<div class="result empty">No matches found in the reference library.</div>';
            return;
        }
        results.innerHTML = '';
        data.matches.forEach((m, idx) => {
            const el = document.createElement('div');
            el.className = 'result' + (idx === 0 ? ' top' : '');
            el.innerHTML = `
                <div class="badge">${idx === 0 ? 'Best Match' : '#' + (idx + 1)}</div>
                <div>
                    <div class="title">${escapeHtml(m.title || m.trackId)}</div>
                    <div class="artist">${escapeHtml(m.artist || 'Unknown artist')} · <code>${escapeHtml(m.trackId)}</code></div>
                </div>
                <div class="conf">
                    <div class="val">${(m.confidence * 100).toFixed(1)}%</div>
                    <div class="sub">confidence</div>
                </div>
                <div class="meta">
                    <span>score ${m.score.toFixed(1)}</span>
                    <span>coverage ${m.trackCoverageSeconds.toFixed(2)}s</span>
                    <span>query length ${m.queryLengthSeconds.toFixed(2)}s</span>
                    <span>track match @ ${m.trackMatchStartsAt.toFixed(2)}s</span>
                </div>`;
            results.appendChild(el);
        });
    }

    // ---- Library ----
    // ── Library tab: cached list + live search + Excel export ─────────
    // The full list returned by /api/tracks is kept in `_libraryAll` so
    // the search box can filter it client-side without re-hitting the
    // server on every keystroke. The export button writes the *current
    // filtered view* (or everything when the search is empty) so the
    // user can grab a subset directly.
    let _libraryAll = [];
    // Render token — bumped every time a new render starts. The chunked
    // render loop checks it on every batch boundary and aborts if it's
    // stale, so a fast typer who triggers N filters per second doesn't
    // pile up N render passes fighting for the main thread.
    let _libRenderToken = 0;
    // Delegated click handler is wired ONCE (below) instead of per-row,
    // so a 50K-track library doesn't register 50K listeners.
    let _libDelegationWired = false;
    const LIB_RENDER_BATCH = 250; // rows per main-thread yield

    function libraryRenderRows(rows) {
        const body = $('#library-rows');
        const countEl = $('#library-count');
        if (!body) return;
        // Bump the token so any in-flight render aborts on its next
        // batch boundary. The new render captures `myToken` and exits
        // immediately if `_libRenderToken` advances past it.
        const myToken = ++_libRenderToken;

        if (!rows || !rows.length) {
            const isFiltered = !!(($('#library-search')?.value || '').trim());
            body.innerHTML = isFiltered
                ? '<tr><td colspan="5" class="empty">No tracks match the search.</td></tr>'
                : '<tr><td colspan="5" class="empty">No tracks yet. Fingerprint one to get started.</td></tr>';
            if (countEl) countEl.textContent = _libraryAll.length
                ? `Showing 0 of ${_libraryAll.length}`
                : '';
            return;
        }

        body.innerHTML = '';
        // Wire the delete-button delegation once. Clicks bubble up from
        // any future <button class="delete"> child to here, so renders
        // can swap rows freely without re-registering listeners.
        if (!_libDelegationWired) {
            body.addEventListener('click', async (e) => {
                const btn = e.target && e.target.closest && e.target.closest('button.delete');
                if (!btn || !body.contains(btn)) return;
                const id = btn.dataset.id;
                if (!id) return;
                if (!confirm(`Delete track "${id}" from library?`)) return;
                const r = await fetch(`/api/tracks/${encodeURIComponent(id)}`, { method: 'DELETE' });
                if (r.ok) { showToast('Track deleted', 'ok'); loadLibrary(); loadStats(); }
                else showToast('Delete failed', 'err');
            });
            _libDelegationWired = true;
        }

        // ── Chunked render ──────────────────────────────────────────────
        // Build LIB_RENDER_BATCH rows into a DocumentFragment, append,
        // yield via requestAnimationFrame, repeat. This keeps the main
        // thread responsive (input events, paint) even for a 50K-track
        // library. Counters update once per batch so the user sees
        // progress instead of a frozen "Loading…" cell.
        const total = rows.length;
        let i = 0;
        function appendBatch() {
            if (myToken !== _libRenderToken) return; // newer render took over
            const end = Math.min(i + LIB_RENDER_BATCH, total);
            const frag = document.createDocumentFragment();
            for (; i < end; i++) {
                const t = rows[i];
                const tr = document.createElement('tr');
                tr.innerHTML = `
                    <td><code>${escapeHtml(t.id)}</code></td>
                    <td>${escapeHtml(t.title || '')}</td>
                    <td>${escapeHtml(t.artist || '')}</td>
                    <td>${escapeHtml(t.mediaType || '')}</td>
                    <td><button class="delete" data-id="${escapeAttr(t.id)}">Delete</button></td>`;
                frag.appendChild(tr);
            }
            body.appendChild(frag);
            if (countEl) {
                countEl.textContent = total === _libraryAll.length
                    ? (i < total
                        ? `Loading ${i} / ${total}…`
                        : `${total} track${total === 1 ? '' : 's'}`)
                    : (i < total
                        ? `Showing ${i} / ${total} (filtered)`
                        : `Showing ${total} of ${_libraryAll.length}`);
            }
            if (i < total) {
                // Use rAF rather than setTimeout(0) so the browser can
                // paint between batches and the page stays interactive.
                requestAnimationFrame(appendBatch);
            }
        }
        appendBatch();
    }

    function libraryFilter() {
        const q = ($('#library-search')?.value || '').trim().toLowerCase();
        const clearBtn = $('#library-search-clear');
        if (clearBtn) clearBtn.hidden = q.length === 0;
        const exportBtn = $('#library-export');
        if (exportBtn) exportBtn.disabled = _libraryAll.length === 0;
        if (!q) { libraryRenderRows(_libraryAll); return; }
        // Substring match on id / title / artist (Album-folder) / mediaType.
        const filtered = _libraryAll.filter(t =>
            (t.id        && String(t.id).toLowerCase().includes(q)) ||
            (t.title     && String(t.title).toLowerCase().includes(q)) ||
            (t.artist    && String(t.artist).toLowerCase().includes(q)) ||
            (t.mediaType && String(t.mediaType).toLowerCase().includes(q))
        );
        libraryRenderRows(filtered);
    }

    // Debounced search wrapper — coalesces fast typing into a single
    // filter pass per ~150ms idle window so we don't re-render the whole
    // table after every single keystroke on a large library.
    let _libSearchTimer = null;
    function libraryFilterDebounced() {
        if (_libSearchTimer) clearTimeout(_libSearchTimer);
        _libSearchTimer = setTimeout(() => {
            _libSearchTimer = null;
            libraryFilter();
        }, 150);
    }

    async function loadLibrary() {
        const body = $('#library-rows');
        body.innerHTML = '<tr><td colspan="5" class="empty">Loading…</td></tr>';
        try {
            const r = await fetch('/api/tracks');
            const tracks = await r.json();
            _libraryAll = Array.isArray(tracks) ? tracks : [];
            libraryFilter();
        } catch (err) {
            _libraryAll = [];
            body.innerHTML = `<tr><td colspan="5" class="empty">Failed to load library: ${escapeHtml(err.message)}</td></tr>`;
            const exportBtn = $('#library-export');
            if (exportBtn) exportBtn.disabled = true;
        }
    }
    $('#refresh-library').addEventListener('click', loadLibrary);

    // Wire search + clear + export. Live-filter as the user types; the
    // export honours the current filter so the analyst can grab a subset.
    const _libSearch = $('#library-search');
    if (_libSearch) {
        // Debounced on `input` (typing storm), immediate on Escape (single
        // intentional action) — gives a snappy clear without thrashing
        // re-renders while the user is mid-typing.
        _libSearch.addEventListener('input', libraryFilterDebounced);
        _libSearch.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') { _libSearch.value = ''; libraryFilter(); }
        });
    }
    const _libSearchClear = $('#library-search-clear');
    if (_libSearchClear) {
        _libSearchClear.addEventListener('click', () => {
            if (_libSearch) { _libSearch.value = ''; libraryFilter(); _libSearch.focus(); }
        });
    }
    const _libExport = $('#library-export');
    if (_libExport) {
        _libExport.addEventListener('click', () => downloadLibraryCsv(_libraryAll, _libSearch?.value || ''));
    }

    /// Export the (filtered) library list as an Excel-friendly CSV.
    /// UTF-8 BOM + CRLF so it opens cleanly in Excel without an import
    /// wizard. Matches the airplay export style for consistency.
    function downloadLibraryCsv(allRows, searchTerm) {
        if (!allRows || !allRows.length) {
            showToast('No tracks to export.', 'err');
            return;
        }
        const q = (searchTerm || '').trim().toLowerCase();
        const rows = q
            ? allRows.filter(t =>
                (t.id && String(t.id).toLowerCase().includes(q)) ||
                (t.title && String(t.title).toLowerCase().includes(q)) ||
                (t.artist && String(t.artist).toLowerCase().includes(q)) ||
                (t.mediaType && String(t.mediaType).toLowerCase().includes(q)))
            : allRows.slice();
        const header = ['#', 'Track ID', 'Title', 'Album / Folder', 'Media Type', 'Library', 'Exported At'];
        const escape = (v) => {
            const s = String(v ?? '');
            return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
        };
        const libName = window._activeLibrary || '';
        const stamp = fmtStampLocal(new Date());
        const lines = ['﻿' + header.map(escape).join(',')];
        rows.forEach((t, i) => {
            lines.push([
                i + 1,
                t.id || '',
                t.title || '',
                t.artist || '',
                t.mediaType || '',
                libName,
                stamp
            ].map(escape).join(','));
        });
        const blob = new Blob([lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `AUDIOBoxx-library-${stamp}${q ? '-filtered' : ''}.csv`;
        a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
        showToast(`Exported ${rows.length} track${rows.length === 1 ? '' : 's'}.`, 'ok');
    }

    // ============================================================
    // LIVE MONITOR
    // ============================================================
    const monState = {
        streams: [],
        workers: {},           // streamId -> status
        statsTotal: { matches: 0, noMatches: 0, errors: 0, total: 0 },
        sse: null,
        editingId: null,
        lastRenderedLogId: 0,
    };

    function workerStreamId(w) {
        return (w && (w.streamId || w.StreamId)) || '';
    }
    function workerIsActive(w) {
        return !!(w && (w.active || w.Active));
    }

    // Activate monitor tab → load streams + status + start SSE.
    // Also load historical entries from the DB so the live log isn't empty
    // until the next monitor cycle fires. Entries are persisted by
    // StreamMonitorService.LogAndBroadcastAsync via MonitorRepository.InsertLog,
    // so /api/monitor/logs always has the full history.
    document.querySelector('.tab[data-tab="monitor"]').addEventListener('click', () => {
        loadStreams().then(() => reloadHistoricalLogs()).catch(() => reloadHistoricalLogs());
        refreshMonitorStatus();
        ensureMonitorSse();
        refreshJioTvStatus();
        fetch('/api/monitor/start-all', { method: 'POST' }).then(() => refreshMonitorStatus()).catch(() => {});
    });

    // Streams tab shares the same form/table IDs — just reload data.
    document.querySelector('.tab[data-tab="streams"]').addEventListener('click', () => {
        loadStreams();
    });

    function ensureMonitorSse() {
        if (monState.sse) return;
        const es = new EventSource('/api/monitor/stream');
        monState.sse = es;
        es.addEventListener('ready', () => {
            $('#mon-sse').textContent = '●';
            $('#mon-sse').style.color = 'var(--success)';
        });
        es.addEventListener('log', (ev) => {
            try {
                appendMonitorLog(JSON.parse(ev.data));
                scheduleReportLiveRefresh();
            } catch {}
        });
        es.onerror = () => {
            $('#mon-sse').textContent = '●';
            $('#mon-sse').style.color = 'var(--danger)';
            // Browser auto-retries; on permanent close we'd null this out
        };
    }

    async function loadStreams() {
        try {
            const r = await fetch('/api/streams');
            const rows = await r.json();
            monState.streams = rows;
            renderStreams();
            // Rebuild filter dropdown
            const sel = $('#mon-filter-stream');
            const prev = sel.value;
            sel.innerHTML = '<option value="">All</option>' +
                rows.map(s => `<option value="${escapeAttr(s.id)}">${escapeHtml(s.name)}</option>`).join('');
            sel.value = prev;
            refreshLicenseLimits();
        } catch (err) {
            showToast('Failed to load streams: ' + err.message, 'err');
        }
    }

    // ── License sync (background poll + UI refresh) ─────────────────────
    let _licensePollTimer = null;
    let _licensePollInFlight = false;
    let _prevLicenseSnapshot = null;
    const LICENSE_POLL_MS = 45_000;

    function applyLicenseSnapshot(lim, { announceChanges = false } = {}) {
        if (!lim || typeof lim !== 'object') return;

        const wasValid = _headerLicenseSnapshot?.hasValidLicense === true;
        const nowValid = lim.hasValidLicense === true;

        if (announceChanges && _prevLicenseSnapshot && nowValid) {
            const prev = _prevLicenseSnapshot;
            const maxS = Number(lim.maxStreams ?? 0);
            const prevS = Number(prev.maxStreams ?? 0);
            if (maxS > 0 && prevS > 0 && maxS !== prevS) {
                showToast(`License updated: ${maxS} stream${maxS === 1 ? '' : 's'} allowed`, 'ok');
            }
            const maxO = Number(lim.maxOfflineHours ?? 0);
            const prevO = Number(prev.maxOfflineHours ?? 0);
            if (maxO !== prevO) {
                const label = maxO > 0 ? `${fmtOfflineHours(maxO)} h offline quota` : 'unlimited offline identify';
                showToast(`License updated: ${label}`, 'ok');
            }
        }

        _headerLicenseSnapshot = lim;
        _prevLicenseSnapshot = { ...lim };

        updateHeaderStreamLimit(lim);
        updateHeaderBranding(lim.organization || '', lim, _headerVitalsSnapshot);
        syncLicenseCardFromLimits(lim);
        syncIdfOfflineLicenseBanner(lim);
        renderAboutLicenseLimits(lim);

        window._streamMax = lim.maxStreams;
        window.dispatchEvent(new Event('stream-limits-updated'));

        const banner = $('#stream-license-banner');
        const submit = $('#stream-submit');
        if (banner && submit) {
            const editing = !!monState.editingId;
            const max = Number(lim.maxStreams) || 1;
            const cur = Number(lim.currentStreams) || 0;
            const atLimit = !editing && max > 0 && cur >= max;
            banner.hidden = false;
            banner.classList.toggle('is-blocked', atLimit);
            banner.innerHTML = atLimit
                ? `<strong>Stream limit reached</strong><br>` +
                  `Your license allows <b>${max}</b> stream${max === 1 ? '' : 's'} ` +
                  `(${cur} configured). Delete one or upgrade your license to add more.`
                : `License allows <b>${max}</b> stream${max === 1 ? '' : 's'} ` +
                  `(${cur} configured, ${max - cur} remaining).`;
            submit.disabled = atLimit;
            ['#stream-name', '#stream-url'].forEach(sel => {
                const el = $(sel); if (el) el.disabled = atLimit;
            });
        }

        maybeShowLicenseServerNotification(lim.notification);

        // Do not re-show the activation gate from background polls — only update
        // in-app limits/UI. Gate appears on next page load if the server cache
        // is truly cleared.
    }

    async function syncLicenseWithServer({ revalidate = false, announceChanges = false } = {}) {
        if (_licensePollInFlight) return _headerLicenseSnapshot;
        _licensePollInFlight = true;
        try {
            const r = await fetch(
                revalidate ? '/api/license/heartbeat' : '/api/license/limits',
                { method: revalidate ? 'POST' : 'GET', cache: 'no-store' }
            );
            if (!r.ok) {
                if (!revalidate) syncIdfOfflineLicenseBanner(_headerLicenseSnapshot);
                return _headerLicenseSnapshot;
            }
            const lim = await r.json();
            applyLicenseSnapshot(lim, { announceChanges });
            return lim;
        } catch {
            syncIdfOfflineLicenseBanner(_headerLicenseSnapshot);
            return _headerLicenseSnapshot;
        } finally {
            _licensePollInFlight = false;
        }
    }

    let _licensePollTick = 0;

    function startLicenseBackgroundSync() {
        if (_licensePollTimer) clearInterval(_licensePollTimer);
        _licensePollTimer = setInterval(() => {
            _licensePollTick++;
            // Light poll every 45s (cached limits). Full license-server check every ~135s
            // so transient PHP hiccups are less likely to disturb the session.
            const revalidate = (_licensePollTick % 3 === 0);
            syncLicenseWithServer({ revalidate, announceChanges: revalidate }).catch(() => {});
        }, LICENSE_POLL_MS);
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === 'visible') {
                syncLicenseWithServer({ revalidate: false, announceChanges: false }).catch(() => {});
            }
        });
    }

    // Read the cached license limit from the server and update UI controls.
    async function refreshLicenseLimits() {
        await syncLicenseWithServer({ revalidate: false, announceChanges: false });
    }

    refreshLicenseLimits().catch(() => {});
    startLicenseBackgroundSync();

    function monitorWorkerDurationHms(w, s) {
        const checks = Number(w?.checks ?? w?.Checks) || 0;
        if (checks <= 0) return '00:00:00';
        const slice =
            (w?.captureSeconds > 0 ? w.captureSeconds : null) ??
            (s?.captureSeconds > 0 ? s.captureSeconds : null) ??
            (w?.intervalSeconds > 0 ? w.intervalSeconds : null) ??
            (s?.intervalSeconds > 0 ? s.intervalSeconds : null) ??
            defaultMonitorSliceSeconds();
        const sec = Math.max(0, Math.round(checks * slice));
        const h = Math.floor(sec / 3600);
        const m = Math.floor((sec % 3600) / 60);
        const ss = sec % 60;
        const pad = (n) => String(n).padStart(2, '0');
        return `${pad(h)}:${pad(m)}:${pad(ss)}`;
    }

    function renderStreams() {
        const body = $('#streams-rows');
        if (!monState.streams.length) {
            body.innerHTML = '<tr><td colspan="10" class="empty">No streams configured yet.</td></tr>';
            return;
        }
        body.innerHTML = monState.streams.map(s => {
            const w = monState.workers[s.id];
            const active = workerIsActive(w);
            const status = active
                ? '<span class="tag tag-added">monitoring</span>'
                : '<span class="tag tag-skipped">idle</span>';
            const lastMatch = (w && w.lastMatchTitle)
                ? `${escapeHtml(w.lastMatchTitle)} (${((w.lastMatchConfidence || 0) * 100).toFixed(1)}%)`
                : '—';
            const duration = monitorWorkerDurationHms(w, s);
            const matches = w ? w.matches : 0;
            const lib = s.libraryName
                ? `<span class="tag">${escapeHtml(s.libraryName)}</span>`
                : '<span class="muted-tag">active</span>';
            const recFolderHint = s.recordingDirectory
                ? s.recordingDirectory
                : `recordings/${(s.name || 'channel').replace(/[\\/:*?"<>|]/g, '_')}/`;
            const recDir = s.recordingDirectory
                ? `<span class="muted-tag" title="${escapeAttr(s.recordingDirectory)}">custom</span>`
                : `<span class="muted-tag" title="${escapeAttr(recFolderHint)}">auto</span>`;
            const btn = active
                ? `<button type="button" class="secondary stream-stop-btn" data-stop="${escapeAttr(s.id)}">Stop</button>`
                : `<button type="button" class="primary" data-start="${escapeAttr(s.id)}">Start</button>`;
            return `<tr>
                <td>${escapeHtml(s.name)}</td>
                <td title="${escapeAttr(s.url)}"><code style="font-size:11px;">${escapeHtml(s.url)}</code></td>
                <td>${escapeHtml(s.category)}</td>
                <td>${lib}</td>
                <td>${recDir}</td>
                <td>${status}</td>
                <td><code class="duration-cell">${duration}</code></td>
                <td>${matches}</td>
                <td>${lastMatch}</td>
                <td style="white-space:nowrap;">
                    ${btn}
                    <button class="secondary" data-edit="${escapeAttr(s.id)}">Edit</button>
                    <button class="secondary" data-del="${escapeAttr(s.id)}">Delete</button>
                </td>
            </tr>`;
        }).join('');
    }

    // Delegate row actions
    $('#streams-rows').addEventListener('click', async (e) => {
        const t = e.target;
        if (!(t instanceof HTMLElement)) return;
        const startId = t.getAttribute('data-start');
        const stopId = t.getAttribute('data-stop');
        const delId = t.getAttribute('data-del');
        const editId = t.getAttribute('data-edit');
        if (startId) return startStream(startId);
        if (stopId) return stopStream(stopId);
        if (delId) return deleteStream(delId);
        if (editId) return editStream(editId);
    });

    async function startStream(id) {
        const s = monState.streams.find(x => x.id === id);
        const w = monState.workers[id];
        const interval = (s?.intervalSeconds > 0 ? s.intervalSeconds : null) ?? w?.intervalSeconds ?? (+$('#stream-interval').value || defaultMonitorIntervalSeconds());
        const capture = (s?.captureSeconds > 0 ? s.captureSeconds : null) ?? w?.captureSeconds ?? (+$('#stream-capture').value || defaultMonitorSliceSeconds());
        const minConf = typeof s?.minConfidence === 'number' ? s.minConfidence : (+$('#stream-minconf').value);
        const threshold = (s?.thresholdVotes > 0 ? s.thresholdVotes : null) ?? w?.thresholdVotes ?? thresholdVotesFromInput($('#stream-threshold'));
        if (!Number.isFinite(minConf) || minConf < 0 || minConf > 1) {
            showToast('Edit the stream, set Min confidence, and save before starting.', 'err');
            return;
        }
        try {
            const r = await fetch('/api/monitor/start', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ streamId: id, intervalSeconds: interval, captureSeconds: capture, minConfidence: minConf, thresholdVotes: threshold })
            });
            if (!r.ok) throw new Error((await r.json()).error || 'start failed');
            showToast('Monitor started', 'ok');
            refreshMonitorStatus();
        } catch (err) {
            showToast('Start failed: ' + err.message, 'err');
        }
    }

    async function stopStream(id) {
        try {
            const r = await fetch('/api/monitor/stop', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ streamId: id })
            });
            if (!r.ok) throw new Error((await r.json()).error || 'stop failed');
            showToast('Monitor stopped', 'ok');
            refreshMonitorStatus();
        } catch (err) {
            showToast('Stop failed: ' + err.message, 'err');
        }
    }

    async function deleteStream(id) {
        if (!confirm('Delete this stream? Monitoring will stop.')) return;
        try {
            const r = await fetch('/api/streams/' + encodeURIComponent(id), { method: 'DELETE' });
            if (!r.ok && r.status !== 204) throw new Error('delete failed');
            showToast('Stream deleted', 'ok');
            loadStreams();
            refreshMonitorStatus();
        } catch (err) {
            showToast('Delete failed: ' + err.message, 'err');
        }
    }

    function editStream(id) {
        const s = monState.streams.find(x => x.id === id);
        if (!s) return;
        monState.editingId = id;
        $('#stream-name').value = s.name;
        $('#stream-url').value = s.url;
        // Default to "video" when an existing stream has no category
        // recorded — matches the new default in the Add-stream form.
        $('#stream-category').value = s.category || 'video';
        if ($('#stream-library')) $('#stream-library').value = s.libraryName || '';
        // Language is a fixed dropdown plus an "Other…" escape hatch — set
        // the dropdown if the saved value is one of the canonical options,
        // otherwise switch to Other… and prefill the custom-language input
        // so legacy free-form values survive a round-trip through Edit.
        if ($('#stream-language')) setStreamLanguageValue(s.language || '');
        // Restore the saved quality on edit; falls back to "Auto"
        // (empty) when the stream has no quality recorded yet.
        if ($('#stream-quality')) $('#stream-quality').value = normalizeStreamQuality(s.quality);
        const w = monState.workers[id];
        $('#stream-interval').value = (s.intervalSeconds > 0 ? s.intervalSeconds : null) ?? w?.intervalSeconds ?? defaultMonitorIntervalSeconds();
        $('#stream-capture').value = (s.captureSeconds > 0 ? s.captureSeconds : null) ?? w?.captureSeconds ?? defaultMonitorSliceSeconds();
        if (typeof s.minConfidence === 'number') $('#stream-minconf').value = s.minConfidence;
        else if (typeof w?.minConfidence === 'number') $('#stream-minconf').value = w.minConfidence;
        else $('#stream-minconf').value = defaultMinConfidence();
        if ($('#stream-threshold')) {
            $('#stream-threshold').value = (s.thresholdVotes > 0 ? s.thresholdVotes : null) ?? w?.thresholdVotes ?? defaultThresholdVotesUi();
        }
        if ($('#stream-recording-dir')) $('#stream-recording-dir').value = s.recordingDirectory || '';
        $('#stream-submit').textContent = 'Update stream';
        $('#stream-name').focus();
    }

    // ── Language dropdown ↔ custom-text-box plumbing ─────────────────
    // The select fires "change" on user interaction, but assigning `.value`
    // from code doesn't fire change events. setStreamLanguageValue keeps
    // the visible state and the hidden text input in sync for both paths
    // (programmatic prefill during edit + manual user interaction).
    function setStreamLanguageValue(raw) {
        const sel = $('#stream-language');
        const other = $('#stream-language-other');
        const wrap = $('#stream-language-other-wrap');
        if (!sel) return;
        const value = String(raw || '').trim();
        const known = Array.from(sel.options).some(o => o.value === value && value !== '');
        if (known) {
            sel.value = value;
            if (wrap) wrap.style.display = 'none';
            if (other) other.value = '';
        } else if (value === '') {
            sel.value = '';
            if (wrap) wrap.style.display = 'none';
            if (other) other.value = '';
        } else {
            sel.value = '__other__';
            if (wrap) wrap.style.display = '';
            if (other) other.value = value;
        }
    }
    const _streamLangSelect = $('#stream-language');
    if (_streamLangSelect) {
        _streamLangSelect.addEventListener('change', () => {
            const isOther = _streamLangSelect.value === '__other__';
            const wrap = $('#stream-language-other-wrap');
            if (wrap) wrap.style.display = isOther ? '' : 'none';
            const other = $('#stream-language-other');
            if (!isOther && other) other.value = '';
        });
    }
    // Resolve the effective language: text-box when dropdown=Other…, else
    // the dropdown's own value. Empty string when none selected.
    function getStreamLanguageValue() {
        const sel = $('#stream-language');
        if (!sel) return '';
        if (sel.value === '__other__') {
            return ($('#stream-language-other')?.value || '').trim();
        }
        return sel.value || '';
    }

    // Populate the "Fingerprint library" dropdown on the stream form from
    // the libraries API. Called whenever the Streams tab is shown or the
    // library list changes (create/switch/delete from the Fingerprint tab).
    async function refreshStreamLibraryOptions() {
        const selectors = ['#stream-library', '#id-library', '#idf-library'];
        const targets = selectors.map(s => $(s)).filter(Boolean);
        try {
            const data = await fetch('/api/libraries').then(r => r.json());
            // Publish the active library globally so the report exporters
            // (PDF / airplay CSV / fingerprint Excel / identify CSV) can
            // stamp it on every output as the "Client".
            window._activeLibrary = (data && data.current) ? String(data.current) : null;
            // Best-effort: ask the backend for the most common root folder
            // among the active library's existing fingerprinted tracks and
            // adopt it as the client name. Skipped if the user has already
            // set one this session via an identify run.
            try {
                const cr = await fetch('/api/client-name').then(r => r.ok ? r.json() : null);
                if (cr && cr.name && !window._clientName) setClientName(cr.name);
            } catch { /* offline / 404 / older server — ignore */ }
            const libs = Array.isArray(data.libraries) ? data.libraries : [];
            if (targets.length === 0) return;
            for (const sel of targets) {
                const prev = sel.value;
                sel.innerHTML = '';
                const def = document.createElement('option');
                def.value = '';
                def.textContent = `(active library: ${data.current || '—'})`;
                sel.appendChild(def);
                for (const l of libs) {
                    const opt = document.createElement('option');
                    opt.value = l.name;
                    opt.textContent = l.name + (l.isActive ? ' (active)' : '');
                    sel.appendChild(opt);
                }
                sel.value = prev;
            }
        } catch { /* ignore */ }
    }
    // Re-populate when libraries change.
    window.addEventListener('libraries-updated', refreshStreamLibraryOptions);
    // Initial fill.
    refreshStreamLibraryOptions();

    function streamUrlDuplicateKey(url) {
        const t = (url || '').trim();
        if (!t) return '';
        try {
            const u = new URL(t);
            const host = u.hostname.toLowerCase();
            const isJio = host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
            let path = u.pathname.replace(/\/+$/, '').toLowerCase();
            if (isJio) {
                if (path.startsWith('/play/')) {
                    const id = path.slice('/play/'.length).split('/')[0].replace(/\.m3u8$/i, '');
                    if (id) path = '/live/' + id + '.m3u8';
                } else if (path.startsWith('/live/') && !path.endsWith('.m3u8')) {
                    path = path + '.m3u8';
                }
                return 'jiotv:' + path;
            }
            u.hash = '';
            return u.href.replace(/\/+$/, '').toLowerCase();
        } catch {
            return t.toLowerCase();
        }
    }

    function findLocalStreamDuplicate(name, url, editingId) {
        const rows = monState.streams || [];
        const urlKey = streamUrlDuplicateKey(url);
        const nameKey = (name || '').trim().toLowerCase();
        const editId = (editingId || '').toLowerCase();
        for (const s of rows) {
            const sid = (s.id || s.Id || '').toLowerCase();
            if (editId && sid === editId) continue;
            if (urlKey && streamUrlDuplicateKey(s.url || s.Url) === urlKey) {
                return { field: 'url', stream: s };
            }
            const sn = (s.name || s.Name || '').trim().toLowerCase();
            if (nameKey && sn === nameKey) {
                return { field: 'name', stream: s };
            }
        }
        return null;
    }

    function showStreamDuplicateError(payload, fieldHint) {
        const st = $('#stream-status');
        if (st) {
            st.textContent = payload.error || 'This stream already exists.';
            st.className = 'status err';
        }
        const focusEl = fieldHint === 'name' ? $('#stream-name') : $('#stream-url');
        if (focusEl) { focusEl.focus(); focusEl.select(); }
        showToast(payload.error || 'Duplicate stream', 'err');
    }

    $('#stream-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const submitBtn = $('#stream-submit');
        if (submitBtn?.disabled) return;
        const name = $('#stream-name').value.trim();
        const url = $('#stream-url').value.trim();
        const category = $('#stream-category').value;
        const libraryName = ($('#stream-library')?.value || '').trim();
        const language = getStreamLanguageValue().trim();
        const quality = normalizeStreamQuality($('#stream-quality')?.value);
        if (!name || !url) return;

        const localDup = findLocalStreamDuplicate(name, url, monState.editingId);
        if (localDup) {
            const dupName = localDup.stream.name || localDup.stream.Name || 'existing stream';
            const msg = localDup.field === 'url'
                ? `This URL is already registered as stream '${dupName}'.`
                : `A stream named '${dupName}' already exists.`;
            showStreamDuplicateError({ error: msg }, localDup.field);
            return;
        }
        // Pass the form's interval/capture/min-confidence/threshold so the
        // server's auto-start uses the user's values instead of hard-coded
        // defaults. Saving an existing stream restarts the worker with the
        // new settings so a Min-confidence change takes effect immediately.
        const intervalSeconds = +$('#stream-interval').value || defaultMonitorIntervalSeconds();
        const captureSeconds = +$('#stream-capture').value || defaultMonitorSliceSeconds();
        const minConfidence = +$('#stream-minconf').value;
        const thresholdVotes = thresholdVotesFromInput($('#stream-threshold'));
        if (!Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1) {
            showToast('Min confidence must be between 0 and 1.', 'err');
            return;
        }
        const recordingDirectory = ($('#stream-recording-dir')?.value || '').trim();
        const common = { name, url, category, libraryName, language, quality,
            intervalSeconds, captureSeconds, minConfidence, thresholdVotes,
            recordingDirectory: recordingDirectory || null };
        const body = monState.editingId
            ? { id: monState.editingId, ...common }
            : common;
        const st = $('#stream-status');
        if (submitBtn) submitBtn.disabled = true;
        if (st) { st.textContent = 'Saving…'; st.className = 'status'; }
        try {
            const r = await fetch('/api/streams', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(body)
            });
            if (!r.ok) {
                let payload = {};
                try { payload = await r.json(); } catch { /* non-JSON */ }
                if (r.status === 409 && payload && payload.duplicateStreamId) {
                    showStreamDuplicateError(payload, payload.duplicateField || 'url');
                    return;
                }
                throw new Error(payload.error || 'save failed');
            }
            let workersStarting = true;
            try {
                const saved = await r.json();
                workersStarting = saved.workersStarting !== false;
            } catch { /* ignore */ }
            const msg = monState.editingId
                ? (workersStarting ? 'Stream saved — restarting monitor…' : 'Stream updated')
                : (workersStarting ? 'Stream saved — starting monitor…' : 'Stream added');
            showToast(msg, 'ok');
            if (st) { st.textContent = ''; st.className = 'status'; }
            monState.editingId = null;
            $('#stream-submit').textContent = 'Save stream';
            $('#stream-form').reset();
            $('#stream-interval').value = defaultMonitorIntervalSeconds();
            $('#stream-capture').value = defaultMonitorSliceSeconds();
            $('#stream-minconf').value = defaultMinConfidence();
            if ($('#stream-threshold')) $('#stream-threshold').value = defaultThresholdVotesUi();
            if ($('#stream-library')) $('#stream-library').value = '';
            // form.reset() resets <select> + <input> values but doesn't
            // touch our manually-managed wrap visibility — collapse the
            // custom-language box back so the form looks clean.
            setStreamLanguageValue('');
            // Quality select snaps back to "Auto" — explicit so the
            // very next add doesn't inherit the last-saved value.
            if ($('#stream-quality')) $('#stream-quality').value = '';
            if ($('#stream-recording-dir')) $('#stream-recording-dir').value = '';
            loadStreams();
            // Server auto-starts monitor + recording on upsert; refresh status
            // so the row immediately shows the "monitoring" tag.
            refreshMonitorStatus();
        } catch (err) {
            if (st) { st.textContent = ''; st.className = 'status'; }
            showToast('Save failed: ' + err.message, 'err');
        } finally {
            if (submitBtn) submitBtn.disabled = false;
        }
    });

    async function refreshMonitorStatus() {
        try {
            const r = await fetch('/api/monitor/status');
            const data = await r.json();
            monState.workers = {};
            (data.workers || []).forEach(w => {
                const id = workerStreamId(w);
                if (id) monState.workers[id] = w;
            });
            const activeCount = (data.workers || []).filter(workerIsActive).length;
            $('#mon-active').textContent = activeCount;
            $('#mon-matches').textContent = data.stats.matches || 0;
            $('#mon-nomatches').textContent = data.stats.noMatches || 0;
            $('#mon-errors').textContent = data.stats.errors || 0;
            renderStreams();
        } catch {
            // server may be down; leave stats
        }
    }
    // Poll status every 5s while on any tab (cheap)
    setInterval(() => {
        if (monState.sse || document.querySelector('.tab[data-tab="monitor"].active')) {
            refreshMonitorStatus();
        }
    }, 5000);

    // How many rows stay in the Live Log panel (DOM). Older rows roll off the top.
    const MON_LOG_MAX_ROWS = 5000;
    const MON_LOG_RELOAD_LIMIT = 5000;

    function appendMonitorLog(entry) {
        const logId = Number(entry.id ?? entry.Id ?? 0);
        if (logId > 0) {
            if (logId <= monState.lastRenderedLogId) return;
            monState.lastRenderedLogId = logId;
        }

        const fStream = $('#mon-filter-stream').value;
        const fType = $('#mon-filter-type').value;
        if (fStream && entry.streamId !== fStream) return;
        if (fType && entry.type !== fType) return;

        if (entry.type === 'nomatch') {
            aboxBuddy('nomatch', { source: 'monitor', streamName: entry.streamName });
        }

        const log = $('#mon-log');
        const row = document.createElement('div');
        row.className = 'folder-row ' + (entry.type === 'match' ? 'added' : entry.type === 'error' ? 'failed' : 'skipped');

        const time = fmtTime(entry.timeUtc);
        const tag = `<span class="tag tag-${entry.type === 'match' ? 'added' : entry.type === 'error' ? 'failed' : 'skipped'}">${displayType(entry.type)}</span>`;
        let line = `${tag} <span class="path"><strong>${escapeHtml(entry.streamName)}</strong> · ${time}</span>`;
        row.innerHTML = line;

        if (entry.type === 'match') {
            aboxBuddy('match', {
                source: 'monitor',
                title: entry.title || entry.trackId,
                artist: entry.artist,
                confidence: entry.confidence,
                streamName: entry.streamName
            });
            const m = document.createElement('span');
            m.className = 'match';
            m.innerHTML = `→ <strong>${((entry.confidence || 0) * 100).toFixed(1)}%</strong> — ${escapeHtml(entry.title || entry.trackId || '')}${entry.artist ? ' · ' + escapeHtml(entry.artist) : ''}`;
            row.appendChild(m);

            const meta = document.createElement('span');
            meta.className = 'match match-meta';
            meta.innerHTML =
                `score ${(entry.score || 0).toFixed(1)}` +
                ` · coverage ${(entry.coverageSeconds || 0).toFixed(2)}s` +
                ` · query length ${(entry.queryLengthSeconds || 0).toFixed(2)}s` +
                ` · track match @ ${(entry.trackMatchAtSeconds || 0).toFixed(2)}s`;
            row.appendChild(meta);
        } else if (entry.message) {
            const m = document.createElement('span');
            m.className = 'match match-meta';
            m.textContent = entry.message;
            row.appendChild(m);
        }

        log.appendChild(row);
        while (log.childElementCount > MON_LOG_MAX_ROWS) log.removeChild(log.firstChild);
        log.scrollTop = log.scrollHeight;
    }

    $('#mon-clear').addEventListener('click', () => {
        $('#mon-log').innerHTML = '';
        monState.lastRenderedLogId = 0;
    });

    // Danger zone: wipe every row from monitor_log. This also resets the
    // Report tab (which is derived entirely from monitor_log). Streams,
    // recordings, and the fingerprint library are not touched.
    const monClearLogsBtn = $('#mon-clear-logs');
    if (monClearLogsBtn) monClearLogsBtn.addEventListener('click', async () => {
        if (!confirm('Permanently delete ALL monitoring history? This clears every identification/error log and resets the Report tab. This cannot be undone.')) return;
        try {
            const r = await fetch('/api/monitor/logs/clear', { method: 'POST' });
            const data = await r.json().catch(() => ({}));
            if (!r.ok) throw new Error(data.error || 'clear failed');
            showToast(`Cleared ${data.deleted || 0} monitoring log entries.`, 'ok');
            $('#mon-log').innerHTML = '';
            // Reset counters on the overview + monitor cards
            ['#mon-matches', '#mon-nomatches', '#mon-errors'].forEach(sel => {
                const el = $(sel); if (el) el.textContent = '0';
            });
            refreshMonitorStatus();
            // If Report tab is currently visible, re-run the query.
            const rptTab = document.querySelector('.tab[data-tab="report"]');
            if (rptTab && rptTab.classList.contains('active') && typeof loadReport === 'function') loadReport();
        } catch (err) {
            showToast('Clear history failed: ' + err.message, 'err');
        }
    });
    // Targeted delete: removes from the DB only the entries that match the
    // current Stream / Type filter. Useful for wiping noise (e.g. just the
    // no-match rows for one channel) without nuking the full history.
    const monDeleteFilteredBtn = $('#mon-delete-filtered');
    if (monDeleteFilteredBtn) monDeleteFilteredBtn.addEventListener('click', async () => {
        const streamId = $('#mon-filter-stream').value;
        const type = $('#mon-filter-type').value;
        const filterDesc = [
            streamId ? `stream "${$('#mon-filter-stream').selectedOptions[0]?.textContent || streamId}"` : '',
            type ? `type "${displayType(type)}"` : ''
        ].filter(Boolean).join(' + ') || 'ALL filters cleared (every row)';
        if (!confirm(`Delete monitor_log entries matching ${filterDesc}? This cannot be undone.`)) return;
        const qs = new URLSearchParams();
        if (streamId) qs.set('streamId', streamId);
        if (type) qs.set('type', type);
        try {
            const r = await fetch('/api/monitor/logs?' + qs.toString(), { method: 'DELETE' });
            const data = await r.json().catch(() => ({}));
            if (!r.ok) throw new Error(data.error || 'delete failed');
            showToast(`Deleted ${data.deleted || 0} matching log entries.`, 'ok');
            reloadHistoricalLogs();
            refreshMonitorStatus();
            const rptTab = document.querySelector('.tab[data-tab="report"]');
            if (rptTab && rptTab.classList.contains('active') && typeof loadReport === 'function') loadReport();
        } catch (err) {
            showToast('Delete failed: ' + err.message, 'err');
        }
    });

    $('#mon-filter-stream').addEventListener('change', reloadHistoricalLogs);
    $('#mon-filter-type').addEventListener('change', reloadHistoricalLogs);

    let _reloadingLogs = false;
    async function reloadHistoricalLogs() {
        // Re-entrancy guard: a fast tab-switch sequence used to fire this
        // multiple times in parallel, each rendering ~500 rows synchronously
        // and freezing the main thread.
        if (_reloadingLogs) return;
        _reloadingLogs = true;
        try {
            $('#mon-log').innerHTML = '';
            monState.lastRenderedLogId = 0;
            const streamId = $('#mon-filter-stream').value;
            const type = $('#mon-filter-type').value;
            const qs = new URLSearchParams();
            if (streamId) qs.set('streamId', streamId);
            if (type) qs.set('type', type);
            qs.set('limit', String(MON_LOG_RELOAD_LIMIT));
            try {
                const r = await fetch('/api/monitor/logs?' + qs.toString());
                const rows = await r.json();
                // Yield to the event loop every 50 rows so the page stays
                // responsive while the historical log paints — important
                // for tabs that have ~thousands of accumulated entries.
                for (let i = 0; i < rows.length; i++) {
                    appendMonitorLog(rows[i]);
                    if ((i % 50) === 49 && i + 1 < rows.length) {
                        await new Promise(r => setTimeout(r, 0));
                    }
                }
            } catch {}
        } finally {
            _reloadingLogs = false;
        }
    }

    // ---- JioTV Go manager ----
    async function refreshJioTvStatus() {
        try {
            const r = await fetch('/api/jiotv/status');
            const s = await r.json();
            const badge = $('#jiotv-status-badge');
            badge.className = 'tag ' + (s.running ? 'tag-added' : (s.exeAvailable ? 'tag-skipped' : 'tag-failed'));
            badge.textContent = s.running ? `running · pid ${s.pid ?? '?'}` : (s.exeAvailable ? 'stopped' : 'not installed');
            $('#jiotv-url-hint').textContent = `${s.url}/live/<channelId>.m3u8`;
            $('#jiotv-exe-hint').textContent = s.exeAvailable
                ? `Executable: ${s.exePath}`
                : `Executable not found at: ${s.exePath} (set JioTv:ExePath in appsettings.json)`;
            $('#jiotv-start').disabled = s.running || !s.exeAvailable;
            $('#jiotv-stop').disabled = !s.running;
            $('#jiotv-restart').disabled = !s.exeAvailable;
            const link = $('#jiotv-open');
            if (s.running) {
                link.style.display = '';
                link.href = s.url;
                link.textContent = 'Open UI';
            } else {
                link.style.display = 'none';
            }
        } catch {}
    }

    async function jiotvAction(path) {
        try {
            const r = await fetch(path, { method: 'POST' });
            const data = await r.json();
            if (!r.ok) throw new Error(data.error || 'request failed');
            showToast('JioTV: ' + (data.success ? 'OK' : (data.stopped ? 'stopped' : 'done')), 'ok');
        } catch (err) {
            showToast('JioTV: ' + err.message, 'err');
        } finally {
            refreshJioTvStatus();
        }
    }
    $('#jiotv-start').addEventListener('click', () => jiotvAction('/api/jiotv/start'));
    $('#jiotv-stop').addEventListener('click', () => jiotvAction('/api/jiotv/stop'));
    $('#jiotv-restart').addEventListener('click', () => jiotvAction('/api/jiotv/restart'));

    // ============================================================
    // REPORT
    // ============================================================
    let rptSourceMode = 'monitor';

    function setPreset(preset) {
        const today = new Date();
        // Local YYYY-MM-DD (NOT UTC). Using toISOString() here would flip the
        // date for users late at night in +TZ regions.
        const ymd = (d) => localYmd(d);
        const fromEl = $('#rpt-from'), toEl = $('#rpt-to');
        if (preset === 'today') {
            fromEl.value = ymd(today); toEl.value = ymd(today);
        } else if (preset === 'yesterday') {
            const y = new Date(today); y.setDate(y.getDate() - 1);
            fromEl.value = ymd(y); toEl.value = ymd(y);
        } else if (preset === '7d') {
            const f = new Date(today); f.setDate(f.getDate() - 6);
            fromEl.value = ymd(f); toEl.value = ymd(today);
        } else if (preset === '30d') {
            const f = new Date(today); f.setDate(f.getDate() - 29);
            fromEl.value = ymd(f); toEl.value = ymd(today);
        } else if (preset === 'all') {
            fromEl.value = ''; toEl.value = '';
        }
    }

    // Auto-refresh interval for the Report tab. While the user is parked
    // on the Report tab we re-run loadReport() every 60 seconds so KPIs,
    // charts and the airplay table stay current with new monitor events.
    // Three safety belts (cumulatively): poll only when the Report tab is
    // active AND the document isn't hidden AND a prior load isn't still
    // pending — so a slow render can't stack and freeze the main thread.
    let _rptAutoTimer = null;
    let _rptLoadInflight = false;
    let _rptRefreshPending = false;

    function startReportAutoRefresh() {
        stopReportAutoRefresh();
        _rptAutoTimer = setInterval(() => {
            const tab = document.querySelector('.tab[data-tab="report"]');
            if (!tab || !tab.classList.contains('active')) return;
            if (document.hidden) return;
            loadReport().catch(() => {});
        }, 30_000);
    }

    let _rptLiveRefreshTimer = null;
    function scheduleReportLiveRefresh() {
        const tab = document.querySelector('.tab[data-tab="report"]');
        if (!tab || !tab.classList.contains('active')) return;
        if (_rptLiveRefreshTimer) clearTimeout(_rptLiveRefreshTimer);
        _rptLiveRefreshTimer = setTimeout(() => {
            _rptLiveRefreshTimer = null;
            loadReport().catch(() => {});
        }, 2000);
    }
    function stopReportAutoRefresh() {
        if (_rptAutoTimer) { clearInterval(_rptAutoTimer); _rptAutoTimer = null; }
    }
    // Stop the poll the moment the user navigates away from Report so we
    // don't hammer the DB while they're on Library / Streams / etc.
    document.querySelectorAll('.tab').forEach(t => {
        t.addEventListener('click', () => {
            if (t.dataset.tab !== 'report') stopReportAutoRefresh();
        });
    });

    function activateReportTab() {
        rptSourceMode = ($('#rpt-source')?.value || 'monitor');
        ensureMonitorSse();
        if (!monState.streams.length) loadStreams().catch(() => {});
        loadRptFilterOptions().catch(() => {});
        if (!fromIsSet()) setPreset('7d');
        loadReport().catch(() => {});
        startReportAutoRefresh();
    }
    window.activateReportTab = activateReportTab;

    async function loadRptFilterOptions() {
        const source = rptSourceMode;
        const streamLabel = document.getElementById('rpt-stream-label');
        const scanLabel = document.getElementById('rpt-scan-label');
        const sel = $('#rpt-stream');
        const scanSel = $('#rpt-scan-root');
        if (source === 'offline') {
            if (streamLabel) streamLabel.hidden = true;
            if (scanLabel) scanLabel.hidden = false;
            try {
                const r = await fetch('/api/offline/scan-roots');
                const rows = r.ok ? await r.json() : [];
                const cur = scanSel?.value || '';
                if (scanSel) {
                    scanSel.innerHTML = '<option value="">All scan folders</option>' +
                        rows.map(x => `<option value="${escapeAttr(x.scanRoot)}">${escapeHtml(x.label)}</option>`).join('');
                    if (cur) scanSel.value = cur;
                }
            } catch {
                if (scanSel) scanSel.innerHTML = '<option value="">All scan folders</option>';
            }
            return;
        }
        if (streamLabel) streamLabel.hidden = false;
        if (scanLabel) scanLabel.hidden = true;
        const cur = sel?.value || '';
        const monSel = $('#mon-filter-stream');
        if (!monSel || monSel.options.length <= 1) {
            try {
                const r = await fetch('/api/streams');
                if (r.ok) {
                    const rows = await r.json();
                    if (sel) {
                        sel.innerHTML = '<option value="">All</option>' +
                            rows.map(s => `<option value="${escapeAttr(s.id)}">${escapeHtml(s.name)}</option>`).join('');
                    }
                    if (monSel) monSel.innerHTML = sel.innerHTML;
                } else if (sel) sel.innerHTML = '<option value="">All</option>';
            } catch {
                if (sel) sel.innerHTML = '<option value="">All</option>';
            }
        } else if (sel) {
            sel.innerHTML = monSel.innerHTML;
        }
        if (sel) sel.value = cur;
    }

    document.addEventListener('visibilitychange', () => {
        if (document.hidden) return;
        const tab = document.querySelector('.tab[data-tab="report"]');
        if (tab && tab.classList.contains('active')) loadReport().catch(() => {});
    });

    function fromIsSet() { return $('#rpt-from').value || $('#rpt-to').value; }

    // Quick Range presets — the buttons sit in the report sidebar under
    // `<div class="preset-stack">` and each carries a `data-preset` attribute
    // (today / yesterday / 7d / 30d / all). The previous selector hunted for
    // `.rpt-presets button`, which doesn't exist in the markup, so the
    // click handlers were never bound and the quick-range buttons did
    // nothing. Bind directly via the data attribute, scoped to the report
    // panel so we don't accidentally pick up unrelated buttons elsewhere.
    document.querySelectorAll('[data-panel="report"] .preset-stack button[data-preset]').forEach(b => {
        b.addEventListener('click', () => { setPreset(b.dataset.preset); loadReport(); });
    });
    $('#rpt-generate').addEventListener('click', () => loadReport());
    $('#rpt-source')?.addEventListener('change', async () => {
        rptSourceMode = $('#rpt-source')?.value || 'monitor';
        await loadRptFilterOptions();
        await loadReport();
    });
    $('#rpt-scan-root')?.addEventListener('change', () => loadReport());
    $('#rpt-stream')?.addEventListener('change', () => loadReport());

    if (document.querySelector('.tab[data-tab="report"]')?.classList.contains('active')) {
        queueMicrotask(() => activateReportTab());
    }
    function reportAirplayExportUrl(path, opts) {
        const qs = reportQuery();
        if (opts && opts.consolidate === false) qs.set('consolidate', 'false');
        return path + '?' + qs.toString();
    }

    $('#rpt-export-csv').addEventListener('click', () => {
        window.location.href = reportAirplayExportUrl('/api/report/airplay.csv');
    });
    $('#rpt-export-xlsx')?.addEventListener('click', () => {
        window.location.href = reportAirplayExportUrl('/api/report/airplay.xlsx');
    });
    $('#rpt-export-csv-raw')?.addEventListener('click', () => {
        window.location.href = reportAirplayExportUrl('/api/report/airplay.csv', { consolidate: false });
    });
    $('#rpt-export-xlsx-raw')?.addEventListener('click', () => {
        window.location.href = reportAirplayExportUrl('/api/report/airplay.xlsx', { consolidate: false });
    });

    $('#mon-export-xlsx')?.addEventListener('click', () => {
        const qs = new URLSearchParams();
        const streamId = $('#mon-filter-stream').value;
        const type = $('#mon-filter-type').value;
        if (streamId) qs.set('streamId', streamId);
        if (type) qs.set('type', type);
        window.location.href = '/api/monitor/logs.xlsx?' + qs.toString();
    });

    function reportQuery() {
        rptSourceMode = ($('#rpt-source')?.value || 'monitor');
        const qs = new URLSearchParams();
        const f = $('#rpt-from').value, t = $('#rpt-to').value;
        const fromIso = localDateToUtcIso(f, false);
        const toIso = localDateToUtcIso(t, true);
        if (fromIso) qs.set('from', fromIso);
        if (toIso) qs.set('to', toIso);
        qs.set('tz', String(tzOffsetMinutes()));
        qs.set('source', rptSourceMode);
        if (rptSourceMode === 'offline') {
            const root = $('#rpt-scan-root')?.value;
            if (root) qs.set('scanRoot', root);
        } else {
            const s = $('#rpt-stream').value;
            if (s) qs.set('streamId', s);
        }
        return qs;
    }

    function setReportLoadStatus(msg, isErr) {
        const el = document.getElementById('rpt-airplay-count');
        if (!el) return;
        el.textContent = msg || '';
        el.style.color = isErr ? 'var(--danger)' : '';
    }

    async function loadReport() {
        if (_rptLoadInflight) {
            _rptRefreshPending = true;
            return;
        }
        _rptLoadInflight = true;
        setReportLoadStatus('Loading report…', false);
        try {
            const qs = reportQuery();
            const airplayQs = new URLSearchParams(qs);
            airplayQs.set('consolidate', 'true');
            const [reportRes, airplayRes] = await Promise.all([
                fetch('/api/report?' + qs.toString()),
                fetch('/api/report/airplay-rows?' + airplayQs.toString())
            ]);
            if (!reportRes.ok) {
                const errBody = await reportRes.json().catch(() => ({}));
                throw new Error(errBody.error || ('Report API ' + reportRes.status));
            }
            const data = await reportRes.json();
            window._lastReport = data;
            const summary = data.summary || {};
            renderReportSummary(summary);
            try { renderReportInsights(data); }
            catch (insErr) { console.warn('Report insights render failed:', insErr); }
            try { renderReportCharts(data); }
            catch (chartErr) { console.warn('Report charts render failed:', chartErr); }
            renderReportTopSongs(data.topSongs);
            renderReportTopArtists(data.topSongs);
            renderReportStreamCards(data.streamCards);
            renderReportHeatmap(data.hourByDow);
            let airplayPayload = {};
            if (airplayRes.ok) {
                airplayPayload = await airplayRes.json();
            } else {
                const errBody = await airplayRes.json().catch(() => ({}));
                console.warn('airplay-rows failed:', airplayRes.status, errBody);
            }
            const rows = Array.isArray(airplayPayload?.rows) ? airplayPayload.rows : [];
            renderAirplayTableFromRows(rows, !!airplayPayload?.truncated);
            const rawN = airplayPayload?.rawLogCount ?? 0;
            const rowN = airplayPayload?.rowCount ?? rows.length;
            const matchN = summary.matches ?? 0;
            if (rowN === 0 && matchN === 0 && rawN === 0) {
                const emptyHint = rptSourceMode === 'offline'
                    ? 'No offline identify results in this range — run folder identify or widen dates / pick another scan folder.'
                    : 'No monitor events in this date range — try Last 7 Days or All Time.';
                setReportLoadStatus(emptyHint, true);
            } else {
                const gapNote = airplayPayload?.gapFillApplied === false && rowN > 0
                    ? ' · grid gaps only filled for ranges ≤72h'
                    : '';
                setReportLoadStatus(
                    `${rowN.toLocaleString()} table rows · ${rawN.toLocaleString()} log events · ${matchN.toLocaleString()} matches${gapNote} · ${new Date().toLocaleTimeString()}`,
                    false);
            }
        } catch (err) {
            setReportLoadStatus('Report failed: ' + err.message, true);
            showToast('Report failed: ' + err.message, 'err');
        } finally {
            _rptLoadInflight = false;
            if (_rptRefreshPending) {
                _rptRefreshPending = false;
                queueMicrotask(() => loadReport().catch(() => {}));
            }
        }
    }
    window.loadReport = loadReport;

    function renderReportSummary(s) {
        if (!s || typeof s !== 'object') s = {};
        const totalSec = (s.totalAirtimeSec != null)
            ? s.totalAirtimeSec
            : ((s.matchSeconds != null || s.noMatchSeconds != null || s.errorSeconds != null)
                ? (Number(s.matchSeconds) || 0) + (Number(s.noMatchSeconds) || 0) + (Number(s.errorSeconds) || 0)
                : null);
        $('#rpt-total').textContent = totalSec != null ? fmtHms(totalSec) : '—';
        $('#rpt-matches').textContent = s.matches != null ? String(s.matches) : '0';
        $('#rpt-nomatch').textContent = s.noMatches != null ? String(s.noMatches) : '0';
        $('#rpt-errors') && ($('#rpt-errors').textContent = s.errors);
        // Match/no-match cards show the airtime side-by-side with the
        // count. The exact airtime needs the detailed row list (loaded a
        // moment later by loadAirplayTable → recomputeConsolidatedCards),
        // so we seed with the summary's coarse second totals when
        // present and fall back to a "—" placeholder so old values
        // from a previous report load don't linger. The ⏱ glyph lives
        // in the static label under each value, not the value itself.
        const mAir = (s.matchSeconds != null) ? fmtHms(s.matchSeconds) : '—';
        const nAir = (s.noMatchSeconds != null) ? fmtHms(s.noMatchSeconds) : '—';
        const mEl = document.getElementById('rpt-matches-airtime');
        const nEl = document.getElementById('rpt-nomatch-airtime');
        if (mEl) mEl.textContent = mAir;
        if (nEl) nEl.textContent = nAir;
        const matchable = (Number(s.matches) || 0) + (Number(s.noMatches) || 0);
        $('#rpt-match-rate').textContent = matchable > 0 ? (((Number(s.matches) || 0) / matchable) * 100).toFixed(1) + '%' : '0%';
        $('#rpt-avg-conf').textContent = (s.avgConfidence > 0) ? (s.avgConfidence * 100).toFixed(1) + '%' : '—';
        $('#rpt-unique').textContent = s.uniqueSongs != null ? String(s.uniqueSongs) : '0';
    }

    // Derived / insight KPIs computed client-side from the report payload.
    function renderReportInsights(data) {
        const s = data.summary || {};
        const hourly = data.hourly || [];
        const dow = data.dayOfWeek || [];
        const daily = data.daily || [];
        const cards = data.streamCards || [];
        const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

        // Peak hour
        let peakH = -1, peakHv = -1;
        hourly.forEach((v, i) => { if (v > peakHv) { peakHv = v; peakH = i; } });
        $('#rpt-peak-hour').textContent = peakH >= 0 && peakHv > 0
            ? `${String(peakH).padStart(2, '0')}:00` : '—';

        // Peak day-of-week
        let peakD = -1, peakDv = -1;
        dow.forEach((v, i) => { if (v > peakDv) { peakDv = v; peakD = i; } });
        $('#rpt-peak-dow').textContent = peakD >= 0 && peakDv > 0 ? DOW_NAMES[peakD] : '—';

        // Busiest channel (highest matches)
        let busy = null;
        cards.forEach(c => { if (!busy || (c.matches || 0) > (busy.matches || 0)) busy = c; });
        const busyName = busy?.streamName || '';
        $('#rpt-busiest-channel').textContent = busy && (busy.matches || 0) > 0
            ? (busyName.length > 14 ? busyName.slice(0, 13) + '…' : busyName)
            : '—';

        // Avg matches per day
        const days = daily.length || 1;
        const avgPerDay = Math.round((s.matches || 0) / days);
        $('#rpt-avg-day').textContent = avgPerDay.toLocaleString();

        // Best day (highest matches)
        let best = null;
        daily.forEach(d => { if (!best || d.matches > best.matches) best = d; });
        $('#rpt-best-day').textContent = best && best.matches > 0
            ? ymdToDmy(best.date).slice(0, 5) : '—';

        // Error rate
        const total = s.total || 0;
        const errPct = total > 0 ? (((s.errors || 0) / total) * 100).toFixed(1) + '%' : '0%';
        $('#rpt-error-rate').textContent = errPct;

        // ─── Extended analytics KPIs ─────────────────────────────────
        // Weekday (Mon-Fri) vs Weekend (Sat+Sun) share of matches
        const dowArr = dow.length === 7 ? dow : new Array(7).fill(0);
        const weekendMatches = (dowArr[0] || 0) + (dowArr[6] || 0);
        const weekdayMatches = dowArr.reduce((a, b) => a + b, 0) - weekendMatches;
        const dowTotal = weekdayMatches + weekendMatches;
        const wdEl = $('#rpt-weekday-share');
        const weEl = $('#rpt-weekend-share');
        if (wdEl) wdEl.textContent = dowTotal > 0 ? ((weekdayMatches / dowTotal) * 100).toFixed(0) + '%' : '—';
        if (weEl) weEl.textContent = dowTotal > 0 ? ((weekendMatches / dowTotal) * 100).toFixed(0) + '%' : '—';

        // Quiet hours — count of hours (of 24) with zero matches
        const quietCount = hourly.length === 24 ? hourly.filter(h => !h).length : 0;
        const qhEl = $('#rpt-quiet-hours');
        if (qhEl) qhEl.textContent = String(quietCount);

        // Longest dry streak (consecutive days with zero matches)
        let curStreak = 0, maxStreak = 0;
        daily.forEach(d => {
            if ((d.matches || 0) === 0) { curStreak++; if (curStreak > maxStreak) maxStreak = curStreak; }
            else { curStreak = 0; }
        });
        const dsEl = $('#rpt-dry-streak');
        if (dsEl) dsEl.textContent = maxStreak + 'd';

        // 24h change — last day's matches vs prior day's
        const chgEl = $('#rpt-24h-change');
        if (chgEl) {
            if (daily.length >= 2) {
                const prev = daily[daily.length - 2].matches || 0;
                const curr = daily[daily.length - 1].matches || 0;
                if (prev === 0 && curr === 0) { chgEl.textContent = '0%'; chgEl.style.color = ''; }
                else if (prev === 0) { chgEl.textContent = '+new'; chgEl.style.color = 'var(--success)'; }
                else {
                    const pct = ((curr - prev) / prev) * 100;
                    chgEl.textContent = (pct >= 0 ? '+' : '') + pct.toFixed(0) + '%';
                    chgEl.style.color = pct >= 0 ? 'var(--success)' : 'var(--danger)';
                }
            } else {
                chgEl.textContent = '—';
                chgEl.style.color = '';
            }
        }

        // Active channels — live monitor only (not applicable to offline scans).
        const acEl = $('#rpt-active-channels');
        if (acEl) {
            if (rptSourceMode === 'offline') {
                acEl.textContent = '—';
            } else {
                acEl.textContent = '…';
                fetch('/api/monitor/status').then(r => r.ok ? r.json() : null).then(d => {
                    if (!d) { acEl.textContent = '—'; return; }
                    const act = (d.workers || []).filter(w => w.active).length;
                    acEl.textContent = String(act);
                }).catch(() => { acEl.textContent = '—'; });
            }
        }
    }

    // ─── Chart.js renderers for the Report tab ────────────────────────────
    const _reportCharts = {};
    function _destroy(id) { if (_reportCharts[id]) { _reportCharts[id].destroy(); delete _reportCharts[id]; } }
    function _ctx(id) { const el = document.getElementById(id); return el ? el.getContext('2d') : null; }

    // Chart palette. "no-match" was retinted from orange to yellow
    // (Tailwind yellow-500 / #eab308) per user request so the analyst's
    // eye can immediately tell match (green) vs no-match (yellow) apart
    // from error (red, used elsewhere).
    const CHART_COLORS = {
        match: 'rgba(34, 197, 94, 0.85)',
        matchBorder: 'rgba(34, 197, 94, 1)',
        nomatch: 'rgba(234, 179, 8, 0.85)',
        nomatchBorder: 'rgba(234, 179, 8, 1)',
        accent: 'rgba(99, 102, 241, 0.85)',
        accentBorder: 'rgba(99, 102, 241, 1)',
        grid: 'rgba(148, 163, 184, 0.35)',
        gridMinor: 'rgba(148, 163, 184, 0.18)',
        tick: 'rgba(226, 232, 240, 0.85)'
    };
    const CHART_BASE = {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
            legend: { labels: { color: CHART_COLORS.tick } },
            tooltip: { mode: 'index', intersect: false }
        },
        elements: {
            bar: { borderWidth: 1.2, borderRadius: 2 },
            line: { borderWidth: 2, tension: 0.25 },
            point: { radius: 3, hoverRadius: 5, borderWidth: 1.5, borderColor: '#fff' }
        },
        scales: {
            x: {
                grid: { color: CHART_COLORS.gridMinor, drawBorder: true, borderColor: CHART_COLORS.grid },
                ticks: { color: CHART_COLORS.tick }
            },
            y: {
                grid: { color: CHART_COLORS.grid, drawBorder: true, borderColor: CHART_COLORS.grid },
                ticks: { color: CHART_COLORS.tick, precision: 0 },
                beginAtZero: true
            }
        }
    };

    function renderReportCharts(data) {
        if (typeof Chart === 'undefined') return;
        const daily = data.daily || [];
        const hourly = data.hourly || new Array(24).fill(0);
        const hourlyNo = data.hourlyNoMatches || new Array(24).fill(0);
        const dow = data.dayOfWeek || new Array(7).fill(0);
        const dowNo = data.dayOfWeekNoMatches || new Array(7).fill(0);
        const conf = data.confidenceBuckets || new Array(10).fill(0);
        const cards = data.streamCards || [];
        const s = data.summary || { matches: 0, noMatches: 0 };

        // Hourly / DOW buckets from /api/report are total airtime seconds
        // (sum of per-row capture/query length). Daily match/nomatch columns
        // are also seconds; confidence chart stays event counts.
        const TS  = v => Number(v) || 0;
        const TSA = arr => (arr || []).map(TS);
        const chartSliceFallback = reportChartSliceSeconds();
        const countToAirtime = n => (Number(n) || 0) * chartSliceFallback;
        const SECONDS_TICKS = { color: CHART_COLORS.tick, callback: v => fmtHms(v) };
        const SECONDS_Y = {
            grid: { color: CHART_COLORS.grid, drawBorder: true, borderColor: CHART_COLORS.grid },
            ticks: SECONDS_TICKS,
            beginAtZero: true
        };
        const SECONDS_TOOLTIP = {
            mode: 'index', intersect: false,
            callbacks: { label: ctx => `${ctx.dataset.label || ''}: ${fmtHms(ctx.parsed.y ?? ctx.parsed)}` }
        };
        const CHART_SECS = {
            ...CHART_BASE,
            plugins: { ...(CHART_BASE.plugins || {}), tooltip: SECONDS_TOOLTIP },
            scales: { x: CHART_BASE.scales.x, y: SECONDS_Y }
        };
        const SECONDS_TOOLTIP_SOLO = {
            callbacks: { label: ctx => `${ctx.label || ctx.dataset.label || ''}: ${fmtHms(ctx.parsed ?? ctx.raw)}` }
        };

        // Daily — stacked bars: matches + no-match
        _destroy('rpt-daily-chart');
        const dctx = _ctx('rpt-daily-chart');
        if (dctx) {
            _reportCharts['rpt-daily-chart'] = new Chart(dctx, {
                type: 'bar',
                data: {
                    labels: daily.map(d => ymdToDmy(d.date)),
                    datasets: [
                        { label: 'Match airtime', data: daily.map(d => TS(d.matches)), backgroundColor: CHART_COLORS.match, borderColor: CHART_COLORS.matchBorder, borderWidth: 1, stack: 's' },
                        { label: 'No-match airtime', data: daily.map(d => TS(d.noMatches)), backgroundColor: CHART_COLORS.nomatch, borderColor: CHART_COLORS.nomatchBorder, borderWidth: 1, stack: 's' }
                    ]
                },
                options: { ...CHART_SECS, scales: { x: { ...CHART_SECS.scales.x, stacked: true }, y: { ...CHART_SECS.scales.y, stacked: true } } }
            });
        }

        // Hourly — grouped matches vs no-match
        _destroy('rpt-hourly-chart');
        const hctx = _ctx('rpt-hourly-chart');
        if (hctx) {
            _reportCharts['rpt-hourly-chart'] = new Chart(hctx, {
                type: 'bar',
                data: {
                    labels: Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0') + 'h'),
                    datasets: [
                        { label: 'Match airtime', data: TSA(hourly), backgroundColor: CHART_COLORS.match, borderColor: CHART_COLORS.matchBorder, borderWidth: 1 },
                        { label: 'No-match airtime', data: TSA(hourlyNo), backgroundColor: CHART_COLORS.nomatch, borderColor: CHART_COLORS.nomatchBorder, borderWidth: 1 }
                    ]
                },
                options: CHART_SECS
            });
        }

        // Confidence histogram (matches only — confidence exists only for matches)
        _destroy('rpt-conf-chart');
        const cctx = _ctx('rpt-conf-chart');
        if (cctx) {
            _reportCharts['rpt-conf-chart'] = new Chart(cctx, {
                type: 'bar',
                data: {
                    labels: ['0–10%', '10–20%', '20–30%', '30–40%', '40–50%', '50–60%', '60–70%', '70–80%', '80–90%', '90–100%'],
                    datasets: [{ label: 'Airtime by confidence', data: TSA(conf), backgroundColor: CHART_COLORS.accent, borderColor: CHART_COLORS.accentBorder, borderWidth: 1 }]
                },
                options: CHART_SECS
            });
        }

        // Donut — match vs no-match
        _destroy('rpt-donut-chart');
        const donctx = _ctx('rpt-donut-chart');
        if (donctx) {
            _reportCharts['rpt-donut-chart'] = new Chart(donctx, {
                type: 'doughnut',
                data: {
                    labels: ['Match airtime', 'No-match airtime'],
                    datasets: [{ data: [TS(s.matches || 0), TS(s.noMatches || 0)], backgroundColor: [CHART_COLORS.match, CHART_COLORS.nomatch], borderColor: [CHART_COLORS.matchBorder, CHART_COLORS.nomatchBorder], borderWidth: 1 }]
                },
                options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: CHART_COLORS.tick } }, tooltip: SECONDS_TOOLTIP_SOLO } }
            });
        }

        // Day-of-week — matches + no-match grouped
        _destroy('rpt-dow-chart');
        const dowctx = _ctx('rpt-dow-chart');
        if (dowctx) {
            _reportCharts['rpt-dow-chart'] = new Chart(dowctx, {
                type: 'bar',
                data: {
                    labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
                    datasets: [
                        { label: 'Match airtime', data: TSA(dow), backgroundColor: CHART_COLORS.match, borderColor: CHART_COLORS.matchBorder, borderWidth: 1 },
                        { label: 'No-match airtime', data: TSA(dowNo), backgroundColor: CHART_COLORS.nomatch, borderColor: CHART_COLORS.nomatchBorder, borderWidth: 1 }
                    ]
                },
                options: CHART_SECS
            });
        }

        // Channel comparison — stacked matches/no-match per stream
        _destroy('rpt-channel-chart');
        const chctx = _ctx('rpt-channel-chart');
        if (chctx) {
            const chSliceFb = reportChartSliceSeconds();
            const chMatchSec = c => Number(c.matchSeconds) > 0 ? Number(c.matchSeconds) : (Number(c.matches) || 0) * chSliceFb;
            const chNoMatchSec = c => Number(c.noMatchSeconds) > 0 ? Number(c.noMatchSeconds) : (Number(c.noMatches) || 0) * chSliceFb;
            _reportCharts['rpt-channel-chart'] = new Chart(chctx, {
                type: 'bar',
                data: {
                    labels: cards.map(c => c.streamName),
                    datasets: [
                        { label: 'Match airtime', data: cards.map(c => TS(chMatchSec(c))), backgroundColor: CHART_COLORS.match, borderColor: CHART_COLORS.matchBorder, borderWidth: 1, stack: 's' },
                        { label: 'No-match airtime', data: cards.map(c => TS(chNoMatchSec(c))), backgroundColor: CHART_COLORS.nomatch, borderColor: CHART_COLORS.nomatchBorder, borderWidth: 1, stack: 's' }
                    ]
                },
                options: { ...CHART_SECS, scales: { x: { ...CHART_SECS.scales.x, stacked: true }, y: { ...CHART_SECS.scales.y, stacked: true } } }
            });
        }

        // Cumulative match trend (line / area)
        _destroy('rpt-cumulative-chart');
        const cumctx = _ctx('rpt-cumulative-chart');
        if (cumctx) {
            let run = 0;
            const cum = daily.map(d => (run += TS(d.matches || 0)));
            _reportCharts['rpt-cumulative-chart'] = new Chart(cumctx, {
                type: 'line',
                data: {
                    labels: daily.map(d => ymdToDmy(d.date)),
                    datasets: [{
                        label: 'Cumulative airtime',
                        data: cum,
                        borderColor: CHART_COLORS.accentBorder,
                        backgroundColor: 'rgba(99, 102, 241, 0.25)',
                        borderWidth: 2, fill: true, tension: 0.3, pointRadius: 2
                    }]
                },
                options: CHART_SECS
            });
        }

        // Channel contribution share (pie)
        _destroy('rpt-contribution-chart');
        const pctx = _ctx('rpt-contribution-chart');
        if (pctx) {
            const palette = ['#6366f1', '#22c55e', '#f59e0b', '#3b82f6', '#f97316', '#ef4444', '#22d3ee', '#a855f7', '#64748b', '#14b8a6'];
            const pcards = cards.filter(c => (c.total || 0) > 0);
            _reportCharts['rpt-contribution-chart'] = new Chart(pctx, {
                type: 'pie',
                data: {
                    labels: pcards.map(c => c.streamName),
                    datasets: [{
                        data: pcards.map(c => TS(c.total)),
                        backgroundColor: pcards.map((_, i) => palette[i % palette.length]),
                        borderColor: 'rgba(15,23,42,0.4)', borderWidth: 1
                    }]
                },
                options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: CHART_COLORS.tick } }, tooltip: SECONDS_TOOLTIP_SOLO } }
            });
        }

        // Top 10 songs — styled ranked list (replaces horizontal Chart.js bars
        // whose label/plot alignment looked cramped). Each row is a grid of
        // rank badge → title/artist → gradient progress bar → play count.
        renderReportTop10List(data.topSongs || []);

        // Channel match-rate comparison — combo chart: vertical bars show the
        // raw match count per channel, an overlaid line shows the match-rate %
        // on a secondary y-axis. Bar colors still use the 80/50 thresholds.
        _destroy('rpt-matchrate-chart');
        const mrctx = _ctx('rpt-matchrate-chart');
        if (mrctx) {
            const sliceFb = reportChartSliceSeconds();
            const mrRows = cards.map(c => {
                const d = (c.matches + c.noMatches) || 1;
                const matchSec = Number(c.matchSeconds) > 0
                    ? Number(c.matchSeconds)
                    : (Number(c.matches) || 0) * sliceFb;
                return {
                    name: c.streamName,
                    matches: c.matches || 0,
                    matchSec: TS(matchSec),
                    rate: Number(((c.matches / d) * 100).toFixed(1))
                };
            }).sort((a, b) => b.rate - a.rate);
            const barColor = v => v >= 80 ? CHART_COLORS.match : v >= 50 ? 'rgba(245, 158, 11, 0.85)' : CHART_COLORS.nomatch;
            const borderColor = v => v >= 80 ? CHART_COLORS.matchBorder : v >= 50 ? 'rgba(245, 158, 11, 1)' : CHART_COLORS.nomatchBorder;
            _reportCharts['rpt-matchrate-chart'] = new Chart(mrctx, {
                type: 'bar',
                data: {
                    labels: mrRows.map(r => r.name),
                    datasets: [
                        {
                            type: 'bar',
                            label: 'Match airtime',
                            data: mrRows.map(r => r.matchSec),
                            backgroundColor: mrRows.map(r => barColor(r.rate)),
                            borderColor: mrRows.map(r => borderColor(r.rate)),
                            borderWidth: 1,
                            yAxisID: 'y',
                            order: 2
                        },
                        {
                            type: 'line',
                            label: 'Match rate %',
                            data: mrRows.map(r => r.rate),
                            borderColor: CHART_COLORS.accentBorder,
                            backgroundColor: 'rgba(99, 102, 241, 0.15)',
                            pointBackgroundColor: CHART_COLORS.accent,
                            pointRadius: 4,
                            pointHoverRadius: 6,
                            borderWidth: 2,
                            tension: 0.3,
                            fill: false,
                            yAxisID: 'yRate',
                            order: 1
                        }
                    ]
                },
                options: {
                    ...CHART_BASE,
                    plugins: {
                        ...(CHART_BASE.plugins || {}),
                        tooltip: {
                            callbacks: {
                                label: ctx => ctx.dataset.yAxisID === 'yRate'
                                    ? `${ctx.dataset.label}: ${(ctx.parsed.y ?? 0).toFixed(1)}%`
                                    : `${ctx.dataset.label}: ${fmtHms(ctx.parsed.y ?? 0)}`
                            }
                        }
                    },
                    scales: {
                        x: CHART_BASE.scales.x,
                        y: {
                            ...SECONDS_Y,
                            position: 'left',
                            title: { display: true, text: 'Airtime', color: CHART_COLORS.tick }
                        },
                        yRate: {
                            position: 'right',
                            min: 0, max: 100,
                            grid: { drawOnChartArea: false },
                            ticks: { color: CHART_COLORS.tick, callback: v => v + '%' },
                            title: { display: true, text: 'Match rate %', color: CHART_COLORS.tick }
                        }
                    }
                }
            });
        }

        // Channel profile radar — compares every channel on three normalised
        // axes (Match rate %, Avg confidence %, Volume %). Shares data with
        // the combo chart above so both visualise the same source fields.
        _destroy('rpt-channel-radar-chart');
        const radctx = _ctx('rpt-channel-radar-chart');
        if (radctx) {
            const maxVol = cards.reduce((m, c) => Math.max(m, (c.matches + c.noMatches) || 0), 0) || 1;
            const palette = ['#6366f1', '#22c55e', '#f59e0b', '#3b82f6', '#f97316', '#ef4444', '#22d3ee', '#a855f7', '#64748b', '#14b8a6'];
            // Limit to top-8 channels by volume so the radar stays legible.
            const radCards = cards.slice().sort((a, b) => (b.matches + b.noMatches) - (a.matches + a.noMatches)).slice(0, 8);
            const datasets = radCards.map((c, i) => {
                const total = (c.matches + c.noMatches) || 1;
                const rate = Number(((c.matches / total) * 100).toFixed(1));
                const conf = Number(((c.avgConfidence || 0) * 100).toFixed(1));
                const vol = Number(((total / maxVol) * 100).toFixed(1));
                const col = palette[i % palette.length];
                return {
                    label: c.streamName,
                    data: [rate, conf, vol],
                    borderColor: col,
                    backgroundColor: col + '33',
                    pointBackgroundColor: col,
                    borderWidth: 2,
                    pointRadius: 3
                };
            });
            _reportCharts['rpt-channel-radar-chart'] = new Chart(radctx, {
                type: 'radar',
                data: { labels: ['Match rate %', 'Avg confidence %', 'Volume %'], datasets },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    plugins: { legend: { position: 'bottom', labels: { color: CHART_COLORS.tick, boxWidth: 10 } } },
                    scales: {
                        r: {
                            min: 0, max: 100,
                            angleLines: { color: CHART_COLORS.grid },
                            grid: { color: CHART_COLORS.grid },
                            pointLabels: { color: CHART_COLORS.tick, font: { size: 11 } },
                            ticks: { color: CHART_COLORS.tick, backdropColor: 'transparent', stepSize: 25 }
                        }
                    }
                }
            });
        }
        _destroy('rpt-hourly-rate-chart');
        const hrctx = _ctx('rpt-hourly-rate-chart');
        if (hrctx) {
            const rateData = hourly.map((m, i) => {
                const tot = (m || 0) + (hourlyNo[i] || 0);
                return tot > 0 ? Number(((m / tot) * 100).toFixed(1)) : null;
            });
            _reportCharts['rpt-hourly-rate-chart'] = new Chart(hrctx, {
                type: 'line',
                data: {
                    labels: Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')),
                    datasets: [{
                        label: 'Match rate %',
                        data: rateData,
                        spanGaps: true,
                        fill: true,
                        backgroundColor: 'rgba(34, 197, 94, 0.15)',
                        borderColor: CHART_COLORS.matchBorder,
                        pointBackgroundColor: CHART_COLORS.match,
                        tension: 0.3
                    }]
                },
                options: { ...CHART_BASE, scales: { x: CHART_BASE.scales.x, y: { ...CHART_BASE.scales.y, min: 0, max: 100 } } }
            });
        }

        // Channel average confidence (horizontal bar)
        _destroy('rpt-channel-conf-chart');
        const ccctx = _ctx('rpt-channel-conf-chart');
        if (ccctx) {
            const confRows = cards.map(c => ({
                name: c.streamName,
                conf: Number(((c.avgConfidence || 0) * 100).toFixed(1))
            })).filter(r => r.conf > 0).sort((a, b) => b.conf - a.conf);
            _reportCharts['rpt-channel-conf-chart'] = new Chart(ccctx, {
                type: 'bar',
                data: {
                    labels: confRows.map(r => r.name),
                    datasets: [{
                        label: 'Avg confidence %',
                        data: confRows.map(r => r.conf),
                        backgroundColor: CHART_COLORS.accent,
                        borderColor: CHART_COLORS.accentBorder,
                        borderWidth: 1
                    }]
                },
                options: { ...CHART_BASE, indexAxis: 'y', scales: { x: { ...CHART_BASE.scales.x, min: 0, max: 100 }, y: CHART_BASE.scales.y } }
            });
        }

        // Weekday vs Weekend doughnut
        _destroy('rpt-weekday-split-chart');
        const wwctx = _ctx('rpt-weekday-split-chart');
        if (wwctx) {
            const wArr = dow.length === 7 ? dow : new Array(7).fill(0);
            const weMatches = (wArr[0] || 0) + (wArr[6] || 0);
            const wdMatches = wArr.reduce((a, b) => a + b, 0) - weMatches;
            _reportCharts['rpt-weekday-split-chart'] = new Chart(wwctx, {
                type: 'doughnut',
                data: {
                    labels: ['Weekday (Mon–Fri)', 'Weekend (Sat–Sun)'],
                    datasets: [{
                        data: [TS(wdMatches), TS(weMatches)],
                        backgroundColor: [CHART_COLORS.accent, CHART_COLORS.match],
                        borderColor: [CHART_COLORS.accentBorder, CHART_COLORS.matchBorder],
                        borderWidth: 1
                    }]
                },
                options: {
                    responsive: true, maintainAspectRatio: false,
                    plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } }, tooltip: SECONDS_TOOLTIP_SOLO }
                }
            });
        }

        // Last 7 days trend (bar)
        _destroy('rpt-recent7-chart');
        const r7ctx = _ctx('rpt-recent7-chart');
        if (r7ctx) {
            const last7 = daily.slice(-7);
            const labels7 = last7.map(d => {
                const s = d.date || '';
                return s.length >= 10 ? s.slice(8, 10) + '/' + s.slice(5, 7) : s;
            });
            const data7 = last7.map(d => TS(d.matches || 0));
            const colors7 = data7.map(v => v === 0 ? CHART_COLORS.nomatch : CHART_COLORS.match);
            const border7 = data7.map(v => v === 0 ? CHART_COLORS.nomatchBorder : CHART_COLORS.matchBorder);
            _reportCharts['rpt-recent7-chart'] = new Chart(r7ctx, {
                type: 'bar',
                data: {
                    labels: labels7,
                    datasets: [{
                        label: 'Match airtime',
                        data: data7,
                        backgroundColor: colors7,
                        borderColor: border7,
                        borderWidth: 1
                    }]
                },
                options: CHART_SECS
            });
        }

        const count = $('#rpt-songs-count'); if (count) count.textContent = (data.topSongs || []).length + ' songs';
    }

    // Extract the immediate parent folder of a file path (mirrors the
    // server-side `FolderOf` used for the CSV "Album / Folder" column).
    function folderOfPath(p) {
        if (!p) return '';
        const parts = String(p).split(/[\/\\]+/).filter(Boolean);
        if (parts.length === 0) return '';
        const last = parts[parts.length - 1];
        const looksLikeFile = /\.[A-Za-z0-9]{1,8}$/.test(last);
        const dirs = looksLikeFile ? parts.slice(0, -1) : parts;
        return dirs.length > 0 ? dirs[dirs.length - 1] : '';
    }

    // Top Album / Folder — compact ranked list. Aggregates plays by the
    // parent folder of each track's path (mirrors the PDF/CSV grouping).
    function renderReportTopArtists(topSongs) {
        const host = $('#rpt-top-artists');
        const badge = $('#rpt-artists-count');
        if (!host) return;
        if (!topSongs || topSongs.length === 0) {
            host.innerHTML = '<div class="empty">No matches in this range.</div>';
            if (badge) badge.textContent = '';
            return;
        }
        const agg = {};
        topSongs.forEach(t => {
            const f = folderOfPath(t.trackId) || '(root)';
            if (!agg[f]) agg[f] = { folder: f, count: 0, airtimeSec: 0, confSum: 0, confN: 0, songs: new Set() };
            agg[f].count += Number(t.count) || 0;
            agg[f].airtimeSec += topSongAirtimeSec(t);
            if (t.avgConfidence > 0) { agg[f].confSum += t.avgConfidence * (t.count || 1); agg[f].confN += (t.count || 1); }
            if (t.trackId) agg[f].songs.add(t.trackId);
        });
        const rows = Object.values(agg)
            .map(r => ({ folder: r.folder, count: r.count, airtimeSec: r.airtimeSec, avgConf: r.confN > 0 ? r.confSum / r.confN : 0, uniqueSongs: r.songs.size }))
            .sort((a, b) => b.airtimeSec - a.airtimeSec)
            .slice(0, 10);
        const secs = rows.map(r => r.airtimeSec);
        const max = secs.reduce((m, v) => Math.max(m, v), 0) || 1;
        host.innerHTML = rows.map((r, i) => {
            const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
            const pct = Math.max(2, Math.round((secs[i] / max) * 100));
            const conf = r.avgConf > 0 ? (r.avgConf * 100).toFixed(1) + '%' : '—';
            const songLbl = r.uniqueSongs + (r.uniqueSongs === 1 ? ' song' : ' songs');
            return `<div class="t10-row ${rankCls}">
                <div class="t10-rank">${i + 1}</div>
                <div class="t10-info">
                    <div class="t10-title" title="${escapeAttr(r.folder)}">${escapeHtml(r.folder)}</div>
                    <div class="t10-artist">${songLbl} • avg ${conf}</div>
                </div>
                <div class="t10-bar-wrap"><div class="t10-bar" style="width:${pct}%"></div></div>
                <div class="t10-stats">
                    <div class="t10-count">${fmtHms(secs[i])}</div>
                    <div class="t10-conf">airtime</div>
                </div>
            </div>`;
        }).join('');
        if (badge) badge.textContent = rows.length + (rows.length === 1 ? ' folder' : ' folders');
    }

    // Hour × Day-of-week heatmap. Expects a 7×24 grid of match counts.
    function renderReportHeatmap(grid) {
        const host = $('#rpt-heatmap');
        if (!host) return;
        if (!grid || !grid.length) {
            host.innerHTML = '<div class="empty">No data for heatmap.</div>';
            return;
        }
        const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
        let max = 0;
        for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) if (grid[d] && grid[d][h] > max) max = grid[d][h];
        // Header row: empty corner + 24 hour labels
        const cells = [];
        cells.push('<div class="hm-corner"></div>');
        for (let h = 0; h < 24; h++) cells.push(`<div class="hm-hour-lbl">${String(h).padStart(2, '0')}</div>`);
        for (let d = 0; d < 7; d++) {
            cells.push(`<div class="hm-day-lbl">${DOW[d]}</div>`);
            for (let h = 0; h < 24; h++) {
                const secs = (grid[d] && grid[d][h]) || 0;
                const t = max > 0 ? secs / max : 0;
                // Green scale with alpha from 0.08 to 1.0
                const alpha = secs === 0 ? 0.08 : 0.18 + t * 0.82;
                const bg = `rgba(52, 199, 89, ${alpha.toFixed(2)})`;
                cells.push(`<div class="hm-cell" data-v="${secs}" style="background:${bg}" title="${DOW[d]} ${String(h).padStart(2, '0')}:00 — ${fmtHms(secs)} airtime"></div>`);
            }
        }
        host.innerHTML = `<div class="heatmap">${cells.join('')}</div>
            <div class="hm-legend"><span>00:00:00</span><div class="hm-scale"></div><span>${fmtHms(max)}</span><span style="margin-left:auto;">Airtime per hour / weekday</span></div>`;
    }

    // Top 10 songs visual — custom ranked list with gradient progress bars.
    // Replaces a Chart.js horizontal bar chart whose label/plot alignment
    // looked cramped when titles were long.
    function renderReportTop10List(rows) {
        const host = $('#rpt-top10-list');
        if (!host) return;
        const top = (rows || []).slice(0, 10);
        if (top.length === 0) {
            host.innerHTML = '<div class="empty">No matches in this range.</div>';
            return;
        }
        const secs = top.map(topSongAirtimeSec);
        const max = secs.reduce((m, v) => Math.max(m, v), 0) || 1;
        host.innerHTML = top.map((t, i) => {
            const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
            const pct = Math.max(2, Math.round((secs[i] / max) * 100));
            const conf = t.avgConfidence != null ? (t.avgConfidence * 100).toFixed(1) + '%' : '—';
            return `<div class="t10-row ${rankCls}">
                <div class="t10-rank">${i + 1}</div>
                <div class="t10-info">
                    <div class="t10-title" title="${escapeAttr(t.title || '')}">${escapeHtml(t.title || '—')}</div>
                    <div class="t10-artist" title="${escapeAttr(t.artist || '')}">${escapeHtml(t.artist || '—')}</div>
                </div>
                <div class="t10-bar-wrap"><div class="t10-bar" style="width:${pct}%"></div></div>
                <div class="t10-stats">
                    <div class="t10-count">${fmtHms(secs[i])}</div>
                    <div class="t10-conf">avg ${conf}</div>
                </div>
            </div>`;
        }).join('');
    }

    // Top matched songs — compact ranked list (matches Top10 visual styling).
    function renderReportTopSongs(rows) {
        const host = $('#rpt-top-songs');
        const badge = $('#rpt-songs-count');
        if (!host) return;
        if (!rows || rows.length === 0) {
            host.innerHTML = '<div class="empty">No matches in this range.</div>';
            if (badge) badge.textContent = '';
            return;
        }
        const top = rows.slice(0, 10);
        const secs = top.map(topSongAirtimeSec);
        const max = secs.reduce((m, v) => Math.max(m, v), 0) || 1;
        host.innerHTML = top.map((t, i) => {
            const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
            const pct = Math.max(2, Math.round((secs[i] / max) * 100));
            const avg = t.avgConfidence != null ? (t.avgConfidence * 100).toFixed(1) + '%' : '—';
            return `<div class="t10-row ${rankCls}">
                <div class="t10-rank">${i + 1}</div>
                <div class="t10-info">
                    <div class="t10-title" title="${escapeAttr(t.title || '')}">${escapeHtml(t.title || '—')}</div>
                    <div class="t10-artist" title="${escapeAttr(t.artist || '')}">${escapeHtml(t.artist || '—')}</div>
                </div>
                <div class="t10-bar-wrap"><div class="t10-bar" style="width:${pct}%"></div></div>
                <div class="t10-stats">
                    <div class="t10-count">${fmtHms(secs[i])}</div>
                    <div class="t10-conf">avg ${avg}</div>
                </div>
            </div>`;
        }).join('');
        if (badge) badge.textContent = rows.length + ' songs';
    }

    // HH:MM:SS time formatting + per-row duration estimate. Used by the
    // Reports + Mapping KPI tiles so match/no-match are reported as airtime
    // instead of raw event counts.
    function fmtHms(sec) {
        const s = Math.max(0, Math.round(Number(sec) || 0));
        const h = Math.floor(s / 3600);
        const m = Math.floor((s % 3600) / 60);
        const ss = s % 60;
        const pad = (n) => String(n).padStart(2, '0');
        return `${pad(h)}:${pad(m)}:${pad(ss)}`;
    }
    /** Matched-track coverage (seconds) for one log row — used for match airtime KPIs. */
    function eventMatchCoverageSec(e) {
        const cov = Number(e?.coverageSeconds ?? e?.CoverageSeconds ?? 0);
        return cov > 0 ? cov : 0;
    }

    /** Total matched coverage for a row (sums merged detections when consolidated). */
    function matchCoverageSeconds(r) {
        if (!r || r.type !== 'match') return 0;
        const parts = (Array.isArray(r.merged) && r.merged.length) ? r.merged : [r];
        let sum = 0;
        for (const m of parts) sum += eventMatchCoverageSec(m);
        return sum;
    }

    /** Match duration (Duration HH:MM:SS): in-track end − start, else query/consolidated/slice. */
    function trackMatchDurationSeconds(r) {
        if (!r || r.type !== 'match') return 0;
        const start = Number(r.trackMatchAtSeconds);
        const end = Number(r.trackMatchEndSeconds);
        if (isFinite(start) && isFinite(end) && end > start) return end - start;
        const ql = Number(r.queryLengthSeconds);
        if (isFinite(start) && ql > 0) return ql;
        const ca = Number(r.consolidatedAirtimeSeconds);
        if (ca > 0) return ca;
        return monitorSliceSeconds(r);
    }

    /** Wall-clock span for a consolidated or single detection row. */
    function rowWallSpanSec(r) {
        const ca = Number(r.consolidatedAirtimeSeconds);
        if (ca > 0) return ca;
        const tS = Date.parse(r.timeUtc || '');
        const tE = r.endTimeUtc ? Date.parse(r.endTimeUtc) : NaN;
        let s = (isFinite(tS) && isFinite(tE)) ? Math.max(0, (tE - tS) / 1000) : 0;
        if (s <= 0) s = Number(r.durationSeconds) || monitorSliceSeconds(r);
        return s;
    }

    function wallOverlapSec(nStartMs, nEndMs, mStartMs, mEndMs) {
        const start = Math.max(nStartMs, mStartMs);
        const end = Math.min(nEndMs, mEndMs);
        return start < end ? (end - start) / 1000 : 0;
    }

    /** Per-stream match/error timeline for faster nomatch overlap subtraction. */
    function buildMatchErrorTimelineIndex(rows) {
        const byStream = new Map();
        for (const r of rows) {
            if (!r || (r.type !== 'match' && r.type !== 'error')) continue;
            const sid = r.streamId || r.streamName || '';
            if (!byStream.has(sid)) byStream.set(sid, []);
            const start = Date.parse(r.timeUtc || '');
            if (!isFinite(start)) continue;
            const end = r.endTimeUtc
                ? Date.parse(r.endTimeUtc)
                : start + rowWallSpanSec(r) * 1000;
            if (!isFinite(end)) continue;
            byStream.get(sid).push({ row: r, start, end });
        }
        for (const arr of byStream.values())
            arr.sort((a, b) => a.start - b.start);
        return byStream;
    }

    /** Match + error airtime to subtract from a no-match row when timelines overlap (same stream). */
    function nonNomatchCreditOverlappingNomatch(nomatchRow, allRows, timelineIndex) {
        if (!nomatchRow || nomatchRow.type !== 'nomatch') return 0;
        const nStart = Date.parse(nomatchRow.timeUtc || '');
        if (!isFinite(nStart)) return 0;
        const nEnd = nomatchRow.endTimeUtc
            ? Date.parse(nomatchRow.endTimeUtc)
            : nStart + rowWallSpanSec(nomatchRow) * 1000;
        if (!isFinite(nEnd)) return 0;
        const sid = nomatchRow.streamId || nomatchRow.streamName || '';
        const candidates = timelineIndex
            ? (timelineIndex.get(sid) || [])
            : (Array.isArray(allRows) ? allRows : [])
                .filter(o => o && o !== nomatchRow && (o.type === 'match' || o.type === 'error')
                    && (o.streamId || o.streamName || '') === sid)
                .map(o => {
                    const start = Date.parse(o.timeUtc || '');
                    const end = o.endTimeUtc ? Date.parse(o.endTimeUtc) : start + rowWallSpanSec(o) * 1000;
                    return { row: o, start, end };
                })
                .filter(o => isFinite(o.start) && isFinite(o.end));
        let subtract = 0;
        for (const other of candidates) {
            if (wallOverlapSec(nStart, nEnd, other.start, other.end) <= 0) continue;
            subtract += rowDurationSec(other.row, allRows, timelineIndex, true);
        }
        return subtract;
    }

    /** Seconds credited for KPI airtime / charts. Matches use Duration (in-track window); no-match subtracts overlapping match + error. */
    function rowDurationSec(r, allRows, timelineIndex, skipNomatchOverlap) {
        const pool = allRows ?? window._lastConsolidatedAirplayRows;
        if (r.type === 'match') return trackMatchDurationSeconds(r);
        let span = rowWallSpanSec(r);
        if (!skipNomatchOverlap && r.type === 'nomatch' && pool?.length) {
            span = Math.max(0, span - nonNomatchCreditOverlappingNomatch(r, pool, timelineIndex));
        }
        return span;
    }

    /** Total duration for Reports airplay table — matches use in-track Duration (HH:MM:SS). */
    function airplayTotalDurationHms(row) {
        return fmtHms(rowDurationSec(row));
    }

    /** Top-song airtime: coverage sum from API/consolidated rows, else count × slice. */
    function topSongAirtimeSec(t) {
        const s = Number(t.airtimeSeconds ?? t.AirtimeSeconds ?? t.airtimeSec ?? 0);
        if (s > 0) return s;
        return (Number(t.count) || 0) * reportChartSliceSeconds();
    }

    function topSongsFromConsolidatedRows(rows) {
        const byTrack = new Map();
        for (const r of rows) {
            if (r.type !== 'match') continue;
            const id = r.trackId || ((r.title || '') + '|' + (r.artist || ''));
            const dur = rowDurationSec(r, rows);
            if (!byTrack.has(id)) {
                byTrack.set(id, {
                    trackId: r.trackId || id,
                    title: r.title,
                    artist: r.artist,
                    count: 0,
                    airtimeSec: 0,
                    confSum: 0,
                    confN: 0,
                    maxConfidence: 0
                });
            }
            const t = byTrack.get(id);
            t.count += (r.count || 1);
            t.airtimeSec += dur;
            if (r.confidence != null) {
                t.confSum += r.confidence;
                t.confN++;
                if (r.confidence > t.maxConfidence) t.maxConfidence = r.confidence;
            }
        }
        return Array.from(byTrack.values())
            .map(t => ({
                trackId: t.trackId,
                title: t.title,
                artist: t.artist,
                count: t.count,
                airtimeSeconds: t.airtimeSec,
                avgConfidence: t.confN > 0 ? t.confSum / t.confN : 0,
                maxConfidence: t.maxConfidence
            }))
            .sort((a, b) => b.airtimeSeconds - a.airtimeSeconds);
    }

    function airtimeBucketsFromConsolidatedRows(rows) {
        const daily = new Map();
        const hourly = Array(24).fill(0);
        const hourlyNo = Array(24).fill(0);
        const dow = Array(7).fill(0);
        const dowNo = Array(7).fill(0);
        const hourByDow = Array.from({ length: 7 }, () => Array(24).fill(0));
        for (const r of rows) {
            const dur = rowDurationSec(r, rows);
            const t = Date.parse(r.timeUtc);
            if (isNaN(t)) continue;
            const d = new Date(t);
            const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
            const h = d.getHours();
            const dowIdx = d.getDay();
            if (!daily.has(dateKey)) daily.set(dateKey, { date: dateKey, matches: 0, noMatches: 0, total: 0 });
            const day = daily.get(dateKey);
            if (r.type === 'match') {
                day.matches += dur;
                hourly[h] += dur;
                dow[dowIdx] += dur;
                hourByDow[dowIdx][h] += dur;
            } else if (r.type === 'nomatch') {
                day.noMatches += dur;
                hourlyNo[h] += dur;
                dowNo[dowIdx] += dur;
            }
            day.total += dur;
        }
        return {
            daily: Array.from(daily.values()).sort((a, b) => a.date.localeCompare(b.date)),
            hourly,
            hourlyNoMatches: hourlyNo,
            dayOfWeek: dow,
            dayOfWeekNoMatches: dowNo,
            hourByDow
        };
    }

    function refreshReportViewsFromConsolidated(rows, snap) {
        if (!window._lastReport || !snap) return;
        const buckets = airtimeBucketsFromConsolidatedRows(rows);
        Object.assign(window._lastReport, buckets);
        window._lastReport.topSongs = topSongsFromConsolidatedRows(rows);
        window._lastReport.streamCards = snap.streamCards.map(c => ({
            streamId: c.streamId,
            streamName: c.streamName,
            total: c.total,
            matches: c.matches,
            noMatches: c.noMatches,
            errors: c.errors,
            avgConfidence: c.avgConfidence,
            uniqueSongs: c.uniqueSongs,
            matchSeconds: c.matchSeconds,
            noMatchSeconds: c.noMatchSeconds,
            errorSeconds: c.errorSeconds
        }));
        renderReportCharts(window._lastReport);
        renderReportTopSongs(window._lastReport.topSongs);
        renderReportTopArtists(window._lastReport.topSongs);
        renderReportTop10List(window._lastReport.topSongs);
        renderReportHeatmap(window._lastReport.hourByDow);
        renderReportInsights(window._lastReport);
    }

    function filterConsolidatedAirplay(airplay, qs, consolidate = true) {
        let rows = Array.isArray(airplay) ? airplay.slice() : [];
        const fromD = qs && qs.get ? qs.get('from') : null;
        const toD = qs && qs.get ? qs.get('to') : null;
        if (fromD) rows = rows.filter(x => (x.timeUtc || '') >= fromD);
        if (toD) rows = rows.filter(x => (x.timeUtc || '') <= toD);
        if (consolidate !== false) rows = consolidateAirplay(rows);
        return rows;
    }

    function pickTopMapEntry(m) {
        let bk = '', bv = 0;
        for (const [k, v] of m) { if (v > bv) { bv = v; bk = k; } }
        return bv > 0 ? `${bk} (${bv})` : '—';
    }

    /** Shared tally used by Report-tab KPI cards and PDF export. */
    function computeConsolidatedKpisFromRows(rows, streams) {
        let total = 0, matches = 0, noMatches = 0, errors = 0;
        let matchSec = 0, noMatchSec = 0, errorSec = 0;
        let confSum = 0, confN = 0;
        let longestMatchSec = 0;
        let longestMatchLabel = '';
        const timelineIndex = buildMatchErrorTimelineIndex(rows);
        const songKey = (r) => r.trackId || ((r.title || '') + '|' + (r.artist || ''));
        const uniq = new Set();
        const byStream = new Map();
        const playKindCounts = new Map();
        const playKindSec = new Map();
        const programCounts = new Map();
        const byLanguage = new Map();
        const streamLangIndex = new Map();
        for (const s of (streams || [])) {
            if (!s || !s.id) continue;
            streamLangIndex.set(s.id, String(s.language || '').trim());
        }
        const languageOfRow = (r) => String(r.language || streamLangIndex.get(r.streamId) || '').trim();
        for (const r of rows) {
            total++;
            const dur = rowDurationSec(r, rows, timelineIndex);
            if (r.type === 'match') {
                matches++; matchSec += dur;
                const lang = languageOfRow(r);
                if (lang) {
                    const slot = byLanguage.get(lang) || { matches: 0, matchSec: 0 };
                    slot.matches++;
                    slot.matchSec += dur;
                    byLanguage.set(lang, slot);
                }
                if (dur > longestMatchSec) {
                    longestMatchSec = dur;
                    longestMatchLabel = (r.title || r.trackId || '—');
                }
                let pk = (r.playKind || '').trim();
                if (!pk && dur >= 60) pk = 'Full Song';
                if (!pk) pk = 'Unclassified';
                playKindCounts.set(pk, (playKindCounts.get(pk) || 0) + 1);
                playKindSec.set(pk, (playKindSec.get(pk) || 0) + dur);
                const ep = r.epg || {};
                const prog = r.epgShowName || ep.programName || ep.showName || '';
                if (prog) programCounts.set(prog, (programCounts.get(prog) || 0) + 1);
            } else if (r.type === 'nomatch') { noMatches++; noMatchSec += dur; }
            else if (r.type === 'error') { errors++; errorSec += dur; }
            if (r.type === 'match' && r.confidence != null) { confSum += r.confidence; confN++; }
            if (r.type === 'match') uniq.add(songKey(r));
            const k = r.streamId || r.streamName || '—';
            let c = byStream.get(k);
            if (!c) {
                c = { streamId: r.streamId, streamName: r.streamName || k, total: 0, matches: 0, noMatches: 0, errors: 0, matchSec: 0, noMatchSec: 0, errorSec: 0, _cs: 0, _cn: 0, _uniq: new Set() };
                byStream.set(k, c);
            }
            c.total++;
            if (r.type === 'match') { c.matches++; c.matchSec += dur; if (r.confidence != null) { c._cs += r.confidence; c._cn++; } c._uniq.add(songKey(r)); }
            else if (r.type === 'nomatch') { c.noMatches++; c.noMatchSec += dur; }
            else if (r.type === 'error') { c.errors++; c.errorSec += dur; }
        }
        const matchable = matches + noMatches;
        const langArr = Array.from(byLanguage.entries())
            .map(([name, v]) => ({
                name, matches: v.matches, matchSec: v.matchSec,
                share: matchSec > 0 ? v.matchSec / matchSec : 0
            }))
            .sort((a, b) => b.matchSec - a.matchSec);
        const streamCards = Array.from(byStream.values()).map(c => ({
            streamId: c.streamId, streamName: c.streamName,
            total: c.total, matches: c.matches, noMatches: c.noMatches, errors: c.errors,
            matchSeconds: c.matchSec, noMatchSeconds: c.noMatchSec, errorSeconds: c.errorSec,
            avgConfidence: c._cn > 0 ? c._cs / c._cn : 0,
            uniqueSongs: c._uniq.size
        }));
        let busiestChannel = '—';
        let busy = null;
        streamCards.forEach(c => { if (!busy || c.matchSeconds > busy.matchSeconds) busy = c; });
        if (busy && busy.matchSeconds > 0) {
            busiestChannel = busy.streamName.length > 18 ? busy.streamName.slice(0, 17) + '…' : busy.streamName;
        }
        const topLang = langArr[0];
        const totalAirtimeSec = matchSec + noMatchSec + errorSec;
        const playKindArr = Array.from(playKindSec.entries())
            .map(([label, seconds]) => ({ label, seconds }))
            .sort((a, b) => b.seconds - a.seconds)
            .slice(0, 8);
        return {
            total, matches, noMatches, errors,
            matchSec, noMatchSec, errorSec,
            totalAirtimeSec,
            matchRateAirtime: (matchSec + noMatchSec) > 0
                ? ((matchSec / (matchSec + noMatchSec)) * 100).toFixed(1) + '%' : '0%',
            channelsActive: streamCards.filter(c => (c.total || 0) > 0).length,
            playKindArr,
            matchRate: matchable > 0 ? ((matches / matchable) * 100).toFixed(1) + '%' : '0%',
            avgConfidence: confN > 0 ? confSum / confN : 0,
            uniqueSongs: uniq.size,
            avgMatchLen: matches > 0 ? matchSec / matches : 0,
            longestMatchSec,
            longestMatchLabel,
            topPlayKind: pickTopMapEntry(playKindCounts),
            topProgram: pickTopMapEntry(programCounts),
            topLanguage: topLang ? `${topLang.name} (${(topLang.share * 100).toFixed(0)}%)` : '—',
            languagesTracked: langArr.length,
            langArr,
            streamCards,
            byStream,
            streamLangIndex,
            busiestChannel
        };
    }

    /** Insight KPIs from /api/report payload (same logic as renderReportInsights). */
    function computeReportInsightKpis(rep, consolidated) {
        const s = Object.assign({}, rep?.summary || {}, consolidated || {});
        const hourly = rep?.hourly || [];
        const dow = rep?.dayOfWeek || [];
        const daily = rep?.daily || [];
        const cards = consolidated?.streamCards || rep?.streamCards || [];
        const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

        let peakH = -1, peakHv = -1;
        hourly.forEach((v, i) => { if (v > peakHv) { peakHv = v; peakH = i; } });
        let peakD = -1, peakDv = -1;
        dow.forEach((v, i) => { if (v > peakDv) { peakDv = v; peakD = i; } });

        let busyName = consolidated?.busiestChannel || '—';
        if (busyName === '—') {
            let busy = null;
            cards.forEach(c => { if (!busy || (c.matches || 0) > (busy.matches || 0)) busy = c; });
            if (busy && (busy.matches || 0) > 0) {
                busyName = busy.streamName.length > 18 ? busy.streamName.slice(0, 17) + '…' : busy.streamName;
            }
        }

        const days = daily.length || 1;
        const avgPerDay = Math.round((s.matches || 0) / days);
        let best = null;
        daily.forEach(d => { if (!best || d.matches > best.matches) best = d; });
        const errPct = (s.total || 0) > 0 ? (((s.errors || 0) / s.total) * 100).toFixed(1) + '%' : '0%';

        const dowArr = dow.length === 7 ? dow : new Array(7).fill(0);
        const weekendMatches = (dowArr[0] || 0) + (dowArr[6] || 0);
        const weekdayMatches = dowArr.reduce((a, b) => a + b, 0) - weekendMatches;
        const dowTotal = weekdayMatches + weekendMatches;

        let change24h = '—';
        let change24hRaw = null;
        if (daily.length >= 2) {
            const prev = daily[daily.length - 2].matches || 0;
            const curr = daily[daily.length - 1].matches || 0;
            if (prev === 0 && curr === 0) change24h = '0%';
            else if (prev === 0) change24h = '+new';
            else {
                change24hRaw = ((curr - prev) / prev) * 100;
                change24h = (change24hRaw >= 0 ? '+' : '') + change24hRaw.toFixed(0) + '%';
            }
        }

        let dryStreak = 0, curStreak = 0;
        daily.forEach(d => {
            if ((d.matches || 0) === 0) { curStreak++; if (curStreak > dryStreak) dryStreak = curStreak; }
            else { curStreak = 0; }
        });

        let quietDay = null;
        daily.forEach(d => {
            if (!quietDay || (d.matches || 0) < (quietDay.matches || 0)) quietDay = d;
        });

        return {
            peakHour: peakH >= 0 && peakHv > 0 ? `${String(peakH).padStart(2, '0')}:00` : '—',
            peakDow: peakD >= 0 && peakDv > 0 ? DOW_NAMES[peakD] : '—',
            busiestChannel: busyName,
            avgMatchesPerDay: avgPerDay.toLocaleString(),
            bestDay: best && best.matches > 0 ? ymdToDmy(best.date).slice(0, 5) : '—',
            errorRate: errPct,
            change24h,
            change24hRaw,
            weekdayShare: dowTotal > 0 ? ((weekdayMatches / dowTotal) * 100).toFixed(0) + '%' : '—',
            weekendShare: dowTotal > 0 ? ((weekendMatches / dowTotal) * 100).toFixed(0) + '%' : '—',
            quietHours: hourly.length === 24 ? hourly.filter(h => !h).length : 0,
            dryStreak: dryStreak + 'd',
            quietestDay: quietDay && quietDay.date ? ymdToDmy(quietDay.date).slice(0, 5) : '—'
        };
    }

    function buildFullReportKpiSnapshot(rows, rep, streams, activeChannels) {
        const consolidated = computeConsolidatedKpisFromRows(rows, streams);
        const insights = computeReportInsightKpis(rep, consolidated);
        return Object.assign({}, consolidated, insights, {
            activeChannels: activeChannels != null ? String(activeChannels) : '—'
        });
    }

    // Recompute Reports-tab KPI cards + per-stream cards from consolidated rows
    // so that on-screen counts match the consolidated PDF/CSV output (×N collapsed).
    function recomputeConsolidatedCards(rows) {
        if (!Array.isArray(rows) || !rows.length) return;
        const forKpi = rows.length > 8000 ? rows.slice(-8000) : rows;
        const snap = computeConsolidatedKpisFromRows(forKpi, monState.streams);
        const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
        const setKpiTxt = (id, v) => {
            const el = document.getElementById(id);
            if (!el) return;
            const s = v == null ? '—' : String(v);
            el.textContent = s;
            el.title = s !== '—' ? s : '';
        };

        setTxt('rpt-total', fmtHms(snap.totalAirtimeSec || 0));
        setTxt('rpt-matches', snap.matches);
        setTxt('rpt-nomatch', snap.noMatches);
        setTxt('rpt-errors', snap.errors);
        setTxt('rpt-match-rate', snap.matchRate);
        setTxt('rpt-avg-conf', snap.avgConfidence > 0 ? (snap.avgConfidence * 100).toFixed(1) + '%' : '—');
        setTxt('rpt-unique', snap.uniqueSongs);

        const matchAirtimeStr = fmtHms(snap.matchSec);
        const noMatchAirtimeStr = fmtHms(snap.noMatchSec);
        setTxt('rpt-airtime-match', matchAirtimeStr);
        setTxt('rpt-airtime-nomatch', noMatchAirtimeStr);
        setTxt('rpt-matches-airtime', matchAirtimeStr);
        setTxt('rpt-nomatch-airtime', noMatchAirtimeStr);
        setTxt('rpt-avg-match-len', fmtHms(snap.avgMatchLen));

        const longestEl = document.getElementById('rpt-longest-match');
        if (longestEl) {
            longestEl.textContent = snap.longestMatchSec > 0 ? fmtHms(snap.longestMatchSec) : '—';
            longestEl.title = snap.longestMatchSec > 0 && snap.longestMatchLabel
                ? `Longest matched play: ${snap.longestMatchLabel}` : '';
        }
        setKpiTxt('rpt-top-playkind', snap.topPlayKind);
        setKpiTxt('rpt-top-program', snap.topProgram);

        const topLangEl = document.getElementById('rpt-top-language');
        const top = snap.langArr[0];
        if (topLangEl) {
            if (top) {
                topLangEl.textContent = `${top.name} (${(top.share * 100).toFixed(0)}%)`;
                topLangEl.title = `${top.name}: ${top.matches} match${top.matches === 1 ? '' : 'es'} · ${fmtHms(top.matchSec)} airtime`;
            } else {
                topLangEl.textContent = '—';
                topLangEl.title = 'No language metadata on the streams that produced matches in this range.';
            }
        }
        setTxt('rpt-language-count', snap.languagesTracked);
        renderLanguageBreakdownTable(snap.langArr);
        drawLanguageChart(snap.langArr);
        drawLanguageCountChart(snap.langArr);
        drawLanguageChannelChart(snap.byStream, snap.streamLangIndex);
        renderChannelLanguageTable(snap.byStream, snap.streamLangIndex, snap.matchSec);
        window._lastLanguageSummary = snap.langArr;
        window._lastReportKpis = snap;

        renderReportStreamCards(snap.streamCards);
        setKpiTxt('rpt-busiest-channel', snap.busiestChannel);
    }

    function renderAirplayTableRowHtml(row) {
        const timeEnd = row.endTimeUtc || airplayEndUtcIso(row);
        const time = (row.count && row.count > 1)
            ? `${fmtTime(row.timeUtc)} → ${fmtTime(timeEnd)} <span class="muted-tag">×${row.count}</span>`
            : (timeEnd && timeEnd !== row.timeUtc)
                ? `${fmtTime(row.timeUtc)} → ${fmtTime(timeEnd)}`
                : fmtTime(row.timeUtc);
        const conf = row.confidence != null ? (row.confidence * 100).toFixed(1) + '%' : '—';
        const tStart = row.trackMatchAtSeconds != null
            ? row.trackMatchAtSeconds.toFixed(2) + 's' : '—';
        const tEnd = (row.trackMatchEndSeconds != null)
            ? row.trackMatchEndSeconds.toFixed(2) + 's'
            : (row.trackMatchAtSeconds != null && row.queryLengthSeconds != null)
                ? (row.trackMatchAtSeconds + row.queryLengthSeconds).toFixed(2) + 's'
                : '—';
        const typeClass = row.type === 'match' ? 'tag tag-added' : row.type === 'error' ? 'tag tag-failed' : 'tag tag-skipped';
        const epg = row.epg || {};
        const programName = row.epgShowName || epg.programName || epg.showName || '';
        const timing = epg.timing || fmtEpgTiming(row.epgStartUtc || epg.startUtc, row.epgEndUtc || epg.endUtc);
        const totalDur = airplayTotalDurationHms(row);
        const programCell = programName
            ? `<span title="${escapeAttr(programName)}">${escapeHtml(programName)}</span>`
            : '<span class="muted-tag">—</span>';
        const timingCell = timing
            ? `<span class="muted-tag">${escapeHtml(timing)}</span>`
            : '<span class="muted-tag">—</span>';
        return `<tr>
            <td>${time}</td>
            <td>${escapeHtml(row.streamName)}</td>
            <td>${programCell}</td>
            <td>${timingCell}</td>
            <td class="c">${escapeHtml(totalDur)}</td>
            <td><span class="${typeClass}">${displayType(row.type, row.message)}</span></td>
            <td>${escapeHtml(row.title || '')}</td>
            <td>${escapeHtml(row.artist || '')}</td>
            <td>${conf}</td>
            <td>${tStart}</td>
            <td>${tEnd}</td>
        </tr>`;
    }

    function renderAirplayTableFromRows(rows, truncated) {
        const body = $('#rpt-log-body');
        if (!body) return;
        const countEl = document.getElementById('rpt-airplay-count');
        try {
            if (!Array.isArray(rows) || !rows.length) {
                body.innerHTML = '<tr><td colspan="11" class="empty">No log rows in range.</td></tr>';
                window._lastConsolidatedAirplayRows = [];
                if (countEl) countEl.textContent = '';
                return;
            }
            window._lastConsolidatedAirplayRows = rows;
            recomputeConsolidatedCards(rows);
            const displayRows = rows.slice().reverse().slice(0, 500);
            if (countEl) {
                const shown = displayRows.length;
                const total = rows.length;
                countEl.textContent = shown < total
                    ? `${shown} of ${total} rows (newest)`
                    : `${total} row${total === 1 ? '' : 's'}`;
            }
            const truncNote = truncated
                ? '<tr><td colspan="11" class="empty">Range has more than 100,000 raw events — table shows the newest slice only. Narrow the date filter for full history.</td></tr>'
                : '';
            body.innerHTML = truncNote + displayRows.map(renderAirplayTableRowHtml).join('');
        } catch {
            body.innerHTML = '<tr><td colspan="11" class="empty">Failed to load log.</td></tr>';
        }
    }

    // ── Per-language breakdown table + doughnut chart ─────────────────
    // Both are populated from the langArr produced inside
    // recomputeConsolidatedCards. Same palette as the other report
    // charts (Chart.js is already loaded), so the styling matches.
    function renderLanguageBreakdownTable(langArr) {
        const tbody = document.getElementById('rpt-language-rows');
        if (!tbody) return;
        if (!langArr || langArr.length === 0) {
            tbody.innerHTML = '<tr><td colspan="4" class="empty">No language data yet.</td></tr>';
            return;
        }
        tbody.innerHTML = langArr.map(l => `
            <tr>
                <td>${escapeHtml(l.name)}</td>
                <td style="text-align:right;">${l.matches.toLocaleString()}</td>
                <td style="text-align:right;">${fmtHms(l.matchSec)}</td>
                <td style="text-align:right;">${(l.share * 100).toFixed(1)}%</td>
            </tr>`).join('');
    }

    // Bar chart: match count per language (companion to the airtime
    // doughnut). Same palette + ordering so the two visuals can be read
    // side-by-side.
    function drawLanguageCountChart(langArr) {
        _destroy('rpt-language-count-chart');
        const ctx = _ctx('rpt-language-count-chart');
        if (!ctx) return;
        if (!langArr || langArr.length === 0) return;
        const top = langArr.slice(0, 10);
        const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981',
                         '#06b6d4', '#3b82f6', '#a855f7', '#64748b', '#22d3ee'];
        _reportCharts['rpt-language-count-chart'] = new Chart(ctx, {
            type: 'bar',
            data: {
                labels: top.map(l => l.name),
                datasets: [{
                    label: 'Matches',
                    data: top.map(l => l.matches),
                    backgroundColor: top.map((_, i) => palette[i % palette.length]),
                    borderWidth: 1
                }]
            },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: { legend: { display: false } },
                scales: {
                    x: { grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.tick } },
                    y: { grid: { color: CHART_COLORS.gridMinor }, ticks: { color: CHART_COLORS.tick, precision: 0 } }
                }
            }
        });
    }

    // Stacked bar: each channel's matched airtime, segmented by the
    // channel's configured language. One bar per channel, stack
    // segments per language — visualizes which channels contribute to
    // which linguistic markets.
    function drawLanguageChannelChart(byStream, streamLangIndex) {
        _destroy('rpt-language-channel-chart');
        const ctx = _ctx('rpt-language-channel-chart');
        if (!ctx) return;
        const channels = Array.from(byStream.values()).filter(c => (c.matches || 0) > 0);
        if (channels.length === 0) return;
        // Collect the set of languages that actually appeared in this
        // report, in descending order of total airtime so the legend
        // surfaces the dominant ones first.
        const langTotals = new Map();
        for (const c of channels) {
            const lang = streamLangIndex.get(c.streamId) || 'Unspecified';
            langTotals.set(lang, (langTotals.get(lang) || 0) + (c.matchSec || 0));
        }
        const languages = Array.from(langTotals.entries())
            .sort((a, b) => b[1] - a[1])
            .map(([name]) => name);
        const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981',
                         '#06b6d4', '#3b82f6', '#a855f7', '#64748b', '#22d3ee'];
        // Trim channel labels so long names don't break the X axis.
        const labels = channels.map(c => {
            const n = c.streamName || '—';
            return n.length > 16 ? n.slice(0, 15) + '…' : n;
        });
        const datasets = languages.map((lang, i) => ({
            label: lang,
            // Each dataset puts the airtime ONLY against the channel
            // whose configured language matches; zeros everywhere else.
            data: channels.map(c => {
                const cl = streamLangIndex.get(c.streamId) || 'Unspecified';
                return cl === lang ? Math.round(c.matchSec || 0) : 0;
            }),
            backgroundColor: palette[i % palette.length],
            stack: 'lang'
        }));
        _reportCharts['rpt-language-channel-chart'] = new Chart(ctx, {
            type: 'bar',
            data: { labels, datasets },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: {
                    legend: { labels: { color: CHART_COLORS.tick } },
                    tooltip: {
                        callbacks: { label: (ctx) => `${ctx.dataset.label}: ${fmtHms(ctx.parsed.y || 0)}` }
                    }
                },
                scales: {
                    x: { stacked: true, grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.tick, autoSkip: false, maxRotation: 30, minRotation: 0 } },
                    y: { stacked: true, grid: { color: CHART_COLORS.gridMinor }, ticks: { color: CHART_COLORS.tick, callback: v => fmtHms(v) } }
                }
            }
        });
    }

    // Tabular mirror of the PDF's "Channel × Language Airtime" so the
    // on-screen view exposes the same figures the printed report carries.
    function renderChannelLanguageTable(byStream, streamLangIndex, totalMatchSec) {
        const tbody = document.getElementById('rpt-channel-language-rows');
        if (!tbody) return;
        const channels = Array.from(byStream.values())
            .filter(c => (c.matches || 0) > 0)
            .sort((a, b) => (b.matchSec || 0) - (a.matchSec || 0));
        if (channels.length === 0) {
            tbody.innerHTML = '<tr><td colspan="5" class="empty">No channel-language data yet.</td></tr>';
            return;
        }
        const denom = Math.max(1, totalMatchSec || 0);
        tbody.innerHTML = channels.map(c => {
            const lang = streamLangIndex.get(c.streamId) || 'Unspecified';
            const pct = ((c.matchSec || 0) / denom) * 100;
            return `<tr>
                <td>${escapeHtml(c.streamName || '—')}</td>
                <td>${escapeHtml(lang)}</td>
                <td style="text-align:right;">${(c.matches || 0).toLocaleString()}</td>
                <td style="text-align:right;">${fmtHms(c.matchSec || 0)}</td>
                <td style="text-align:right;">${pct.toFixed(1)}%</td>
            </tr>`;
        }).join('');
    }

    function drawLanguageChart(langArr) {
        _destroy('rpt-language-chart');
        const ctx = _ctx('rpt-language-chart');
        if (!ctx) return;
        if (!langArr || langArr.length === 0) {
            // Leave the canvas blank — the table already shows "No
            // language data yet". Drawing an empty doughnut would render
            // a single grey ring which is more confusing than nothing.
            return;
        }
        // Top 8 languages then a single "Other" bucket so the legend
        // stays readable on dense reports.
        const top = langArr.slice(0, 8);
        const restSec = langArr.slice(8).reduce((s, l) => s + l.matchSec, 0);
        const labels  = top.map(l => l.name).concat(restSec > 0 ? ['Other'] : []);
        const data    = top.map(l => Math.round(l.matchSec)).concat(restSec > 0 ? [Math.round(restSec)] : []);
        // Stable palette — cycle a small set so reruns look consistent.
        const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981',
                         '#06b6d4', '#3b82f6', '#a855f7', '#64748b'];
        const colors = labels.map((_, i) => palette[i % palette.length]);
        _reportCharts['rpt-language-chart'] = new Chart(ctx, {
            type: 'doughnut',
            data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: '#0b1220', borderWidth: 1 }] },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                plugins: {
                    legend: { labels: { color: CHART_COLORS.tick } },
                    tooltip: {
                        callbacks: {
                            label: (ctx) => `${ctx.label}: ${fmtHms(ctx.parsed)}`
                        }
                    }
                }
            }
        });
    }

    function renderReportStreamCards(cards) {
        const host = $('#rpt-stream-cards');
        if (!cards || cards.length === 0) {
            host.innerHTML = '<div class="empty" style="grid-column:1/-1;">No events in this range.</div>';
            return;
        }
        host.innerHTML = cards.map(c => {
            const matchable = c.matches + c.noMatches;
            const ratePct = matchable > 0 ? (c.matches / matchable) * 100 : 0;
            const rate = ratePct.toFixed(1);
            const rateClass = ratePct < 50 ? 'low' : '';
            const avgConf = (c.avgConfidence * 100).toFixed(1);
            return `<div class="ch-card" title="${escapeAttr(c.streamName)}">
                <div class="name">
                    <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(c.streamName)}</span>
                    <span class="tag-rate ${rateClass}">${rate}%</span>
                </div>
                <div class="kv">
                    <div><span>${c.total}</span><label>events</label></div>
                    <div><span class="s-success">${fmtHms(c.matchSeconds || 0)}</span><label>match time</label></div>
                    <div><span>${fmtHms(c.noMatchSeconds || 0)}</span><label>no-match time</label></div>
                    <div><span class="s-danger">${fmtHms(c.errorSeconds || 0)}</span><label>error time</label></div>
                    <div><span>${avgConf}%</span><label>avg conf</label></div>
                    <div><span>${c.uniqueSongs}</span><label>unique</label></div>
                </div>
            </div>`;
        }).join('');
    }

    async function loadAirplayTable(qs) {
        const airplayQs = new URLSearchParams(qs);
        airplayQs.set('consolidate', 'true');
        try {
            const r = await fetch('/api/report/airplay-rows?' + airplayQs.toString());
            if (!r.ok) throw new Error('server error');
            const payload = await r.json();
            renderAirplayTableFromRows(Array.isArray(payload?.rows) ? payload.rows : [], payload?.truncated);
        } catch {
            const body = $('#rpt-log-body');
            if (body) body.innerHTML = '<tr><td colspan="11" class="empty">Failed to load log.</td></tr>';
        }
    }

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

    /** Last path segment only — avoids full paths blowing out the header progress pill. */
    function fileBaseName(path) {
        if (!path) return '';
        const s = String(path).trim();
        const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
        return i >= 0 ? s.slice(i + 1) : s;
    }

    function truncateDisplayText(text, maxLen = 64) {
        const t = String(text ?? '');
        return t.length <= maxLen ? t : t.slice(0, Math.max(0, maxLen - 1)) + '…';
    }

    // ============================================================
    // RECORDING MANAGEMENT
    // ============================================================
    function fmtSize(b) {
        if (!b) return '0 B';
        const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = b;
        while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
        return v.toFixed(v >= 10 ? 0 : 1) + ' ' + u[i];
    }
    function fmtTime(iso) {
        if (!iso) return '—';
        const d = new Date(iso);
        if (isNaN(d.getTime())) return iso;
        const pad = n => String(n).padStart(2, '0');
        return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    }

    /**
     * Render an EPG program timing range as "08:00 PM - 09:00 PM" using the
     * browser's local timezone. Used as a fallback when the server-side
     * `epg.timing` field is unavailable. Returns '' when both inputs are
     * missing / unparseable.
     */
    function fmtEpgTiming(startIso, endIso) {
        function one(iso) {
            if (!iso) return '';
            const d = new Date(iso);
            if (isNaN(d.getTime())) return '';
            let h = d.getHours();
            const m = d.getMinutes();
            const ampm = h >= 12 ? 'PM' : 'AM';
            h = h % 12; if (h === 0) h = 12;
            return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ' ' + ampm;
        }
        const s = one(startIso), e = one(endIso);
        if (!s && !e) return '';
        return s + ' - ' + e;
    }

    async function loadRecDefaultDirectory() {
        const input = $('#rec-default-dir');
        const hint = $('#rec-default-dir-effective');
        if (!input || !hint) return;
        try {
            const r = await fetch('/api/recording/directory');
            if (!r.ok) return;
            const d = await r.json();
            input.value = d.storedDirectory || d.appsettingsDirectory || '';
            const src = d.source === 'settings' ? 'saved setting'
                : d.source === 'appsettings' ? 'appsettings.json'
                    : 'app folder';
            hint.textContent = 'Active path: ' + (d.effectiveDirectory || '—') + ' (' + src + ')';
        } catch { /* silent */ }
    }

    async function saveRecDefaultDirectory(clear) {
        const input = $('#rec-default-dir');
        const dir = clear ? '' : (input?.value || '').trim();
        try {
            const r = await fetch('/api/recording/directory', {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ directory: dir || null })
            });
            const data = await r.json().catch(() => ({}));
            if (!r.ok) throw new Error(data.error || 'save failed');
            showToast(data.message || (clear ? 'Default folder reset' : 'Default folder saved'), 'ok');
            await loadRecDefaultDirectory();
            refreshRecStatus();
        } catch (err) {
            showToast('Recording folder: ' + (err?.message || err), 'err');
        }
    }

    $('#rec-default-dir-save')?.addEventListener('click', () => saveRecDefaultDirectory(false));
    $('#rec-default-dir-clear')?.addEventListener('click', () => {
        if (!confirm('Reset the default recording folder? Streams without a custom folder will use recordings/ next to the app (or appsettings).')) return;
        saveRecDefaultDirectory(true);
    });

    async function loadRecording() {
        const sel = $('#rec-filter-stream');
        const current = sel.value;
        sel.innerHTML = $('#mon-filter-stream').innerHTML;
        sel.value = current;
        await Promise.all([loadRecDefaultDirectory(), refreshRecStatus(), loadRecFiles()]);
    }

    async function refreshRecStatus() {
        try {
            const r = await fetch('/api/recording/status');
            if (!r.ok) throw new Error();
            const data = await r.json();
            const streamsR = await fetch('/api/streams');
            const streams = streamsR.ok ? await streamsR.json() : [];
            renderRecStreams(streams, data.workers || []);
            const workers = data.workers || [];
            renderRecWorkers(workers);
            updateHeaderRecordingStats(workers);
            $('#rec-active-count').textContent = workers.filter(workerIsActive).length;
        } catch {
            /* silent */
        }
    }

    // Per-stream cache of the available quality choices returned by
    // GET /api/streams/{id}/variants. Each entry is an array of
    // {value, label} suitable for direct rendering. JioTV-Go streams
    // get a fixed [High / Medium / Low] list (the ?q= levels); other
    // HLS sources get whatever the master playlist's EXT-X-STREAM-INF
    // entries advertise (1080p / 720p / ...).
    const _streamVariantsCache = new Map(); // id -> [{value, label}]
    // UI + API: null / empty / "auto" all mean Auto (source default).
    function normalizeStreamQuality(q) {
        const v = (q == null ? '' : String(q)).trim().toLowerCase();
        return !v || v === 'auto' ? '' : v;
    }

    function qualityLabel(q) {
        const n = normalizeStreamQuality(q);
        if (!n) return 'Auto';
        return n.toUpperCase();
    }

    async function fetchStreamVariants(id) {
        if (_streamVariantsCache.has(id)) return _streamVariantsCache.get(id);
        try {
            const r = await fetch(`/api/streams/${encodeURIComponent(id)}/variants`);
            if (!r.ok) { _streamVariantsCache.set(id, []); return []; }
            const data = await r.json();
            const list = Array.isArray(data.variants) ? data.variants : [];
            _streamVariantsCache.set(id, list);
            return list;
        } catch {
            _streamVariantsCache.set(id, []);
            return [];
        }
    }

    // Build the dropdown's <option> list. Always includes "Auto" at
    // the top; appends the server-provided variants (already shaped
    // as {value, label}); also preserves the stream's currently-saved
    // value if it's not in the variant list so we don't silently lose
    // a legacy choice the operator can still see.
    function buildQualityOptions(currentSaved, variants, compact = false) {
        const current = normalizeStreamQuality(currentSaved);
        const opts = [{ value: '', label: compact ? 'Auto' : 'Auto (best available)' }];
        const seen = new Set(['', 'auto']);
        for (const v of variants) {
            const value = normalizeStreamQuality(v.value);
            if (seen.has(value)) continue;
            seen.add(value);
            const raw = v.label || value;
            opts.push({ value, label: compact ? (qualityLabel(value) || raw) : raw });
        }
        if (current && !seen.has(current)) {
            opts.push({ value: current, label: qualityLabel(current) });
        }
        return opts;
    }

    function streamLanguageTag(language) {
        const v = (language == null ? '' : String(language)).trim();
        if (!v) return '';
        return v.length > 14 ? `${v.slice(0, 13)}…` : v;
    }

    // Repopulate a single row's <select> with the latest cached
    // variants, preserving the currently-selected value. Used after
    // an async probe completes so the dropdown reflects what the
    // upstream actually exposes.
    function repopulateQualitySelect(sel, current) {
        const id = sel.dataset.qualitySelect;
        const variants = _streamVariantsCache.get(id) || [];
        const compact = sel.classList.contains('rec-quality');
        const opts = buildQualityOptions(current, variants, compact);
        sel.innerHTML = opts
            .map(o => `<option value="${escapeAttr(o.value)}" ${o.value === (current || '') ? 'selected' : ''}>${escapeHtml(o.label)}</option>`)
            .join('');
    }

    function renderRecStreams(streams, workers) {
        const host = $('#rec-live-streams');
        $('#rec-live-count').textContent = streams.length ? streams.length + ' total' : '';
        if (!streams.length) { host.innerHTML = '<div class="empty">No streams configured yet.</div>'; return; }
        const byId = Object.fromEntries(workers.map(w => [workerStreamId(w), w]).filter(([id]) => id));
        host.innerHTML = streams.map(s => {
            const w = byId[s.id];
            const active = workerIsActive(w);
            const current = normalizeStreamQuality(s.quality);
            const variants = _streamVariantsCache.get(s.id) || [];
            const opts = buildQualityOptions(current, variants, true)
                .map(o => `<option value="${escapeAttr(o.value)}" ${o.value === current ? 'selected' : ''}>${escapeHtml(o.label)}</option>`)
                .join('');
            const lang = streamLanguageTag(s.language);
            const langTag = lang
                ? `<span class="rsi-pill rsi-pill-lang" title="${escapeAttr(s.language)}">${escapeHtml(lang)}</span>`
                : '';
            const liveCls = active ? ' rsi-live' : '';
            return `<div class="rec-stream-item${liveCls}">
                <div class="rsi-top">
                <div class="rsi-info">
                        <div class="rsi-name" title="${escapeAttr(s.name)}">${escapeHtml(s.name)}</div>
                        <div class="rsi-meta">
                            <span class="rsi-pill">${escapeHtml((s.category || 'video').toLowerCase())}</span>
                            ${langTag}
                </div>
                    </div>
                    <button type="button" class="${active ? 'rsi-record-btn rsi-record-stop' : 'primary rsi-record-btn'}" data-rec-toggle="${escapeAttr(s.id)}" data-active="${active ? '1' : '0'}" title="${active ? 'Stop recording' : 'Start recording'}">
                        ${active ? '⏹' : '⏺'}
                </button>
                </div>
                <label class="rsi-quality-row">
                    <span class="rsi-quality-lbl">Quality</span>
                    <select class="rec-quality" data-quality-select="${escapeAttr(s.id)}" title="Stream quality (Auto = best available)">
                        ${opts}
                    </select>
                </label>
            </div>`;
        }).join('');
        host.querySelectorAll('[data-quality-select]').forEach(sel => {
            const id = sel.dataset.qualitySelect;
            // Fire the variant probe in the background. Don't await —
            // the dropdown is already usable with the Auto + saved
            // fallback, and we want the streams list to render now.
            // When the probe resolves we repopulate this specific row.
            (async () => {
                const variants = await fetchStreamVariants(id);
                if (!variants.length) return;
                // The user may have changed selection while waiting —
                // preserve whatever they have right now.
                const current = sel.value || '';
                repopulateQualitySelect(sel, current);
            })();
            // Mousedown beats focus on some browsers and opens the
            // dropdown before our focus handler runs. Refresh options
            // on both events so the latest probed list is always
            // visible by the time the popup opens.
            const refreshOnInteract = () => {
                sel.dataset.prev = sel.value;
                if (_streamVariantsCache.has(id)) {
                    repopulateQualitySelect(sel, sel.value || '');
                }
            };
            sel.addEventListener('focus', refreshOnInteract);
            sel.addEventListener('mousedown', refreshOnInteract);
            sel.addEventListener('change', async () => {
                const want = normalizeStreamQuality(sel.value);
                const prev = sel.dataset.prev ?? '';
                sel.disabled = true;
                try {
                    const r = await fetch(`/api/streams/${encodeURIComponent(id)}/quality`, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ quality: want }),
                    });
                    if (!r.ok) sel.value = prev;
                    await refreshRecStatus();
                } catch {
                    sel.value = prev;
                } finally {
                    sel.disabled = false;
                }
            });
        });
        host.querySelectorAll('[data-rec-toggle]').forEach(btn => {
            btn.addEventListener('click', async () => {
                const id = btn.dataset.recToggle;
                const active = btn.dataset.active === '1';
                btn.disabled = true;
                try {
                    if (active) {
                        await fetch('/api/recording/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ streamId: id }) });
                    } else {
                        await fetch('/api/recording/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ streamId: id, segmentSeconds: 3600 }) });
                    }
                    await refreshRecStatus();
                } finally { btn.disabled = false; }
            });
        });
    }

    function renderRecWorkers(workers) {
        const host = $('#rec-worker-cards');
        const active = workers.filter(w => w.active);
        const totalKbps = active.reduce((sum, w) => sum + parseVariantBandwidthKbps(w.variantInfo), 0);
        const kbpsHead = $('#rec-workers-kbps-total');
        if (kbpsHead) {
            const mbps = formatBitrateMbpsValue(totalKbps);
            kbpsHead.textContent = totalKbps > 0 ? `· ${mbps} Mbps total` : '';
        }
        if (!active.length) { host.innerHTML = '<div class="empty">No active recordings.</div>'; return; }
        host.innerHTML = active.map(w => {
            const qLine = w.quality
                ? `<div><label>Quality</label><span>${escapeHtml(w.quality)}</span></div>`
                : '';
            const vLine = w.variantInfo
                ? `<div><label>Variant</label><span title="${escapeAttr(w.variantInfo)}">${escapeHtml(w.variantInfo)}</span></div>`
                : '';
            return `
            <div class="rec-worker">
                <div class="rw-head">
                    <span class="live-dot"></span>
                    <strong>${escapeHtml(w.streamName)}</strong>
                    <span class="muted-tag">${escapeHtml(w.category)}</span>
                </div>
                <div class="rw-body">
                    <div><label>Started</label><span>${fmtTime(w.startedUtc)}</span></div>
                    <div><label>Current file</label><span>${escapeHtml(w.currentFile || '—')}</span></div>
                    <div><label>Files</label><span>${w.filesCount}</span></div>
                    <div><label>Size</label><span>${fmtSize(w.totalBytes)}</span></div>
                    ${qLine}
                    ${vLine}
                </div>
            </div>`;
        }).join('');
    }

    async function loadRecFiles() {
        const qs = new URLSearchParams();
        const s = $('#rec-filter-stream').value;
        const d = $('#rec-filter-date').value; // HTML <input type="date"> → YYYY-MM-DD
        if (s) qs.set('streamId', s);
        // Server stores folder names as DD-MM-YYYY; convert the picker value.
        if (d) {
            const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d);
            qs.set('date', mm ? `${mm[3]}-${mm[2]}-${mm[1]}` : d);
        }
        const r = await fetch('/api/recording/files?' + qs.toString());
        const files = r.ok ? await r.json() : [];
        const host = $('#rec-files-tree');
        if (!files.length) { host.innerHTML = '<div class="empty">No recordings yet.</div>'; return; }

        // Helpers — present the on-disk tree as: Channel ▸ Date ▸ Time
        // The on-disk filename is a bare "HH-MM-SS.ext" (cleaner layout);
        // older recordings used "{Channel}_{dd-MM-yyyy}_{HH-MM-SS}.ext".
        // Both are normalized below for display + download names.
        const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
        // Folder format is DD-MM-YYYY; also tolerate legacy YYYY-MM-DD folders.
        const fmtDateHeader = (iso) => {
            let y, mo, d;
            let m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(iso || '');
            if (m) { d = +m[1]; mo = +m[2]; y = +m[3]; }
            else {
                m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
                if (!m) return iso || '';
                y = +m[1]; mo = +m[2]; d = +m[3];
            }
            const dt = new Date(y, mo - 1, d);
            const wd = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][dt.getDay()];
            return `${wd}, ${d} ${monthNames[mo - 1]} ${y}`;
        };
        // Sort key: chronological (descending) regardless of folder format.
        const dateSortKey = (iso) => {
            let m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(iso || '');
            if (m) return `${m[3]}-${m[2]}-${m[1]}`;
            return iso || '';
        };
        const parseTimeFromFilename = (name) => {
            // New format: "{Channel} HH-MM-SS.ext"  →  trailing "HH-MM-SS"
            let m = /(\d{2})-(\d{2})-(\d{2})\.[^.]+$/.exec(name);
            if (m) return `${m[1]}:${m[2]}:${m[3]}`;
            // Time-only format: "HH-MM-SS.ext"
            m = /^(\d{2})-(\d{2})-(\d{2})\./.exec(name);
            if (m) return `${m[1]}:${m[2]}:${m[3]}`;
            // Legacy format: "...{dd-MM-yyyy}_{HH-MM-SS}.ext"
            m = /_(\d{2})-(\d{2})-(\d{2})\.[^.]+$/.exec(name);
            if (m) return `${m[1]}:${m[2]}:${m[3]}`;
            return '';
        };
        const friendlyDownloadName = (channel, date, fileName) => {
            const t = parseTimeFromFilename(fileName).replace(/:/g, '-');
            const ext = fileName.split('.').pop();
            const safeChannel = channel.replace(/[^\w\- ]+/g, '').trim();
            const stem = t ? `${safeChannel} ${date} ${t}` : `${safeChannel} ${date} ${fileName.replace(/\.[^.]+$/, '')}`;
            return `${stem}.${ext}`;
        };

        const streamNameById = Object.fromEntries(
            (monState.streams || []).map(s => [s.id || s.Id, (s.name || s.Name || '').trim()]).filter(([id]) => id));
        const channelLabel = (f) =>
            (f.streamName || f.StreamName || streamNameById[f.streamId || f.StreamId] || '').trim()
            || streamNameById[f.streamId || f.StreamId]
            || f.streamId
            || '—';

        // Group by stream display name (Streams tab), then by date (descending).
        const byChannel = {};
        let total = 0, totalBytes = 0;
        for (const f of files) {
            total++;
            totalBytes += f.sizeBytes;
            const label = channelLabel(f);
            const ch = (byChannel[label] = byChannel[label] || {});
            (ch[f.date] = ch[f.date] || []).push(f);
        }
        $('#rec-total-count').textContent = total;
        $('#rec-total-size').textContent = fmtSize(totalBytes);

        const channelKeys = Object.keys(byChannel).sort((a, b) => a.localeCompare(b));
        host.innerHTML = channelKeys.map(channel => {
            const dateKeys = Object.keys(byChannel[channel]).sort((a, b) => dateSortKey(b).localeCompare(dateSortKey(a)));
            const channelTotal = dateKeys.reduce((a, dk) =>
                a + byChannel[channel][dk].reduce((b, f) => b + f.sizeBytes, 0), 0);
            const channelFiles = dateKeys.reduce((a, dk) => a + byChannel[channel][dk].length, 0);
            return `<div class="rec-channel">
                <div class="rc-head">
                    <span class="rc-icon">🎵</span>
                    <strong>${escapeHtml(channel)}</strong>
                    <span class="muted-tag">${channelFiles} files · ${fmtSize(channelTotal)}</span>
                </div>
                ${dateKeys.map(dk => {
                    const list = byChannel[channel][dk].slice().sort((a, b) => a.fileName.localeCompare(b.fileName));
                    const dayBytes = list.reduce((a, f) => a + f.sizeBytes, 0);
                    return `<div class="rec-group">
                        <div class="rg-head">
                            <span class="rg-date">${escapeHtml(fmtDateHeader(dk))}</span>
                            <span class="muted-tag">${list.length} files · ${fmtSize(dayBytes)}</span>
                        </div>
                        <div class="rg-files">
                            ${list.map(f => {
                                const t = parseTimeFromFilename(f.fileName) || fmtTime(f.createdUtc);
                                const dl = friendlyDownloadName(channel, dk, f.fileName);
                                return `<div class="rg-file">
                                    <span class="fn-time">${escapeHtml(t)}</span>
                                    <span class="muted-tag">${fmtSize(f.sizeBytes)}</span>
                                    <span class="fn-spacer"></span>
                                    <a class="secondary btn-tiny" href="${f.url}" target="_blank" rel="noopener" title="Open in new tab">▶ Open</a>
                                    <a class="secondary btn-tiny" href="${f.url}" download="${escapeAttr(dl)}" title="Download as ${escapeAttr(dl)}">⇩ Save</a>
                                    <button type="button" class="secondary btn-tiny" data-del-rec="${escapeAttr(f.streamId)}/${escapeAttr(f.date)}/${escapeAttr(f.fileName)}" title="Delete">✕</button>
                                </div>`;
                            }).join('')}
                        </div>
                    </div>`;
                }).join('')}
            </div>`;
        }).join('');
        host.querySelectorAll('[data-del-rec]').forEach(b => {
            b.addEventListener('click', async () => {
                if (!confirm('Delete this recording file?')) return;
                await fetch('/api/recording/file/' + b.dataset.delRec, { method: 'DELETE' });
                loadRecFiles();
            });
        });
    }

    document.querySelector('.tab[data-tab="recording"]').addEventListener('click', loadRecording);
    $('#rec-refresh-files').addEventListener('click', loadRecFiles);
    $('#rec-filter-stream').addEventListener('change', loadRecFiles);
    $('#rec-filter-date').addEventListener('change', loadRecFiles);
    $('#rec-stop-all').addEventListener('click', async () => {
        if (!confirm('Stop all recordings?')) return;
        await fetch('/api/recording/stop-all', { method: 'POST' });
        refreshRecStatus();
    });
    $('#rec-clear-finished').addEventListener('click', async () => {
        await fetch('/api/recording/clear-finished', { method: 'POST' });
        refreshRecStatus();
    });
    // Danger zone: stop every recorder and delete every file under recordings/.
    // Auto-started workers will come back on the next stream upsert.
    const recDeleteAllBtn = $('#rec-delete-all');
    if (recDeleteAllBtn) recDeleteAllBtn.addEventListener('click', async () => {
        if (!confirm('Delete ALL recordings? This stops every active recorder and permanently removes every file under the recordings/ folder. This cannot be undone.')) return;
        try {
            const r = await fetch('/api/recording/files/clear', { method: 'POST' });
            const data = await r.json().catch(() => ({}));
            if (!r.ok) throw new Error(data.error || 'delete failed');
            showToast(`Deleted ${data.files || 0} recording file(s) (${fmtSize(data.bytes || 0)}).`, 'ok');
            refreshRecStatus();
            loadRecFiles();
        } catch (err) {
            showToast('Clear recordings failed: ' + err.message, 'err');
        }
    });
    // Periodic status refresh while on the recording tab
    setInterval(() => {
        const tab = document.querySelector('.tab[data-tab="recording"]');
        if (tab && tab.classList.contains('active')) refreshRecStatus();
    }, 5000);

    // ============================================================
    // VIEW RESULTS (mapping)
    // ============================================================
    let mapCurrentEvent = null;
    // Flat list of the currently-rendered events + the index of the one that
    // is playing. Used by the player 'ended' handler to auto-advance to the
    // next event that has a recording attached.
    let mapEventsList = [];
    let mapCurrentIdx = -1;
    // Legend-driven timeline filter: null = show all, otherwise one of
    // 'match' | 'nomatch' | 'error' | 'active'. CSS on #map-timeline[data-filter]
    // hides every dot that doesn't match the selected class.
    let mapTimelineFilter = null;
    let mapSourceMode = 'monitor';

    // Wire the legend once — clicking a legend item filters the timeline to
    // show only that event type; clicking the same item again clears the
    // filter. 'active' restricts to the currently-playing dot.
    (function wireTimelineLegend() {
        const legend = document.getElementById('map-legend');
        if (!legend) return;
        legend.addEventListener('click', (ev) => {
            const item = ev.target.closest('.leg-item');
            if (!item) return;
            const kind = item.dataset.filter;
            if (mapTimelineFilter === kind) {
                mapTimelineFilter = null;
            } else {
                mapTimelineFilter = kind;
            }
            legend.querySelectorAll('.leg-item').forEach(el => {
                el.classList.toggle('selected', el.dataset.filter === mapTimelineFilter);
            });
            const host = document.getElementById('map-timeline');
            if (host) {
                if (mapTimelineFilter) host.setAttribute('data-filter', mapTimelineFilter);
                else host.removeAttribute('data-filter');
            }
        });
    })();

    async function loadMapping() {
        mapSourceMode = ($('#map-source')?.value || 'monitor');
        await loadMapFilterOptions();
        if (!$('#map-date').value) {
            $('#map-date').value = localYmd();
        }
        await fetchMapping();
    }

    async function loadMapFilterOptions() {
        const source = mapSourceMode;
        const streamLabel = document.getElementById('map-stream-label');
        const scanLabel = document.getElementById('map-scan-label');
        const sel = $('#map-stream');
        const scanSel = $('#map-scan-root');
        if (source === 'offline') {
            if (streamLabel) streamLabel.hidden = true;
            if (scanLabel) scanLabel.hidden = false;
            try {
                const r = await fetch('/api/offline/scan-roots');
                const rows = r.ok ? await r.json() : [];
                const cur = scanSel?.value || '';
                if (scanSel) {
                    scanSel.innerHTML = '<option value="">All scan folders</option>' +
                        rows.map(x => `<option value="${escapeAttr(x.scanRoot)}">${escapeHtml(x.label)}</option>`).join('');
                    if (cur) scanSel.value = cur;
                }
            } catch {
                if (scanSel) scanSel.innerHTML = '<option value="">All scan folders</option>';
            }
            return;
        }
        if (streamLabel) streamLabel.hidden = false;
        if (scanLabel) scanLabel.hidden = true;
        const cur = sel.value;
        const monSel = $('#mon-filter-stream');
        if (!monSel || monSel.options.length <= 1) {
            try {
                const r = await fetch('/api/streams');
                if (r.ok) {
                    const rows = await r.json();
                    sel.innerHTML = '<option value="">All</option>' +
                        rows.map(s => `<option value="${escapeAttr(s.id)}">${escapeHtml(s.name)}</option>`).join('');
                    if (monSel) monSel.innerHTML = sel.innerHTML;
                } else {
                    sel.innerHTML = '<option value="">All</option>';
                }
            } catch {
                sel.innerHTML = '<option value="">All</option>';
            }
        } else {
            sel.innerHTML = monSel.innerHTML;
        }
        sel.value = cur;
    }

    async function fetchMapping() {
        mapSourceMode = ($('#map-source')?.value || 'monitor');
        const qs = new URLSearchParams();
        const d = $('#map-date').value;
        const t = $('#map-type').value;
        if (mapSourceMode === 'offline') {
            const root = $('#map-scan-root')?.value;
            if (root) qs.set('scanRoot', root);
        } else {
            const s = $('#map-stream').value;
            if (s) qs.set('streamId', s);
        }
        if (d) qs.set('date', d);
        if (t && t !== 'all') qs.set('type', t);
        const base = mapSourceMode === 'offline' ? '/api/offline/mapping' : '/api/mapping';
        const r = await fetch(base + '?' + qs.toString());
        if (!r.ok) { showToast('Mapping failed', 'err'); return; }
        const data = await r.json();
        // Consolidate continuous detections of the same song into single
        // events for both timeline + list. summary is recomputed from the
        // consolidated list so "Total events" reflects the deduped view.
        if (Array.isArray(data.events)) {
            data.events = consolidateAirplay(data.events);
            const matches = data.events.filter(e => e.type === 'match').length;
            const nomatches = data.events.filter(e => e.type === 'nomatch').length;
            const recordingsCount = data.events.filter(e => e.recording).length;
            const pool = data.events;
            const matchSec = pool.filter(e => e.type === 'match').reduce((a, e) => a + rowDurationSec(e, pool), 0);
            const nomatchSec = pool.filter(e => e.type === 'nomatch').reduce((a, e) => a + rowDurationSec(e, pool), 0);
            data.summary = { total: data.events.length, matches, nomatches, recordingsCount, matchSeconds: matchSec, nomatchSeconds: nomatchSec };
        }
        renderMappingSummary(data.summary);
        renderMappingTimeline(data.events, d);
        renderMappingEvents(data.events);
    }

    function renderMappingSummary(s) {
        $('#map-total').textContent = s.total;
        $('#map-matches').textContent = fmtHms(s.matchSeconds || 0);
        $('#map-nomatch').textContent = fmtHms(s.nomatchSeconds || 0);
        $('#map-rec-count').textContent = s.recordingsCount;
    }

    function renderMappingTimeline(events, date) {
        const host = $('#map-timeline');
        if (!events.length) { host.innerHTML = '<div class="empty">No events for this date.</div>'; return; }
        const dayStart = new Date(date + 'T00:00:00').getTime();
        const dayMs = 24 * 60 * 60 * 1000;
        // Three horizontal lanes: match / nomatch / error. Each event becomes
        // a horizontal bar segment spanning timeUtc → endTimeUtc on its lane,
        // so matches and no-matches no longer overlap and the day's airplay
        // pattern is visually obvious at a glance.
        host.innerHTML = `<div class="map-hours">
            ${Array.from({ length: 25 }, (_, i) => `<span style="left:${(i / 24) * 100}%">${i.toString().padStart(2, '0')}</span>`).join('')}
        </div>
        <div class="map-lanes" id="map-track">
            <div class="map-lane match"   data-lane="match"><span class="map-lane-label">Match</span></div>
            <div class="map-lane nomatch" data-lane="nomatch"><span class="map-lane-label">No match</span></div>
            <div class="map-lane error"   data-lane="error"><span class="map-lane-label">Error</span></div>
        </div>`;
        const lanes = {
            match:   host.querySelector('.map-lane.match'),
            nomatch: host.querySelector('.map-lane.nomatch'),
            error:   host.querySelector('.map-lane.error')
        };
        events.forEach((e, idx) => {
            const tStart = new Date(e.timeUtc).getTime();
            const tEndRaw = e.endTimeUtc ? new Date(e.endTimeUtc).getTime() : tStart;
            const sliceMs = monitorSliceSeconds(e) * 1000;
            const tEnd = Math.max(tEndRaw, tStart + sliceMs);
            const pctL = ((tStart - dayStart) / dayMs) * 100;
            const pctW = ((tEnd - tStart) / dayMs) * 100;
            if (pctL > 100 || pctL + pctW < 0) return;
            const lane = lanes[e.type] || lanes.nomatch;
            const bar = document.createElement('button');
            bar.className = 'map-bar map-dot ' + e.type;
            bar.style.left  = Math.max(0, pctL) + '%';
            bar.style.width = 'calc(' + Math.max(0.2, Math.min(100 - Math.max(0, pctL), pctW)) + '% + 2px)';
            const dur = ((tEnd - tStart) / 1000);
            const durTxt = dur >= 60 ? Math.round(dur / 60) + 'm' : Math.round(dur) + 's';
            bar.title = `${fmtTime(e.timeUtc)} · ${e.streamName} · ${displayType(e.type)}${e.title ? ' · ' + e.title : ''} · ${durTxt}`;
            bar.dataset.idx = idx;
            bar.addEventListener('click', () => { mapCurrentIdx = idx; playMappingEvent(e, bar); });
            lane.appendChild(bar);
        });
        if (mapTimelineFilter) host.setAttribute('data-filter', mapTimelineFilter);
        else host.removeAttribute('data-filter');
    }

    function renderMappingEvents(events) {
        const host = $('#map-events');
        $('#map-events-count').textContent = events.length + ' events';
        // Cache for the player auto-advance hook.
        mapEventsList = events;
        // Hide the Play details panel until the user selects an event from
        // the freshly-loaded list (avoids leaving stale classifier state from
        // a prior date / stream visible).
        const pdPanel = document.getElementById('play-details');
        if (pdPanel) pdPanel.hidden = true;
        if (!events.length) { host.innerHTML = '<div class="empty">No data.</div>'; return; }
        host.innerHTML = events.map((e, i) => {
            const typeClass = e.type === 'match' ? 'tag tag-added' : e.type === 'error' ? 'tag tag-failed' : 'tag tag-skipped';
            const conf = e.confidence != null ? (e.confidence * 100).toFixed(1) + '%' : '—';
            const hasRec = e.recording ? 'has-rec' : 'no-rec';
            const timeHtml = (e.count && e.count > 1)
                ? `${fmtTime(e.timeUtc)} <span class="muted-tag">→ ${fmtTime(e.endTimeUtc)}</span>`
                : fmtTime(e.timeUtc);
            const countBadge = (e.count && e.count > 1)
                ? `<span class="muted-tag">×${e.count} detections</span>`
                : '';
            const pkBadge = e.playKind
                ? `<span class="me-playkind muted-tag">🏷 ${escapeHtml(e.playKind)}</span>`
                : '';
            const epgProg = mapEventEpgProgram(e);
            const epgBadge = epgProg
                ? `<span class="me-epg muted-tag">📺 ${escapeHtml(epgProg)}</span>`
                : '';
            return `<div class="map-event ${hasRec}" data-idx="${i}" data-id="${e.id || ''}">
                <div class="me-time">${timeHtml}</div>
                <div class="me-body">
                    <div class="me-top">
                        <span class="${typeClass}">${displayType(e.type)}</span>
                        <strong>${escapeHtml(e.title || (e.type === 'match' ? '(untitled)' : ''))}</strong>
                        <span class="muted-tag">${escapeHtml(e.artist || '')}</span>
                    </div>
                    <div class="me-meta">
                        <span>${escapeHtml(e.streamName)}</span>
                        <span>conf ${conf}</span>
                        ${countBadge}
                        ${e.recording
                            ? `<span>⧉ ${escapeHtml(e.recording.fileName)}</span>`
                            : (e.offline ? '<span class="muted-tag">file missing</span>' : '<span class="muted-tag">no recording</span>')}
                        ${pkBadge}
                        ${epgBadge}
                    </div>
                </div>
                <div class="me-actions">
                    <button type="button" class="primary btn-tiny" data-play="${i}" ${e.recording ? '' : 'disabled'}>▶ Play</button>
                    ${(e.recording && e.type === 'match')
                        ? `<a class="primary btn-tiny" href="${escapeAttr(clipUrl(e))}" download title="Download matched clip (MP4 for video streams, MP3 for audio streams)">⬇ Clip</a>`
                        : (e.type === 'nomatch')
                            ? `<button type="button" class="primary btn-tiny" disabled title="Clip download is available only for matched events">⬇ Clip</button>`
                            : ''}
                </div>
            </div>`;
        }).join('');
        host.querySelectorAll('[data-play]').forEach(b => {
            b.addEventListener('click', (e) => {
                e.stopPropagation();
                const idx = +b.dataset.play;
                const dot = document.querySelector(`#map-track .map-dot[data-idx="${idx}"]`);
                mapCurrentIdx = idx;
                playMappingEvent(events[idx], dot);
            });
        });
        host.querySelectorAll('.map-event').forEach(row => {
            row.addEventListener('click', (e) => {
                if (e.target.closest('button, a')) return;
                const idx = Number(row.dataset.idx);
                if (!Number.isFinite(idx) || !events[idx]) return;
                mapCurrentIdx = idx;
                const dot = document.querySelector(`#map-track .map-dot[data-idx="${idx}"]`);
                selectMappingEvent(events[idx], dot);
            });
        });
        // Store events for timeline refs
        host.dataset.events = '1';
    }

    /** Seconds of recording prepended/appended around matched clip play & download. */
    const MATCH_CLIP_PAD_SEC = 5;

    /** In-file start offset + span for play/clip (handles consolidated multi-play rows). */
    function eventRecordingClipRange(ev) {
        if (!ev) return { startSec: 0, spanSec: 120 };
        const PAD = MATCH_CLIP_PAD_SEC;
        let startOff = 0;
        let endOff = 0;
        let hasRange = false;

        if (ev.offline && ev.chunkStartSeconds != null && ev.chunkEndSeconds != null
            && ev.chunkEndSeconds > ev.chunkStartSeconds) {
            startOff = ev.chunkStartSeconds;
            endOff = ev.chunkEndSeconds;
            hasRange = true;
        } else if (ev.merged && ev.merged.length > 1) {
            for (const p of ev.merged) {
                if (p.recordingOffsetSeconds == null || !Number.isFinite(Number(p.recordingOffsetSeconds))) continue;
                const off = Number(p.recordingOffsetSeconds);
                const end = off + monitorSliceSeconds(p);
                if (!hasRange) {
                    startOff = off;
                    endOff = end;
                    hasRange = true;
                } else {
                    startOff = Math.min(startOff, off);
                    endOff = Math.max(endOff, end);
                }
            }
        }
        if (!hasRange) {
            startOff = Number(ev.recordingOffsetSeconds);
            if (!Number.isFinite(startOff)) startOff = 0;
            endOff = Number(ev.recordingEndOffsetSeconds);
            if (!Number.isFinite(endOff) || endOff <= startOff) {
                endOff = startOff + monitorSliceSeconds(ev);
                if (ev.merged && ev.merged.length) {
                    const last = ev.merged[ev.merged.length - 1];
                    if (last.recordingOffsetSeconds != null && Number.isFinite(Number(last.recordingOffsetSeconds))) {
                        endOff = Math.max(endOff, Number(last.recordingOffsetSeconds) + monitorSliceSeconds(last));
                    }
                }
            }
            hasRange = true;
        }

        const paddedStart = Math.max(0, startOff - PAD);
        const core = Math.max(0, endOff - startOff);
        const spanSec = Math.max(15, Math.min(600, core + (PAD * 2)));
        return { startSec: paddedStart, spanSec };
    }

    function eventPlaybackStartSec(ev) {
        return eventRecordingClipRange(ev).startSec;
    }

    function eventPlaybackSpanSec(ev) {
        return eventRecordingClipRange(ev).spanSec;
    }

    // Build the URL for the "Download matched section" link. The match
    // starts at `recordingOffsetSeconds` and spans through the last merged
    // detection when consolidated. We pad by 5s on each side so the clip
    // always includes lead-in and tail-out around the matched portion.
    function clipUrl(ev) {
        if (!ev || !ev.recording) return '#';
        const start = eventPlaybackStartSec(ev);
        const dur = eventPlaybackSpanSec(ev);
        const r = ev.recording;
        if (r.offline && r.logId) {
            return `/api/offline/clip/${r.logId}?start=${start.toFixed(2)}&duration=${dur.toFixed(2)}`;
        }
        const sid = encodeURIComponent(r.streamId || '');
        const dt  = encodeURIComponent(r.date || '');
        const fn  = encodeURIComponent(r.fileName || '');
        return `/api/recording/clip/${sid}/${dt}/${fn}?start=${start.toFixed(2)}&duration=${dur.toFixed(2)}`;
    }

    function isVideoFile(fileName) {
        if (!fileName) return false;
        const ext = String(fileName).toLowerCase().split('.').pop();
        return /^(mkv|ts|mp4|avi|mov|webm|flv|m4v|3gp)$/.test(ext);
    }

    // Browser can range-seek these directly; MKV/TS need server ffmpeg preview.
    function recordingUsesDirectFile(fileName) {
        if (!fileName) return false;
        const ext = String(fileName).toLowerCase().split('.').pop();
        return /^(mp3|wav|aac|m4a|ogg|flac|mp4|m4v)$/.test(ext);
    }

    function recordingPlayUrl(recording, offsetSec, durationSec) {
        if (!recording) return '';
        if (recording.offline && recording.logId) {
            const off = Math.max(0, offsetSec || 0);
            const dur = Math.max(15, Math.min(600, durationSec || 120));
            if (recordingUsesDirectFile(recording.fileName)) {
                return recording.url || `/api/offline/file/${recording.logId}`;
            }
            return `/api/offline/play/${recording.logId}?start=${off.toFixed(2)}&duration=${dur.toFixed(2)}`;
        }
        const sid = encodeURIComponent(recording.streamId || '');
        const dt = encodeURIComponent(recording.date || '');
        const fn = encodeURIComponent(recording.fileName || '');
        const off = Math.max(0, offsetSec || 0);
        if (recordingUsesDirectFile(recording.fileName)) {
            const base = recording.url || `/api/recording/file/${sid}/${dt}/${fn}`;
            return base;
        }
        const dur = Math.max(15, Math.min(600, durationSec || 120));
        return `/api/recording/play/${sid}/${dt}/${fn}?start=${off.toFixed(2)}&duration=${dur.toFixed(2)}`;
    }

    function setMapPlayerLoading(loading, label) {
        const ph = $('#map-player-placeholder');
        if (!ph) return;
        if (loading) {
            ph.style.display = 'flex';
            ph.textContent = label || 'Preparing playback…';
        } else {
            ph.style.display = 'none';
        }
    }

    function startMapMedia(media, src, seekSec) {
        if (!media || !src) return;
        const resolveSrc = (u) => { try { return new URL(u, location.href).href; } catch { return u; } };
        const sameSrc = media.src && resolveSrc(media.src) === resolveSrc(src);
        if (!sameSrc) {
            try { media.pause(); } catch { /* ignore */ }
            media.src = src;
            media.load();
        }
        const offset = seekSec || 0;
        const playNow = () => {
            if (offset > 0) {
                try { media.currentTime = offset; } catch { /* play URL already starts at offset */ }
            }
            media.play().catch(() => { });
        };
        if (media.readyState >= 2) playNow();
        else media.addEventListener('canplay', playNow, { once: true });
    }

    function selectMappingEvent(ev, dotEl) {
        if (!ev) return;
        mapCurrentEvent = ev;
        ensurePlayDetails(ev);
        abxUpdateOverlay(ev);
        document.querySelectorAll('.map-event.is-selected').forEach(el => el.classList.remove('is-selected'));
        const row = document.querySelector(`#map-events .map-event[data-id="${ev.id || ''}"]`);
        if (row) row.classList.add('is-selected');
        const track = document.getElementById('map-track');
        if (track) {
            track.querySelectorAll('.map-dot.active').forEach(d => d.classList.remove('active'));
            const target = dotEl || track.querySelector(`.map-dot[data-idx="${ev._idx ?? ''}"]`);
            if (target) target.classList.add('active');
        }
    }

    function playMappingEvent(ev, dotEl) {
        selectMappingEvent(ev, dotEl);
        if (!ev || !ev.recording) {
            showToast('No recording for playback — you can still set play kind below.', 'warn');
            return;
        }
        abxLastOverlayIdx = ev._idx ?? -1;
        const video = $('#map-video');
        const audio = $('#map-audio');
        const ph = $('#map-player-placeholder');
        const isVideo = isVideoFile(ev.recording.fileName);
        const startSec = eventPlaybackStartSec(ev);
        const spanSec = eventPlaybackSpanSec(ev);
        const directFile = recordingUsesDirectFile(ev.recording.fileName);
        const playUrl = recordingPlayUrl(ev.recording, startSec, spanSec);
        mapPlaybackTimeBase = directFile ? 0 : startSec;
        setMapPlayerLoading(true, directFile ? 'Loading recording…' : 'Preparing playback…');
        ph.style.display = 'flex';
        const player = document.getElementById('map-player');
        const audioArt = document.getElementById('map-audio-art');
        const media = isVideo ? video : audio;
        const seekAt = directFile ? startSec : 0;
        const onReady = () => setMapPlayerLoading(false);
        const onFail = () => {
            setMapPlayerLoading(true, 'Playback failed — try Clip download or check ffmpeg.');
            showToast('Could not start playback (ffmpeg may still be starting)', 'warn');
        };
        if (isVideo) {
            try { audio.pause(); } catch { /* ignore */ }
            video.style.display = 'block';
            media.addEventListener('canplay', onReady, { once: true });
            media.addEventListener('error', onFail, { once: true });
            startMapMedia(video, playUrl, seekAt);
            if (audioArt) audioArt.hidden = true;
            if (player) { player.dataset.kind = 'video'; player.classList.remove('kind-audio'); player.classList.add('kind-video'); }
            abxBindMedia(video);
        } else {
            try { video.pause(); } catch { /* ignore */ }
            video.style.display = 'none';
            media.addEventListener('canplay', onReady, { once: true });
            media.addEventListener('error', onFail, { once: true });
            startMapMedia(audio, playUrl, seekAt);
            if (audioArt) audioArt.hidden = false;
            if (player) { player.dataset.kind = 'audio'; player.classList.remove('kind-video'); player.classList.add('kind-audio'); }
            abxBindMedia(audio);
        }
        $('#map-now-playing').textContent = (ev.title || ev.type) + ' · ' + ev.streamName + ' · @' + (ev.recordingOffsetSeconds ? ev.recordingOffsetSeconds.toFixed(0) + 's' : '0s');
        // "Download clip" button — placed inside the player's control bar
        // (next to the play / stop buttons) so it sits right beside the player
        // instead of up in the card header. Enabled for matches, disabled
        // (visible but inert) for no-match, hidden for errors / no recording.
        (function ensureClipLink() {
            const controls = document.getElementById('abx-controls');
            if (!controls) return;
            let a = document.getElementById('map-clip-link');
            if (!a) {
                a = document.createElement('a');
                a.id = 'map-clip-link';
                // Style as a player control button so it blends with the bar.
                a.className = 'abx-btn abx-clip';
                a.setAttribute('download', '');
                a.title = 'Download matched clip (MP4 video / MP3 audio)';
                a.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">'
                            + '<path fill="currentColor" d="M12 3v10.59l3.3-3.3 1.4 1.42L12 16.41l-4.7-4.7 1.4-1.42L12 13.59V3h0zM5 19h14v2H5z"/>'
                            + '</svg>';
            }
            // Make sure the button lives inside the player control bar
            // (next to the play/stop buttons). If a previous render parked
            // it elsewhere — e.g. up in the card head from older code — move
            // it now.
            if (a.parentNode !== controls) {
                const stopBtn = document.getElementById('abx-stop');
                if (stopBtn && stopBtn.parentNode === controls) {
                    stopBtn.insertAdjacentElement('afterend', a);
                } else {
                    controls.appendChild(a);
                }
                // Normalize styling now that it's a player control, not a
                // header chip — drop any legacy classes/inline styles.
                a.classList.remove('primary', 'btn-tiny');
                a.classList.add('abx-btn', 'abx-clip');
                a.style.marginLeft = '';
            }
            // Force icon-only content (strip any legacy text label that an
            // older cached render may have left behind, e.g. "Clip download").
            const oldLabel = a.querySelector('.abx-clip-label');
            if (oldLabel) oldLabel.remove();
            if (!a.querySelector('svg')) {
                a.textContent = '';
                a.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">'
                            + '<path fill="currentColor" d="M12 3v10.59l3.3-3.3 1.4 1.42L12 16.41l-4.7-4.7 1.4-1.42L12 13.59V3h0zM5 19h14v2H5z"/>'
                            + '</svg>';
            }
            // Reset any prior disabled styling/attrs before re-deciding state.
            a.classList.remove('is-disabled');
            a.removeAttribute('aria-disabled');
            a.style.pointerEvents = '';
            a.style.opacity = '';

            if (ev.recording && ev.type === 'match') {
                a.href = clipUrl(ev);
                a.title = 'Download matched clip (MP4 video / MP3 audio)';
                a.style.display = '';
            } else if (ev.type === 'nomatch') {
                // Render as a disabled button-style link.
                a.removeAttribute('href');
                a.title = 'Clip download is available only for matched events';
                a.style.display = '';
                a.classList.add('is-disabled');
                a.setAttribute('aria-disabled', 'true');
                a.style.pointerEvents = 'none';
                a.style.opacity = '0.45';
            } else {
                a.removeAttribute('href');
                a.style.display = 'none';
            }
        })();
        document.querySelectorAll('.map-dot.active').forEach(d => d.classList.remove('active'));
        const dots = document.querySelectorAll('.map-dot');
        for (const d of dots) {
            if (d.title.startsWith(fmtTime(ev.timeUtc))) { d.classList.add('active'); break; }
        }
    }

    // ── Play details (analyst classification) ──────────────────────────
    // Free-form classification for a match: Full Song, Trailer, Promo,
    // Jingle, Snippet, Cover, Live Performance, or a custom string.
    //
    // Auto-suggest rule: if the match's coverage / query-length is over
    // 60 seconds we pre-fill "Full Song" — broadcast spots are typically
    // 10-30 s, so anything past a minute is almost certainly a full play.
    // The analyst is still free to override; the change is only persisted
    // once they hit "Save".
    //
    // The preset list is user-editable now. Defaults seed a fresh browser;
    // anything the analyst adds via the ✎ Manage list popover is persisted
    // to localStorage("playKindOptions") and survives reloads.
    const PLAY_KIND_DEFAULTS = [
        'Full Song', 'Trailer', 'Promo', 'Jingle',
        'Snippet', 'Cover', 'Live Performance', 'Background'
    ];
    const PLAY_KIND_LS_KEY = 'playKindOptions';
    let PLAY_KIND_PRESETS = readPlayKindOptions();

    function readPlayKindOptions() {
        try {
            const raw = localStorage.getItem(PLAY_KIND_LS_KEY);
            if (!raw) return PLAY_KIND_DEFAULTS.slice();
            const parsed = JSON.parse(raw);
            if (!Array.isArray(parsed)) return PLAY_KIND_DEFAULTS.slice();
            // Drop blanks / non-strings; preserve user order. Cap at 50
            // entries so a runaway script can't bloat the dropdown.
            const clean = parsed
                .filter(s => typeof s === 'string')
                .map(s => s.trim())
                .filter(Boolean)
                .slice(0, 50);
            return clean.length ? clean : PLAY_KIND_DEFAULTS.slice();
        } catch {
            return PLAY_KIND_DEFAULTS.slice();
        }
    }
    function writePlayKindOptions(list) {
        try { localStorage.setItem(PLAY_KIND_LS_KEY, JSON.stringify(list)); }
        catch { /* private-mode or full disk — ignore, in-memory list still works */ }
    }
    /// Rebuild the <select id="pd-kind"> from the current PLAY_KIND_PRESETS
    /// list, preserving the currently-selected value when possible.
    function rebuildPlayKindSelect() {
        const sel = document.getElementById('pd-kind');
        if (!sel) return;
        const prev = sel.value;
        // Preserve a value the analyst typed into the custom input so a
        // mid-edit dropdown rebuild doesn't blank their text.
        sel.innerHTML = '';
        const optBlank = document.createElement('option');
        optBlank.value = ''; optBlank.textContent = '— Unclassified —';
        sel.appendChild(optBlank);
        for (const name of PLAY_KIND_PRESETS) {
            const o = document.createElement('option');
            o.value = name; o.textContent = name;
            sel.appendChild(o);
        }
        const optOther = document.createElement('option');
        optOther.value = '__other__'; optOther.textContent = 'Other (specify…)';
        sel.appendChild(optOther);
        // Restore previous selection if still valid; otherwise fall back
        // to blank.
        const existed = Array.from(sel.options).some(o => o.value === prev);
        sel.value = existed ? prev : '';
    }

    /// Pick a sensible default kind for an event. Returns '' (unclassified)
    /// for non-match events; "Full Song" when the match runs longer than
    /// 60 s; null otherwise so the analyst is prompted explicitly.
    function suggestPlayKind(ev) {
        if (!ev || ev.type !== 'match') return '';
        const dur = Number(ev.queryLengthSeconds || ev.coverageSeconds || 0);
        if (dur >= 60) return 'Full Song';
        return null;
    }

    function mapEventEpgProgram(ev) {
        if (!ev) return '';
        const ep = ev.epg || {};
        return (ev.epgShowName || ep.programName || ep.showName || '').trim();
    }

    function formatClassificationTime(isoUtc) {
        if (!isoUtc) return '';
        try {
            const d = new Date(isoUtc);
            if (Number.isNaN(d.getTime())) return String(isoUtc);
            return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
        } catch {
            return String(isoUtc);
        }
    }

    function renderPlayDetailsAttribution(ev) {
        const el = document.getElementById('pd-attribution');
        if (!el) return;
        if (!ev) {
            el.hidden = true;
            el.textContent = '';
            return;
        }
        const lines = [];
        const pkBy = (ev.playKindUpdatedBy || '').trim();
        const pkAt = ev.playKindUpdatedAtUtc;
        if (pkBy || pkAt) {
            lines.push(`Play kind classified by ${pkBy || '—'}`
                + (pkAt ? ` · ${formatClassificationTime(pkAt)}` : ''));
        }
        const epgBy = (ev.epgUpdatedBy || '').trim();
        const epgAt = ev.epgUpdatedAtUtc;
        if (epgBy || epgAt) {
            lines.push(`EPG set by ${epgBy || '—'}`
                + (epgAt ? ` · ${formatClassificationTime(epgAt)}` : ''));
        }
        if (!lines.length) {
            el.hidden = true;
            el.textContent = '';
            return;
        }
        el.hidden = false;
        el.textContent = lines.join(' ');
    }

    function ensurePlayDetails(ev) {
        applyPermissionUi();
        const panel = document.getElementById('play-details');
        if (!panel) return;
        const sel = document.getElementById('pd-kind');
        const custom = document.getElementById('pd-custom');
        const epgIn = document.getElementById('pd-epg');
        const save = document.getElementById('pd-save');
        const status = document.getElementById('pd-status');

        const dbId = playKindDbId(ev);
        // Hide for events with no DB row to persist against.
        if (!dbId) {
            panel.hidden = true;
            renderPlayDetailsAttribution(null);
            return;
        }
        panel.hidden = false;

        // Reset status text/colour each time the panel re-binds.
        if (status) { status.textContent = ''; status.classList.remove('is-ok', 'is-err'); }

        // Reflect existing saved value first, otherwise apply the auto-suggest.
        const saved = (ev.playKind || '').trim();
        const suggested = suggestPlayKind(ev);
        const initial = saved || suggested || '';

        // Push the value into the select / custom input.
        const isPreset = !initial || PLAY_KIND_PRESETS.includes(initial);
        const canEdit = canEditPlayKind();
        sel.disabled = !canEdit;
        sel.value = isPreset ? initial : '__other__';
        if (sel.value !== '__other__') {
            // Sanity: if the option doesn't exist (e.g. legacy free-form
            // value matches no preset and isn't __other__), fall back to
            // custom mode so the value stays visible.
            const exists = Array.from(sel.options).some(o => o.value === sel.value);
            if (!exists) sel.value = '__other__';
        }
        if (sel.value === '__other__') {
            custom.hidden = false;
            custom.disabled = !canEdit;
            custom.value = initial;
        } else {
            custom.hidden = true;
            custom.value = '';
        }
        save.disabled = !canEdit;
        const savedEpg = mapEventEpgProgram(ev);
        if (epgIn) {
            epgIn.disabled = !canEdit;
            epgIn.value = savedEpg;
        }
        panel.dataset.evId = String(dbId);
        panel.dataset.saved = saved;
        panel.dataset.savedEpg = savedEpg;
        renderPlayDetailsAttribution(ev);
    }

    /// Wire the select / custom input / save button once at startup.
    /// Subsequent event selections only update the values inside the
    /// already-bound controls.
    function bindPlayDetailsControls() {
        const panel = document.getElementById('play-details');
        if (!panel || panel.dataset.bound) return;
        panel.dataset.bound = '1';
        const sel = document.getElementById('pd-kind');
        const custom = document.getElementById('pd-custom');
        const epgIn = document.getElementById('pd-epg');
        const save = document.getElementById('pd-save');
        const status = document.getElementById('pd-status');

        if (!sel || !custom || !save) return;

        // Repaint the dropdown from the user's stored option list (if any)
        // BEFORE wiring listeners — so the very first change event already
        // sees the personalised list.
        rebuildPlayKindSelect();

        sel.addEventListener('change', () => {
            if (sel.value === '__other__') {
                custom.hidden = false;
                custom.disabled = !canEditPlayKind();
                custom.focus();
            } else {
                custom.hidden = true;
                custom.value = '';
            }
            if (status) { status.textContent = ''; status.classList.remove('is-ok', 'is-err'); }
        });

        custom.addEventListener('input', () => {
            if (status) { status.textContent = ''; status.classList.remove('is-ok', 'is-err'); }
        });
        epgIn?.addEventListener('input', () => {
            if (status) { status.textContent = ''; status.classList.remove('is-ok', 'is-err'); }
        });

        save.addEventListener('click', async () => {
            const panel = document.getElementById('play-details');
            const evId = panel?.dataset?.evId;
            if (!evId) return;
            if (!canEditPlayKind()) {
                if (status) {
                    status.textContent = 'You do not have permission to save play kind.';
                    status.classList.add('is-err');
                }
                return;
            }
            const kind = (sel.value === '__other__')
                ? (custom.value || '').trim()
                : sel.value;
            const epgVal = (epgIn?.value || '').trim();
            save.disabled = true;
            if (epgIn) epgIn.disabled = true;
            if (status) { status.textContent = 'Saving…'; status.classList.remove('is-ok', 'is-err'); }
            try {
                const playKindBase = mapSourceMode === 'offline' ? '/api/offline/logs/' : '/api/monitor/logs/';
                const logIds = collectPlayDetailsLogIds(evId);
                if (!logIds.length) throw new Error('No database row for this event');
                let out = null;
                for (const logId of logIds) {
                    const resp = await fetch(`${playKindBase}${encodeURIComponent(logId)}/playkind`, {
                        method: 'POST',
                        credentials: 'same-origin',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            playKind: kind || null,
                            epgProgram: epgVal || null,
                            classifiedBy: playKindClassifiedByLabel() || null
                        })
                    });
                    if (!resp.ok) {
                        let msg = 'HTTP ' + resp.status;
                        try {
                            const errBody = await resp.json();
                            if (errBody && errBody.error) msg = errBody.error;
                        } catch { /* ignore */ }
                        throw new Error(msg);
                    }
                    out = await resp.json();
                }
                const newVal = (out.playKind || '').trim();
                const newEpg = (out.epgShowName || '').trim();

                panel.dataset.saved = newVal;
                panel.dataset.savedEpg = newEpg;
                const attribution = {
                    playKindUpdatedBy: out.playKindUpdatedBy,
                    playKindUpdatedAtUtc: out.playKindUpdatedAtUtc,
                    epgUpdatedBy: out.epgUpdatedBy,
                    epgUpdatedAtUtc: out.epgUpdatedAtUtc,
                };
                for (const logId of logIds) {
                    applyEventDetailsToCache(logId, newVal, newEpg, attribution);
                    if (mapSourceMode === 'offline') {
                        applyPlayKindToIdfRows(logId, newVal);
                        applyEpgToIdfRows(logId, newEpg);
                    }
                }
                if (typeof mapCurrentEvent !== 'undefined' && mapCurrentEvent
                    && Number(mapCurrentEvent.id) === Number(evId)) {
                    Object.assign(mapCurrentEvent, attribution);
                    renderPlayDetailsAttribution(mapCurrentEvent);
                }

                // If the analyst typed a brand-new value via "Other…", auto-
                // add it to the dropdown list so they don't have to retype
                // it next time they classify a similar match.
                if (newVal && !PLAY_KIND_PRESETS.includes(newVal)) {
                    PLAY_KIND_PRESETS = PLAY_KIND_PRESETS.concat([newVal]);
                    writePlayKindOptions(PLAY_KIND_PRESETS);
                    rebuildPlayKindSelect();
                    sel.value = newVal;
                    custom.hidden = true; custom.value = '';
                    renderPlayKindManagerList();
                }

                // Re-render the events list so the badge appears on the row.
                if (typeof mapEventsList !== 'undefined' && mapEventsList) {
                    const card = document.querySelector(`#map-events .map-event[data-id="${evId}"]`);
                    if (card) {
                        renderEventBadge(card, newVal);
                        renderEventEpgBadge(card, newEpg);
                    }
                }

                if (status) {
                    status.textContent = (newVal || newEpg) ? '✓ Saved' : '✓ Cleared';
                    status.classList.add('is-ok');
                }
            } catch (e) {
                const detail = (e && e.message) ? String(e.message) : '';
                if (status) {
                    status.textContent = detail ? ('✗ ' + detail) : '✗ Save failed';
                    status.classList.add('is-err');
                }
                console.error('Play details save failed', e);
            } finally {
                const canEdit = canEditPlayKind();
                save.disabled = !canEdit;
                if (epgIn) epgIn.disabled = !canEdit;
                if (sel) sel.disabled = !canEdit;
            }
        });

        // ── Manage-list popover ────────────────────────────────────────
        const manageBtn = document.getElementById('pd-manage');
        const manager = document.getElementById('pd-manager');
        const closeBtn = document.getElementById('pd-manager-close');
        const newOpt = document.getElementById('pd-new-option');
        const addBtn = document.getElementById('pd-add-option');
        const resetBtn = document.getElementById('pd-reset-options');

        if (manageBtn && manager) {
            manageBtn.addEventListener('click', () => {
                manager.hidden = !manager.hidden;
                if (!manager.hidden) {
                    renderPlayKindManagerList();
                    if (newOpt) newOpt.focus();
                }
            });
        }
        if (closeBtn) closeBtn.addEventListener('click', () => { manager.hidden = true; });
        if (addBtn && newOpt) {
            const doAdd = () => {
                const val = (newOpt.value || '').trim();
                if (!val) return;
                if (PLAY_KIND_PRESETS.includes(val)) {
                    newOpt.value = '';
                    showToast(`"${val}" already in the list`, 'err');
                    return;
                }
                PLAY_KIND_PRESETS = PLAY_KIND_PRESETS.concat([val]);
                writePlayKindOptions(PLAY_KIND_PRESETS);
                rebuildPlayKindSelect();
                renderPlayKindManagerList();
                newOpt.value = '';
            };
            addBtn.addEventListener('click', doAdd);
            newOpt.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') { e.preventDefault(); doAdd(); }
            });
        }
        if (resetBtn) {
            resetBtn.addEventListener('click', () => {
                if (!confirm('Reset the play-details dropdown to the built-in defaults? Your custom additions will be removed.')) return;
                PLAY_KIND_PRESETS = PLAY_KIND_DEFAULTS.slice();
                writePlayKindOptions(PLAY_KIND_PRESETS);
                rebuildPlayKindSelect();
                renderPlayKindManagerList();
            });
        }
    }

    /// Render the editable option list inside the Manage popover. Each
    /// row has a × button that removes that option from the list AND
    /// rebuilds the dropdown so the change is visible immediately.
    function renderPlayKindManagerList() {
        const list = document.getElementById('pd-option-list');
        if (!list) return;
        list.innerHTML = '';
        if (PLAY_KIND_PRESETS.length === 0) {
            const li = document.createElement('li');
            li.className = 'pd-opt-empty';
            li.textContent = '— no options — add one below —';
            list.appendChild(li);
            return;
        }
        for (const name of PLAY_KIND_PRESETS) {
            const li = document.createElement('li');
            const span = document.createElement('span');
            span.className = 'pd-opt-name';
            span.textContent = name;
            const rm = document.createElement('button');
            rm.type = 'button';
            rm.className = 'pd-opt-remove';
            rm.title = `Remove "${name}"`;
            rm.textContent = '× Remove';
            rm.addEventListener('click', () => {
                PLAY_KIND_PRESETS = PLAY_KIND_PRESETS.filter(x => x !== name);
                writePlayKindOptions(PLAY_KIND_PRESETS);
                rebuildPlayKindSelect();
                renderPlayKindManagerList();
            });
            li.appendChild(span);
            li.appendChild(rm);
            list.appendChild(li);
        }
    }

    /** Log row ids to persist play details (header + consolidated merges). */
    function collectPlayDetailsLogIds(evId) {
        const id = Number(evId);
        const ids = new Set();
        if (Number.isFinite(id) && id > 0) ids.add(id);
        try {
            if (Array.isArray(mapEventsList)) {
                const row = mapEventsList.find(e => e && playKindDbId(e) === id);
                if (row && Array.isArray(row.merged)) {
                    for (const m of row.merged) {
                        const mid = Number(m?.id);
                        if (Number.isFinite(mid) && mid > 0) ids.add(mid);
                    }
                }
            }
        } catch { /* ignore */ }
        return [...ids];
    }

    /// Update any locally-cached copy of the event so subsequent renders
    /// (the events list, the saved-state marker) reflect the new value.
    function applyEventDetailsToCache(evId, playKind, epgProgram, attribution) {
        const att = attribution && typeof attribution === 'object' ? attribution : {};
        try {
            if (typeof mapEventsList !== 'undefined' && Array.isArray(mapEventsList)) {
                for (const e of mapEventsList) {
                    if (e && (playKindDbId(e) === Number(evId) || collectPlayDetailsLogIds(playKindDbId(e)).includes(Number(evId)))) {
                        e.playKind = playKind || null;
                        e.epgShowName = epgProgram || null;
                        if (att.playKindUpdatedBy !== undefined) e.playKindUpdatedBy = att.playKindUpdatedBy;
                        if (att.playKindUpdatedAtUtc !== undefined) e.playKindUpdatedAtUtc = att.playKindUpdatedAtUtc;
                        if (att.epgUpdatedBy !== undefined) e.epgUpdatedBy = att.epgUpdatedBy;
                        if (att.epgUpdatedAtUtc !== undefined) e.epgUpdatedAtUtc = att.epgUpdatedAtUtc;
                        if (epgProgram) {
                            e.epg = Object.assign({}, e.epg || {}, {
                                programName: epgProgram,
                                showName: epgProgram
                            });
                        } else if (e.epg) {
                            e.epg = Object.assign({}, e.epg, { programName: '', showName: '' });
                        }
                        if (Array.isArray(e.merged)) {
                            for (const m of e.merged) {
                                if (m) {
                                    m.playKind = playKind || null;
                                    m.epgShowName = epgProgram || null;
                                }
                            }
                        }
                    }
                }
            }
            if (typeof mapCurrentEvent !== 'undefined' && mapCurrentEvent &&
                Number(mapCurrentEvent.id) === Number(evId)) {
                mapCurrentEvent.playKind = playKind || null;
                mapCurrentEvent.epgShowName = epgProgram || null;
                if (att.playKindUpdatedBy !== undefined) mapCurrentEvent.playKindUpdatedBy = att.playKindUpdatedBy;
                if (att.playKindUpdatedAtUtc !== undefined) mapCurrentEvent.playKindUpdatedAtUtc = att.playKindUpdatedAtUtc;
                if (att.epgUpdatedBy !== undefined) mapCurrentEvent.epgUpdatedBy = att.epgUpdatedBy;
                if (att.epgUpdatedAtUtc !== undefined) mapCurrentEvent.epgUpdatedAtUtc = att.epgUpdatedAtUtc;
                if (epgProgram) {
                    mapCurrentEvent.epg = Object.assign({}, mapCurrentEvent.epg || {}, {
                        programName: epgProgram,
                        showName: epgProgram
                    });
                }
            }
        } catch { /* best effort */ }
    }

    function applyPlayKindToCache(evId, value) {
        let epg = '';
        try {
            if (Array.isArray(mapEventsList)) {
                const row = mapEventsList.find(e => e && Number(e.id) === Number(evId));
                if (row) epg = mapEventEpgProgram(row);
            }
        } catch { /* ignore */ }
        applyEventDetailsToCache(evId, value, epg);
    }

    function applyEpgToIdfRows(logId, value) {
        if (!logId || !idfRows.length) return;
        let touched = false;
        for (const r of idfRows) {
            if (Number(r.logId) === Number(logId)) {
                r.epgShowName = value || null;
                touched = true;
            }
        }
        if (touched) refreshIdfReport();
    }

    /// Render or remove the "play kind" badge on an event row.
    function renderEventBadge(rowEl, kind) {
        if (!rowEl) return;
        let badge = rowEl.querySelector('.me-playkind');
        if (kind) {
            if (!badge) {
                badge = document.createElement('span');
                badge.className = 'me-playkind muted-tag';
                const meta = rowEl.querySelector('.me-meta');
                if (meta) meta.appendChild(badge);
            }
            badge.textContent = '🏷 ' + kind;
        } else if (badge) {
            badge.remove();
        }
    }

    function renderEventEpgBadge(rowEl, program) {
        if (!rowEl) return;
        let badge = rowEl.querySelector('.me-epg');
        if (program) {
            if (!badge) {
                badge = document.createElement('span');
                badge.className = 'me-epg muted-tag';
                const meta = rowEl.querySelector('.me-meta');
                if (meta) meta.appendChild(badge);
            }
            badge.textContent = '📺 ' + program;
        } else if (badge) {
            badge.remove();
        }
    }

    // Bind the controls as soon as the script runs — they live in the
    // panel above the player and are present from initial HTML render.
    bindPlayDetailsControls();

    // ── Custom branded player (AUDIOBoxx) ──────────────────────────────
    let abxMedia = null;
    let abxStatusTimer = null;
    let abxLastOverlayIdx = -1;
    // When playback uses /api/recording/play excerpts, media.currentTime is
    // relative to the clip; add this base to align with recording offsets.
    let mapPlaybackTimeBase = 0;

    function abxFindEventAt(currentTime) {
        if (!mapCurrentEvent || !mapCurrentEvent.recording) return null;
        const fn = mapCurrentEvent.recording.fileName;
        const t = currentTime + (mapPlaybackTimeBase || 0);
        let bestIdx = -1; let bestOff = -1;
        for (let i = 0; i < mapEventsList.length; i++) {
            const e = mapEventsList[i];
            if (!e.recording || e.recording.fileName !== fn) continue;
            const off = e.recordingOffsetSeconds || 0;
            if (off <= t + 0.25 && off >= bestOff) { bestOff = off; bestIdx = i; }
        }
        if (bestIdx < 0) return null;
        const ev = mapEventsList[bestIdx];
        ev._idx = bestIdx;
        return ev;
    }

    function abxUpdateOverlay(ev, showBanner) {
        if (showBanner === undefined) showBanner = true;
        const titleEl = document.getElementById('abx-title');
        const subEl   = document.getElementById('abx-sub');
        const card    = document.getElementById('abx-match-card');
        const kindEl  = document.getElementById('abx-mc-kind');
        const confEl  = document.getElementById('abx-mc-conf');
        const tEl     = document.getElementById('abx-mc-title');
        const aEl     = document.getElementById('abx-mc-artist');
        const metaEl  = document.getElementById('abx-mc-meta');
        const banner  = document.getElementById('abx-status');
        const bKind   = document.getElementById('abx-status-kind');
        const bTitle  = document.getElementById('abx-status-title');
        const bArtist = document.getElementById('abx-status-artist');
        if (titleEl) titleEl.textContent = ev.streamName || 'AUDIOBoxx';
        if (subEl)   subEl.textContent   = fmtTime(ev.timeUtc);
        if (card && kindEl) {
            card.hidden = false;
            card.classList.remove('is-match','is-nomatch','is-error');
            let kindLabel = 'No match';
            if (ev.type === 'match')      { card.classList.add('is-match');   kindLabel = 'Match'; }
            else if (ev.type === 'error') { card.classList.add('is-error');   kindLabel = 'Error'; }
            else                          { card.classList.add('is-nomatch'); kindLabel = 'No match'; }
            kindEl.textContent = kindLabel;
            if (confEl) confEl.textContent = (ev.confidence != null) ? (ev.confidence * 100).toFixed(1) + '%' : '';
            if (tEl)    tEl.textContent    = ev.title  || (ev.type === 'match' ? '(untitled)' : '—');
            if (aEl)    aEl.textContent    = ev.artist || '';
            if (metaEl) {
                const offs = ev.recordingOffsetSeconds != null ? ('@' + Math.round(ev.recordingOffsetSeconds) + 's') : '';
                metaEl.textContent = [ev.streamName, fmtTime(ev.timeUtc), offs].filter(Boolean).join(' · ');
            }
        }
        if (banner && bKind) {
            banner.classList.remove('is-match','is-nomatch','is-error');
            if (ev.type === 'match')      { banner.classList.add('is-match');   bKind.textContent = 'MATCH FOUND'; }
            else if (ev.type === 'error') { banner.classList.add('is-error');   bKind.textContent = 'ERROR'; }
            else                          { banner.classList.add('is-nomatch'); bKind.textContent = 'NO MATCH'; }
            if (bTitle)  bTitle.textContent  = ev.title  || '';
            if (bArtist) bArtist.textContent = ev.artist || '';
            if (showBanner) {
                banner.hidden = false;
                banner.style.animation = 'none'; void banner.offsetWidth; banner.style.animation = '';
                if (abxStatusTimer) clearTimeout(abxStatusTimer);
                abxStatusTimer = setTimeout(() => { banner.hidden = true; }, 2400);
            }
        }
    }

    function abxFmt(sec) {
        if (!isFinite(sec) || sec < 0) sec = 0;
        const m = Math.floor(sec / 60); const s = Math.floor(sec % 60);
        return m + ':' + String(s).padStart(2, '0');
    }

    function abxBindMedia(media) {
        abxMedia = media;
        const player = document.getElementById('map-player');
        const onPlay  = () => { player.classList.add('is-playing'); player.classList.remove('is-paused'); };
        const onPause = () => { player.classList.remove('is-playing'); player.classList.add('is-paused'); };
        const onTime = () => {
            const fill = document.getElementById('abx-seek-fill');
            const knob = document.getElementById('abx-seek-knob');
            const cur  = document.getElementById('abx-time-cur');
            const tot  = document.getElementById('abx-time-tot');
            if (fill) {
                const d = media.duration || 0;
                const pct = d > 0 ? (media.currentTime / d) * 100 : 0;
                fill.style.width = pct + '%';
                if (knob) knob.style.left = pct + '%';
                if (cur) cur.textContent = abxFmt(media.currentTime);
                if (tot) tot.textContent = abxFmt(d);
            }
            // Live update of match card / banner based on playback position.
            const ev = abxFindEventAt(media.currentTime);
            if (ev && ev._idx !== abxLastOverlayIdx) {
                abxLastOverlayIdx = ev._idx;
                abxUpdateOverlay(ev, /*showBanner*/ true);
                // Also highlight the active dot on the timeline.
                const track = document.getElementById('map-track');
                if (track) {
                    track.querySelectorAll('.map-dot.active').forEach(d => d.classList.remove('active'));
                    const dot = track.querySelector(`.map-dot[data-idx="${ev._idx}"]`);
                    if (dot) dot.classList.add('active');
                }
            }
        };
        const onVol = () => {
            const vf = document.getElementById('abx-vol-fill');
            if (vf) vf.style.width = (media.muted ? 0 : media.volume * 100) + '%';
            player.classList.toggle('is-muted', media.muted || media.volume === 0);
        };
        if (media._abxBound) { onTime(); onVol(); return; }
        media._abxBound = true;
        media.addEventListener('play', onPlay);
        media.addEventListener('playing', onPlay);
        media.addEventListener('pause', onPause);
        media.addEventListener('ended', onPause);
        media.addEventListener('timeupdate', onTime);
        media.addEventListener('loadedmetadata', onTime);
        media.addEventListener('volumechange', onVol);
        onVol();
    }

    (function abxInitControls() {
        const player = document.getElementById('map-player');
        if (!player) return;
        const playBtn  = document.getElementById('abx-play');
        const stopBtn  = document.getElementById('abx-stop');
        const muteBtn  = document.getElementById('abx-mute');
        const fsBtn    = document.getElementById('abx-fs');
        const seek     = document.getElementById('abx-seek');
        const vol      = player.querySelector('.abx-vol');
        const clickLay = document.getElementById('abx-click');
        function togglePlay() {
            if (!abxMedia) return;
            if (abxMedia.paused) abxMedia.play().catch(() => {});
            else abxMedia.pause();
        }
        function stopPlayback() {
            if (abxMedia) { try { abxMedia.pause(); } catch {} try { abxMedia.currentTime = 0; } catch {} }
            abxLastOverlayIdx = -1;
            const video = document.getElementById('map-video');
            const audio = document.getElementById('map-audio');
            const ph    = document.getElementById('map-player-placeholder');
            const art   = document.getElementById('map-audio-art');
            const card  = document.getElementById('abx-match-card');
            const banner = document.getElementById('abx-status');
            if (video) { try { video.pause(); } catch {} video.removeAttribute('src'); video.load(); video.style.display = 'none'; }
            if (audio) { try { audio.pause(); } catch {} audio.removeAttribute('src'); audio.load(); }
            if (art) art.hidden = true;
            if (ph) ph.style.display = '';
            if (card) card.hidden = true;
            if (banner) banner.hidden = true;
            player.dataset.kind = 'idle';
            player.classList.remove('is-playing','kind-video','kind-audio');
            player.classList.add('is-paused');
            const fill = document.getElementById('abx-seek-fill');
            const knob = document.getElementById('abx-seek-knob');
            const cur  = document.getElementById('abx-time-cur');
            const tot  = document.getElementById('abx-time-tot');
            if (fill) fill.style.width = '0%';
            if (knob) knob.style.left  = '0%';
            if (cur) cur.textContent = '0:00';
            if (tot) tot.textContent = '0:00';
            const npLbl = document.getElementById('map-now-playing');
            if (npLbl) npLbl.textContent = '';
        }
        if (playBtn) playBtn.addEventListener('click', togglePlay);
        if (stopBtn) stopBtn.addEventListener('click', stopPlayback);
        if (clickLay) clickLay.addEventListener('click', togglePlay);
        if (muteBtn) muteBtn.addEventListener('click', () => { if (abxMedia) abxMedia.muted = !abxMedia.muted; });
        if (seek) {
            const seekTo = (e) => {
                if (!abxMedia || !abxMedia.duration) return;
                const rect = seek.getBoundingClientRect();
                const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
                abxMedia.currentTime = pct * abxMedia.duration;
            };
            let dragging = false;
            seek.addEventListener('mousedown', (e) => { dragging = true; seekTo(e); });
            window.addEventListener('mousemove', (e) => { if (dragging) seekTo(e); });
            window.addEventListener('mouseup', () => { dragging = false; });
        }
        if (vol) {
            const apply = (e) => {
                if (!abxMedia) return;
                const rect = vol.getBoundingClientRect();
                const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
                abxMedia.volume = pct; abxMedia.muted = pct === 0;
            };
            let dragging = false;
            vol.addEventListener('mousedown', (e) => { dragging = true; apply(e); });
            window.addEventListener('mousemove', (e) => { if (dragging) apply(e); });
            window.addEventListener('mouseup', () => { dragging = false; });
        }
        if (fsBtn) fsBtn.addEventListener('click', () => {
            if (document.fullscreenElement) document.exitFullscreen();
            else player.requestFullscreen().catch(() => {});
        });
        player.setAttribute('tabindex', '0');
        player.addEventListener('keydown', (e) => {
            if (!abxMedia) return;
            if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
            else if (e.code === 'ArrowRight') { abxMedia.currentTime = Math.min(abxMedia.duration || 0, abxMedia.currentTime + 5); }
            else if (e.code === 'ArrowLeft')  { abxMedia.currentTime = Math.max(0, abxMedia.currentTime - 5); }
            else if (e.key === 'm' || e.key === 'M') { abxMedia.muted = !abxMedia.muted; }
            else if (e.key === 'f' || e.key === 'F') { fsBtn && fsBtn.click(); }
            else if (e.key === 's' || e.key === 'S') { stopPlayback(); }
        });
    })();

    document.querySelector('.tab[data-tab="results"]').addEventListener('click', () => {
        applyPermissionUi();
        loadMapping();
    });
    document.addEventListener('audioboxx-auth-ready', () => {
        applyPermissionUi();
        if (mapCurrentEvent) ensurePlayDetails(mapCurrentEvent);
    });
    $('#map-refresh').addEventListener('click', async () => {
        // Force-reload the currently playing media with a cache-busting query
        // string so the browser drops its cached metadata. For recordings
        // that are still being written, this lets the player see the newly
        // appended bytes and updates its duration without a full page reload.
        await fetchMapping();
        try {
            const video = $('#map-video');
            const audio = $('#map-audio');
            const active = (video && !video.paused && video.src) ? video
                : (audio && !audio.paused && audio.src) ? audio : null;
            const target = active || (video && video.src ? video : (audio && audio.src ? audio : null));
            if (target && target.src) {
                const t = Math.max(0, target.currentTime || 0);
                const wasPlaying = !target.paused;
                const base = target.src.split('?')[0];
                target.src = base + '?_=' + Date.now();
                target.addEventListener('loadedmetadata', function once() {
                    target.removeEventListener('loadedmetadata', once);
                    try { target.currentTime = t; } catch { /* seek may fail */ }
                    if (wasPlaying) target.play().catch(() => { });
                });
            }
        } catch { /* ignore */ }
    });
    $('#map-stream').addEventListener('change', fetchMapping);
    $('#map-scan-root')?.addEventListener('change', fetchMapping);
    $('#map-source')?.addEventListener('change', async () => {
        mapSourceMode = $('#map-source')?.value || 'monitor';
        await loadMapFilterOptions();
        await fetchMapping();
    });
    $('#map-date').addEventListener('change', fetchMapping);
    $('#map-type').addEventListener('change', fetchMapping);

    // Auto-advance: when the current clip finishes, jump to the next event
    // in the list that has a recording attached. Works for both the video
    // and audio elements since either may be the active player.
    function playNextMappingEvent() {
        if (!mapEventsList.length) return;
        for (let i = mapCurrentIdx + 1; i < mapEventsList.length; i++) {
            const next = mapEventsList[i];
            if (next && next.recording) {
                mapCurrentIdx = i;
                const dot = document.querySelector(`#map-track .map-dot[data-idx="${i}"]`);
                playMappingEvent(next, dot);
                return;
            }
        }
        // No more playable events — leave the final state as-is.
    }
    ['#map-video', '#map-audio'].forEach(sel => {
        const el = $(sel);
        if (el) el.addEventListener('ended', playNextMappingEvent);
    });

    // ═══════════════ OVERVIEW TAB ═══════════════
    function ovChartOptions({ showLegend = false } = {}) {
        return {
            responsive: true,
            maintainAspectRatio: false,
            animation: false,
            plugins: {
                legend: {
                    display: showLegend,
                    position: 'bottom',
                    labels: {
                        color: 'rgba(255,255,255,0.55)',
                        boxWidth: 10,
                        padding: 10,
                        font: { size: 10 }
                    }
                },
                tooltip: { enabled: true }
            },
            scales: {
                x: { display: false },
                y: {
                    min: 0,
                    max: 100,
                    ticks: { color: 'rgba(255,255,255,0.35)', font: { size: 9 }, callback: v => v + '%' },
                    grid: { color: 'rgba(255,255,255,0.06)' }
                }
            },
            elements: { point: { radius: 0 }, line: { borderWidth: 2 } }
        };
    }

    function ovCanvasReady(canvas) {
        if (!canvas) return false;
        const panel = document.querySelector('.panel[data-panel="overview"]');
        if (panel && panel.style.display === 'none') return false;
        const r = canvas.getBoundingClientRect();
        return r.width >= 8 && r.height >= 8;
    }

    function destroyOvChart(which) {
        if (which === 'cpu' && ovState.cpuChart) {
            ovState.cpuChart.destroy();
            ovState.cpuChart = null;
        }
        if (which === 'ram' && ovState.ramChart) {
            ovState.ramChart.destroy();
            ovState.ramChart = null;
        }
    }

    function ensureOvCharts() {
        if (!window.Chart) return false;
        const cpuCanvas = document.getElementById('ov-cpu-canvas');
        const ramCanvas = document.getElementById('ov-ram-canvas');
        if (!cpuCanvas || !ramCanvas) return false;
        if (!ovCanvasReady(cpuCanvas) || !ovCanvasReady(ramCanvas)) return false;

        const labels = ovState.cpu.map((_, i) => String(i));

        if (ovState.cpuChart) {
            const bad = ovState.cpuChart.canvas !== cpuCanvas;
            if (bad) destroyOvChart('cpu');
        }
        if (!ovState.cpuChart) {
            ovState.cpuChart = new Chart(cpuCanvas.getContext('2d'), {
                type: 'line',
                data: {
                    labels,
                    datasets: [
                        {
                            label: 'System',
                            data: ovState.cpu.slice(),
                            borderColor: '#fbbf24',
                            backgroundColor: 'rgba(251,191,36,0.14)',
                            fill: true,
                            tension: 0.35
                        },
                        {
                            label: 'AUDIOBoxx',
                            data: ovState.processCpu.slice(),
                            borderColor: '#38bdf8',
                            backgroundColor: 'rgba(56,189,248,0.12)',
                            fill: true,
                            tension: 0.35
                        }
                    ]
                },
                options: ovChartOptions({ showLegend: true })
            });
        }

        if (ovState.ramChart) {
            const bad = ovState.ramChart.canvas !== ramCanvas;
            if (bad) destroyOvChart('ram');
        }
        if (!ovState.ramChart) {
            ovState.ramChart = new Chart(ramCanvas.getContext('2d'), {
                type: 'line',
                data: {
                    labels,
                    datasets: [
                        {
                            label: 'System',
                            data: ovState.ram.slice(),
                            borderColor: '#c4b5fd',
                            backgroundColor: 'rgba(196,181,253,0.14)',
                            fill: true,
                            tension: 0.35
                        },
                        {
                            label: 'AUDIOBoxx',
                            data: ovState.processRam.slice(),
                            borderColor: '#38bdf8',
                            backgroundColor: 'rgba(56,189,248,0.12)',
                            fill: true,
                            tension: 0.35
                        }
                    ]
                },
                options: ovChartOptions({ showLegend: true })
            });
        }
        resizeOvCharts();
        return true;
    }

    function resizeOvCharts() {
        try {
            if (ovState.cpuChart) ovState.cpuChart.resize();
            if (ovState.ramChart) ovState.ramChart.resize();
        } catch { /* ignore */ }
    }

    /** Parse /api/system/stats (whole-machine metrics, camel or Pascal case). */
    function parseOverviewSystemMetrics(sys) {
        if (!sys || typeof sys !== 'object') return { cpu: 0, processCpu: 0, ram: 0, processRam: 0 };
        const pick = (c, p) => (sys[c] !== undefined && sys[c] !== null) ? sys[c] : sys[p];
        let cpu = Number(pick('cpuPercent', 'CpuPercent') ?? 0);
        let processCpu = Number(pick('processCpuPercent', 'ProcessCpuPercent') ?? 0);
        let ram = Number(pick('ramPercent', 'RamPercent') ?? 0);
        let processRam = Number(pick('processRamPercent', 'ProcessRamPercent') ?? 0);
        const usedB = Number(pick('usedRamBytes', 'UsedRamBytes') ?? 0);
        const totalB = Number(pick('totalSystemRamBytes', 'TotalSystemRamBytes') ?? 0);
        const usedMb = Number(pick('systemRamUsedMb', 'SystemRamUsedMb') ?? 0);
        const totalMb = Number(pick('systemRamTotalMb', 'SystemRamTotalMb') ?? 0);
        const procMb = Number(pick('processMemoryMb', 'ProcessMemoryMb') ?? 0);
        if ((!Number.isFinite(ram) || ram <= 0) && totalB > 0) ram = (usedB / totalB) * 100;
        if ((!Number.isFinite(ram) || ram <= 0) && totalMb > 0) ram = (usedMb / totalMb) * 100;
        if ((!Number.isFinite(processRam) || processRam <= 0) && totalB > 0 && procMb > 0) {
            processRam = (procMb * 1024 * 1024 / totalB) * 100;
        }
        if ((!Number.isFinite(processRam) || processRam <= 0) && totalMb > 0 && procMb > 0) {
            processRam = (procMb / totalMb) * 100;
        }
        if (!Number.isFinite(cpu) || cpu < 0) cpu = 0;
        if (!Number.isFinite(processCpu) || processCpu < 0) processCpu = 0;
        if (!Number.isFinite(ram) || ram < 0) ram = 0;
        if (!Number.isFinite(processRam) || processRam < 0) processRam = 0;
        return {
            cpu: Math.min(100, cpu),
            processCpu: Math.min(100, processCpu),
            ram: Math.min(100, ram),
            processRam: Math.min(100, processRam)
        };
    }

    function pushOverviewChartSamples(cpu, processCpu, ram, processRam) {
        ovState.cpu.push(cpu);
        ovState.cpu.shift();
        ovState.processCpu.push(processCpu);
        ovState.processCpu.shift();
        ovState.ram.push(ram);
        ovState.ram.shift();
        ovState.processRam.push(processRam);
        ovState.processRam.shift();
        if (!ensureOvCharts()) return;
        const labels = ovState.cpu.map((_, i) => String(i));
        if (ovState.cpuChart) {
            ovState.cpuChart.data.labels = labels;
            ovState.cpuChart.data.datasets[0].data = ovState.cpu.slice();
            if (ovState.cpuChart.data.datasets[1]) {
                ovState.cpuChart.data.datasets[1].data = ovState.processCpu.slice();
            }
            ovState.cpuChart.update('none');
        }
        if (ovState.ramChart) {
            ovState.ramChart.data.labels = labels;
            ovState.ramChart.data.datasets[0].data = ovState.ram.slice();
            if (ovState.ramChart.data.datasets[1]) {
                ovState.ramChart.data.datasets[1].data = ovState.processRam.slice();
            }
            ovState.ramChart.update('none');
        }
    }

    function fmtUptime(sec) {
        if (sec == null) return '—';
        const d = Math.floor(sec / 86400);
        const h = Math.floor((sec % 86400) / 3600);
        const m = Math.floor((sec % 3600) / 60);
        if (d > 0) return `${d}d ${h}h`;
        if (h > 0) return `${h}h ${m}m`;
        return `${m}m`;
    }

    function overviewSystemRamGb(sys) {
        const totalB = Number(sys?.totalSystemRamBytes ?? sys?.TotalSystemRamBytes ?? 0);
        if (totalB > 0) return totalB / (1024 ** 3);
        const totalMb = Number(sys?.systemRamTotalMb ?? sys?.SystemRamTotalMb ?? 0);
        if (totalMb > 0) return totalMb / 1024;
        return 8;
    }

    /**
     * Universal AUDIOBoxx sizing (radio/audio monitor + record + identify).
     * Assumes ~128 kbps MP3 recording, 20s capture interval, separate recording volume.
     * Aligned with docs/CLIENT_CAPACITY_GUIDE.md.
     */
    const OV_SIZING_ICON_SVG = {
        /** Server rack — this machine / hardware capacity */
        system: '<svg class="ov-sizing-ic system" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false"><path fill="currentColor" d="M4 3h16a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm0 8h16a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1zm2 2v1h2v-1H6zm0-8v1h2V5H6zm10 0v1h2V5h-2zm0 8v1h2v-1h-2z"/></svg>',
        /** License key — stream limit from license */
        license: '<span class="ov-sizing-ic license ov-sizing-emoji" aria-hidden="true">🔑</span>',
    };

    function ovSizingIcon(kind) {
        return OV_SIZING_ICON_SVG[kind] || '';
    }

    function ovSizingMark(kind, tier) {
        const label = kind === 'license' ? 'License limit' : 'This machine';
        const tierHtml = tier != null && tier !== ''
            ? `<span class="ov-sizing-mark-tier">${tier}</span>`
            : '';
        return `<span class="ov-sizing-mark ${kind}" title="${label}">${ovSizingIcon(kind)}${tierHtml}</span>`;
    }

    function ovSizingStreamCell(count, isSystem, isLicense) {
        if (!isSystem && !isLicense) return String(count);
        const badges = [
            isSystem ? `<span class="ov-sizing-badge" title="This machine">${ovSizingIcon('system')}</span>` : '',
            isLicense ? `<span class="ov-sizing-badge" title="License limit">${ovSizingIcon('license')}</span>` : '',
        ].join('');
        return `<span class="ov-sizing-stream-cell">${count}<span class="ov-sizing-badges">${badges}</span></span>`;
    }

    function wireSizingKeyLegend() {
        const sysEl = document.getElementById('ov-sizing-key-system');
        const licEl = document.getElementById('ov-sizing-key-license');
        if (sysEl && !sysEl.dataset.wired) {
            sysEl.dataset.wired = '1';
            sysEl.innerHTML = ovSizingIcon('system');
            sysEl.title = 'This machine (comfortable max)';
        }
        if (licEl && !licEl.dataset.wired) {
            licEl.dataset.wired = '1';
            licEl.innerHTML = ovSizingIcon('license');
            licEl.title = 'License limit';
        }
    }

    const STREAM_SIZING_GUIDE = [
        { streams: 10,  cpu: '8 cores',       ram: '16 GB',  osDisk: '512 GB NVMe', recDisk: '500 GB', monthly: '~400 GB', network: '100 Mbps',  rocksDb: '4 GB' },
        { streams: 15,  cpu: '12 cores',      ram: '24 GB',  osDisk: '512 GB NVMe', recDisk: '750 GB', monthly: '~600 GB', network: '100 Mbps',  rocksDb: '6 GB' },
        { streams: 20,  cpu: '16 cores',    ram: '32 GB',  osDisk: '512 GB NVMe', recDisk: '1 TB',   monthly: '~800 GB', network: '1 Gbps',    rocksDb: '8 GB' },
        { streams: 25,  cpu: '16–20 cores',   ram: '48 GB',  osDisk: '1 TB NVMe',   recDisk: '1.5 TB', monthly: '~1 TB',   network: '1 Gbps',    rocksDb: '8 GB' },
        { streams: 30,  cpu: '20 cores',      ram: '64 GB',  osDisk: '1 TB NVMe',   recDisk: '2 TB',   monthly: '~1.2 TB', network: '1 Gbps',    rocksDb: '12 GB' },
        { streams: 35,  cpu: '24 cores',      ram: '80 GB',  osDisk: '1 TB NVMe',   recDisk: '2.5 TB', monthly: '~1.4 TB', network: '1 Gbps',    rocksDb: '12 GB' },
        { streams: 40,  cpu: '24–28 cores',   ram: '96 GB',  osDisk: '1 TB NVMe',   recDisk: '3 TB',   monthly: '~1.6 TB', network: '1 Gbps',    rocksDb: '12 GB' },
        { streams: 45,  cpu: '28–32 cores',   ram: '128 GB', osDisk: '2 TB NVMe',   recDisk: '4 TB',   monthly: '~1.8 TB', network: '1 Gbps',    rocksDb: '16 GB' },
        { streams: 50,  cpu: '32 cores',      ram: '128 GB', osDisk: '2 TB NVMe',   recDisk: '5 TB',   monthly: '~2 TB',   network: '1 Gbps',    rocksDb: '16 GB' },
    ];

    function parseSizingCoresMin(cpuStr) {
        const nums = String(cpuStr || '').match(/\d+/g);
        return nums && nums.length ? Number(nums[0]) : 0;
    }

    function parseSizingRamGb(ramStr) {
        const m = String(ramStr || '').match(/(\d+)/);
        return m ? Number(m[1]) : 0;
    }

    function computeHardwareFitTier(cores, ramGb) {
        const c = Number(cores) || 0;
        const r = Number(ramGb) || 0;
        if (c <= 0 || r <= 0) return 0;
        let fit = 0;
        for (const row of STREAM_SIZING_GUIDE) {
            const minCores = parseSizingCoresMin(row.cpu);
            const minRam = parseSizingRamGb(row.ram);
            if (c >= minCores * 0.85 && r >= minRam * 0.85) fit = row.streams;
        }
        return fit;
    }

    function snapStreamCountToGuideTier(streamCount) {
        const n = Math.max(0, Number(streamCount) || 0);
        if (n <= 0) return 0;
        let tier = 0;
        for (const row of STREAM_SIZING_GUIDE) {
            if (row.streams <= n) tier = row.streams;
            else break;
        }
        return tier || STREAM_SIZING_GUIDE[0].streams;
    }

    function nearestGuideTier(streamCount) {
        const n = Math.max(0, Number(streamCount) || 0);
        if (n <= 0) return 0;
        let nearest = STREAM_SIZING_GUIDE[0].streams;
        let minDist = Infinity;
        for (const row of STREAM_SIZING_GUIDE) {
            const d = Math.abs(row.streams - n);
            if (d < minDist) {
                minDist = d;
                nearest = row.streams;
            }
        }
        return nearest;
    }

    function renderStreamSizingTable(licenseMaxStreams, machineCores, machineRamGb, hwCap) {
        const tbody = document.getElementById('ov-sizing-tbody');
        const legend = document.getElementById('ov-sizing-legend');
        if (!tbody) return;
        const lic = Number(licenseMaxStreams) || 0;
        const cores = Number(machineCores) || 0;
        const ram = Number(machineRamGb) || 0;
        const effectiveCap = Math.max(1, Math.floor(Number(hwCap) || 0));
        const hardwareFit = computeHardwareFitTier(cores, ram);
        const systemTier = snapStreamCountToGuideTier(effectiveCap);
        const licExact = lic > 0 && STREAM_SIZING_GUIDE.some(row => row.streams === lic);
        const licenseTier = licExact ? lic : (lic > 0 ? nearestGuideTier(lic) : 0);

        tbody.innerHTML = STREAM_SIZING_GUIDE.map(row => {
            const isSystem = systemTier > 0 && row.streams === systemTier;
            const isLicense = lic > 0 && (licExact ? row.streams === lic : row.streams === licenseTier);
            const beyondHardware = hardwareFit > 0 && row.streams > hardwareFit;
            const cls = [
                isSystem ? 'is-system-tier' : '',
                isLicense ? 'is-license-tier' : '',
                beyondHardware ? 'is-beyond-hardware' : '',
            ].filter(Boolean).join(' ');
            const streamsCell = ovSizingStreamCell(row.streams, isSystem, isLicense);
            return `<tr class="${cls}" data-streams="${row.streams}">
                <td>${streamsCell}</td>
                <td>${escapeHtml(row.cpu)}</td>
                <td>${escapeHtml(row.ram)}</td>
                <td>${escapeHtml(row.osDisk)}</td>
                <td>${escapeHtml(row.recDisk)}</td>
                <td>${escapeHtml(row.monthly)}</td>
                <td>${escapeHtml(row.network)}</td>
                <td>${escapeHtml(row.rocksDb)}</td>
            </tr>`;
        }).join('');

        if (legend) {
            const parts = [];
            if (cores > 0 && ram > 0) {
                parts.push(`Detected <strong>${cores} cores</strong> · <strong>${ram.toFixed(0)} GB RAM</strong>.`);
            }
            if (hardwareFit > 0) {
                parts.push(`Raw hardware fits up to the <strong>${hardwareFit}-stream</strong> tier.`);
            }
            if (systemTier > 0) {
                parts.push(`Under current CPU/RAM load, about <strong>${effectiveCap} stream(s)</strong> is comfortable — see ${ovSizingMark('system', systemTier)} below.`);
            }
            if (lic > 0) {
                if (licExact) {
                    parts.push(`License allows <strong>${lic} stream(s)</strong> — see ${ovSizingMark('license', lic)}.`);
                } else {
                    parts.push(`License allows <strong>${lic} stream(s)</strong> (nearest guide tier ${ovSizingMark('license', licenseTier)}).`);
                }
                if (hardwareFit > 0 && lic > hardwareFit) {
                    parts.push(`License exceeds hardware — stay within the <strong>${hardwareFit}-stream</strong> tier for stable operation.`);
                }
            }
            legend.innerHTML = parts.length ? parts.join(' ') : 'Hardware detection pending…';
        }

        wireSizingKeyLegend();

        const hi = tbody.querySelector('.is-system-tier');
        const scrollEl = document.querySelector('.ov-sizing-scroll');
        const scrollToSystemRow = () => {
            if (!hi || !scrollEl || scrollEl.dataset.scrolled === '1') return;
            requestAnimationFrame(() => {
                const rowTop = hi.offsetTop;
                const rowH = hi.offsetHeight;
                scrollEl.scrollTop = Math.max(0, rowTop - scrollEl.clientHeight / 2 + rowH / 2);
                scrollEl.dataset.scrolled = '1';
            });
        };
        scrollToSystemRow();

        const details = document.getElementById('ov-sizing-details');
        if (details && !details.dataset.scrollWired) {
            details.dataset.scrollWired = '1';
            details.addEventListener('toggle', () => {
                if (details.open) scrollToSystemRow();
            });
        }
    }

    function overviewHardwareComfortBand(cores, ramGb) {
        const fit = computeHardwareFitTier(cores, ramGb);
        if (fit <= 0) {
            const c = Math.max(1, Math.floor(Number(cores) || 4));
            const r = Math.max(1, Number(ramGb) || 8);
            if (c <= 4 && r < 12) return { start: 3, stretch: 4 };
            if (c <= 8 && r < 20) return { start: 7, stretch: 10 };
            return { start: 10, stretch: 15 };
        }
        const idx = STREAM_SIZING_GUIDE.findIndex(row => row.streams === fit);
        const prev = idx > 0 ? STREAM_SIZING_GUIDE[idx - 1].streams : Math.max(1, fit - 5);
        const next = idx >= 0 && idx < STREAM_SIZING_GUIDE.length - 1
            ? STREAM_SIZING_GUIDE[idx + 1].streams
            : fit;
        return { start: prev, stretch: fit, next };
    }

    function overviewEffectiveHardwareCap(cores, ramGb, cpuPct, ramPct) {
        const { start, stretch, next } = overviewHardwareComfortBand(cores, ramGb);
        const cpu = Number(cpuPct) || 0;
        const ram = Number(ramPct) || 0;
        if (cpu >= 88 || ram >= 92) return Math.max(1, Math.floor(start * 0.45));
        if (cpu >= 75 || ram >= 85) return Math.max(1, Math.floor(start * 0.72));
        if (cpu >= 60 || ram >= 75) return Math.max(start, Math.floor((start + stretch) * 0.82));
        if (next && next > stretch && cpu < 45 && ram < 55) return next;
        return stretch;
    }

    function renderOverviewCapacitySuggestions(sys, mon, rec, lic) {
        const card = document.getElementById('ov-capacity-card');
        if (!card) return;

        const cores = Number(sys?.processorCount) || 4;
        const ramGb = overviewSystemRamGb(sys);
        const { cpu, ram } = parseOverviewSystemMetrics(sys || {});
        const configured = Number(lic?.currentStreams ?? sys?.streamCount ?? 0) || 0;
        const maxLic = Math.max(1, Number(lic?.maxStreams ?? window._streamMax ?? 1) || 1);
        const licRemain = Math.max(0, maxLic - configured);
        const hwCap = overviewEffectiveHardwareCap(cores, ramGb, cpu, ram);
        const comfortTotal = Math.min(maxLic, hwCap);
        const workers = mon?.workers || [];
        const activeMon = workers.filter(workerIsActive).length;
        const recWorkers = rec?.workers || [];
        const activeRec = recWorkers.filter(workerIsActive).length;
        const canStartMon = Math.max(0, Math.min(configured, comfortTotal) - activeMon);
        const canStartRec = Math.max(0, Math.min(configured, comfortTotal) - activeRec);
        const suggestAddStreams = Math.max(0, Math.min(licRemain, comfortTotal - configured));
        const stressed = cpu >= 75 || ram >= 85;
        const diskFree = Number(sys?.diskFreeGb) || 0;

        const tag = document.getElementById('ov-capacity-tag');
        if (tag) {
            tag.textContent = `${cores} cores · ${ramGb.toFixed(0)} GB · CPU ${cpu.toFixed(0)}% · RAM ${ram.toFixed(0)}%`;
        }

        const licVal = document.getElementById('ov-cap-lic-val');
        if (licVal) {
            licVal.textContent = licRemain > 0 ? `+${licRemain}` : '0';
            licVal.title = licRemain > 0
                ? `Add up to ${licRemain} more stream channel(s) in Streams`
                : 'License stream limit reached';
        }
        const licDet = document.getElementById('ov-cap-lic-detail');
        if (licDet) {
            licDet.textContent = `${configured} / ${maxLic} configured`
                + (suggestAddStreams < licRemain && suggestAddStreams >= 0
                    ? ` · ~${suggestAddStreams} before hardware comfort (~${hwCap})`
                    : '');
        }

        const monVal = document.getElementById('ov-cap-mon-val');
        const monDet = document.getElementById('ov-cap-mon-detail');
        if (monVal) {
            if (canStartMon > 0) monVal.textContent = `+${canStartMon}`;
            else if (suggestAddStreams > 0 && configured < comfortTotal) monVal.textContent = 'Add stream';
            else monVal.textContent = '0';
            monVal.title = canStartMon > 0
                ? `Start monitoring on up to ${canStartMon} configured channel(s) without exceeding guidance`
                : 'No spare monitoring capacity under current load';
        }
        if (monDet) {
            monDet.textContent = `${activeMon} active · ${configured} configured · ~${hwCap} comfortable max`;
        }

        const recVal = document.getElementById('ov-cap-rec-val');
        const recDet = document.getElementById('ov-cap-rec-detail');
        if (recVal) {
            if (canStartRec > 0) recVal.textContent = `+${canStartRec}`;
            else if (suggestAddStreams > 0 && configured < comfortTotal) recVal.textContent = 'Add stream';
            else recVal.textContent = '0';
            recVal.title = canStartRec > 0
                ? `Start recording on up to ${canStartRec} configured channel(s)`
                : 'No spare recording capacity under current load';
        }
        if (recDet) {
            recDet.textContent = `${activeRec} active · same hardware cap as monitoring`;
        }

        const note = document.getElementById('ov-cap-note');
        if (note) {
            const parts = [];
            parts.push(`This machine is sized for about ${hwCap} radio channel(s) with monitor + record (license allows ${maxLic}).`);
            if (suggestAddStreams > 0) {
                parts.push(`You can add ${suggestAddStreams} more stream(s) before hitting the lower of license and hardware guidance.`);
            } else if (licRemain > 0 && comfortTotal <= configured) {
                parts.push(`License allows ${licRemain} more stream(s), but CPU/RAM suggests staying at ${configured} until load drops.`);
            }
            if (canStartMon > 0 || canStartRec > 0) {
                const bits = [];
                if (canStartMon > 0) bits.push(`${canStartMon} monitor(s)`);
                if (canStartRec > 0) bits.push(`${canStartRec} recording(s)`);
                parts.push(`On existing streams you can start ${bits.join(' and ')}.`);
            }
            if (stressed) {
                parts.push('CPU or RAM is elevated — add channels slowly or stop idle workers first.');
            }
            if (diskFree > 0 && diskFree < 5) {
                parts.push(`Only ${diskFree.toFixed(1)} GB disk free — avoid new recordings until space is cleared.`);
            }
            note.textContent = parts.join(' ');
        }

        card.classList.toggle('is-stressed', stressed);
        card.classList.toggle('is-ok', !stressed && (licRemain > 0 || canStartMon > 0 || canStartRec > 0));
        renderStreamSizingTable(maxLic, cores, ramGb, hwCap);
    }

    async function refreshTrackMetadataStatus() {
        try {
            const r = await fetch('/api/track-metadata/status');
            if (!r.ok) return;
            const s = await r.json();
            const badge = $('#ov-meta-state');
            const info = $('#ov-meta-info');
            const del = $('#ov-meta-delete');
            if (badge) {
                badge.textContent = s.loaded ? 'Loaded' : 'Not loaded';
                badge.style.color = s.loaded ? 'var(--success)' : '';
            }
            if (del) del.disabled = !s.loaded;
            if (info) {
                info.style.display = s.loaded ? '' : 'none';
                if (s.loaded) {
                    $('#ov-meta-filename').textContent = s.fileName || '—';
                    $('#ov-meta-rows').textContent = String(s.rowCount ?? 0);
                    $('#ov-meta-trackcol').textContent = s.trackColumn || '—';
                    const ac = $('#ov-meta-albumcol');
                    if (ac) ac.textContent = s.albumColumn || '(not detected — title-only match)';
                    const cols = Array.isArray(s.headers) ? s.headers : [];
                    $('#ov-meta-cols').textContent = cols.length
                        ? cols.slice(0, 6).join(', ') + (cols.length > 6 ? ` (+${cols.length - 6} more)` : '')
                        : '—';
                }
            }
        } catch { /* optional */ }
    }

    $('#ov-meta-form')?.addEventListener('submit', async (e) => {
        e.preventDefault();
        const input = $('#ov-meta-file');
        const st = $('#ov-meta-status');
        if (!input?.files?.length) {
            if (st) { st.textContent = 'Choose a .xlsx or .csv file first.'; st.className = 'status err'; }
            return;
        }
        const fd = new FormData();
        fd.append('file', input.files[0]);
        if (st) { st.textContent = 'Uploading…'; st.className = 'status'; }
        try {
            const r = await fetch('/api/track-metadata/upload', { method: 'POST', body: fd });
            const data = await r.json().catch(() => ({}));
            if (!r.ok) throw new Error(data.error || 'Upload failed');
            if (st) {
                const album = data.status?.albumColumn;
                st.textContent = `Loaded ${data.rowCount} rows — title: ${data.trackColumn || 'auto'}`
                    + (album ? `, album: ${album}` : ' (album column not found; matching on title only)');
                st.className = 'status ok';
            }
            showToast('Track metadata catalogue loaded', 'ok');
            input.value = '';
            refreshTrackMetadataStatus();
        } catch (err) {
            if (st) { st.textContent = err.message; st.className = 'status err'; }
            showToast('Metadata upload failed: ' + err.message, 'err');
        }
    });

    $('#ov-meta-delete')?.addEventListener('click', async () => {
        if (!confirm('Remove the uploaded track metadata catalogue? Report exports will no longer include Meta columns.')) return;
        try {
            const r = await fetch('/api/track-metadata', { method: 'DELETE' });
            if (!r.ok) throw new Error('Delete failed');
            showToast('Track metadata removed', 'ok');
            refreshTrackMetadataStatus();
            const st = $('#ov-meta-status');
            if (st) { st.textContent = ''; st.className = 'status'; }
        } catch (err) {
            showToast('Delete failed: ' + err.message, 'err');
        }
    });

    async function refreshOverview() {
        try {
            const tzMin = tzOffsetMinutes();
            // Time-based Overview KPIs: last 24 hours of database events.
            const _now = new Date();
            const _from = new Date(_now.getTime() - 24 * 60 * 60 * 1000);
            const _fromIso = _from.toISOString();
            const _toIso = _now.toISOString();
            const [info, mon, sys, rec, backup, recent, lifetime, airtime, lic] = await Promise.all([
                fetch('/api/info').then(r => r.json()).catch(() => ({})),
                fetch('/api/monitor/status').then(r => r.json()).catch(() => ({ workers: [], stats: {} })),
                fetch('/api/system/stats').then(r => r.json()).catch(() => ({})),
                fetch('/api/recording/status').then(r => r.json()).catch(() => ({ workers: [] })),
                fetch('/api/backup/stats').then(r => r.json()).catch(() => ({ totalFiles: 0, totalBytes: 0, history: [] })),
                fetch('/api/overview/recent-matches?limit=10').then(r => r.json()).catch(() => []),
                fetch(`/api/report?from=${encodeURIComponent(_fromIso)}&to=${encodeURIComponent(_toIso)}&tz=${tzMin}`).then(r => r.json()).catch(() => null),
                fetch(`/api/report/airplay-duration?from=${encodeURIComponent(_fromIso)}&to=${encodeURIComponent(_toIso)}&tz=${tzMin}`).then(r => r.json()).catch(() => null),
                fetch('/api/license/limits').then(r => r.ok ? r.json() : null).catch(() => null)
            ]);

            // Library
            $('#ov-tracks').textContent = (info.totalTracks ?? 0).toLocaleString();
            $('#ov-hashes').textContent = (info.totalSubFingerprints ?? 0).toLocaleString();

            // Monitoring
            const workers = mon.workers || [];
            const active = workers.filter(workerIsActive).length;
            $('#ov-mon-active').textContent = active;
            { const c = document.getElementById('ov-mon-card'); if (c) c.classList.toggle('is-active', active > 0); }
            const stats = mon.stats || {};
            // Lifetime totals from the database (preferred). Fall back to
            // the in-memory per-session counters only if the report call
            // failed.
            const lifeSummary = lifetime && lifetime.summary ? lifetime.summary : null;
            const checks = lifeSummary
                ? (lifeSummary.total ?? 0)
                : (stats.totalChecks ?? stats.TotalChecks ?? workers.reduce((a, w) => a + (w.checks || w.Checks || 0), 0));
            const matches = lifeSummary
                ? (lifeSummary.matches ?? 0)
                : (stats.matches ?? stats.Matches ?? workers.reduce((a, w) => a + (w.matches || w.Matches || 0), 0));
            $('#ov-mon-checks').textContent = checks.toLocaleString();
            // Airtime (24h): consolidated in-track match duration (Duration HH:MM:SS).
            const airSeconds = (airtime && typeof airtime.totalSeconds === 'number')
                ? airtime.totalSeconds : 0;
            $('#ov-mon-matches').textContent = (function (s) {
                const t = Math.max(0, Math.floor(s));
                const h = Math.floor(t / 3600);
                const m = Math.floor((t % 3600) / 60);
                const sec = t % 60;
                const pad = n => String(n).padStart(2, '0');
                return `${pad(h)}:${pad(m)}:${pad(sec)}`;
            })(airSeconds);
            // Match rate denominator excludes errors, mirroring the Report tab.
            const matchable = lifeSummary
                ? ((lifeSummary.matches ?? 0) + (lifeSummary.noMatches ?? 0))
                : checks;
            const rate = matchable > 0 ? (matches / matchable * 100) : 0;
            $('#ov-match-rate').textContent = rate.toFixed(1) + '%';

            // System (whole machine — matches header gauges)
            const { cpu, processCpu, ram, processRam } = parseOverviewSystemMetrics(sys);
            aboxBuddy('system', { cpu, ram });
            $('#ov-cpu').textContent = cpu.toFixed(1) + '%';
            $('#ov-ram').textContent = ram.toFixed(1) + '%';
            const cpuLive = document.getElementById('ov-cpu-live');
            if (cpuLive) {
                cpuLive.innerHTML = `<span style="color:#fbbf24">System ${cpu.toFixed(1)}%</span>`
                    + ` · <span style="color:#38bdf8">AUDIOBoxx ${processCpu.toFixed(1)}%</span>`;
            }
            const ramLive = document.getElementById('ov-ram-live');
            if (ramLive) {
                ramLive.innerHTML = `<span style="color:#c4b5fd">System ${ram.toFixed(1)}%</span>`
                    + ` · <span style="color:#38bdf8">AUDIOBoxx ${processRam.toFixed(1)}%</span>`;
            }
            $('#ov-procmem').textContent = sys.processMemoryMb ? sys.processMemoryMb.toFixed(0) + ' MB' : '—';
            $('#ov-diskfree').textContent = sys.diskFreeGb ? sys.diskFreeGb.toFixed(1) + ' GB' : '—';
            $('#ov-uptime').textContent = fmtUptime(sys.uptimeSeconds);

            // Recordings
            const recWorkers = rec.workers || [];
            const recActive = recWorkers.filter(workerIsActive).length;
            $('#ov-rec-active').textContent = recActive;
            { const c = document.getElementById('ov-rec-card'); if (c) c.classList.toggle('is-active', recActive > 0); }

            // Extended KPIs (row 3) — also pulled from the lifetime report
            // summary when available so they reflect database totals rather
            // than session-only counters.
            const avgConf = lifeSummary
                ? (lifeSummary.avgConfidence ?? 0)
                : (stats.avgConfidence ?? stats.AvgConfidence ?? 0);
            $('#ov-avg-conf').textContent = avgConf > 0 ? (avgConf * 100).toFixed(1) + '%' : '—';
            const uniqMatched = lifeSummary
                ? (lifeSummary.uniqueSongs ?? 0)
                : (stats.uniqueSongs ?? stats.UniqueSongs ?? 0);
            $('#ov-unique-matched').textContent = uniqMatched.toLocaleString();
            $('#ov-rec-size').textContent = fmtBytes(sys.recordingFolderBytes || 0);
            $('#ov-stream-count').textContent = sys.streamCount || 0;
            $('#ov-backup-files').textContent = (backup.totalFiles || 0).toLocaleString();
            $('#ov-backup-size').textContent = fmtBytes(backup.totalBytes || 0);

            // Recent matches
            renderRecentMatches(recent || []);

            // Backup history
            renderBackupHistory(backup);

            // Env
            $('#ov-machine').textContent = sys.machineName || '—';
            $('#ov-os').textContent = sys.osDescription || '—';
            $('#ov-dotnet').textContent = sys.dotNetVersion || '—';
            $('#ov-cores').textContent = sys.processorCount || '—';
            $('#ov-threads').textContent = sys.threadCount || '—';
            $('#ov-started').textContent = sys.serverStartedUtc ? fmtTime(sys.serverStartedUtc) : '—';

            // About mirror (if rendered)
            if ($('#about-machine')) {
                $('#about-machine').textContent = sys.machineName || '—';
                $('#about-os').textContent = sys.osDescription || '—';
                $('#about-dotnet').textContent = sys.dotNetVersion || '—';
                $('#about-cores').textContent = sys.processorCount || '—';
                renderAboutLicenseLimits(_headerLicenseSnapshot);
                if ($('#about-streams')) $('#about-streams').textContent = (sys.streamCount ?? 0).toLocaleString();
                if ($('#about-rec-size')) $('#about-rec-size').textContent = fmtBytes(sys.recordingFolderBytes || 0);
                if ($('#about-rec-folder')) $('#about-rec-folder').textContent = sys.recordingFolder || '—';
                if ($('#about-disk-free')) {
                    const free = Number(sys.diskFreeGb || 0);
                    const total = Number(sys.diskTotalGb || 0);
                    const used = Number(sys.diskUsedPercent || 0);
                    $('#about-disk-free').textContent = total > 0
                        ? `${free.toFixed(2)} GB free / ${total.toFixed(2)} GB (${used.toFixed(1)}% used)`
                        : '—';
                }
                $('#about-started').textContent = sys.serverStartedUtc ? fmtTime(sys.serverStartedUtc) : '—';
                $('#about-uptime').textContent = fmtUptime(sys.uptimeSeconds);
            }

            // Smart capacity (license + hardware + active workers)
            renderOverviewCapacitySuggestions(
                sys,
                mon,
                rec,
                lic || _headerLicenseSnapshot || { maxStreams: window._streamMax, currentStreams: sys.streamCount }
            );

            // Charts (last ~5 min at 5s poll ≈ 60 samples)
            pushOverviewChartSamples(cpu, processCpu, ram, processRam);
        } catch (e) { /* ignore */ }

        // Top songs (cheap, from last 30d report)
        try {
            const now = new Date();
            // Last 30 LOCAL days, with tz so the backend buckets match what
            // the Report tab shows for the same window.
            const from = localDateToUtcIso(localYmd(new Date(now.getTime() - 30 * 86400000)), false);
            const to = localDateToUtcIso(localYmd(now), true);
            const tz = tzOffsetMinutes();
            const rep = await fetch(`/api/report?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&tz=${tz}`).then(r => r.json());
            const top = (rep.topSongs || []).slice(0, 5);
            const host = $('#ov-top-songs');
            if (!top.length) {
                host.innerHTML = '<div class="empty">No matches in the last 30 days.</div>';
            } else {
                host.innerHTML = top.map((s, i) => {
                    const cls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
                    return `<div class="lb-row ${cls}">
                        <div class="rank">${i + 1}</div>
                        <div><div class="title">${escapeHtml(s.title || s.trackId || '—')}</div><div class="artist">${escapeHtml(s.artist || '')}</div></div>
                        <div class="stats"><div class="count">${fmtHms(topSongAirtimeSec(s))}</div><div class="conf">${((s.avgConfidence || 0) * 100).toFixed(0)}%</div></div>
                    </div>`;
                }).join('');
                $('#ov-top-count').textContent = `top ${top.length}`;
            }
        } catch { /* ignore */ }
    }

    function startOverviewPolling() {
        if (ovState.pollTimer) return;
        wireSizingKeyLegend();
        refreshOverview();
        refreshTrackMetadataStatus();
        ovState.pollTimer = setInterval(refreshOverview, 5000);
        requestAnimationFrame(() => {
            ensureOvCharts();
            resizeOvCharts();
        });
        setTimeout(() => {
            ensureOvCharts();
            resizeOvCharts();
        }, 120);
    }
    document.querySelector('.tab[data-tab="overview"]')?.addEventListener('click', () => {
        if (!tabIsAllowed('overview')) return;
        startOverviewPolling();
    });
    document.querySelectorAll('.tab:not([data-tab="overview"])').forEach(t => {
        t.addEventListener('click', stopOverviewPolling);
    });
    if (tabIsAllowed('overview') && document.querySelector('.panel[data-panel="overview"].active')) {
        startOverviewPolling();
    } else {
        stopOverviewPolling();
        refreshTrackMetadataStatus();
    }

    // ═══════════════ STREAMS TAB (search + refresh) ═══════════════
    (function wireStreams() {
        const search = $('#stream-search');
        const refresh = $('#streams-refresh');
        if (search) {
            search.addEventListener('input', () => {
                const q = search.value.trim().toLowerCase();
                document.querySelectorAll('#streams-rows tr').forEach(tr => {
                    if (!q) { tr.style.display = ''; return; }
                    tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
                });
            });
        }
        if (refresh) refresh.addEventListener('click', () => {
            if (typeof loadStreams === 'function') loadStreams();
            else fetch('/api/streams').then(r => r.json()).then(() => window.dispatchEvent(new Event('streams-updated')));
        });
        // Update streams-count whenever rows are (re)rendered. Renders as
        // "(current/max)" when a license max is known, otherwise just
        // "(current)". The max is published on window by refreshLicenseLimits.
        const tbody = document.getElementById('streams-rows');
        if (tbody) {
            const renderCount = () => {
                const rows = tbody.querySelectorAll('tr');
                const isEmpty = rows.length === 1 && rows[0].querySelector('td.empty');
                const n = isEmpty ? 0 : rows.length;
                const c = $('#streams-count');
                if (!c) return;
                const max = window._streamMax;
                if (max && max > 0) {
                    c.textContent = `(${n}/${max})`;
                    c.classList.toggle('limit-reached', n >= max);
                } else {
                    c.textContent = n ? `(${n})` : '';
                    c.classList.remove('limit-reached');
                }
            };
            const obs = new MutationObserver(renderCount);
            obs.observe(tbody, { childList: true });
            window.addEventListener('stream-limits-updated', renderCount);
        }
    })();

    // ═══════════════ ABOUT TAB ═══════════════
    document.querySelector('.tab[data-tab="about"]').addEventListener('click', async () => {
        try {
            const sys = await fetch('/api/system/stats').then(r => r.json());
            $('#about-machine').textContent = sys.machineName || '—';
            $('#about-os').textContent = sys.osDescription || '—';
            $('#about-dotnet').textContent = sys.dotNetVersion || '—';
            $('#about-cores').textContent = sys.processorCount || '—';
            try {
                const lim = await fetch('/api/license/limits').then(r => r.ok ? r.json() : null);
                if (lim) {
                    _headerLicenseSnapshot = lim;
                    renderAboutLicenseLimits(lim);
                } else {
                    renderAboutLicenseLimits(_headerLicenseSnapshot);
                }
            } catch {
                renderAboutLicenseLimits(_headerLicenseSnapshot);
            }
            if ($('#about-streams')) $('#about-streams').textContent = (sys.streamCount ?? 0).toLocaleString();
            if ($('#about-rec-size')) $('#about-rec-size').textContent = fmtBytes(sys.recordingFolderBytes || 0);
            if ($('#about-rec-folder')) $('#about-rec-folder').textContent = sys.recordingFolder || '—';
            if ($('#about-disk-free')) {
                const free = Number(sys.diskFreeGb || 0);
                const total = Number(sys.diskTotalGb || 0);
                const used = Number(sys.diskUsedPercent || 0);
                $('#about-disk-free').textContent = total > 0
                    ? `${free.toFixed(2)} GB free / ${total.toFixed(2)} GB (${used.toFixed(1)}% used)`
                    : '—';
            }
            $('#about-started').textContent = sys.serverStartedUtc ? fmtTime(sys.serverStartedUtc) : '—';
            $('#about-uptime').textContent = fmtUptime(sys.uptimeSeconds);
            if ($('#lic-machine')) $('#lic-machine').textContent = sys.machineName || '-';
        } catch { /* ignore */ }
        loadLicense();
    });

    // ═══════════════ FINGERPRINT LIBRARIES ═══════════════
    // Each library is a separate SQLite .db file under the data directory.
    // The server tracks a single "active" library; switching triggers a
    // background re-index, which shows up in the main progress banner
    // driven by /api/info (the existing loadStats() poll).
    function libShowError(msg) {
        const e = $('#lib-error'), ok = $('#lib-ok');
        if (ok) { ok.hidden = true; ok.textContent = ''; }
        if (!e) return;
        e.textContent = msg || '';
        e.hidden = !msg;
    }
    function libShowOk(msg) {
        const e = $('#lib-error'), ok = $('#lib-ok');
        if (e) { e.hidden = true; e.textContent = ''; }
        if (!ok) return;
        ok.textContent = msg || '';
        ok.hidden = !msg;
    }

    async function loadLibraries() {
        const sel = $('#lib-select');
        const badge = $('#lib-active-badge');
        if (!sel) return;
        try {
            const data = await fetch('/api/libraries').then(r => r.json());
            const libs = Array.isArray(data.libraries) ? data.libraries : [];
            sel.innerHTML = '';
            if (libs.length === 0) {
                const opt = document.createElement('option');
                opt.value = data.current || '';
                opt.textContent = data.current || '(none)';
                sel.appendChild(opt);
            } else {
                for (const l of libs) {
                    const opt = document.createElement('option');
                    opt.value = l.name;
                    const size = l.sizeBytes ? ` · ${(l.sizeBytes / 1048576).toFixed(1)} MB` : '';
                    const tracks = (l.tracksCount !== null && l.tracksCount !== undefined)
                        ? ` · ${l.tracksCount} tracks` : '';
                    opt.textContent = `${l.name}${l.isActive ? ' (active)' : ''}${tracks}${size}`;
                    if (l.isActive) opt.selected = true;
                    sel.appendChild(opt);
                }
            }
            if (badge) {
                badge.textContent = data.current || '—';
                badge.className = 'tag tag-success';
            }
            // Notify the stream form (and any other listeners) that the
            // library list may have changed.
            window.dispatchEvent(new CustomEvent('libraries-updated'));
        } catch (ex) {
            libShowError('Failed to load libraries: ' + (ex.message || ex));
        }
    }

    const libSwitchBtn = $('#lib-switch-btn');
    if (libSwitchBtn) libSwitchBtn.addEventListener('click', async () => {
        const name = ($('#lib-select').value || '').trim();
        if (!name) return;
        libShowError('');
        libSwitchBtn.disabled = true;
        try {
            const r = await fetch('/api/libraries/switch', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ name })
            });
            if (!r.ok) {
                const err = await r.json().catch(() => ({}));
                throw new Error(err.error || `HTTP ${r.status}`);
            }
            libShowOk(`Switched to library '${name}'. Re-indexing in background…`);
            await loadLibraries();
            // The header progress banner (driven by /api/info polling) will
            // reflect the fresh rehydration automatically.
            if (typeof loadStats === 'function') loadStats();
        } catch (ex) {
            libShowError('Switch failed: ' + (ex.message || ex));
        } finally {
            libSwitchBtn.disabled = false;
        }
    });

    const libCreateBtn = $('#lib-create-btn');
    if (libCreateBtn) libCreateBtn.addEventListener('click', async () => {
        const input = $('#lib-new-name');
        const name = (input.value || '').trim();
        if (!name) { libShowError('Enter a library name.'); return; }
        libShowError('');
        libCreateBtn.disabled = true;
        try {
            const r = await fetch('/api/libraries', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ name })
            });
            if (!r.ok) {
                const err = await r.json().catch(() => ({}));
                throw new Error(err.error || `HTTP ${r.status}`);
            }
            input.value = '';
            libShowOk(`Created library '${name}'. Use Switch to activate it.`);
            await loadLibraries();
        } catch (ex) {
            libShowError('Create failed: ' + (ex.message || ex));
        } finally {
            libCreateBtn.disabled = false;
        }
    });

    const libDeleteBtn = $('#lib-delete-btn');
    if (libDeleteBtn) libDeleteBtn.addEventListener('click', async () => {
        const name = ($('#lib-select').value || '').trim();
        if (!name) return;
        if (!confirm(`Delete library '${name}'? The .db file and its snapshot will be permanently removed.`)) return;
        libShowError('');
        libDeleteBtn.disabled = true;
        try {
            const r = await fetch('/api/libraries/' + encodeURIComponent(name), { method: 'DELETE' });
            if (!r.ok) {
                const err = await r.json().catch(() => ({}));
                throw new Error(err.error || `HTTP ${r.status}`);
            }
            libShowOk(`Deleted library '${name}'.`);
            await loadLibraries();
        } catch (ex) {
            libShowError('Delete failed: ' + (ex.message || ex));
        } finally {
            libDeleteBtn.disabled = false;
        }
    });

    // ═══════════════ STORAGE BACKEND SELECTOR ═══════════════
    // GET /api/storage/backend returns { active, configured, choices }.
    // POST /api/storage/backend writes appsettings.json and asks the user
    // to restart the server (the backend is locked at process start).
    function sbShowError(msg) {
        const e = $('#sb-error'), ok = $('#sb-ok');
        if (ok) { ok.hidden = true; ok.textContent = ''; }
        if (!e) return;
        e.textContent = msg || '';
        e.hidden = !msg;
    }
    function sbShowOk(msg) {
        const e = $('#sb-error'), ok = $('#sb-ok');
        if (e) { e.hidden = true; e.textContent = ''; }
        if (!ok) return;
        ok.innerHTML = msg || '';
        ok.hidden = !msg;
    }

    async function loadStorageBackend() {
        const sel = $('#sb-select');
        const badge = $('#sb-active-badge');
        const restartRow = $('#sb-restart-row');
        if (!sel) return;
        try {
            const r = await fetch('/api/storage/backend');
            if (!r.ok) throw new Error(`HTTP ${r.status}`);
            const data = await r.json();
            const active = (data.active || 'rocksdb').toLowerCase();
            const configured = (data.configured || active).toLowerCase();
            sel.value = configured;
            if (badge) {
                badge.textContent = active === 'inmemory' ? 'In-Memory (active)' : 'RocksDB (active)';
                badge.style.background = active === 'inmemory' ? 'rgba(245,158,11,0.18)' : 'rgba(20,184,166,0.18)';
                badge.style.color = active === 'inmemory' ? '#f59e0b' : '#14b8a6';
            }
            // If persisted config differs from active, let the user know and
            // expose the Restart button.
            if (configured !== active) {
                sbShowOk(`Configured backend <strong>${configured}</strong> differs from active <strong>${active}</strong> &mdash; restart to apply.`);
                if (restartRow) restartRow.hidden = false;
            } else {
                sbShowOk('');
                if (restartRow) restartRow.hidden = true;
            }
        } catch (ex) {
            sbShowError('Failed to load backend: ' + (ex.message || ex));
        }
    }

    const sbSaveBtn = $('#sb-save-btn');
    if (sbSaveBtn) sbSaveBtn.addEventListener('click', async () => {
        const chosen = ($('#sb-select').value || '').trim().toLowerCase();
        if (!chosen) return;
        sbShowError('');
        sbSaveBtn.disabled = true;
        try {
            const r = await fetch('/api/storage/backend', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ backend: chosen })
            });
            const body = await r.json().catch(() => ({}));
            if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
            sbShowOk(`Saved &mdash; backend will be <strong>${body.configured || chosen}</strong> after restart.`);
            const restartRow = $('#sb-restart-row');
            if (restartRow) restartRow.hidden = false;
            loadStorageBackend();
        } catch (ex) {
            sbShowError('Save failed: ' + (ex.message || ex));
        } finally {
            sbSaveBtn.disabled = false;
        }
    });

    const sbRestartBtn = $('#sb-restart-btn');
    if (sbRestartBtn) sbRestartBtn.addEventListener('click', async () => {
        if (!confirm('Restart the server now? Any in-flight requests will be cancelled.')) return;
        sbShowError('');
        sbRestartBtn.disabled = true;
        const origText = sbRestartBtn.textContent;
        sbRestartBtn.textContent = '⏳ Restarting…';
        try {
            // The server's response may or may not make it back before the
            // process exits; treat network errors as "restart succeeded".
            try {
                const r = await fetch('/api/server/restart', { method: 'POST' });
                if (!r.ok) {
                    const body = await r.json().catch(() => ({}));
                    throw new Error(body.error || body.title || `HTTP ${r.status}`);
                }
            } catch (netErr) {
                // Likely the server already closed the connection.
                console.log('Restart request ended:', netErr && netErr.message);
            }
            sbShowOk('Server is restarting &mdash; waiting for it to come back online…');

            // Poll /api/info until the server responds again.
            const deadline = Date.now() + 90_000;
            let backOnline = false;
            // Give it a beat to actually exit before we start polling.
            await new Promise(res => setTimeout(res, 2000));
            while (Date.now() < deadline) {
                try {
                    const r = await fetch('/api/info', { cache: 'no-store' });
                    if (r.ok) { backOnline = true; break; }
                } catch { /* still down */ }
                await new Promise(res => setTimeout(res, 1500));
            }

            if (backOnline) {
                sbShowOk('Server is back online &mdash; reloading page…');
                setTimeout(() => window.location.reload(), 800);
            } else {
                sbShowError('Server did not come back within 90s. Start it manually, then reload the page.');
                sbRestartBtn.disabled = false;
                sbRestartBtn.textContent = origText;
            }
        } catch (ex) {
            sbShowError('Restart failed: ' + (ex.message || ex));
            sbRestartBtn.disabled = false;
            sbRestartBtn.textContent = origText;
        }
    });


    // =============== LICENSE (AUDIOBoxx-compatible) ===============
    // Talks to POST /api/license/validate which proxies to a PHP license
    // server (worklabgroup.com/blackboxx/audioboxx_license.php). Stores the key and
    // returned info in localStorage so offline launches still work.
    const LS_KEY = 'audioboxx_license';
    const LS_INFO = 'audioboxx_license_info';
    const LS_NOTIF_SEEN = 'audioboxx_license_notif_seen';
    const LICENSE_NOTIF_PRODUCT = 'AUDIOBoxx';
    let _pendingLicenseNotifId = null;

    function hideLicenseServerNotification() {
        const overlay = $('#license-server-notification');
        if (overlay) overlay.classList.add('is-hidden');
        _pendingLicenseNotifId = null;
    }

    function dismissLicenseServerNotification() {
        const id = _pendingLicenseNotifId;
        if (id) localStorage.setItem(LS_NOTIF_SEEN, id);
        hideLicenseServerNotification();
        fetch('/api/license/ack-notification', { method: 'POST' }).catch(() => {});
    }

    function maybeShowLicenseServerNotification(notification) {
        if (!notification || !notification.message) return;
        const id = notification.id || notification.message;
        if (localStorage.getItem(LS_NOTIF_SEEN) === id) return;
        const overlay = $('#license-server-notification');
        const msgEl = $('#license-notif-message');
        const metaEl = $('#license-notif-meta');
        const titleEl = $('#license-notif-title');
        const card = overlay ? overlay.querySelector('.license-notif-card') : null;
        if (!overlay || !msgEl) return;
        const defaultTitle = notification.source === 'admin_push'
            ? (LICENSE_NOTIF_PRODUCT + ' — Notice')
            : (LICENSE_NOTIF_PRODUCT + ' License Server');
        if (titleEl) titleEl.textContent = String(notification.title || '').trim() || defaultTitle;
        msgEl.textContent = String(notification.message);
        if (metaEl) {
            const when = notification.created ? ('Sent ' + notification.created) : 'Message from license server';
            metaEl.textContent = notification.source === 'admin_push' ? ('Kraft Junction · ' + when) : when;
        }
        if (card) {
            card.classList.remove('license-notif-card--success', 'license-notif-card--warn');
            const t = String(notification.type || 'info').toLowerCase();
            if (t === 'success') card.classList.add('license-notif-card--success');
            else if (t === 'warn' || t === 'error') card.classList.add('license-notif-card--warn');
        }
        _pendingLicenseNotifId = id;
        overlay.classList.remove('is-hidden');
        const okBtn = $('#license-notif-ok');
        if (okBtn && !okBtn.__licenseNotifBound) {
            okBtn.__licenseNotifBound = true;
            okBtn.addEventListener('click', dismissLicenseServerNotification);
        }
    }

    function showLicenseError(msg) {
        const el = $('#license-error'); if (!el) return;
        el.textContent = msg;
        el.hidden = false;
    }

    function showLicenseInput() {
        const ia = $('#license-input-area'), info = $('#license-info'), badge = $('#license-badge');
        if (ia) ia.hidden = false;
        if (info) info.hidden = true;
        if (badge) { badge.textContent = 'Not Activated'; badge.className = 'tag tag-failed'; }
        // Clear any stashed key so it's not recoverable from the DOM after
        // deactivation.
        var keyEl = $('#lic-key'); if (keyEl) { keyEl.__licKey = ''; keyEl.textContent = '—'; keyEl.dataset.revealed = '0'; }
        var tBtn = $('#lic-key-toggle'); if (tBtn) { tBtn.hidden = true; clearTimeout(tBtn.__hideTimer); }
        resetEulaDetails();
    }

    function maskLicenseKey(key) {
        if (!key) return '—';
        var s = String(key);
        if (s.length <= 4) return '••••';
        // Keep last 4 chars, mask the rest, preserve any dashes for layout.
        var visibleTail = s.slice(-4);
        var masked = '';
        for (var i = 0; i < s.length - 4; i++) {
            masked += s.charAt(i) === '-' ? '-' : '•';
        }
        return masked + visibleTail;
    }

    function setLicenseKeyDisplay(key) {
        var el = $('#lic-key');
        var btn = $('#lic-key-toggle');
        if (btn) btn.hidden = !key;
        if (!el) return;
        el.__licKey = key || '';
        el.dataset.revealed = '0';
        el.textContent = maskLicenseKey(key);
    }

    function normalizeValidateLicense(data) {
        if (!data || data.valid !== true) return null;
        const lifetime = data.lifetime === true || data.expires === 'lifetime'
            || String(data.days_left ?? '').toLowerCase() === 'unlimited';
        const issuedRaw = data.issued || data.activated_at || '';
        return {
            valid: true,
            organization: data.organization || '',
            expires: lifetime ? 'lifetime' : (data.expires || ''),
            days_left: lifetime ? 'unlimited' : data.days_left,
            max_streams: data.max_streams ?? data.maxStreams ?? 1,
            lifetime,
            issued: issuedRaw ? String(issuedRaw).split(' ')[0] : '',
        };
    }

    /** Build card fields from server snapshot only — never stale localStorage for expiry/type. */
    function licenseSnapshotFromApi(lim, issuedFallback) {
        if (!lim || lim.hasValidLicense !== true) return null;
        const lifetime = lim.lifetime === true
            || lim.expires === 'lifetime'
            || lim.daysLeftUnlimited === true
            || String(lim.daysLeft ?? lim.days_left ?? '').toLowerCase() === 'unlimited';
        const daysRaw = lifetime ? 'unlimited' : (lim.daysLeft ?? lim.days_left);
        return {
            valid: true,
            organization: String(lim.organization ?? '').trim(),
            expires: lifetime ? 'lifetime' : String(lim.expires ?? ''),
            days_left: daysRaw,
            max_streams: lim.maxStreams ?? lim.max_streams ?? 1,
            max_offline_hours: lim.maxOfflineHours ?? lim.max_offline_hours,
            used_offline_hours: lim.usedOfflineHours ?? lim.used_offline_hours,
            offline_hours_remaining: lim.offlineHoursRemaining ?? lim.offline_hours_remaining,
            offline_identify_at_limit: lim.offlineIdentifyAtLimit ?? lim.offline_identify_at_limit,
            lifetime,
            issued: (lim.issued ? String(lim.issued).split(' ')[0] : '') || issuedFallback || '',
        };
    }

    let _serverLicenseValidSeen = null;

    function renderLicenseCard(data, key) {
        const ia = $('#license-input-area'), info = $('#license-info'), badge = $('#license-badge');
        if (ia) ia.hidden = true;
        if (info) info.hidden = false;

        const isLifetime = data.lifetime === true || data.expires === 'lifetime';
        if ($('#lic-org')) $('#lic-org').textContent = data.organization || '—';
        if (key) {
            const keyEl = $('#lic-key');
            if (!keyEl || keyEl.__licKey !== key) setLicenseKeyDisplay(key);
        }
        if ($('#lic-type')) $('#lic-type').textContent = isLifetime ? '⭐ Lifetime' : 'Subscription';
        if ($('#lic-issued')) {
            $('#lic-issued').textContent = data.issued ? String(data.issued).split(' ')[0] : '—';
        }
        if ($('#lic-expires')) $('#lic-expires').textContent = isLifetime ? 'Never' : (data.expires ? String(data.expires).split(' ')[0] : '—');
        if ($('#lic-days')) {
            $('#lic-days').textContent = isLifetime ? '∞' : (data.days_left != null ? data.days_left + ' days' : '—');
        }
        if ($('#lic-streams')) $('#lic-streams').textContent = data.max_streams != null ? data.max_streams : 1;
        if ($('#lic-offline-hours')) {
            $('#lic-offline-hours').textContent = formatOfflineHoursLabel(data);
        }

        if (badge) {
            if (isLifetime) {
                badge.textContent = '★ Lifetime';
                badge.className = 'tag tag-added';
            } else if (data.days_left != null && data.days_left !== 'unlimited' && Number(data.days_left) <= 30) {
                badge.textContent = '⚠ Expiring Soon';
                badge.className = 'tag tag-skipped';
            } else {
                badge.textContent = '✓ Licensed';
                badge.className = 'tag tag-added';
            }
        }
    }

    function syncLicenseCardFromLimits(lim) {
        if (!lim || typeof lim !== 'object') return;
        renderAboutLicenseLimits(lim);
        const valid = lim.hasValidLicense === true;
        const savedKey = localStorage.getItem(LS_KEY);

        if (!valid) {
            // Keep the stored key; do not pop the About-tab input or activation
            // gate on transient license-server hiccups.
            _serverLicenseValidSeen = false;
            return;
        }

        _serverLicenseValidSeen = true;
        const stored = readLicenseInfo() || {};
        const data = licenseSnapshotFromApi(lim, stored.issued ? String(stored.issued).split(' ')[0] : '');
        if (!data) return;
        try { localStorage.setItem(LS_INFO, JSON.stringify(data)); } catch { /* ignore */ }
        renderLicenseCard(data, savedKey || '');
        updateEulaDetails(data, savedKey || '');
        updateHeaderBranding(data.organization || '', data, _headerVitalsSnapshot);
    }

    function renderAboutLicenseLimits(lim) {
        const el = $('#about-license-streams');
        const offEl = $('#about-offline-hours');
        if (!el && !offEl) return;
        if (!lim || typeof lim !== 'object') {
            if (el) el.textContent = '—';
            if (offEl) offEl.textContent = '—';
            return;
        }
        const max = Number(lim.maxStreams ?? lim.max_streams ?? 0);
        const used = Number(lim.currentStreams ?? lim.current_streams ?? 0);
        if (el) {
            if (max > 0) el.textContent = `${used}/${max}`;
            else el.textContent = '—';
        }
        if (offEl) offEl.textContent = formatOfflineHoursLabel(lim);
    }

    function showLicenseActive(data, key) {
        const norm = normalizeValidateLicense(data) || data;
        renderLicenseCard(norm, key);
        try { localStorage.setItem(LS_INFO, JSON.stringify(norm)); } catch { }
        updateEulaDetails(norm, key);
        updateHeaderBranding(norm.organization || '', norm, _headerVitalsSnapshot);
        refreshLicenseFromServer().catch(() => {});
    }

    async function refreshLicenseFromServer() {
        await syncLicenseWithServer({ revalidate: true, announceChanges: false });
    }

    function updateEulaDetails(data, key) {
        const isLifetime = data.lifetime === true || data.expires === 'lifetime';
        const org = data.organization || 'the Licensee';
        const maxStreams = data.max_streams || 1;
        const expiresDate = isLifetime ? 'Never (Lifetime)' : (data.expires ? String(data.expires).split(' ')[0] : '—');
        const typeLabel = isLifetime ? 'Lifetime' : 'Subscription';
        const today = localYmd();

        const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
        const summaryEl = $('#eula-license-summary');
        if (summaryEl) {
            summaryEl.hidden = false;
            setText('eula-org', org);
            setText('eula-key', maskLicenseKey(key));
            setText('eula-type', typeLabel);
            setText('eula-issued', data.issued ? String(data.issued).split(' ')[0] : today);
            setText('eula-expires', expiresDate);
            setText('eula-streams', maxStreams);
        }
        setText('eula-grantee', org);
        setText('eula-grant-key', maskLicenseKey(key));
        setText('eula-grant-type', typeLabel.toLowerCase());
        setText('eula-grant-streams', maxStreams);
        setText('eula-restrict-streams', maxStreams);
        setText('eula-limit-org', org);
        setText('eula-limit-streams', maxStreams);
        const termEl = $('#eula-term-detail');
        if (termEl) {
            termEl.textContent = isLifetime
                ? 'in perpetuity (lifetime license) from the date of activation'
                : `from ${today} until ${expiresDate} (${data.days_left != null ? data.days_left + ' days remaining' : 'see license expiry'})`;
        }
    }

    function resetEulaDetails() {
        const summaryEl = $('#eula-license-summary');
        if (summaryEl) summaryEl.hidden = true;
        const defaults = {
            'eula-grantee': 'the Licensee', 'eula-grant-key': '—', 'eula-grant-type': 'subscription',
            'eula-grant-streams': '1', 'eula-restrict-streams': '1', 'eula-limit-org': 'the Licensee',
            'eula-limit-streams': '1', 'eula-term-detail': 'until the expiration date associated with the license key'
        };
        Object.entries(defaults).forEach(([id, val]) => { const el = document.getElementById(id); if (el) el.textContent = val; });
    }

    async function safeJson(r) { try { return await r.json(); } catch { return {}; } }

    function checkLicenseStatus() {
        const saved = localStorage.getItem(LS_KEY);
        if (!saved) { showLicenseInput(); return; }
        fetch('/api/license/validate', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ license_key: saved })
        })
        .then(safeJson)
        .then(data => {
            if (data && data.valid) {
                showLicenseActive(data, saved);
                maybeShowLicenseServerNotification(data.notification);
            } else {
                showLicenseInput();
                if (data && data.error) showLicenseError(data.error);
            }
        })
        .catch(() => {
            const cached = localStorage.getItem(LS_INFO);
            if (cached) {
                try { showLicenseActive(JSON.parse(cached), saved); return; }
                catch { showLicenseInput(); }
            } else {
                showLicenseInput();
                showLicenseError('Cannot reach license server. Check your internet connection.');
            }
        });
    }

    function activateLicense() {
        const input = $('#license-key-input'); if (!input) return;
        const key = (input.value || '').trim().toUpperCase();
        const errEl = $('#license-error'); if (errEl) errEl.hidden = true;
        if (!key) { showLicenseError('Please enter a license key'); return; }

        fetch('/api/license/validate', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ license_key: key })
        })
        .then(safeJson)
        .then(data => {
            if (data && data.valid) {
                localStorage.setItem(LS_KEY, key);
                localStorage.setItem(LS_INFO, JSON.stringify(data));
                showLicenseActive(data, key);
                maybeShowLicenseServerNotification(data.notification);
                if (typeof showToast === 'function') showToast('License activated', 'ok');
                // The newly activated license may permit more streams; refresh
                // the stream-form gate immediately.
                if (typeof refreshLicenseFromServer === 'function') refreshLicenseFromServer();
                else if (typeof refreshLicenseLimits === 'function') refreshLicenseLimits();
            } else {
                showLicenseError((data && data.error) || 'Invalid license key');
            }
        })
        .catch(() => showLicenseError('Cannot reach license server. Check your internet connection.'));
    }

    function deactivateLicense() {
        if (!confirm('Deactivate this license?')) return;
        fetch('/api/license/deactivate', { method: 'POST' }).catch(() => {});
        localStorage.removeItem(LS_KEY);
        localStorage.removeItem(LS_INFO);
        _serverLicenseValidSeen = false;
        const input = $('#license-key-input'); if (input) input.value = '';
        showLicenseInput();
        if (typeof toast === 'function') toast('License deactivated', 'warn');
    }

    document.addEventListener('click', (e) => {
        if (!e.target) return;
        if (e.target.id === 'license-activate-btn') activateLicense();
        else if (e.target.id === 'license-deactivate-btn') deactivateLicense();
    });

    document.addEventListener('input', (e) => {
        if (!e.target || e.target.id !== 'license-key-input') return;
        let v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
        if (!v.startsWith('BB')) v = 'BB' + v;
        const groups = [v.slice(0, 2), v.slice(2, 6), v.slice(6, 10), v.slice(10, 14), v.slice(14, 18)];
        e.target.value = groups.filter(g => g).join('-');
    });

    // Backwards-compatible entry point used by the About-tab click handler
    async function loadLicense() {
        await syncLicenseWithServer({ revalidate: true, announceChanges: false });
        if (_headerLicenseSnapshot?.hasValidLicense === true) return;
        const saved = localStorage.getItem(LS_KEY);
        if (saved) checkLicenseStatus();
        else showLicenseInput();
    }

    checkLicenseStatus();

    // ═══════════════ OVERVIEW HELPERS: bytes, recent matches, backup ═══════════════
    function fmtBytes(n) {
        if (!n) return '0 B';
        const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = n;
        while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
        return v.toFixed(v >= 10 ? 0 : 1) + ' ' + u[i];
    }

    // DD-MM-YYYY_HH-MM-SS local timestamp for generated filenames
    function fmtStampLocal(d) {
        const pad = n => String(n).padStart(2, '0');
        return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
    }
    // Convert YYYY-MM-DD → DD-MM-YYYY (for PDF report range etc.)
    function ymdToDmy(s) {
        if (!s || s === '—') return s || '—';
        const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
        return m ? `${m[3]}-${m[2]}-${m[1]}` : s;
    }

    function renderRecentMatches(rows) {
        const host = $('#ov-recent-matches');
        const count = $('#ov-recent-count');
        const top = (rows || []).slice(0, 10);
        if (!top.length) {
            host.innerHTML = '<div class="empty">No recent matches.</div>';
            if (count) count.textContent = 'Top 10';
            return;
        }
        if (count) count.textContent = `${top.length} of 10`;
        host.innerHTML = top.map(r => {
            const conf = ((r.confidence || 0) * 100).toFixed(1);
            const color = r.confidence >= 0.9 ? '#4ade80' : r.confidence >= 0.7 ? '#fbbf24' : '#f87171';
            return `<div class="lb-row">
                <div class="rank" style="background:${color};">♪</div>
                <div>
                    <div class="title">${escapeHtml(r.title || r.trackId || '(untitled)')}</div>
                    <div class="artist">${escapeHtml(r.artist || '—')} · ${escapeHtml(r.streamName || '')} · ${fmtTime(r.timeUtc)}</div>
                </div>
                <div class="stats"><div class="count" style="color:${color};">${conf}%</div><div class="conf">confidence</div></div>
            </div>`;
        }).join('');
    }

    function renderBackupHistory(backup) {
        $('#backup-total-files').textContent = (backup.totalFiles || 0).toLocaleString();
        $('#backup-total-size').textContent = fmtBytes(backup.totalBytes || 0);
        $('#backup-stats-summary').textContent = backup.runs ? `${backup.runs} runs` : '';
        const host = $('#backup-history-list');
        const hist = backup.history || [];
        if (!hist.length) { host.innerHTML = '<div class="empty">No backups yet.</div>'; return; }
        host.innerHTML = hist.map(h => {
            const ok = h.success;
            const t = fmtTime(h.startedUtc);
            const tag = ok ? 'tag-added' : 'tag-failed';
            const label = ok ? 'OK' : 'FAIL';
            return `<div class="folder-row ${ok ? 'added' : 'failed'}" style="margin-bottom:6px;">
                <span class="tag ${tag}">${label}</span>
                <span class="path"><strong>${escapeHtml(h.destination || '')}</strong></span>
                <span class="match match-meta">${t} · ${h.filesMoved || 0} files · ${fmtBytes(h.bytesMoved || 0)}${h.channelFilter ? ' · ' + escapeHtml(h.channelFilter) : ''}${h.error ? ' · ' + escapeHtml(h.error) : ''}</span>
            </div>`;
        }).join('');
    }

    // Populate backup channel filter from streams on overview load
    async function populateBackupChannel() {
        try {
            const streams = await fetch('/api/streams').then(r => r.json());
            const sel = $('#backup-channel');
            const cur = sel.value;
            sel.innerHTML = '<option value="">All streams</option>' +
                streams.map(s => `<option value="${escapeAttr(s.id)}">${escapeHtml(s.name)}</option>`).join('');
            sel.value = cur;
        } catch { }
    }

    // Backup form submit
    const backupForm = document.getElementById('backup-form');
    if (backupForm) {
        backupForm.addEventListener('submit', async (e) => {
            e.preventDefault();
            const dest = $('#backup-drive').value.trim();
            if (!dest) return;
            const btn = $('#backup-start-btn');
            const status = $('#backup-status');
            const progress = $('#backup-progress');
            const progressText = $('#backup-progress-text');
            const bar = $('#backup-bar');
            btn.disabled = true;
            status.className = 'status';
            status.innerHTML = '<span class="spinner"></span>Backing up recordings…';
            progress.style.display = 'block';
            bar.style.width = '50%';
            progressText.textContent = 'Copying files…';
            try {
                const r = await fetch('/api/backup/start', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        destination: dest,
                        streamId: $('#backup-channel').value || null,
                        deleteSource: $('#backup-delete').checked,
                        olderThanDays: parseInt($('#backup-older').value, 10) || 0
                    })
                });
                const data = await r.json();
                if (!r.ok) throw new Error(data.error || data.detail || 'Backup failed');
                bar.style.width = '100%';
                progressText.textContent = `${data.filesMoved} files · ${fmtBytes(data.bytesMoved)} · ${(data.durationMs / 1000).toFixed(1)}s`;
                status.textContent = `Backed up ${data.filesMoved} files (${fmtBytes(data.bytesMoved)}).`;
                status.className = 'status ok';
                showToast('Backup complete', 'ok');
                refreshOverview();
            } catch (err) {
                status.textContent = 'Backup failed: ' + err.message;
                status.className = 'status err';
                showToast('Backup failed: ' + err.message, 'err');
                bar.style.width = '0%';
            } finally {
                btn.disabled = false;
                setTimeout(() => { progress.style.display = 'none'; }, 4000);
            }
        });
    }
    // Populate backup channel on overview entry
    document.querySelector('.tab[data-tab="overview"]').addEventListener('click', populateBackupChannel);
    if (document.querySelector('.panel[data-panel="overview"].active')) populateBackupChannel();

    // ═══════════════ PDF REPORT EXPORT (AUDIOBoxx format) ═══════════════
    async function exportReportPDF(opts = {}) {
        if (!window.jspdf) { showToast('jsPDF not loaded', 'err'); return; }
        const consolidate = opts.consolidate !== false;
        const btn = opts.button || $('#rpt-export-pdf');
        if (!btn) return;
        btn.disabled = true;
        const origText = btn.textContent;
        btn.textContent = '⏳ Generating PDF…';
        try {
            const qs = reportQuery();
            const airplayQs = new URLSearchParams(qs);
            airplayQs.set('consolidate', consolidate ? 'true' : 'false');
            const [rep, airplayPayload, sys, backup, streamsLatest, monStatus] = await Promise.all([
                fetch('/api/report?' + qs.toString()).then(r => r.json()),
                fetch('/api/report/airplay-rows?' + airplayQs.toString()).then(r => r.ok ? r.json() : { rows: [] }).catch(() => ({ rows: [] })),
                fetch('/api/system/stats').then(r => r.json()).catch(() => ({})),
                fetch('/api/backup/stats').then(r => r.json()).catch(() => ({ totalFiles: 0, totalBytes: 0 })),
                // Always fetch the streams list so the Language column on
                // the airplay table (and every other PDF section that
                // joins per-channel language) has data to read — without
                // requiring the user to visit the Streams tab first.
                fetch('/api/streams').then(r => r.json()).catch(() => []),
                // Refresh the cached active-library name so the PDF
                // "Client : ..." header is always current at export time.
                refreshStreamLibraryOptions().catch(() => {}),
                fetch('/api/monitor/status').then(r => r.ok ? r.json() : null).catch(() => null)
            ]);
            const activeChannels = (monStatus?.workers || []).filter(w => w.active).length;
            // Update the in-memory streams cache so any function that
            // reads monState.streams (PDF airplay log, Channel × Language
            // matrix, etc.) sees the freshest configuration without
            // needing the Streams tab to have been opened.
            if (Array.isArray(streamsLatest) && streamsLatest.length > 0) {
                monState.streams = streamsLatest;
            }
            const airplay = Array.isArray(airplayPayload?.rows) ? airplayPayload.rows : [];
            buildPdf(rep, airplay, sys, backup, qs, activeChannels, { consolidate });
        } catch (err) {
            console.error('PDF export failed', err);
            showToast('PDF export failed: ' + (err?.message || err), 'err');
        } finally {
            btn.disabled = false;
            btn.textContent = origText;
        }
    }

    // AUDIOBoxx palette
    // Brand palette for the PDF. NOTE: the "ORANGE", "WARN" and "NOMATCH"
    // slots were retinted yellow per user request so charts read as
    // green (match) / yellow (no-match) instead of green / orange. They
    // keep their old names so the dozens of call sites still work
    // unchanged — only the RGB values have shifted into the yellow band.
    const PDF = {
        BRAND: [74, 74, 79], BRAND_DARK: [58, 58, 62], BRAND_LIGHT: [209, 209, 214],
        SLATE: [58, 58, 62], LIGHT_BG: [245, 245, 247], MUTED: [134, 134, 139],
        SUCCESS: [52, 199, 89], WARN: [234, 179, 8], DANGER: [255, 59, 48],
        BLUE: [0, 122, 255], INDIGO: [88, 86, 214], ORANGE: [234, 179, 8],
        YELLOW: [234, 179, 8],
        GRID: [229, 229, 234], NOMATCH: [234, 179, 8], CYAN: [34, 211, 238]
    };

    function buildPdf(rep, airplay, sys, backup, qs, activeChannels, pdfOpts = {}) {
        const pdfConsolidate = pdfOpts.consolidate !== false;
        const { jsPDF } = window.jspdf;
        const doc = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'landscape', compress: true });
        _setPdfValFmt(null);
        // Robust autoTable call — works whether the plugin attached to the
        // prototype (classic UMD build) or exposed a named export.
        const runAutoTable = (opts) => {
            if (typeof doc.autoTable === 'function') return doc.autoTable(opts);
            if (window.jspdf && typeof window.jspdf.autoTable === 'function') return window.jspdf.autoTable(doc, opts);
            if (typeof window.autoTable === 'function') return window.autoTable(doc, opts);
            throw new Error('jspdf-autotable plugin not loaded');
        };
        const W = doc.internal.pageSize.getWidth();
        const H = doc.internal.pageSize.getHeight();
        // AUDIOBoxx v3.5.7 page metrics
        const MM = 2.8346;                    // 1 mm = 2.8346 pt
        const MARGIN = 20 * MM;               // 20 mm left/right
        const TOP_MARGIN = 42 * MM;           // 42 mm top (header lives here)
        const BOTTOM_MARGIN = 20 * MM;        // 20 mm bottom (footer lives here)
        const channel = qs.get('streamId') || 'All';
        const streamName = channel === 'All' ? 'All' : (document.querySelector(`#rpt-stream option[value="${channel}"]`)?.textContent || channel);
        const from = qs.get('from') || 'All';
        const to = qs.get('to') || 'All';
        // AUDIOBoxx uses '%d %b %Y, %H:%M' → '21 Apr 2026, 14:30'
        const _now = new Date();
        const generated = _now.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
            + ', ' + _now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
        // AUDIOBoxx date format: DD-MM-YYYY
        const fmtDmy = (s) => {
            if (!s || s === 'All') return 'All';
            const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
            return m ? `${m[3]}-${m[2]}-${m[1]}` : s;
        };
        const fromDmy = fmtDmy(from);
        const toDmy = fmtDmy(to);

        let y = TOP_MARGIN;

        function drawHeader() {
            // AUDIOBoxx header: logo + title + timestamp + accent line + subtitle.
            // Note: AUDIOBoxx uses ReportLab (origin bottom-left) so `h - 18mm` means
            // "18 mm below the top". jsPDF origin is top-left, so we use 18*MM directly.
            const lm = MARGIN;
            const rm = W - MARGIN;
            const top_y = 18 * MM + 10;  // baseline for title (after logo vertical offset)
            // Logo: exact port of the in-app SVG (48×48 viewBox) — three
            // nested C-ridges forming a fingerprint whorl + center dot on the
            // left, three audio bars (short-tall-short) on the right.
            // Rendered in greyscale to preserve the opacity hierarchy.
            const logo_s = 30;
            const logo_x = lm;
            const logo_y = top_y - 10 - logo_s / 2;
            doc.setFillColor(0, 0, 0);
            doc.setDrawColor(0, 0, 0);
            doc.setLineWidth(0.6);
            doc.roundedRect(logo_x, logo_y, logo_s, logo_s, 6, 6, 'FD');
            const S = logo_s / 48;
            const px = (x) => logo_x + x * S;
            const py = (y) => logo_y + y * S;
            // Two connected quadratic beziers: M p0 Q c1 m Q c2 e
            const drawRidge = (p0, c1, m, c2, e, grey) => {
                doc.setDrawColor(grey, grey, grey);
                doc.setLineWidth(1.1);
                doc.setLineCap('round');
                let lx = px(p0[0]), ly = py(p0[1]);
                const segs = [[p0, c1, m], [m, c2, e]];
                for (const [a, b, c] of segs) {
                    for (let i = 1; i <= 20; i++) {
                        const t = i / 20, u = 1 - t;
                        const sx = u*u*a[0] + 2*u*t*b[0] + t*t*c[0];
                        const sy = u*u*a[1] + 2*u*t*b[1] + t*t*c[1];
                        const nx = px(sx), ny = py(sy);
                        doc.line(lx, ly, nx, ny);
                        lx = nx; ly = ny;
                    }
                }
            };
            drawRidge([27,10], [10,10], [10,24], [10,38], [27,38],  77);
            drawRidge([25,15], [15,15], [15,24], [15,33], [25,33], 140);
            drawRidge([23,20], [20,20], [20,24], [20,28], [23,28], 235);
            doc.setFillColor(255, 255, 255);
            doc.circle(px(23), py(24), 1.4 * S, 'F');
            doc.rect(px(31), py(20), 2*S,  8*S, 'F');
            doc.rect(px(36), py(16), 2*S, 16*S, 'F');
            doc.rect(px(41), py(22), 2*S,  4*S, 'F');
            // Title — 'AUDIOBoxx' in Helvetica-Bold 18, brand-dark
            doc.setFont('helvetica', 'bold'); doc.setFontSize(18);
            doc.setTextColor(...PDF.BRAND_DARK);
            doc.text('AUDIOBoxx', lm + logo_s + 8, top_y);
            // Generated timestamp — Helvetica 9 muted, right-aligned
            doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
            doc.setTextColor(...PDF.MUTED);
            doc.text('Generated: ' + generated, rm, top_y, { align: 'right' });
            // Accent line — brand, 2pt, 8pt below title baseline
            const accent_y = top_y + 8;
            doc.setDrawColor(...PDF.BRAND); doc.setLineWidth(2);
            doc.line(lm, accent_y, rm, accent_y);
            // Subtitle — Helvetica 8 muted (Client name removed per request)
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
            doc.setTextColor(...PDF.MUTED);
            doc.text(`Channel : ${streamName}   |   Date : ${fromDmy} – ${toDmy}   |   Airplay : ${pdfConsolidate ? 'consolidated' : 'raw'}`,
                     lm, accent_y + 12);
        }

        function drawFooter(pageNum, totalPages) {
            const lm = MARGIN;
            const rm = W - MARGIN;
            // AUDIOBoxx footer: line at 14mm, text at 10mm
            doc.setDrawColor(...PDF.BRAND_LIGHT); doc.setLineWidth(0.5);
            doc.line(lm, H - 14 * MM, rm, H - 14 * MM);
            doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...PDF.MUTED);
            doc.text('\u00A9 KRAFT JUNCTION LLP \u00B7 AUDIOBoxx Version 3.5.7', lm, H - 10 * MM);
            doc.text(`Page ${pageNum} of ${totalPages}`, rm, H - 10 * MM, { align: 'right' });
        }

        function ensure(spaceNeeded) {
            if (y + spaceNeeded > H - BOTTOM_MARGIN) {
                doc.addPage();
                drawHeader();
                y = TOP_MARGIN;
            }
        }

        function sectionTitle(txt) {
            if (!_inChartTitle) _closeFrame();
            if (!_inChartTitle) ensure(34);
            // AUDIOBoxx SectionHead: Helvetica-Bold 13, brand-dark, spaceBefore 18, spaceAfter 8
            y += 10;                                    // portion of spaceBefore
            doc.setFont('helvetica', 'bold'); doc.setFontSize(13); doc.setTextColor(...PDF.BRAND_DARK);
            doc.text(txt, MARGIN, y);
            doc.setDrawColor(...PDF.BRAND); doc.setLineWidth(1);
            doc.line(MARGIN, y + 3, MARGIN + 60, y + 3);
            y += 16;                                    // spaceAfter 8 + body offset
        }

        // ─── Framed-chart-section tracking ───
        // Chart sections get wrapped in a rounded border that encloses the title
        // and the chart. We open the frame in sectionTitleWithChart and close it
        // on the NEXT sectionTitle*() call (or at PDF finalization).
        let _frameStartY = null;
        let _frameStartPage = null;
        let _inChartTitle = false;
        const FRAME_PAD_X = 8;
        const FRAME_PAD_TOP = 6;
        const FRAME_PAD_BOTTOM = 10;

        function _closeFrame() {
            if (_frameStartY === null) return;
            const curPage = doc.internal.getCurrentPageInfo().pageNumber;
            const rectX = MARGIN - FRAME_PAD_X;
            const rectW = W - 2 * MARGIN + 2 * FRAME_PAD_X;
            if (curPage === _frameStartPage) {
                const rectY = _frameStartY;
                const rectH = (y - _frameStartY) + FRAME_PAD_BOTTOM;
                doc.setDrawColor(...PDF.BRAND_LIGHT);
                doc.setLineWidth(0.7);
                doc.roundedRect(rectX, rectY, rectW, rectH, 8, 8, 'S');
                y += FRAME_PAD_BOTTOM + 4;
            } else {
                // Content already moved to a later page (e.g. via doc.addPage or autoTable).
                // Hop back to the saved page and draw the frame to the page bottom there.
                try {
                    doc.setPage(_frameStartPage);
                    const rectY = _frameStartY;
                    const rectH = (H - BOTTOM_MARGIN - 4) - _frameStartY;
                    if (rectH > 10) {
                        doc.setDrawColor(...PDF.BRAND_LIGHT);
                        doc.setLineWidth(0.7);
                        doc.roundedRect(rectX, rectY, rectW, rectH, 8, 8, 'S');
                    }
                } finally {
                    doc.setPage(curPage);
                }
            }
            _frameStartY = null;
            _frameStartPage = null;
        }

        // Keep a section title together with the following chart — if the chart
        // doesn't fit on the current page, break to a new page BEFORE drawing the
        // title so the header never ends up orphaned from its chart.
        function sectionTitleWithChart(txt, chartReserveHeight) {
            _closeFrame();
            // Title costs ~34pt (10 spaceBefore + 13 font + 3 underline + 8 spaceAfter);
            // reserve title + chart + frame padding so ensure() can force a page break up-front.
            ensure(34 + chartReserveHeight + FRAME_PAD_TOP + FRAME_PAD_BOTTOM + 20);
            _frameStartY = y + 2;
            _frameStartPage = doc.internal.getCurrentPageInfo().pageNumber;
            y += FRAME_PAD_TOP;
            _inChartTitle = true;
            sectionTitle(txt);
            _inChartTitle = false;
        }

        // Keep a section heading on the same page as the KPI cards (or other block) below it.
        function sectionTitleWithBlock(txt, contentReserveHeight) {
            _closeFrame();
            ensure(34 + contentReserveHeight + FRAME_PAD_TOP + FRAME_PAD_BOTTOM + 16);
            _frameStartY = y + 2;
            _frameStartPage = doc.internal.getCurrentPageInfo().pageNumber;
            y += FRAME_PAD_TOP;
            _inChartTitle = true;
            sectionTitle(txt);
            _inChartTitle = false;
        }

        function kpiRowHeight(opts) {
            const tall = opts && opts.tall;
            return (tall ? 50 : 44) + 10;
        }

        function truncatePdfLine(doc, text, maxW, fontSize) {
            doc.setFontSize(fontSize);
            let t = String(text ?? '');
            const ell = '…';
            while (t.length > 1 && doc.getTextWidth(t) > maxW)
                t = t.slice(0, -2) + ell;
            return t;
        }

        function drawKpiRow(kpis, opts) {
            const tall = opts && opts.tall;
            const cardH = tall ? 50 : 44;
            if (!opts || !opts.skipPageCheck) ensure(cardH + 10);
            const pad = 6;
            const n = Math.max(1, kpis.length);
            const w = (W - MARGIN * 2 - pad * (n - 1)) / n;
            const fsMain = n >= 6 ? 10 : 13;
            const maxTextW = w - 10;
            kpis.forEach((k, i) => {
                const x = MARGIN + i * (w + pad);
                doc.setFillColor(...PDF.LIGHT_BG);
                doc.setDrawColor(...PDF.BRAND_LIGHT); doc.setLineWidth(0.8);
                doc.roundedRect(x, y, w, cardH, 4, 4, 'FD');
                doc.setFont('helvetica', 'bold');
                doc.setTextColor(...(k.color || PDF.BRAND_DARK));
                const valMain = truncatePdfLine(doc, k.value, maxTextW, fsMain);
                const valSub = k.subValue != null && k.subValue !== ''
                    ? truncatePdfLine(doc, k.subValue, maxTextW, 8) : '';
                if (valSub) {
                    doc.setFontSize(fsMain);
                    doc.text(valMain, x + w / 2, y + 15, { align: 'center' });
                    doc.setFontSize(8);
                    doc.text(valSub, x + w / 2, y + 27, { align: 'center' });
                    doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); doc.setTextColor(...PDF.MUTED);
                    doc.text(truncatePdfLine(doc, k.label, maxTextW, 6.5), x + w / 2, y + cardH - 6, { align: 'center' });
                } else {
                    doc.setFontSize(fsMain);
                    doc.text(valMain, x + w / 2, y + (tall ? 20 : 22), { align: 'center' });
                doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...PDF.MUTED);
                    doc.text(truncatePdfLine(doc, k.label, maxTextW, 7), x + w / 2, y + cardH - 8, { align: 'center' });
                }
            });
            y += cardH + 10;
        }

        function drawReportTabKpiCards(k) {
            if (!k) return;
            const rowOpts = [{ tall: true }, {}, {}, {}, {}, {}];
            const blockH = rowOpts.reduce((sum, o) => sum + kpiRowHeight(o), 0) + 4;
            sectionTitleWithBlock('1. Report KPIs', blockH);
            const grouped = { skipPageCheck: true };
            drawKpiRow([
                { label: 'Total duration', value: fmtHms(k.totalAirtimeSec || 0) },
                { label: 'Matches', value: (k.matches || 0).toLocaleString(), subValue: fmtHms(k.matchSec || 0), color: PDF.SUCCESS },
                { label: 'No match', value: (k.noMatches || 0).toLocaleString(), subValue: fmtHms(k.noMatchSec || 0), color: PDF.WARN },
                { label: 'Match rate', value: k.matchRate || '0%' }
            ], Object.assign({ tall: true }, grouped));
            drawKpiRow([
                { label: 'Avg confidence', value: k.avgConfidence > 0 ? (k.avgConfidence * 100).toFixed(1) + '%' : '—', color: PDF.INDIGO },
                { label: 'Unique songs', value: (k.uniqueSongs || 0).toLocaleString() },
                { label: 'Avg matches / day', value: k.avgMatchesPerDay || '0', color: PDF.SUCCESS },
                { label: 'Error rate', value: k.errorRate || '0%', color: PDF.WARN },
                { label: '24h change', value: k.change24h || '—', color: (k.change24hRaw != null && k.change24hRaw < 0) ? PDF.DANGER : PDF.SUCCESS },
                { label: 'Active channels', value: k.activeChannels || '—' }
            ], grouped);
            drawKpiRow([
                { label: 'Peak hour', value: k.peakHour || '—', color: PDF.INDIGO },
                { label: 'Peak day', value: k.peakDow || '—', color: PDF.INDIGO },
                { label: 'Busiest channel', value: k.busiestChannel || '—' },
                { label: 'Best day', value: k.bestDay || '—' },
                { label: 'Weekday share', value: k.weekdayShare || '—' },
                { label: 'Weekend share', value: k.weekendShare || '—', color: PDF.INDIGO }
            ], grouped);
            drawKpiRow([
                { label: 'Quiet hours (0-match)', value: String(k.quietHours ?? 0), color: PDF.WARN },
                { label: 'Longest dry streak', value: k.dryStreak || '0d', color: PDF.WARN },
                { label: 'Avg match length', value: fmtHms(k.avgMatchLen || 0), color: PDF.INDIGO },
                { label: 'Longest match', value: k.longestMatchSec > 0 ? fmtHms(k.longestMatchSec) : '—', color: PDF.SUCCESS },
                { label: 'Top play kind', value: k.topPlayKind || '—' },
                { label: 'Top program', value: k.topProgram || '—', color: PDF.INDIGO }
            ], grouped);
            drawKpiRow([
                { label: 'Top language', value: k.topLanguage || '—', color: PDF.SUCCESS },
                { label: 'Languages tracked', value: String(k.languagesTracked ?? 0), color: PDF.INDIGO },
                { label: 'Quietest day', value: k.quietestDay || '—', color: PDF.WARN },
                { label: 'Channels with data', value: String(k.channelsActive ?? 0), color: PDF.INDIGO }
            ], grouped);
            drawKpiRow([
                { label: 'Total airtime', value: fmtHms(k.totalAirtimeSec || 0), color: PDF.INDIGO },
                { label: 'Match rate (airtime)', value: k.matchRateAirtime || '0%', color: PDF.SUCCESS },
                { label: 'Error airtime', value: fmtHms(k.errorSec || 0), color: (k.errors || 0) ? PDF.WARN : undefined },
                { label: 'Error events', value: (k.errors || 0).toLocaleString(), color: (k.errors || 0) ? PDF.WARN : undefined }
            ], grouped);
            y += 4;
            _closeFrame();
        }

        // ─── HEADER ───
        drawHeader();
        y = TOP_MARGIN;

        // ─── Airplay rows → override rep.summary + rep.streamCards
        // Consolidated PDF merges consecutive same-track runs; raw PDF keeps
        // every monitor slice (matches CSV/Excel ?consolidate=false).
        (function _applyConsolidatedToReport() {
            if (!Array.isArray(airplay) || !airplay.length) return;
            const rowsC = filterConsolidatedAirplay(airplay, qs, pdfConsolidate);
            if (!rowsC.length) return;
            let tTot = 0, tM = 0, tN = 0, tE = 0, cs = 0, cn = 0;
            let tMSec = 0, tNSec = 0, tESec = 0;
            const songKey = (r) => r.trackId || ((r.title || '') + '|' + (r.artist || ''));
            const uniq = new Set();
            const byStream = new Map();
            for (const r of rowsC) {
                tTot++;
                const dur = rowDurationSec(r, rowsC);
                if (r.type === 'match') { tM++; tMSec += dur; }
                else if (r.type === 'nomatch') { tN++; tNSec += dur; }
                else if (r.type === 'error') { tE++; tESec += dur; }
                if (r.type === 'match' && r.confidence != null) { cs += r.confidence; cn++; }
                if (r.type === 'match') uniq.add(songKey(r));
                const k = r.streamId || r.streamName || '—';
                let c = byStream.get(k);
                if (!c) { c = { streamId: r.streamId, streamName: r.streamName || k, total: 0, matches: 0, noMatches: 0, errors: 0, matchSec: 0, noMatchSec: 0, errorSec: 0, _cs: 0, _cn: 0, _uniq: new Set() }; byStream.set(k, c); }
                c.total++;
                if (r.type === 'match') { c.matches++; c.matchSec += dur; if (r.confidence != null) { c._cs += r.confidence; c._cn++; } c._uniq.add(songKey(r)); }
                else if (r.type === 'nomatch') { c.noMatches++; c.noMatchSec += dur; }
                else if (r.type === 'error') { c.errors++; c.errorSec += dur; }
            }
            rep.summary = Object.assign({}, rep.summary || {}, {
                total: tTot, matches: tM, noMatches: tN, errors: tE,
                matchSeconds: tMSec, noMatchSeconds: tNSec, errorSeconds: tESec,
                avgConfidence: cn > 0 ? cs / cn : 0,
                uniqueSongs: uniq.size
            });
            // Join each channel against the cached streams list so the
            // PDF channel summary, language breakdown, and analytics
            // charts all carry the configured Language alongside the
            // numeric counters. Cached at PDF-build time so a stream
            // config edit is picked up next export without a refresh.
            const langByStreamId = new Map();
            for (const s of (monState.streams || [])) {
                if (s && s.id) langByStreamId.set(s.id, String(s.language || '').trim());
            }
            rep.streamCards = Array.from(byStream.values()).map(c => ({
                streamId: c.streamId, streamName: c.streamName,
                language: langByStreamId.get(c.streamId) || '',
                total: c.total, matches: c.matches, noMatches: c.noMatches, errors: c.errors,
                matchSeconds: c.matchSec, noMatchSeconds: c.noMatchSec, errorSeconds: c.errorSec,
                avgConfidence: c._cn > 0 ? c._cs / c._cn : 0,
                uniqueSongs: c._uniq.size
            }));
        })();

        // ─── BUILD TIME-BASED CHART AGGREGATES (PDF graphs use airtime, not counts) ───
        (function _applyTimeBasedAggregates() {
            if (!Array.isArray(airplay) || !airplay.length) return;
            const rowsC = filterConsolidatedAirplay(airplay, qs, pdfConsolidate);
            if (!rowsC.length) return;
            const hourlyMSec = new Array(24).fill(0);
            const hourlyNSec = new Array(24).fill(0);
            const dowMSec = new Array(7).fill(0);
            const dowNSec = new Array(7).fill(0);
            const hourByDowMSec = Array.from({ length: 7 }, () => new Array(24).fill(0));
            const dailyMap = new Map();
            const songSec = new Map();
            const songKeyT = (r) => r.trackId || ((r.title || '') + '|' + (r.artist || ''));
            for (const r of rowsC) {
                const t = new Date(r.timeUtc);
                if (isNaN(t.getTime())) continue;
                const dur = rowDurationSec(r, rowsC);
                const hh = t.getHours();
                const dw = t.getDay();
                const dk = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
                let dd = dailyMap.get(dk);
                if (!dd) { dd = { date: dk, total: 0, matches: 0, noMatches: 0 }; dailyMap.set(dk, dd); }
                if (r.type === 'match') {
                    hourlyMSec[hh] += dur;
                    dowMSec[dw] += dur;
                    hourByDowMSec[dw][hh] += dur;
                    dd.matches += dur; dd.total += dur;
                    const sk = songKeyT(r);
                    let so = songSec.get(sk);
                    if (!so) { so = { trackId: r.trackId || null, title: r.title || '', artist: r.artist || '', count: 0, seconds: 0, confSum: 0, confN: 0, maxConfidence: 0 }; songSec.set(sk, so); }
                    so.count++; so.seconds += dur;
                    if (r.confidence != null) {
                        so.confSum += r.confidence; so.confN++;
                        if (r.confidence > so.maxConfidence) so.maxConfidence = r.confidence;
                    }
                } else if (r.type === 'nomatch') {
                    hourlyNSec[hh] += dur;
                    dowNSec[dw] += dur;
                    dd.noMatches += dur; dd.total += dur;
                }
            }
            rep.hourly = hourlyMSec;
            rep.hourlyNoMatches = hourlyNSec;
            rep.dayOfWeek = dowMSec;
            rep.dayOfWeekNoMatches = dowNSec;
            rep.hourByDow = hourByDowMSec;
            rep.daily = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date));
            rep.topSongs = Array.from(songSec.values()).map(o => ({
                trackId: o.trackId, title: o.title, artist: o.artist,
                count: o.count, seconds: o.seconds,
                avgConfidence: o.confN > 0 ? o.confSum / o.confN : 0,
                maxConfidence: o.maxConfidence
            })).sort((a, b) => b.seconds - a.seconds);
        })();

        const s = rep.summary || {};

        // Date format helpers (used by multiple sections below).
        const toDdMm = (s) => {
            const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
            return m ? `${m[3]}-${m[2]}` : String(s).slice(5);
        };
        const toDdMmYyyy = (s) => {
            const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
            return m ? `${m[3]}-${m[2]}-${m[1]}` : String(s);
        };

        // ─── TABLE OF CONTENTS (first, then body sections in same order) ───
        sectionTitle('Table of Contents');
        const PDF_TOC = [
            { title: '1. Report KPIs', desc: 'Summary cards for the selected range: event totals, match and no-match counts, airtime, match rates, peaks, channels, languages, and quality metrics.' },
            { title: '2. System storage', desc: 'Recording folder size, backup file count and size, and host disk free space / used percentage.' },
            { title: '3. Top Matched Songs', desc: 'Ranked table of tracks with the most matched airtime, including artist, track ID, average and peak confidence.' },
            { title: '4. Top Album / Folder', desc: 'Albums and folders rolled up from matched plays, sorted by total matched airtime and unique song count.' },
            { title: '5. Channel Summary', desc: 'One row per monitored stream: events, match rate, matched and unmatched airtime, errors, and unique songs.' },
            { title: '6. Key Insights', desc: 'Narrative highlights: peak hour and day, busiest channel, best and quietest days, streaks, and weekday vs weekend split.' },
            { title: '6a. Matched Airtime by Language', desc: 'Bar chart and tables showing how much matched listening time each configured stream language contributed.' },
            { title: '7. Hourly Airtime Distribution (Match vs No-Match)', desc: 'Stacked bars for each hour of the day — green is matched airtime, yellow is unmatched.' },
            { title: '8. Hourly Match-Rate %', desc: 'Line chart of the percentage of successful matches in each hour (0–100%).' },
            { title: '9. Weekday vs Weekend Airtime', desc: 'Side-by-side comparison of matched airtime on weekdays (Mon–Fri) versus weekends (Sat–Sun).' },
            { title: '10. Last 7 Days Airtime Trend', desc: 'Bar chart of matched airtime for the most recent seven days in the report, with change and dry-streak notes.' },
            { title: '11. Hour × Day-of-Week Heatmap (Airtime)', desc: 'Heatmap grid: rows are days of the week, columns are hours — darker cells mean more matched airtime.' },
            { title: '12. Daily Airtime Distribution', desc: 'Per-calendar-day matched and no-match airtime bars, plus a table with daily match rate.' },
            { title: '13. Day-of-Week Airtime (Match vs No-Match)', desc: 'Stacked bars for Sun–Sat comparing matched vs unmatched airtime by weekday.' },
            { title: '14. Confidence Score Distribution', desc: 'Histogram of how many match events fell into each fingerprint confidence band (0–100%).' },
            { title: '15. Match vs No-Match Airtime Ratio', desc: 'Donut chart of overall listening time split between matched (green) and unmatched (yellow) airtime.' },
            { title: '16. Channel Airtime Contribution', desc: 'Pie chart showing each channel\'s share of total matched plus unmatched airtime in the range.' },
            { title: '17. Top 10 Songs — Airtime Ranking', desc: 'Horizontal bars for the ten longest matched tracks by total seconds of airtime.' },
            { title: '18. Channel Match-Rate Comparison', desc: 'Indexed bar chart per channel (match airtime) with an overlaid line for match-rate percentage.' },
            { title: '19. Channel Profile Radar', desc: 'Radar chart comparing top channels on match rate %, average confidence %, and relative volume.' },
            { title: '20. Cumulative Airtime Trend', desc: 'Line chart of running total matched airtime from the first day to the last day in the report.' },
            { title: '21. Daily Match vs No-Match Trend', desc: 'Stacked daily bars — matched airtime on the bottom, unmatched on top — across the full date range.' },
            { title: '22. Daily Match Rate %', desc: 'Day-by-day line chart of match success rate (matched airtime ÷ matchable airtime).' },
            { title: '23. Play Kind Distribution', desc: 'Pie chart of matched airtime grouped by play kind (e.g. Full Song, jingle, or unclassified).' },
            { title: '24. Language Distribution', desc: 'Doughnut chart of matched airtime share per broadcast language (top languages plus Other).' },
            { title: '25. Matches by Language', desc: 'Bar chart of match event counts per language (number of matches, not seconds).' },
            { title: '26. Channel Match vs No-Match Airtime', desc: 'Stacked bars for the busiest channels — matched vs unmatched airtime side by side.' },
            { title: '27. Channel Average Confidence', desc: 'Bar chart of mean fingerprint confidence (%) for each of the top active channels.' },
            { title: '28. Airplay / compliance log', desc: pdfConsolidate
                ? 'Detailed audit table — consecutive same-track detections merged (consolidated), with times, channel, song, and program.'
                : 'Detailed audit table — one row per monitor slice (raw), with times, channel, song, and program.' }
        ];
        const tocTextW = W - MARGIN * 2 - 16;
        PDF_TOC.forEach(item => {
            ensure(28);
            doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(...PDF.SLATE);
            doc.text(item.title, MARGIN + 8, y);
            y += 11;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
            const descLines = doc.splitTextToSize(item.desc, tocTextW);
            descLines.forEach(line => {
                ensure(10);
                doc.text(line, MARGIN + 14, y);
                y += 10;
            });
            y += 5;
        });
        y += 4;

        // ─── 1. REPORT KPIs ───
        const rowsForKpi = filterConsolidatedAirplay(airplay, qs, pdfConsolidate);
        const pdfKpi = buildFullReportKpiSnapshot(rowsForKpi, rep, monState.streams, activeChannels);
        window._lastReportKpis = pdfKpi;
        window._lastLanguageSummary = pdfKpi.langArr || [];
        drawReportTabKpiCards(pdfKpi);

        // ─── 2. SYSTEM STORAGE ───
        sectionTitleWithBlock('2. System storage', kpiRowHeight({}) + 4);
        const diskUsedPct = sys.diskUsedPercent || 0;
        const diskColor = diskUsedPct >= 90 ? PDF.DANGER : diskUsedPct >= 75 ? PDF.WARN : PDF.SUCCESS;
        drawKpiRow([
            { label: 'Recording Size', value: fmtBytes(sys.recordingFolderBytes || 0) },
            { label: 'Backed Up Files', value: (backup.totalFiles || 0).toLocaleString() },
            { label: 'Backup Size', value: fmtBytes(backup.totalBytes || 0) },
            { label: 'Disk Free', value: (sys.diskFreeGb || 0).toFixed(1) + ' GB' },
            { label: 'Disk Used', value: diskUsedPct.toFixed(1) + '%', color: diskColor }
        ], { skipPageCheck: true });
        y += 4;
        _closeFrame();

        // ─── 3. TOP MATCHED SONGS ───
        sectionTitle('3. Top Matched Songs');
        const topSongs = rep.topSongs || [];
        if (topSongs.length === 0) {
            doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(...PDF.MUTED);
            doc.text('No matches in the selected range.', MARGIN, y); y += 16;
        } else {
            const maxCount = Math.max(...topSongs.map(t => t.count));
            runAutoTable({
                startY: y,
                head: [['#', 'Song', 'Artist', 'Track ID', 'Airtime', 'Avg Conf', 'Max Conf']],
                body: topSongs.slice(0, 20).map((t, i) => [
                    i + 1,
                    t.title || '—',
                    t.artist || '—',
                    t.trackId || '—',
                    fmtHms(t.seconds || (t.count || 0) * defaultMonitorSliceSeconds()),
                    (t.avgConfidence * 100).toFixed(1) + '%',
                    (t.maxConfidence * 100).toFixed(1) + '%'
                ]),
                theme: 'grid',
                styles: { font: 'helvetica', fontSize: 7, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 3 },
                headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7, halign: 'center', cellPadding: 5 },
                alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                columnStyles: { 0: { cellWidth: 22, halign: 'center' }, 4: { halign: 'right', fontStyle: 'bold' }, 5: { halign: 'right' }, 6: { halign: 'right' } },
                margin: { left: MARGIN, right: MARGIN },
                didParseCell: (d) => {
                    if (d.section === 'body' && d.column.index === 0) {
                        if (d.row.index === 0) d.cell.styles.textColor = [212, 175, 55];
                        else if (d.row.index === 1) d.cell.styles.textColor = [160, 160, 160];
                        else if (d.row.index === 2) d.cell.styles.textColor = [176, 100, 40];
                        if (d.row.index < 3) d.cell.styles.fontStyle = 'bold';
                    }
                }
            });
            y = doc.lastAutoTable.finalY + 14;
        }

        // ─── 2. TOP ALBUM / FOLDER (aggregated) ───
        sectionTitle('4. Top Album / Folder');
        {
            const agg = {};
            (rep.topSongs || []).forEach(t => {
                const a = (t.artist || 'Unknown').trim() || 'Unknown';
                if (!agg[a]) agg[a] = { artist: a, count: 0, confSum: 0, confN: 0, songs: new Set() };
                agg[a].count += Number(t.count) || 0;
                if (t.avgConfidence > 0) { agg[a].confSum += t.avgConfidence * (t.count || 1); agg[a].confN += (t.count || 1); }
                if (t.trackId) agg[a].songs.add(t.trackId);
            });
            const artistRows = Object.values(agg)
                .map(r => ({ artist: r.artist, count: r.count, avgConf: r.confN > 0 ? r.confSum / r.confN : 0, uniqueSongs: r.songs.size }))
                .sort((a, b) => b.count - a.count)
                .slice(0, 15);
            if (artistRows.length === 0) {
                doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(...PDF.MUTED);
                doc.text('No artist data in range.', MARGIN, y); y += 16;
            } else {
                runAutoTable({
                    startY: y,
                    head: [['#', 'Album / Folder', 'Unique Songs', 'Total Plays', 'Avg Confidence']],
                    body: artistRows.map((r, i) => [
                        i + 1,
                        r.artist,
                        r.uniqueSongs,
                        r.count,
                        (r.avgConf * 100).toFixed(1) + '%'
                    ]),
                    theme: 'grid',
                    styles: { font: 'helvetica', fontSize: 7, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 3 },
                    headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7, halign: 'center', cellPadding: 5 },
                    alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                    columnStyles: { 0: { cellWidth: 22, halign: 'center' }, 2: { halign: 'right' }, 3: { halign: 'right', fontStyle: 'bold' }, 4: { halign: 'right' } },
                    margin: { left: MARGIN, right: MARGIN },
                    didParseCell: (d) => {
                        if (d.section === 'body' && d.column.index === 0 && d.row.index < 3) {
                            d.cell.styles.fontStyle = 'bold';
                            if (d.row.index === 0) d.cell.styles.textColor = [212, 175, 55];
                            else if (d.row.index === 1) d.cell.styles.textColor = [160, 160, 160];
                            else d.cell.styles.textColor = [176, 100, 40];
                        }
                    }
                });
                y = doc.lastAutoTable.finalY + 14;
            }
        }

        // ─── 3. CHANNEL SUMMARY ───
        sectionTitle('5. Channel Summary');
        const cards = rep.streamCards || [];
        if (cards.length === 0) {
            doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(...PDF.MUTED);
            doc.text('No channel activity in range.', MARGIN, y); y += 16;
        } else {
            runAutoTable({
                startY: y,
                // Language column inserted between Channel and Total so
                // the broadcast market is visible at a glance next to
                // the channel name. Indices in didParseCell shift by 1
                // accordingly — see comments below.
                head: [['Channel', 'Language', 'Total', 'Matches', 'No Match', 'Match Airtime', 'Match Rate', 'Avg Conf', 'Unique', 'Status']],
                body: cards.map(c => {
                    const mr = (c.matches + c.noMatches) > 0 ? (c.matches / (c.matches + c.noMatches) * 100) : 0;
                    const st = mr >= 80 ? 'Active' : mr >= 50 ? 'Warning' : 'Low';
                    return [
                        c.streamName,
                        c.language || '—',
                        c.total,
                        c.matches,
                        c.noMatches,
                        fmtHms(c.matchSeconds || 0),
                        mr.toFixed(1) + '%',
                        (c.avgConfidence * 100).toFixed(1) + '%',
                        c.uniqueSongs,
                        st
                    ];
                }),
                theme: 'grid',
                styles: { font: 'helvetica', fontSize: 7, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 3 },
                headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7, halign: 'center', cellPadding: 5 },
                alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                columnStyles: {
                    1: { halign: 'center', fontStyle: 'bold' },   // Language
                    5: { halign: 'right' }                        // Match Airtime
                },
                margin: { left: MARGIN, right: MARGIN },
                didParseCell: (d) => {
                    if (d.section !== 'body') return;
                    // Column indices below shifted by +1 because the
                    // Language column was inserted at index 1.
                    if (d.column.index === 3) d.cell.styles.textColor = PDF.SUCCESS;   // Matches
                    if (d.column.index === 4) d.cell.styles.textColor = PDF.NOMATCH;   // No Match
                    if (d.column.index === 9) {                                        // Status
                        const v = d.cell.raw;
                        if (v === 'Active') d.cell.styles.textColor = PDF.SUCCESS;
                        else if (v === 'Warning') d.cell.styles.textColor = PDF.WARN;
                        else d.cell.styles.textColor = PDF.DANGER;
                        d.cell.styles.fontStyle = 'bold';
                    }
                }
            });
            y = doc.lastAutoTable.finalY + 14;
        }

        // ─── 4. KEY INSIGHTS (derived analytics) ───
        sectionTitle('6. Key Insights');
        {
            const DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
            const sm = rep.summary || {};
            const hly = rep.hourly || [];
            const dw = rep.dayOfWeek || [];
            const dly = rep.daily || [];
            const cds = rep.streamCards || [];

            // Peak hour
            let pH = -1, pHv = -1;
            hly.forEach((v, i) => { if (v > pHv) { pHv = v; pH = i; } });
            // Peak DoW
            let pD = -1, pDv = -1;
            dw.forEach((v, i) => { if (v > pDv) { pDv = v; pD = i; } });
            // Busiest channel
            let busy = null;
            cds.forEach(c => { if (!busy || (c.matches || 0) > (busy.matches || 0)) busy = c; });
            // Best day
            let bd = null;
            dly.forEach(d => { if (!bd || d.matches > bd.matches) bd = d; });
            // Quietest day (with data)
            let qd = null;
            dly.forEach(d => { if (!qd || d.matches < qd.matches) qd = d; });
            // Averages
            const days = dly.length || 1;
            const avgPerDay = Math.round((sm.matches || 0) / days);
            const total = sm.total || 0;
            const mrTotal = (sm.matches || 0) + (sm.noMatches || 0);
            const overallRate = mrTotal > 0 ? ((sm.matches / mrTotal) * 100).toFixed(1) + '%' : '0%';
            const errorRate = total > 0 ? (((sm.errors || 0) / total) * 100).toFixed(2) + '%' : '0%';

            const rows = [
                ['Peak Hour', pH >= 0 && pHv > 0 ? `${String(pH).padStart(2, '0')}:00 \u2014 ${fmtHms(pHv)} airtime` : '\u2014'],
                ['Peak Day of Week', pD >= 0 && pDv > 0 ? `${DOW_NAMES[pD]} \u2014 ${fmtHms(pDv)} airtime` : '\u2014'],
                ['Busiest Channel', busy && busy.total > 0 ? `${busy.streamName} \u2014 ${fmtHms((busy.matchSeconds || 0) + (busy.noMatchSeconds || 0))} airtime` : '\u2014'],
                ['Best Day', bd && bd.matches > 0 ? `${toDdMmYyyy(bd.date)} \u2014 ${fmtHms(bd.matches)} airtime` : '\u2014'],
                ['Quietest Day', qd && dly.length > 1 ? `${toDdMmYyyy(qd.date)} \u2014 ${fmtHms(qd.matches || 0)} airtime` : '\u2014'],
                ['Avg Airtime per Day', `${fmtHms((sm.matchSeconds || 0) / (dly.length || 1))} (over ${dly.length} day${dly.length !== 1 ? 's' : ''})`],
                // Count-based companion to the airtime row above — mirrors
                // the on-screen "Avg matches / day" KPI tile so the PDF
                // matches the dashboard tile-for-tile.
                ['Avg Matches per Day', avgPerDay.toLocaleString()],
                ['Overall Match Rate', overallRate],
                ['Error Rate', errorRate],
                ['Unique Songs Detected', (sm.uniqueSongs || 0).toLocaleString()],
                ['Active Channels', `${cds.filter(c => c.total > 0).length} of ${cds.length}`]
            ];
            // ─── Extended analytics rows ─────────────────────────────────
            {
                const wArr = dw.length === 7 ? dw : new Array(7).fill(0);
                const weMatches = (wArr[0] || 0) + (wArr[6] || 0);
                const wdMatches = wArr.reduce((a, b) => a + b, 0) - weMatches;
                const wTotal = wdMatches + weMatches;
                const wdPct = wTotal > 0 ? ((wdMatches / wTotal) * 100).toFixed(0) + '%' : '\u2014';
                const wePct = wTotal > 0 ? ((weMatches / wTotal) * 100).toFixed(0) + '%' : '\u2014';
                rows.push(['Weekday vs Weekend', wTotal > 0 ? `${wdPct} weekday \u2014 ${wePct} weekend` : '\u2014']);
                const quietH = hly.length === 24 ? hly.filter(h => !h).length : 0;
                rows.push(['Quiet Hours (0-match)', `${quietH} of 24 hours`]);
                let curS = 0, maxS = 0;
                dly.forEach(d => {
                    if ((d.matches || 0) === 0) { curS++; if (curS > maxS) maxS = curS; } else { curS = 0; }
                });
                rows.push(['Longest Dry Streak', `${maxS} day${maxS !== 1 ? 's' : ''} with no airtime`]);
                if (dly.length >= 2) {
                    const pv = dly[dly.length - 2].matches || 0;
                    const cv = dly[dly.length - 1].matches || 0;
                    let chgTxt;
                    if (pv === 0 && cv === 0) chgTxt = '0% (no activity either day)';
                    else if (pv === 0) chgTxt = `+new (${fmtHms(cv)} airtime vs 0)`;
                    else {
                        const p = ((cv - pv) / pv) * 100;
                        chgTxt = `${p >= 0 ? '+' : ''}${p.toFixed(0)}% (${fmtHms(cv)} vs ${fmtHms(pv)})`;
                    }
                    rows.push(['24h Change', chgTxt]);
                } else {
                    rows.push(['24h Change', '\u2014 (need at least 2 days)']);
                }
                const activeChans = cds.filter(c => (c.matches || 0) > 0).length;
                rows.push(['Channels With Matches', `${activeChans} of ${cds.length}`]);
                if (cds.length > 0) {
                    const best = [...cds].filter(c => (c.avgConfidence || 0) > 0)
                        .sort((a, b) => b.avgConfidence - a.avgConfidence)[0];
                    rows.push(['Highest-Confidence Channel',
                        best ? `${best.streamName} \u2014 ${(best.avgConfidence * 100).toFixed(1)}%` : '\u2014']);
                }
            }
            // \u2500\u2500\u2500 Deep-dive KPIs (airtime + classification + language) \u2500\u2500\u2500
            // Mirror every on-screen tile so the printed report matches
            // what the analyst saw on the dashboard. Pulled from the
            // last on-screen recompute via window._lastReportKpis +
            // window._lastLanguageSummary so the wording can't drift
            // between the two surfaces.
            {
                const kpi = window._lastReportKpis || null;
                if (kpi) {
                    rows.push(['Total Events', (kpi.total || 0).toLocaleString()]);
                    rows.push(['Matches (count / airtime)',
                        `${(kpi.matches || 0).toLocaleString()} \u2014 ${fmtHms(kpi.matchSec || 0)}`]);
                    rows.push(['No Match (count / airtime)',
                        `${(kpi.noMatches || 0).toLocaleString()} \u2014 ${fmtHms(kpi.noMatchSec || 0)}`]);
                    if ((kpi.errors || 0) > 0) {
                        rows.push(['Errors (count / airtime)',
                            `${(kpi.errors || 0).toLocaleString()} \u2014 ${fmtHms(kpi.errorSec || 0)}`]);
                    }
                    rows.push(['Avg Match Length', fmtHms(kpi.avgMatchLen || 0)]);
                    // Longest match is duration-only here too \u2014 mirrors
                    // the on-screen tile after the song-name strip.
                    rows.push(['Longest Match',
                        kpi.longestMatchSec > 0 ? fmtHms(kpi.longestMatchSec) : '\u2014']);
                    rows.push(['Avg Confidence',
                        kpi.avgConfidence > 0 ? (kpi.avgConfidence * 100).toFixed(1) + '%' : '\u2014']);
                    rows.push(['Top Play Kind',  kpi.topPlayKind || '\u2014']);
                    rows.push(['Top Program',    kpi.topProgram  || '\u2014']);
                }
                const langSummary = Array.isArray(window._lastLanguageSummary)
                    ? window._lastLanguageSummary : [];
                if (langSummary.length > 0) {
                    const top = langSummary[0];
                    const topPct = (top.share * 100).toFixed(0);
                    rows.push(['Top Language',
                        `${top.name} \u2014 ${topPct}% (${fmtHms(top.matchSec)})`]);
                    rows.push(['Languages Tracked', langSummary.length.toString()]);
                } else {
                    rows.push(['Top Language', '\u2014']);
                    rows.push(['Languages Tracked', '0']);
                }
            }
            runAutoTable({
                startY: y,
                head: [['Metric', 'Value']],
                body: rows,
                theme: 'grid',
                styles: { font: 'helvetica', fontSize: 8, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 4 },
                headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8, halign: 'left', cellPadding: 5 },
                alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                columnStyles: { 0: { fontStyle: 'bold', cellWidth: 160 }, 1: {} },
                margin: { left: MARGIN, right: MARGIN }
            });
            y = doc.lastAutoTable.finalY + 14;

            // ─── Per-language breakdown ─────────────────────────────────
            // Dedicated table immediately below the KPI summary so the
            // language distribution is visible at-a-glance without
            // needing the doughnut chart. Sorted descending by matched
            // airtime — same order as the on-screen table.
            const langSummary = Array.isArray(window._lastLanguageSummary)
                ? window._lastLanguageSummary : [];
            if (langSummary.length > 0) {
                doc.setFont('helvetica', 'bold'); doc.setFontSize(10);
                doc.setTextColor(...PDF.BRAND_DARK);
                doc.text('Per-Language Airtime Breakdown', MARGIN, y);
                y += 12;
                const langRows = langSummary.map(l => [
                    l.name,
                    l.matches.toLocaleString(),
                    fmtHms(l.matchSec),
                    (l.share * 100).toFixed(1) + '%'
                ]);
                runAutoTable({
                    startY: y,
                    head: [['Language', 'Matches', 'Match Airtime', 'Share']],
                    body: langRows,
                    theme: 'grid',
                    styles: { font: 'helvetica', fontSize: 8, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 4 },
                    headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8, halign: 'center', cellPadding: 5 },
                    alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                    columnStyles: {
                        0: { fontStyle: 'bold' },
                        1: { halign: 'right', cellWidth: 70 },
                        2: { halign: 'right', cellWidth: 100 },
                        3: { halign: 'right', cellWidth: 60 }
                    },
                    margin: { left: MARGIN, right: MARGIN }
                });
                y = doc.lastAutoTable.finalY + 14;

                // ─── Language airtime bar chart ──────────────────────────
                // Drawn with the same native drawBarChart helper used by
                // the other PDF analytics so the visual style matches.
                // Top 10 languages by airtime — beyond that the bars
                // become illegible. Bars are coloured with PDF.INDIGO so
                // they stand out from the green/yellow match/no-match
                // pairs elsewhere in the report.
                const langChartData = langSummary.slice(0, 10);
                if (langChartData.length > 0) {
                    sectionTitleWithChart('6a. Matched Airtime by Language', 130);
                    drawBarChart(
                        doc, MARGIN, y, W - MARGIN * 2, 100,
                        langChartData.map(l => l.matchSec),
                        i => langChartData[i].name,
                        PDF.INDIGO
                    );
                    y += 124;
                    const totalMatched = langSummary.reduce((s, l) => s + l.matchSec, 0);
                    doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
                    doc.text(
                        `Total matched airtime across ${langSummary.length} language(s): ${fmtHms(totalMatched)}` +
                        (langSummary.length > 10 ? `  •  showing top 10` : ''),
                        MARGIN, y);
                    y += 18;
                }

                // ─── Channel × Language airtime matrix ───────────────────
                // Compact table answering "which channel contributed how
                // much airtime in each language". Rebuilt from the
                // streamCards we joined with language earlier — each
                // language column shows that channel's matched airtime
                // when its configured language matches the column header.
                // Channels with no language fall under "Unspecified".
                const chCards = (rep.streamCards || []).filter(c => (c.matches || 0) > 0);
                if (chCards.length > 0 && langSummary.length > 0) {
                    doc.setFont('helvetica', 'bold'); doc.setFontSize(10);
                    doc.setTextColor(...PDF.BRAND_DARK);
                    doc.text('Channel × Language Airtime', MARGIN, y);
                    y += 12;
                    const chLangRows = chCards
                        .slice()
                        .sort((a, b) => (b.matchSeconds || 0) - (a.matchSeconds || 0))
                        .map(c => [
                            c.streamName,
                            c.language || 'Unspecified',
                            (c.matches || 0).toLocaleString(),
                            fmtHms(c.matchSeconds || 0),
                            (((c.matchSeconds || 0) /
                                Math.max(1, langSummary.reduce((s, l) => s + l.matchSec, 0))
                              ) * 100).toFixed(1) + '%'
                        ]);
                    runAutoTable({
                        startY: y,
                        head: [['Channel', 'Language', 'Matches', 'Match Airtime', '% of all matched']],
                        body: chLangRows,
                        theme: 'grid',
                        styles: { font: 'helvetica', fontSize: 8, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 4 },
                        headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8, halign: 'center', cellPadding: 5 },
                        alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                        columnStyles: {
                            0: { fontStyle: 'bold' },
                            1: { halign: 'center' },
                            2: { halign: 'right', cellWidth: 70 },
                            3: { halign: 'right', cellWidth: 100 },
                            4: { halign: 'right', cellWidth: 80 }
                        },
                        margin: { left: MARGIN, right: MARGIN }
                    });
                    y = doc.lastAutoTable.finalY + 14;
                }
            }
        }

        // ─── 5. HOURLY AIRTIME DISTRIBUTION (stacked match vs no-match) ───
        _setPdfValFmt(fmtHms);
        const hourly = rep.hourly || [];
        const hourlyNm = rep.hourlyNoMatches || [];
        sectionTitleWithChart('7. Hourly Airtime Distribution (Match vs No-Match)', 170);
        drawStackedBarChart(doc, MARGIN, y, W - MARGIN * 2, 120, [
            { data: hourly, color: PDF.SUCCESS, label: 'Matches' },
            { data: hourlyNm, color: PDF.NOMATCH, label: 'No Match' }
        ], i => String(i).padStart(2, '0'));
        y += 144;
        drawLegend(doc, MARGIN, y, [
            { color: PDF.SUCCESS, label: 'Matches' },
            { color: PDF.NOMATCH, label: 'No Match' }
        ]);
        y += 12;
        if (hourly.length) {
            const peakHr = hourly.indexOf(Math.max(...hourly));
            const totalH = hourly.reduce((a, b) => a + b, 0);
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
            doc.text(`Peak hour: ${String(peakHr).padStart(2, '0')}:00  •  Total airtime: ${fmtHms(totalH)}  •  Avg/hour: ${fmtHms(totalH / 24)}`, MARGIN, y);
            y += 18;
        }

        // ─── 6. HOURLY MATCH-RATE % (line chart) ───
        if (hourly.length === 24 && hourlyNm.length === 24) {
            _setPdfValFmt((v) => `${Math.round(v)}%`);
            sectionTitleWithChart('8. Hourly Match-Rate %', 140);
            const rateSeries = hourly.map((m, i) => {
                const tot = (m || 0) + (hourlyNm[i] || 0);
                return tot > 0 ? (m / tot) * 100 : 0;
            });
            drawLineChart(doc, MARGIN, y, W - MARGIN * 2, 100, rateSeries,
                i => String(i).padStart(2, '0'), PDF.SUCCESS);
            y += 124;
            const active = rateSeries.filter((_, i) => ((hourly[i] || 0) + (hourlyNm[i] || 0)) > 0);
            const avgRate = active.length > 0 ? (active.reduce((a, b) => a + b, 0) / active.length) : 0;
            const peakRateHr = rateSeries.indexOf(Math.max(...rateSeries));
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
            doc.text(`Avg rate: ${avgRate.toFixed(1)}%  •  Peak rate hour: ${String(peakRateHr).padStart(2, '0')}:00 (${rateSeries[peakRateHr].toFixed(1)}%)`, MARGIN, y);
            y += 18;
        }
        _setPdfValFmt(fmtHms);

        // ─── 7. WEEKDAY vs WEEKEND SPLIT ───
        {
            const dwArr = (rep.dayOfWeek || []);
            const wArr7 = dwArr.length === 7 ? dwArr : new Array(7).fill(0);
            const weM = (wArr7[0] || 0) + (wArr7[6] || 0);
            const wdM = wArr7.reduce((a, b) => a + b, 0) - weM;
            const splitTotal = wdM + weM;
            if (splitTotal > 0) {
                sectionTitleWithChart('9. Weekday vs Weekend Airtime', 120);
                drawBarChart(doc, MARGIN, y, W - MARGIN * 2, 90, [wdM, weM],
                    i => ['Weekday (Mon–Fri)', 'Weekend (Sat–Sun)'][i], PDF.INDIGO);
                y += 114;
                const wdPct = ((wdM / splitTotal) * 100).toFixed(0);
                const wePct = ((weM / splitTotal) * 100).toFixed(0);
                doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
                doc.text(`Weekday: ${fmtHms(wdM)} (${wdPct}%)  •  Weekend: ${fmtHms(weM)} (${wePct}%)`, MARGIN, y);
                y += 18;
            } else {
                sectionTitleWithChart('9. Weekday vs Weekend Airtime', 40);
                doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(...PDF.MUTED);
                doc.text('No day-of-week data available in the selected range.', MARGIN, y);
                y += 16;
            }
        }

        // ─── 8. LAST 7 DAYS TREND ───
        {
            const dlyArr = rep.daily || [];
            if (dlyArr.length > 0) {
                const last7 = dlyArr.slice(-7);
                const values7 = last7.map(d => d.matches || 0);
                const labels7 = last7.map(d => {
                    const s = d.date || '';
                    return s.length >= 10 ? `${s.slice(8, 10)}/${s.slice(5, 7)}` : s;
                });
                sectionTitleWithChart('10. Last 7 Days Airtime Trend', 130);
                drawBarChart(doc, MARGIN, y, W - MARGIN * 2, 100, values7,
                    i => labels7[i], PDF.SUCCESS);
                y += 124;
                // Dry streak within last 7 + 24h change
                let curS = 0, maxS = 0;
                values7.forEach(v => { if (v === 0) { curS++; if (curS > maxS) maxS = curS; } else curS = 0; });
                let chgTxt = '—';
                if (values7.length >= 2) {
                    const pv = values7[values7.length - 2];
                    const cv = values7[values7.length - 1];
                    if (pv === 0 && cv === 0) chgTxt = '0%';
                    else if (pv === 0) chgTxt = '+new';
                    else {
                        const p = ((cv - pv) / pv) * 100;
                        chgTxt = `${p >= 0 ? '+' : ''}${p.toFixed(0)}%`;
                    }
                }
                doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
                doc.text(`24h change: ${chgTxt}  •  Dry streak (window): ${maxS} day${maxS !== 1 ? 's' : ''}  •  Avg: ${fmtHms(values7.reduce((a, b) => a + b, 0) / values7.length)} airtime/day`, MARGIN, y);
                y += 18;
            }
        }

        // ─── 6. HOUR × DAY-OF-WEEK HEATMAP ───
        const heatmap = rep.hourByDow || [];
        if (heatmap.length) {
            sectionTitleWithChart('11. Hour × Day-of-Week Heatmap (Airtime)', 160);
            drawHeatmap(doc, MARGIN, y, W - MARGIN * 2, 110, heatmap);
            y += 134;
        }

        // ─── 6. DAILY DISTRIBUTION ───
        const daily = rep.daily || [];
        // Reserve chart height only when we have data; empty-state just needs a line.
        sectionTitleWithChart('12. Daily Airtime Distribution', daily.length ? 130 : 20);
        if (!daily.length) {
            doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(...PDF.MUTED);
            doc.text('No daily data.', MARGIN, y); y += 16;
        } else {
            const dailyTotals = daily.map(d => d.total);
            ensure(130);
            drawBarChart(doc, MARGIN, y, W - MARGIN * 2, 100, dailyTotals, i => toDdMm(daily[i].date), PDF.INDIGO);            y += 124; // chart (100) + label row (10) + buffer (14)
            runAutoTable({
                startY: y,
                head: [['Date', 'Total Airtime', 'Match Airtime', 'No-Match Airtime', 'Match Rate']],
                body: daily.map(d => {
                    const mr = (d.matches + d.noMatches) > 0 ? (d.matches / (d.matches + d.noMatches) * 100).toFixed(1) + '%' : '0%';
                    return [toDdMmYyyy(d.date), fmtHms(d.total), fmtHms(d.matches), fmtHms(d.noMatches), mr];
                }),
                theme: 'grid',
                styles: { font: 'helvetica', fontSize: 7, textColor: PDF.SLATE, lineColor: PDF.GRID, lineWidth: 0.3, cellPadding: 3 },
                headStyles: { fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7, halign: 'center', cellPadding: 5 },
                alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                margin: { left: MARGIN, right: MARGIN }
            });
            y = doc.lastAutoTable.finalY + 14;
        }

        // ─── 5. DAY-OF-WEEK (stacked match vs no-match) ───
        const dow = rep.dayOfWeek || [];
        const dowNm = rep.dayOfWeekNoMatches || [];
        sectionTitleWithChart('13. Day-of-Week Airtime (Match vs No-Match)', 150);
        drawStackedBarChart(doc, MARGIN, y, W - MARGIN * 2, 100, [
            { data: dow, color: PDF.SUCCESS, label: 'Matches' },
            { data: dowNm, color: PDF.NOMATCH, label: 'No Match' }
        ], i => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][i]);
        y += 124;
        drawLegend(doc, MARGIN, y, [
            { color: PDF.SUCCESS, label: 'Matches' },
            { color: PDF.NOMATCH, label: 'No Match' }
        ]);
        y += 12;

        // ─── 6. CONFIDENCE DISTRIBUTION (event counts) ───
        _setPdfValFmt(null);
        const buckets = rep.confidenceBuckets || [];
        // Backend returns 10 buckets: 0-10%, 10-20%, ..., 90-100%
        const confLabels = ['0-10', '10-20', '20-30', '30-40', '40-50', '50-60', '60-70', '70-80', '80-90', '90-100'];
        const confColors = buckets.map((_, i) =>
            i >= 9 ? PDF.SUCCESS : i >= 7 ? PDF.BLUE : i >= 5 ? PDF.SLATE : i >= 3 ? PDF.WARN : PDF.DANGER);
        sectionTitleWithChart('14. Confidence Score Distribution', 130);
        drawBarChart(doc, MARGIN, y, W - MARGIN * 2, 100, buckets, i => confLabels[i] || '', null, confColors);
        y += 124; // chart (100) + label row (10) + buffer (14)
        _setPdfValFmt(fmtHms);

        // ─── 7. DONUT (match vs no-match airtime) ───
        sectionTitleWithChart('15. Match vs No-Match Airtime Ratio', 160);
        drawDonut(doc, MARGIN + 100, y + 60, 50, [
            { value: s.matchSeconds || 0, color: PDF.SUCCESS, label: 'Matches' },
            { value: s.noMatchSeconds || 0, color: PDF.WARN, label: 'No Match' }
        ]);
        // Legend
        doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(...PDF.SLATE);
        doc.setFillColor(...PDF.SUCCESS); doc.rect(MARGIN + 230, y + 40, 10, 10, 'F');
        doc.text(`Matches: ${fmtHms(s.matchSeconds || 0)}`, MARGIN + 246, y + 49);
        doc.setFillColor(...PDF.WARN); doc.rect(MARGIN + 230, y + 60, 10, 10, 'F');
        doc.text(`No Match: ${fmtHms(s.noMatchSeconds || 0)}`, MARGIN + 246, y + 69);
        doc.setFont('helvetica', 'bold'); doc.setFontSize(10);
        const donutDenom = (s.matchSeconds || 0) + (s.noMatchSeconds || 0);
        const donutMatchRate = donutDenom > 0
            ? (((s.matchSeconds || 0) / donutDenom) * 100).toFixed(1) + '%'
            : '0%';
        doc.text(`Match Rate (airtime): ${donutMatchRate}`, MARGIN + 230, y + 90);
        y += 140;

        // ─── 10. CHANNEL CONTRIBUTION (pie) ─ aligned with the donut above ───
        const chCards = (rep.streamCards || []).filter(c => (c.total || 0) > 0);
        if (chCards.length) {
            sectionTitleWithChart('16. Channel Airtime Contribution', 160);
            const pieColors = [PDF.INDIGO, PDF.SUCCESS, PDF.WARN, PDF.BLUE, PDF.ORANGE, PDF.DANGER, PDF.CYAN, PDF.NOMATCH, PDF.SLATE, PDF.BRAND];
            const segs = chCards.map((c, i) => ({ value: (c.matchSeconds || 0) + (c.noMatchSeconds || 0), color: pieColors[i % pieColors.length], label: c.streamName }));
            // Match donut geometry: center (MARGIN + 100, y + 60), radius 50.
            drawPie(doc, MARGIN + 100, y + 60, 50, segs);
            // Legend aligned with donut legend (MARGIN + 230, starting y + 40)
            const pTotal = segs.reduce((a, s) => a + s.value, 0) || 1;
            let ly = y + 40;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
            segs.slice(0, 8).forEach(s => {
                doc.setFillColor(...s.color);
                doc.rect(MARGIN + 230, ly - 7, 10, 10, 'F');
                doc.setTextColor(...PDF.SLATE);
                const pct = ((s.value / pTotal) * 100).toFixed(1);
                const lbl = String(s.label).length > 26 ? String(s.label).slice(0, 25) + '…' : s.label;
                doc.text(`${lbl} — ${fmtHms(s.value)} (${pct}%)`, MARGIN + 246, ly + 2);
                ly += 14;
            });
            y += 140;
        }

        // ─── 11. TOP 10 SONGS — VISUAL (by airtime) ───
        const top10 = (rep.topSongs || []).slice(0, 10);
        if (top10.length) {
            const hbH = Math.max(60, top10.length * 14);
            sectionTitleWithChart('17. Top 10 Songs — Airtime Ranking', hbH + 10);
            const items = top10.map(t => ({
                label: `${t.title || '—'} — ${t.artist || '—'}`,
                value: Number(t.seconds) || (Number(t.count) || 0) * defaultMonitorSliceSeconds()
            }));
            drawHorizontalBarChart(doc, MARGIN, y, W - MARGIN * 2, hbH, items, PDF.INDIGO);
            y += hbH + 8;
        }

        // ─── 16. CHANNEL MATCH-RATE COMPARISON (combo: bars + line) ───
        // Mirrors the Reports-tab combo chart: vertical bars = raw match count,
        // overlaid line = match-rate %. Bars use a refined 3-tier palette so
        // the chart reads cleanly even when many channels are present.
        if (chCards.length) {
            // Refined, softer palette so the tiered colors harmonise on the
            // page (previous red/amber/green felt harsh next to the frame).
            const TIER_HIGH = [20, 184, 166];   // teal-500   — strong performers
            const TIER_MID  = [217, 160, 54];   // amber, muted
            const TIER_LOW  = [148, 163, 184];  // slate-400  — low (calm, not alarming)
            const mrItems = chCards.map(c => {
                const denom = (c.matches + c.noMatches) || 1;
                const mr = (c.matches / denom) * 100;
                return {
                    label: c.streamName,
                    matches: c.matchSeconds || 0,
                    rate: Number(mr.toFixed(1)),
                    color: mr >= 80 ? TIER_HIGH : mr >= 50 ? TIER_MID : TIER_LOW
                };
            }).sort((a, b) => b.rate - a.rate);
            const mrH = 170;
            // Reserve room for chart + numbered axis + 2-column index legend
            // (~14pt per row across two columns).
            const legendRows = Math.ceil(mrItems.length / 2);
            const legendH = Math.max(28, legendRows * 12 + 18);
            sectionTitleWithChart('18. Channel Match-Rate Comparison', mrH + legendH + 14);
            // Vertical bars for match count — we supply a numbered label fn so
            // the x-axis shows compact indices (1, 2, 3…) instead of rotated
            // channel names which used to overlap / cross each other.
            drawBarChart(doc, MARGIN, y, W - MARGIN * 2, mrH,
                mrItems.map(m => m.matches),
                i => String(i + 1),
                null,
                mrItems.map(m => m.color));
            const plotH = mrH - 14;
            const barW = W - MARGIN * 2;
            const pad = 4;
            const bw = Math.max(2, (barW - pad * (mrItems.length - 1)) / mrItems.length);
            // Overlay line for match-rate % on a secondary 0-100 scale.
            doc.setDrawColor(...PDF.INDIGO); doc.setLineWidth(1.2);
            let prevX = null, prevY = null;
            mrItems.forEach((m, i) => {
                const bx = MARGIN + i * (bw + pad) + bw / 2;
                const by = y + plotH - (m.rate / 100) * plotH;
                if (prevX != null) doc.line(prevX, prevY, bx, by);
                doc.setFillColor(...PDF.INDIGO);
                doc.circle(bx, by, 1.6, 'F');
                prevX = bx; prevY = by;
            });
            // Secondary axis (right) — % ticks drawn INSIDE the plot area,
            // right-aligned, indigo to match the overlay line.
            doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.INDIGO);
            for (let i = 0; i <= 4; i++) {
                const gy = y + plotH - (i / 4) * plotH;
                const pct = Math.round((i / 4) * 100);
                doc.text(pct + '%', MARGIN + barW - 3, gy - 2, { align: 'right' });
            }
            y += mrH + 6;
            // ─ Channel index legend ─ two columns, neat swatch + "N. Channel".
            const legendTop = y;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
            const colW = (W - MARGIN * 2) / 2;
            const maxTextW = colW - 28;
            mrItems.forEach((m, i) => {
                const col = i % 2;
                const row = Math.floor(i / 2);
                const cx = MARGIN + col * colW;
                const ry = legendTop + row * 12;
                doc.setFillColor(...m.color);
                doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.2);
                doc.roundedRect(cx, ry - 6, 10, 8, 1.5, 1.5, 'FD');
                // Index + channel name (truncated if too wide).
                doc.setTextColor(...PDF.SLATE);
                let nameTxt = `${i + 1}. ${m.label}`;
                while (doc.getTextWidth(nameTxt) > maxTextW - 40 && nameTxt.length > 6) {
                    nameTxt = nameTxt.slice(0, -2) + '…';
                }
                doc.text(nameTxt, cx + 14, ry);
                // Rate/count on the right of the row.
                doc.setTextColor(...PDF.MUTED); doc.setFontSize(7);
                doc.text(`${m.rate}% · ${fmtHms(m.matches)}`, cx + colW - 6, ry, { align: 'right' });
                doc.setFontSize(8);
            });
            y = legendTop + legendRows * 12 + 6;
            // Tier legend strip (colored dots + line swatch) below the index.
            drawLegend(doc, MARGIN, y, [
                { color: TIER_HIGH, label: '>= 80% rate' },
                { color: TIER_MID,  label: '50-79%' },
                { color: TIER_LOW,  label: 'Below 50%' },
                { color: PDF.INDIGO, label: 'Match rate % (line)' }
            ]);
            y += 14;
        }

        // ─── 17. CHANNEL PROFILE RADAR ───
        // 3-axis radar (Match rate %, Avg confidence %, Volume %) across the
        // top-8 channels by volume. Mirrors the second chart in the Channel
        // match-rate card on the Reports tab.
        if (chCards.length) {
            const _chTotalSec = (c) => (c.matchSeconds || 0) + (c.noMatchSeconds || 0);
            const maxVol = chCards.reduce((m, c) => Math.max(m, _chTotalSec(c)), 0) || 1;
            const radarColors = [PDF.INDIGO, PDF.SUCCESS, PDF.WARN, PDF.BLUE, PDF.ORANGE, PDF.DANGER, PDF.CYAN, PDF.NOMATCH];
            const radCards = chCards.slice().sort((a, b) => _chTotalSec(b) - _chTotalSec(a)).slice(0, 8);
            const series = radCards.map((c, i) => {
                const evTotal = (c.matches + c.noMatches) || 1;
                const totalSec = _chTotalSec(c) || 1;
                return {
                    label: c.streamName,
                    color: radarColors[i % radarColors.length],
                    values: [
                        (c.matches / evTotal) * 100,
                        (c.avgConfidence || 0) * 100,
                        (totalSec / maxVol) * 100
                    ]
                };
            });
            const radH = 220;
            sectionTitleWithChart('19. Channel Profile Radar', radH + 10);
            drawRadarChart(doc,
                MARGIN + (W - MARGIN * 2) / 2, y + radH / 2 + 4,
                Math.min(80, radH / 2 - 20),
                ['Match rate %', 'Avg confidence %', 'Volume %'],
                series, 100);
            // Legend on the right.
            let ly = y + 20;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
            series.forEach(s => {
                doc.setFillColor(...s.color);
                doc.rect(W - MARGIN - 160, ly - 7, 10, 10, 'F');
                doc.setTextColor(...PDF.SLATE);
                const lbl = String(s.label).length > 24 ? String(s.label).slice(0, 23) + '…' : s.label;
                doc.text(lbl, W - MARGIN - 146, ly + 2);
                ly += 14;
            });
            y += radH + 8;
        }

        // ─── 18. CUMULATIVE AIRTIME TREND (line) ───
        if (daily.length >= 2) {
            sectionTitleWithChart('20. Cumulative Airtime Trend', 150);
            let run = 0;
            const cum = daily.map(d => (run += (d.matches || 0)));
            drawLineChart(doc, MARGIN, y, W - MARGIN * 2, 120, cum, i => toDdMm(daily[i].date), PDF.INDIGO, [230, 229, 252]);
            y += 144;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
            doc.text(`Total cumulative airtime across ${daily.length} days: ${fmtHms(run)}`, MARGIN, y);
            y += 14;
        }

        const pdfLangArr = pdfKpi.langArr || [];
        const pdfPlayKinds = pdfKpi.playKindArr || [];
        const shortChLabel = (name, max) => {
            const s = String(name || '—');
            return s.length > max ? s.slice(0, max - 1) + '…' : s;
        };
        const topChForCharts = chCards.slice()
            .sort((a, b) => ((b.matchSeconds || 0) + (b.noMatchSeconds || 0)) - ((a.matchSeconds || 0) + (a.noMatchSeconds || 0)))
            .slice(0, 8);

        // ─── 21. DAILY MATCH VS NO-MATCH (stacked) ───
        if (daily.length >= 2) {
            sectionTitleWithChart('21. Daily Match vs No-Match Trend', 150);
            drawStackedBarChart(doc, MARGIN, y, W - MARGIN * 2, 100, [
                { data: daily.map(d => d.matches || 0), color: PDF.SUCCESS, label: 'Match' },
                { data: daily.map(d => d.noMatches || 0), color: PDF.NOMATCH, label: 'No Match' }
            ], i => toDdMm(daily[i].date));
            y += 124;
            drawLegend(doc, MARGIN, y, [
                { color: PDF.SUCCESS, label: 'Match airtime' },
                { color: PDF.NOMATCH, label: 'No-match airtime' }
            ]);
            y += 14;
        }

        // ─── 22. DAILY MATCH RATE % ───
        if (daily.length >= 2) {
            const dailyRates = daily.map(d => {
                const t = (d.matches || 0) + (d.noMatches || 0);
                return t > 0 ? ((d.matches || 0) / t) * 100 : 0;
            });
            _setPdfValFmt((v) => `${Math.round(v)}%`);
            sectionTitleWithChart('22. Daily Match Rate %', 140);
            drawLineChart(doc, MARGIN, y, W - MARGIN * 2, 100, dailyRates, i => toDdMm(daily[i].date), PDF.SUCCESS, [220, 252, 231]);
            y += 124;
            _setPdfValFmt(fmtHms);
        }

        // ─── 23. PLAY KIND DISTRIBUTION ───
        if (pdfPlayKinds.length) {
            sectionTitleWithChart('23. Play Kind Distribution', 160);
            const pkColors = [PDF.INDIGO, PDF.SUCCESS, PDF.WARN, PDF.BLUE, PDF.ORANGE, PDF.DANGER, PDF.CYAN, PDF.NOMATCH];
            const pkSegs = pdfPlayKinds.map((p, i) => ({
                value: p.seconds || 0,
                color: pkColors[i % pkColors.length],
                label: p.label
            }));
            const pkTotal = pkSegs.reduce((a, s) => a + s.value, 0) || 1;
            drawPie(doc, MARGIN + 100, y + 60, 50, pkSegs);
            let pkLy = y + 40;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
            pkSegs.forEach(s => {
                doc.setFillColor(...s.color);
                doc.rect(MARGIN + 230, pkLy - 7, 10, 10, 'F');
                doc.setTextColor(...PDF.SLATE);
                const pct = ((s.value / pkTotal) * 100).toFixed(1);
                const lbl = String(s.label).length > 22 ? String(s.label).slice(0, 21) + '…' : s.label;
                doc.text(`${lbl} — ${fmtHms(s.value)} (${pct}%)`, MARGIN + 246, pkLy + 2);
                pkLy += 14;
            });
            y += 140;
        }

        // ─── 24. LANGUAGE DISTRIBUTION (doughnut) ───
        if (pdfLangArr.length) {
            sectionTitleWithChart('24. Language Distribution', 160);
            const langTop = pdfLangArr.slice(0, 8);
            const langRest = pdfLangArr.slice(8).reduce((sum, l) => sum + (l.matchSec || 0), 0);
            const langSegs = langTop.map((l, i) => ({
                value: l.matchSec || 0,
                color: [PDF.INDIGO, PDF.SUCCESS, PDF.WARN, PDF.BLUE, PDF.ORANGE, PDF.DANGER, PDF.CYAN, PDF.NOMATCH][i % 8],
                label: l.name
            }));
            if (langRest > 0) langSegs.push({ value: langRest, color: PDF.SLATE, label: 'Other' });
            const langTotal = langSegs.reduce((a, s) => a + s.value, 0) || 1;
            drawDonut(doc, MARGIN + 100, y + 60, 50, langSegs);
            let langLy = y + 40;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
            langSegs.forEach(s => {
                doc.setFillColor(...s.color);
                doc.rect(MARGIN + 230, langLy - 7, 10, 10, 'F');
                doc.setTextColor(...PDF.SLATE);
                const pct = ((s.value / langTotal) * 100).toFixed(1);
                doc.text(`${s.label} — ${fmtHms(s.value)} (${pct}%)`, MARGIN + 246, langLy + 2);
                langLy += 14;
            });
            y += 140;
        }

        // ─── 25. MATCHES BY LANGUAGE (count) ───
        if (pdfLangArr.length) {
            const langCountData = pdfLangArr.slice(0, 10);
            sectionTitleWithChart('25. Matches by Language', 130);
            _setPdfValFmt(null);
            drawBarChart(doc, MARGIN, y, W - MARGIN * 2, 100,
                langCountData.map(l => l.matches || 0),
                i => langCountData[i].name,
                PDF.INDIGO);
            y += 124;
            _setPdfValFmt(fmtHms);
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
            doc.text(`Match event counts across ${pdfLangArr.length} language(s) — bars show match count, not airtime.`, MARGIN, y);
            y += 18;
        }

        // ─── 26. CHANNEL MATCH VS NO-MATCH (stacked) ───
        if (topChForCharts.length) {
            sectionTitleWithChart('26. Channel Match vs No-Match Airtime', 150);
            drawStackedBarChart(doc, MARGIN, y, W - MARGIN * 2, 100, [
                { data: topChForCharts.map(c => c.matchSeconds || 0), color: PDF.SUCCESS, label: 'Match' },
                { data: topChForCharts.map(c => c.noMatchSeconds || 0), color: PDF.NOMATCH, label: 'No Match' }
            ], i => shortChLabel(topChForCharts[i].streamName, 10));
            y += 124;
            drawLegend(doc, MARGIN, y, [
                { color: PDF.SUCCESS, label: 'Match airtime' },
                { color: PDF.NOMATCH, label: 'No-match airtime' }
            ]);
            y += 14;
        }

        // ─── 27. CHANNEL AVERAGE CONFIDENCE ───
        if (topChForCharts.length) {
            _setPdfValFmt((v) => `${Math.round(v)}%`);
            sectionTitleWithChart('27. Channel Average Confidence', 130);
            drawBarChart(doc, MARGIN, y, W - MARGIN * 2, 100,
                topChForCharts.map(c => (c.avgConfidence || 0) * 100),
                i => shortChLabel(topChForCharts[i].streamName, 10),
                PDF.INDIGO);
            y += 124;
            _setPdfValFmt(fmtHms);
        }

        // ─── 28. AIRPLAY LOG ───
        _closeFrame();
        doc.addPage();
        drawHeader();
        y = TOP_MARGIN;
        sectionTitle('28. Airplay / compliance log');
        // Apply same date-range filter + most-recent-first ordering as the on-screen table.
        // `from`/`to` are precise UTC ISO instants (see reportQuery) that are
        // already the exact window edges, so compare directly — do NOT append
        // any extra time component (that used to be done when the query sent
        // bare YYYY-MM-DD values).
        let filtered = filterConsolidatedAirplay(airplay, qs, pdfConsolidate);
        filtered.reverse();
        const rows = filtered.slice(0, 500);
        if (!rows.length) {
            doc.setFont('helvetica', 'italic'); doc.setFontSize(9); doc.setTextColor(...PDF.MUTED);
            doc.text('No airplay events.', MARGIN, y); y += 16;
        } else {
            // Use slightly smaller side margins for section 28 so the wide
            // compliance table can show full header labels.
            const airplayMargin = 12 * MM;
            const airplayTableW = W - 2 * airplayMargin;
            const clipCell = (s, max) => {
                const t = String(s ?? '').trim();
                if (!t) return '';
                return t.length <= max ? t : t.slice(0, Math.max(1, max - 1)) + '…';
            };
            runAutoTable({
                startY: y,
                tableWidth: airplayTableW,
                head: [['Date', 'Start Time', 'End Time', 'Total Duration', 'Channel', 'Language', 'Type', 'Play Kind', 'Song', 'Album / Folder', 'Confidence', 'Quality', 'Program']],
                body: rows.map(r => {
                    const conf = r.confidence != null ? (r.confidence * 100).toFixed(1) + '%' : '—';
                    const q = r.confidence >= 0.9 ? 'Excellent' : r.confidence >= 0.75 ? 'Good' : r.confidence >= 0.5 ? 'Fair' : r.type === 'match' ? 'Poor' : '—';
                    const splitDT = (iso) => {
                        const s = fmtTime(iso);
                        const parts = s.split(' ');
                        return parts.length === 2 ? { d: parts[0], t: parts[1] } : { d: '—', t: s };
                    };
                    const startDT = splitDT(r.timeUtc);
                    let endIso = r.endTimeUtc || airplayEndUtcIso(r) || r.timeUtc;
                    const _tStart = Date.parse(r.timeUtc);
                    const _tEnd0  = Date.parse(endIso);
                    if (!isNaN(_tStart) && !isNaN(_tEnd0) && _tEnd0 <= _tStart) {
                        endIso = airplayEndUtcIso(r);
                    }
                    const endDT = splitDT(endIso);
                    const endTimeText = endDT.d !== startDT.d ? `${endDT.t} (${endDT.d})` : endDT.t;
                    const ep = r.epg || {};
                    const programName = r.epgShowName || ep.programName || ep.showName || '';
                    // Play Kind: analyst-classified value if set; otherwise
                    // auto-infer "Full Song" when the match coverage exceeds
                    // 60 s (matches the View Results auto-suggest rule), so
                    // unclassified long matches still surface a sensible
                    // category in the report.
                    let playKind = (r.playKind || '').trim();
                    if (!playKind && r.type === 'match') {
                        const dur = Number(r.queryLengthSeconds || r.coverageSeconds || 0);
                        if (dur >= 60) playKind = 'Full Song';
                    }
                    // Look up the stream's language from the in-memory
                    // streams map (loaded by Streams tab on page load).
                    // Joined at render time so a stream-config edit is
                    // immediately reflected in the next PDF without
                    // re-running the monitor.
                    const _lang = (monState.streams || [])
                        .find(s => s && s.id === r.streamId);
                    const language = _lang ? (_lang.language || '') : '';
                    return [
                        startDT.d,
                        startDT.t,
                        endTimeText,
                        airplayTotalDurationHms(r),
                        clipCell(r.streamName || '', 24),
                        clipCell(language, 16),
                        displayType(r.type),
                        clipCell(playKind, 18),
                        clipCell(r.title || '', 48),
                        clipCell(folderOf(r) || (r.artist || ''), 40),
                        conf,
                        q,
                        clipCell(programName, 36)
                    ];
                }),
                theme: 'grid',
                styles: {
                    font: 'helvetica', fontSize: 6, textColor: PDF.SLATE, lineColor: PDF.GRID,
                    lineWidth: 0.3, cellPadding: 2, valign: 'middle', overflow: 'ellipsize', minCellHeight: 9
                },
                alternateRowStyles: { fillColor: PDF.LIGHT_BG },
                columnStyles: {
                    0:  { cellWidth: 42, fontStyle: 'bold' },              // Date
                    1:  { cellWidth: 50, halign: 'center' },               // Start Time
                    2:  { cellWidth: 50, halign: 'center' },               // End Time
                    3:  { cellWidth: 62, halign: 'center', fontStyle: 'bold' }, // Total Duration
                    4:  { cellWidth: 58 },                                 // Channel
                    5:  { cellWidth: 40 },                                 // Language
                    6:  { cellWidth: 34, halign: 'center' },               // Type
                    7:  { cellWidth: 56, halign: 'center', fontStyle: 'bold' }, // Play Kind
                    8:  { cellWidth: 110, fontStyle: 'bold' },             // Song
                    9:  { cellWidth: 80 },                                 // Album / Folder
                    10: { cellWidth: 58, halign: 'right' },                // Confidence
                    11: { cellWidth: 42, halign: 'center' },               // Quality
                    12: { cellWidth: 78 }                                  // Program
                },
                headStyles: {
                    fillColor: PDF.BRAND, textColor: [255, 255, 255], fontStyle: 'bold',
                    fontSize: 6.5, halign: 'center', valign: 'middle', overflow: 'hidden',
                    cellPadding: 2, minCellHeight: 12
                },
                margin: { left: airplayMargin, right: airplayMargin },
                didParseCell: (d) => {
                    if (d.section === 'head') {
                        d.cell.styles.overflow = 'hidden';
                        return;
                    }
                    if (d.section !== 'body') return;
                    // Column order:
                    // 0 Date, 1 Start, 2 End, 3 Total Duration, 4 Channel,
                    // 5 Language, 6 Type, 7 Play Kind, 8 Song, 9 Album/Folder,
                    // 10 Confidence, 11 Quality, 12 Program.
                    if (d.column.index === 6) {
                        const v = String(d.cell.raw || '').toLowerCase().replace(/\s+/g, '');
                        if (v === 'match') d.cell.styles.textColor = PDF.SUCCESS;
                        else if (v === 'error') d.cell.styles.textColor = PDF.DANGER;
                        else if (v) d.cell.styles.textColor = PDF.ORANGE;
                    }
                    if (d.column.index === 11) {
                        const v = d.cell.raw;
                        if (v === 'Excellent') d.cell.styles.textColor = PDF.SUCCESS;
                        else if (v === 'Good') d.cell.styles.textColor = PDF.CYAN;
                        else if (v === 'Fair') d.cell.styles.textColor = PDF.ORANGE;
                        else if (v === 'Poor') d.cell.styles.textColor = PDF.DANGER;
                        d.cell.styles.fontStyle = 'bold';
                    }
                    if (d.column.index === 10) {
                        const raw = parseFloat(d.cell.raw);
                        if (raw >= 90) d.cell.styles.textColor = PDF.SUCCESS;
                        else if (raw >= 70) d.cell.styles.textColor = PDF.WARN;
                        else if (!isNaN(raw)) d.cell.styles.textColor = PDF.DANGER;
                    }
                    if (d.column.index === 7) {
                        const v = String(d.cell.raw || '');
                        if (v === 'Full Song') d.cell.styles.textColor = PDF.SUCCESS;
                        else if (v === 'Trailer' || v === 'Promo' || v === 'Jingle' || v === 'Snippet') {
                            d.cell.styles.textColor = PDF.ORANGE;
                        }
                    }
                }
            });
            y = doc.lastAutoTable.finalY + 10;
            doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
            doc.text(`Showing ${rows.length} of ${filtered.length} entries`, MARGIN, y);
        }

        // ─── FOOTERS ON ALL PAGES ───
        _closeFrame();
        const total = doc.getNumberOfPages();
        for (let p = 1; p <= total; p++) { doc.setPage(p); drawFooter(p, total); }

        // Save
        const pdfTag = pdfConsolidate ? '' : '_raw';
        const fn = `AUDIOBoxx_Report_${(streamName || 'All').replace(/[^a-z0-9]/gi, '_')}_${ymdToDmy(from)}_${ymdToDmy(to)}${pdfTag}.pdf`;
        doc.save(fn);
        showToast(pdfConsolidate ? 'PDF exported (consolidated)' : 'PDF exported (raw)', 'ok');
    }

    // Nice axis max (rounded up to a "friendly" number) and matching ticks.
    function _niceMax(v) {
        if (v <= 1) return 1;
        const pow = Math.pow(10, Math.floor(Math.log10(v)));
        const n = v / pow;
        const nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10;
        return nice * pow;
    }
    // Value formatter used by chart tick labels and on-bar values. Defaults to
    // integer counts; buildPdf swaps it to fmtHms when the chart represents
    // airtime (seconds) instead of raw event counts.
    let _pdfValFmt = (v) => String(Math.round(Number(v) || 0));
    function _setPdfValFmt(fn) { _pdfValFmt = (typeof fn === 'function') ? fn : ((v) => String(Math.round(Number(v) || 0))); }
    // Draws horizontal gridlines + y-axis tick labels for a rectangular plot.
    // Returns the reduced plot height (leaving room for bottom labels).
    function _drawYGrid(doc, x, y, w, h, max, ticks) {
        ticks = ticks || 5;
        const plotH = h - 14; // reserve bottom for x-labels
        doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.3);
        doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.MUTED);
        for (let i = 0; i <= ticks; i++) {
            const gy = y + plotH - (i / ticks) * plotH;
            // Solid line for baseline, dashed-like light for others (jsPDF has no dash; use light)
            if (i === 0) { doc.setLineWidth(0.6); doc.setDrawColor(...PDF.SLATE); }
            else { doc.setLineWidth(0.3); doc.setDrawColor(...PDF.GRID); }
            doc.line(x, gy, x + w, gy);
            // Tick label — drawn INSIDE the plot area just above the line so
            // it doesn't collide with the rounded frame border on the left.
            const val = (i / ticks) * max;
            const lbl = _pdfValFmt(val);
            if (i > 0) {
                doc.text(lbl, x + 3, gy - 2, { align: 'left' });
            } else {
                // Baseline "0" sits below the line, inside the plot area.
                doc.text(lbl, x + 3, gy + 7, { align: 'left' });
            }
        }
        // Reset
        doc.setLineWidth(0.3); doc.setDrawColor(...PDF.GRID);
        return plotH;
    }

    function drawBarChart(doc, x, y, w, h, data, labelFn, colorOrNull, perColor) {
        if (!data.length) {
            doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.5);
            doc.line(x, y + h, x + w, y + h);
            return;
        }
        const rawMax = Math.max(...data, 1);
        const max = _niceMax(rawMax);
        const plotH = _drawYGrid(doc, x, y, w, h, max, 5);
        const pad = 4;
        const bw = Math.max(2, (w - pad * (data.length - 1)) / data.length);
        // Thin x-axis labels when bars are too narrow to avoid overlap
        const labelEvery = Math.max(1, Math.ceil(data.length / Math.max(6, Math.floor(w / 24))));
        data.forEach((v, i) => {
            const bx = x + i * (bw + pad);
            const bh = (v / max) * plotH;
            const c = (perColor && perColor[i]) || colorOrNull || PDF.SUCCESS;
            doc.setFillColor(...c);
            doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.3);
            doc.rect(bx, y + plotH - bh, bw, bh, 'FD');
            // Label
            if (i % labelEvery === 0) {
                doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.MUTED);
                doc.text(String(labelFn(i)), bx + bw / 2, y + h, { align: 'center' });
            }
            // Value on top — only when it won't collide with the top y-grid label
            if (v > 0 && data.length <= 30) {
                const labelY = y + plotH - bh - 2;
                if (labelY > y + 8) {
                    doc.setFontSize(6); doc.setTextColor(...PDF.SLATE);
                    doc.text(_pdfValFmt(v), bx + bw / 2, labelY, { align: 'center' });
                }
            }
        });
    }

    function drawDonut(doc, cx, cy, r, segs) {
        const total = segs.reduce((a, s) => a + s.value, 0) || 1;
        let start = -Math.PI / 2;
        segs.forEach(s => {
            const ang = (s.value / total) * Math.PI * 2;
            const steps = Math.max(6, Math.ceil(ang * 20));
            doc.setFillColor(...s.color);
            for (let i = 0; i < steps; i++) {
                const a1 = start + (i / steps) * ang;
                const a2 = start + ((i + 1) / steps) * ang;
                doc.triangle(cx, cy, cx + Math.cos(a1) * r, cy + Math.sin(a1) * r, cx + Math.cos(a2) * r, cy + Math.sin(a2) * r, 'F');
            }
            start += ang;
        });
        // Outer ring stroke for crisp edge
        doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.4);
        doc.circle(cx, cy, r, 'S');
        // Inner hole
        doc.setFillColor(255, 255, 255);
        doc.circle(cx, cy, r * 0.55, 'F');
        doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.3);
        doc.circle(cx, cy, r * 0.55, 'S');
        // Center rate label
        const matches = segs[0]?.value || 0;
        const rate = total > 0 ? ((matches / total) * 100).toFixed(0) + '%' : '0%';
        doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(...PDF.BRAND_DARK);
        doc.text(rate, cx, cy + 4, { align: 'center' });
    }

    // Solid pie (no inner hole) — used for Channel Contribution
    function drawPie(doc, cx, cy, r, segs) {
        const total = segs.reduce((a, s) => a + s.value, 0) || 1;
        let start = -Math.PI / 2;
        segs.forEach(s => {
            const ang = (s.value / total) * Math.PI * 2;
            const steps = Math.max(6, Math.ceil(ang * 20));
            doc.setFillColor(...s.color);
            for (let i = 0; i < steps; i++) {
                const a1 = start + (i / steps) * ang;
                const a2 = start + ((i + 1) / steps) * ang;
                doc.triangle(cx, cy, cx + Math.cos(a1) * r, cy + Math.sin(a1) * r, cx + Math.cos(a2) * r, cy + Math.sin(a2) * r, 'F');
            }
            // Slice separator line
            const ex = cx + Math.cos(start + ang) * r;
            const ey = cy + Math.sin(start + ang) * r;
            doc.setDrawColor(255, 255, 255); doc.setLineWidth(0.8);
            doc.line(cx, cy, ex, ey);
            start += ang;
        });
        // Outer ring stroke
        doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.4);
        doc.circle(cx, cy, r, 'S');
    }

    // Stacked vertical bars — series = [{data:[], color:[r,g,b], label:''}, ...]
    function drawStackedBarChart(doc, x, y, w, h, series, labelFn) {
        const n = series[0]?.data?.length || 0;
        if (!n) {
            doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.5);
            doc.line(x, y + h, x + w, y + h);
            return;
        }
        const totals = Array.from({ length: n }, (_, i) => series.reduce((a, s) => a + (s.data[i] || 0), 0));
        const rawMax = Math.max(...totals, 1);
        const max = _niceMax(rawMax);
        const plotH = _drawYGrid(doc, x, y, w, h, max, 5);
        const pad = 4;
        const bw = Math.max(2, (w - pad * (n - 1)) / n);
        const labelEvery = Math.max(1, Math.ceil(n / Math.max(6, Math.floor(w / 24))));
        for (let i = 0; i < n; i++) {
            const bx = x + i * (bw + pad);
            let cursor = y + plotH;
            series.forEach(s => {
                const v = s.data[i] || 0;
                if (v <= 0) return;
                const bh = (v / max) * plotH;
                cursor -= bh;
                doc.setFillColor(...s.color);
                doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.3);
                doc.rect(bx, cursor, bw, bh, 'FD');
            });
            // X-axis label (thinned when bars are dense)
            if (i % labelEvery === 0) {
                doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.MUTED);
                doc.text(String(labelFn(i)), bx + bw / 2, y + h, { align: 'center' });
            }
            // Stacked total on top — only when it won't collide with top y-grid label
            if (totals[i] > 0 && n <= 30) {
                const topY = y + plotH - (totals[i] / max) * plotH;
                if (topY - 2 > y + 8) {
                    doc.setFontSize(6); doc.setTextColor(...PDF.SLATE);
                    doc.text(_pdfValFmt(totals[i]), bx + bw / 2, topY - 2, { align: 'center' });
                }
            }
        }
    }

    // Legend row drawn horizontally below a chart — entries = [{color, label}, ...]
    // Radar chart — N axes around a center. Each series is drawn as a
    // translucent polygon + thin outline + point markers. `max` is the
    // top of every axis (all axes share the same scale; values in 0..max).
    function drawRadarChart(doc, cx, cy, r, axisLabels, series, max) {
        max = max || 100;
        const n = axisLabels.length;
        if (n < 3) return;
        // Background grid rings (4 rings).
        doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.3);
        for (let ring = 1; ring <= 4; ring++) {
            const rr = (r * ring) / 4;
            for (let i = 0; i < n; i++) {
                const a1 = -Math.PI / 2 + (2 * Math.PI * i) / n;
                const a2 = -Math.PI / 2 + (2 * Math.PI * ((i + 1) % n)) / n;
                doc.line(cx + rr * Math.cos(a1), cy + rr * Math.sin(a1),
                         cx + rr * Math.cos(a2), cy + rr * Math.sin(a2));
            }
        }
        // Axis spokes.
        for (let i = 0; i < n; i++) {
            const a = -Math.PI / 2 + (2 * Math.PI * i) / n;
            doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.3);
            doc.line(cx, cy, cx + r * Math.cos(a), cy + r * Math.sin(a));
        }
        // Axis labels.
        doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.MUTED);
        axisLabels.forEach((lbl, i) => {
            const a = -Math.PI / 2 + (2 * Math.PI * i) / n;
            const lx = cx + (r + 12) * Math.cos(a);
            const ly = cy + (r + 12) * Math.sin(a) + 3;
            const align = Math.abs(Math.cos(a)) < 0.2 ? 'center' : (Math.cos(a) > 0 ? 'left' : 'right');
            doc.text(String(lbl), lx, ly, { align });
        });
        // Series polygons.
        (series || []).forEach(s => {
            const pts = [];
            for (let i = 0; i < n; i++) {
                const a = -Math.PI / 2 + (2 * Math.PI * i) / n;
                const v = Math.max(0, Math.min(max, Number(s.values[i]) || 0));
                const rr = (v / max) * r;
                pts.push([cx + rr * Math.cos(a), cy + rr * Math.sin(a)]);
            }
            // Translucent fill via triangles fan from center.
            doc.setDrawColor(...s.color); doc.setLineWidth(1.2);
            for (let i = 0; i < n; i++) {
                const p1 = pts[i], p2 = pts[(i + 1) % n];
                doc.line(p1[0], p1[1], p2[0], p2[1]);
            }
            // Point markers.
            doc.setFillColor(...s.color);
            pts.forEach(p => doc.circle(p[0], p[1], 1.5, 'F'));
        });
    }

    function drawLegend(doc, x, y, entries) {
        let cx = x;
        doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...PDF.SLATE);
        entries.forEach(e => {
            doc.setFillColor(...e.color);
            doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.3);
            doc.rect(cx, y - 7, 9, 9, 'FD');
            doc.text(e.label, cx + 12, y);
            cx += 12 + doc.getTextWidth(e.label) + 14;
        });
    }

    // Horizontal bar chart with left-aligned labels — items = [{label, value}]
    function drawHorizontalBarChart(doc, x, y, w, h, items, color) {
        if (!items.length) return;
        const labelW = 120;
        const chartW = w - labelW - 30;
        const rawMax = Math.max(...items.map(i => i.value), 1);
        const max = _niceMax(rawMax);
        const rowH = Math.max(10, Math.min(18, (h - 4) / items.length));
        // Vertical gridlines across chart area
        const ticks = 5;
        doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.3);
        doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.MUTED);
        for (let t = 0; t <= ticks; t++) {
            const gx = x + labelW + (t / ticks) * chartW;
            if (t === 0) { doc.setDrawColor(...PDF.SLATE); doc.setLineWidth(0.5); }
            else { doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.3); }
            doc.line(gx, y, gx, y + items.length * rowH);
            const tv = (t / ticks) * max;
            doc.text(_pdfValFmt(tv), gx, y + items.length * rowH + 8, { align: 'center' });
        }
        items.forEach((it, i) => {
            const ry = y + i * rowH + rowH / 2;
            // Label (truncated to fit)
            doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...PDF.SLATE);
            const lbl = String(it.label).length > 28 ? String(it.label).slice(0, 26) + '…' : String(it.label);
            doc.text(lbl, x, ry + 2);
            // Bar
            const bw = (it.value / max) * chartW;
            doc.setFillColor(...(it.color || color));
            doc.setDrawColor(...PDF.BRAND_DARK); doc.setLineWidth(0.3);
            doc.rect(x + labelW, ry - rowH / 2 + 2, bw, rowH - 4, 'FD');
            // Value
            doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.setTextColor(...PDF.BRAND_DARK);
            doc.text(_pdfValFmt(it.value), x + labelW + bw + 4, ry + 2);
        });
    }

    // Simple area/line chart — data is an array of numbers
    function drawLineChart(doc, x, y, w, h, data, labelFn, color, fillColor) {
        if (data.length < 2) {
            doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.5);
            doc.line(x, y + h, x + w, y + h);
            return;
        }
        const rawMax = Math.max(...data, 1);
        const max = _niceMax(rawMax);
        const plotH = _drawYGrid(doc, x, y, w, h, max, 5);
        // Y axis vertical line
        doc.setDrawColor(...PDF.SLATE); doc.setLineWidth(0.5);
        doc.line(x, y, x, y + plotH);
        const step = w / (data.length - 1);
        const pts = data.map((v, i) => [x + i * step, y + plotH - (v / max) * plotH]);
        // Filled area under line
        if (fillColor) {
            doc.setFillColor(...fillColor);
            const poly = [[x, y + plotH], ...pts, [x + w, y + plotH]];
            for (let i = 1; i < poly.length - 1; i++) {
                doc.triangle(poly[0][0], poly[0][1], poly[i][0], poly[i][1], poly[i + 1][0], poly[i + 1][1], 'F');
            }
        }
        // Line
        doc.setDrawColor(...color); doc.setLineWidth(1.5);
        for (let i = 1; i < pts.length; i++) {
            doc.line(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]);
        }
        // Dots + labels (skip dots if too many data points)
        const showDots = data.length <= 20;
        const labelEvery = Math.max(1, Math.ceil(data.length / 12));
        pts.forEach(([px, py], i) => {
            if (showDots) {
                doc.setFillColor(...color);
                doc.setDrawColor(255, 255, 255); doc.setLineWidth(0.6);
                doc.circle(px, py, 2, 'FD');
            }
            if (i % labelEvery === 0 && labelFn) {
                doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.MUTED);
                doc.text(String(labelFn(i)), px, y + h, { align: 'center' });
            }
        });
    }

    // 7×24 heatmap — x=hour of day, y=day of week, color intensity = match count.
    function drawHeatmap(doc, x, y, w, h, grid) {
        const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
        const labelW = 28;
        const gridX = x + labelW;
        const gridW = w - labelW;
        const cellW = gridW / 24;
        const cellH = h / 7;
        // Compute max
        let max = 0;
        for (let d = 0; d < 7; d++) for (let hh = 0; hh < 24; hh++) {
            const v = (grid[d] && grid[d][hh]) || 0;
            if (v > max) max = v;
        }
        // Green base color (matches PDF.SUCCESS)
        const BASE = PDF.SUCCESS; // [52, 199, 89]
        // Hour labels above (every 3 hours)
        doc.setFont('helvetica', 'normal'); doc.setFontSize(6); doc.setTextColor(...PDF.MUTED);
        for (let hh = 0; hh < 24; hh += 3) {
            doc.text(String(hh).padStart(2, '0'), gridX + hh * cellW + cellW / 2, y - 3, { align: 'center' });
        }
        // Cells
        for (let d = 0; d < 7; d++) {
            // Day label
            doc.setFont('helvetica', 'bold'); doc.setFontSize(7); doc.setTextColor(...PDF.SLATE);
            doc.text(DOW[d], x + labelW - 4, y + d * cellH + cellH / 2 + 2, { align: 'right' });
            for (let hh = 0; hh < 24; hh++) {
                const v = (grid[d] && grid[d][hh]) || 0;
                const t = max > 0 ? v / max : 0;
                // Green gradient: from near-white to BASE green
                const r = Math.round(248 - (248 - BASE[0]) * (v === 0 ? 0 : 0.2 + t * 0.8));
                const g = Math.round(252 - (252 - BASE[1]) * (v === 0 ? 0 : 0.2 + t * 0.8));
                const b = Math.round(248 - (248 - BASE[2]) * (v === 0 ? 0 : 0.2 + t * 0.8));
                doc.setFillColor(r, g, b);
                doc.setDrawColor(...PDF.GRID); doc.setLineWidth(0.2);
                doc.rect(gridX + hh * cellW, y + d * cellH, cellW, cellH, 'FD');
                // Numeric value for non-zero cells (only if cell is wide enough)
                if (v > 0 && cellW >= 14) {
                    doc.setFont('helvetica', 'normal'); doc.setFontSize(5);
                    doc.setTextColor(t > 0.55 ? 255 : PDF.SLATE[0], t > 0.55 ? 255 : PDF.SLATE[1], t > 0.55 ? 255 : PDF.SLATE[2]);
                    doc.text(_pdfValFmt(v), gridX + hh * cellW + cellW / 2, y + d * cellH + cellH / 2 + 1.5, { align: 'center' });
                }
            }
        }
        // Footer legend with min/max
        doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(...PDF.MUTED);
        doc.text(`0`, gridX, y + h + 12);
        doc.text(`${max.toLocaleString()}`, gridX + gridW, y + h + 12, { align: 'right' });
        // Scale bar
        const sbX = gridX + 12, sbW = gridW - 40, sbY = y + h + 6, sbH = 4;
        for (let i = 0; i < sbW; i++) {
            const tt = i / sbW;
            const r2 = Math.round(248 - (248 - BASE[0]) * (0.2 + tt * 0.8));
            const g2 = Math.round(252 - (252 - BASE[1]) * (0.2 + tt * 0.8));
            const b2 = Math.round(248 - (248 - BASE[2]) * (0.2 + tt * 0.8));
            doc.setFillColor(r2, g2, b2);
            doc.rect(sbX + i, sbY, 1, sbH, 'F');
        }
    }


    // Wire PDF buttons (consolidated = default; raw = per-detection slices)
    document.getElementById('rpt-export-pdf')?.addEventListener('click', () => exportReportPDF({ consolidate: true }));
    document.getElementById('rpt-export-pdf-raw')?.addEventListener('click', () =>
        exportReportPDF({ consolidate: false, button: document.getElementById('rpt-export-pdf-raw') }));
    document.getElementById('rpt-export-json')?.addEventListener('click', async () => {
        const qs = reportQuery();
        const r = await fetch('/api/report?' + qs.toString());
        const data = await r.json();
        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url; a.download = `AUDIOBoxx-report-${fmtStampLocal(new Date())}.json`; a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    });
})();
