/**
 * Performance charts — real-time CPU / RAM / GPU / Temp readouts.
 *
 * Rendered as inline SVG sparklines (no Chart.js dependency) so the card
 * layout can put the big numeric value over the trend line like a studio
 * instrument. History is seeded from the server-side ring buffer on init;
 * each poll pushes a sample and re-renders the SVG path strings — trivially
 * cheap for 120-sample lines.
 */

import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts';

const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'network', 'device_latency', 'send_timing'] as const;
/** Every cell key the user can color-customize, including the
 *  patches / devices cells that don't have sparklines but still
 *  carry a header accent stripe. */
const ALL_COLORABLE_KEYS = ['patches', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'devices', 'cpu', 'ram', 'gpu', 'temp', 'network', 'device_latency', 'send_timing'] as const;
const PERF_MODE_KEY = 'perfMetricsMode';
const SPARK_W = 600;   // SVG viewBox width (scales with preserveAspectRatio)
const SPARK_H = 64;

/** Metrics that don't have a per-process variant (host-only). */
const HOST_ONLY_KEYS = new Set(['temp', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'network', 'device_latency', 'send_timing']);

/** Default accent per metric — maps to channel palette via CSS vars so the
    perf cards share the same language as the rest of the app. Overrides
    per-user in localStorage still honoured by `_getColor`. */
const METRIC_CSS_VARS: Record<string, string> = {
    patches:     '--ch-magenta',
    fps:         '--ch-cyan',
    capture_fps: '--ch-signal',
    capture_fps_actual: '--ch-cyan',
    errors:      '--ch-coral',
    devices:     '--ch-signal',
    cpu:         '--ch-coral',
    ram:         '--ch-violet',
    gpu:         '--ch-signal',
    temp:        '--ch-amber',
    network:        '--ch-violet',
    device_latency: '--ch-amber',
    send_timing:    '--ch-magenta',
};

/** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */
const METRIC_FALLBACK: Record<string, string> = {
    patches:     '#EC4899',
    fps:         '#00D8FF',
    capture_fps: '#22D3EE',
    capture_fps_actual: '#00D8FF',
    errors:      '#FF6B6B',
    devices:     '#10B981',
    cpu:         '#FF6B6B',
    ram:         '#A855F7',
    gpu:         '#10B981',
    temp:        '#FCD34D',
    network:        '#A855F7',
    device_latency: '#FCD34D',
    send_timing:    '#EC4899',
};

type PerfMode = 'system' | 'app' | 'both';

let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] };
/** Peak errors-per-second observed during the session — y-axis ceiling
 *  for the errors sparkline so a single spike doesn't flatten the rest
 *  of the line. */
let _errorsPeak = 1;
/** Cumulative error count across all running targets at the end of the
 *  previous poll. Used to compute per-poll deltas → rate. `null` until
 *  the first sample arrives so we don't synthesize a fake initial spike
 *  from "0 → live count". */
let _prevErrorsTotal: number | null = null;
/** Same as `_prevErrorsTotal` for `frames_skipped`. */
let _prevSkippedTotal: number | null = null;
/** Peak FPS observed during the session — used as the y-axis ceiling for
 *  the FPS sparkline so slow targets look proportional to fast ones. */
let _fpsPeak = 60;
/** Same role as `_fpsPeak`, but for the capture-side sparkline. */
let _captureFpsPeak = 60;
/** Same role as `_captureFpsPeak`, but for the *measured* capture
 *  rate ("Total Capture FPS"). Tracked independently so a slow
 *  external capture doesn't get its scale clamped to whatever the
 *  source-side spark already touched. */
let _captureFpsActualPeak = 60;
/** Y-axis ceiling tracker for the network-throughput sparkline.
 *  Bytes/sec — scales freely. Initial floor of 1 KB/s keeps the
 *  spark from collapsing onto a single pixel during idle periods. */
let _networkPeak = 1024;
/** Y-axis ceilings for the latency / send-timing sparks (ms).
 *  Floors chosen so common WiFi-good values don't pin the line. */
let _deviceLatencyPeak = 50;
let _sendTimingPeak = 20;
/** Sum of fps_target across running targets — rendered as a dashed
 *  reference line on the FPS spark ("max achievable throughput"). */
let _fpsTargetSum = 0;
let _hasGpu: boolean | null = null;
let _hasTemp: boolean | null = null;
/** Last successful /system/performance payload. Re-applied on layout
 *  changes so the perf grid can rebuild without flashing zeros — value
 *  labels stay populated until the next live poll overwrites them. */
let _lastFetchData: any = null;
/** Cached external-setter inputs so `rerenderPerfGrid()` can repopulate
 *  the patches/fps/devices cells without waiting for the dashboard
 *  loader to fire its next pass. */
let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null;
let _lastTotalFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null; targetSum: number } | null = null;
let _lastTotalCaptureFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null } | null = null;
let _lastTotalCaptureFpsActualArgs: { totalFps: number; targetSum: number; reportingCount: number } | null = null;
let _lastErrorsArgs: { totalErrors: number; totalSkipped: number; pollMs: number } | null = null;
let _lastNetworkArgs: { bytesPerSec: number; totalBytes: number } | null = null;
let _lastDeviceLatencyArgs: { avgMs: number | null; maxMs: number | null; onlineCount: number; totalCount: number } | null = null;
let _lastSendTimingArgs: { avgMs: number; maxMs: number; reportingCount: number } | null = null;
let _lastDevicesArgs: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[] | null = null;
/** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy
 *  callers that read it directly; sync'd from the layout on every read
 *  via `_syncMode()`. */
let _mode: PerfMode = (() => {
    const fromLayout = (() => { try { return getGlobalConfig().perfMode; } catch { return null; } })();
    return fromLayout ?? ((localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both');
})();
function _syncMode(): void {
    try { _mode = getGlobalConfig().perfMode; } catch { /* layout not ready */ }
}

function _resolveCssVar(varName: string, fallback: string): string {
    try {
        const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
        return v || fallback;
    } catch {
        return fallback;
    }
}

function _getColor(key: string): string {
    const override = localStorage.getItem(`perfChartColor_${key}`);
    if (override) return override;
    const cssVar = METRIC_CSS_VARS[key];
    return cssVar ? _resolveCssVar(cssVar, METRIC_FALLBACK[key]) : METRIC_FALLBACK[key];
}

function _onChartColorChange(key: string, hex: string | null): void {
    if (hex) {
        localStorage.setItem(`perfChartColor_${key}`, hex);
    } else {
        localStorage.removeItem(`perfChartColor_${key}`);
        hex = _getColor(key);
        const swatch = document.getElementById(`cp-swatch-perf-${key}`);
        if (swatch) swatch.style.background = hex;
    }
    _renderChartSvg(key);
    const card = document.querySelector(`.perf-chart-card[data-metric="${key}"]`) as HTMLElement | null;
    if (card) card.style.setProperty('--perf-accent', hex!);
}

/** Build the 3-way toggle HTML for perf section header. */
export function renderPerfModeToggle(): string {
    return `<span class="perf-mode-toggle" onclick="event.stopPropagation()">
        <button class="perf-mode-btn${_mode === 'system' ? ' active' : ''}" data-perf-mode="system" onclick="setPerfMode('system')" title="${t('dashboard.perf.mode.system')}">${t('dashboard.perf.mode.system')}</button>
        <button class="perf-mode-btn${_mode === 'app' ? ' active' : ''}" data-perf-mode="app" onclick="setPerfMode('app')" title="${t('dashboard.perf.mode.app')}">${t('dashboard.perf.mode.app')}</button>
        <button class="perf-mode-btn${_mode === 'both' ? ' active' : ''}" data-perf-mode="both" onclick="setPerfMode('both')" title="${t('dashboard.perf.mode.both')}">${t('dashboard.perf.mode.both')}</button>
    </span>`;
}

/** Change the perf metrics display mode. */
export function setPerfMode(mode: PerfMode): void {
    _mode = mode;
    localStorage.setItem(PERF_MODE_KEY, mode);
    // Persist to layout so Customize panel reflects the toggle and the
    // setting follows the user across browsers.
    try { saveDashboardLayout(setGlobalPerfMode(getDashboardLayout(), mode)); } catch { /* layout not ready */ }

    document.querySelectorAll('.perf-mode-btn').forEach(btn => {
        btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode);
    });

    document.querySelectorAll('.perf-chart-card').forEach(card => {
        (card as HTMLElement).dataset.perfMode = mode;
    });

    for (const key of CHART_KEYS) _renderChartSvg(key);
    // Force an immediate poll so value + app-tag positioning updates now,
    // rather than waiting for the next interval tick.
    _fetchPerformance();
}

/** Color-picker widget rendered next to each cell's label. Even
 *  cells without sparklines (patches/devices) get one — it drives
 *  the card's `--perf-accent` CSS var for the header stripe. */
function _colorWidget(key: string): string {
    return createColorPicker({
        id: `perf-${key}`,
        currentColor: _getColor(key),
        onPick: undefined,
        anchor: 'left',
        showReset: true,
    });
}

function _sparkCardHtml(key: string, labelKey: string, hiddenByEnv: boolean): string {
    return `
        <div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}" style="--perf-accent:${_getColor(key)}"${hiddenByEnv ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}>
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t(labelKey)} ${_colorWidget(key)}</span>
                <span class="perf-chart-app" id="perf-${key}-app" aria-hidden="true"></span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-${key}-value">—</span>
                    ${key === 'cpu' || key === 'gpu' ? `<span class="perf-chart-subtitle" id="perf-${key}-name"></span>` : ''}
                </div>
                <div class="perf-chart-spark" id="perf-chart-${key}"></div>
            </div>
        </div>`;
}

/** Build HTML for a single perf cell. Returns null for unknown keys.
 *  Cells with env-gated visibility (gpu, temp) start hidden and reveal
 *  themselves when the server reports a real reading; user can also
 *  force them hidden via Customize. */
function _renderCellHtml(key: string): string | null {
    switch (key) {
        case 'patches':
            return `
        <div class="perf-chart-card perf-patches-cell" data-metric="patches" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('patches')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.active_patches') || 'Active Patches'} ${_colorWidget('patches')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value">
                        <span id="perf-patches-running">0</span><span class="perf-patches-sep"> / </span><span id="perf-patches-total" class="perf-patches-total">0</span>
                    </span>
                </div>
                <div class="perf-patches-list" id="perf-patches-list"></div>
            </div>
        </div>`;
        case 'fps':
            return `
        <div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('fps')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'} ${_colorWidget('fps')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-fps-value">—</span>
                    <span class="perf-chart-subtitle" id="perf-fps-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-fps"></div>
            </div>
        </div>`;
        case 'capture_fps':
            return `
        <div class="perf-chart-card" data-metric="capture_fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.total_capture_fps') || 'Total Source FPS'} ${_colorWidget('capture_fps')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-capture_fps-value">—</span>
                    <span class="perf-chart-subtitle" id="perf-capture_fps-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-capture_fps"></div>
            </div>
        </div>`;
        case 'capture_fps_actual':
            return `
        <div class="perf-chart-card" data-metric="capture_fps_actual" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps_actual')}" id="perf-capture_fps_actual-card">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.total_capture_fps_actual') || 'Total Capture FPS'} ${_colorWidget('capture_fps_actual')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-capture_fps_actual-value">—</span>
                    <span class="perf-chart-subtitle" id="perf-capture_fps_actual-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-capture_fps_actual"></div>
            </div>
        </div>`;
        case 'errors':
            return `
        <div class="perf-chart-card perf-errors-cell" data-metric="errors" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('errors')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.errors') || 'Errors'} ${_colorWidget('errors')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-errors-value">0</span>
                    <span class="perf-chart-subtitle" id="perf-errors-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-errors"></div>
            </div>
        </div>`;
        case 'network':
            return `
        <div class="perf-chart-card" data-metric="network" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('network')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.network') || 'Network'} ${_colorWidget('network')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-network-value">—</span>
                    <span class="perf-chart-subtitle" id="perf-network-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-network"></div>
            </div>
        </div>`;
        case 'device_latency':
            return `
        <div class="perf-chart-card" data-metric="device_latency" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('device_latency')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.device_latency') || 'Device Latency'} ${_colorWidget('device_latency')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-device_latency-value">—</span>
                    <span class="perf-chart-subtitle" id="perf-device_latency-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-device_latency"></div>
            </div>
        </div>`;
        case 'send_timing':
            return `
        <div class="perf-chart-card" data-metric="send_timing" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('send_timing')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.send_timing') || 'Send Timing'} ${_colorWidget('send_timing')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value" id="perf-send_timing-value">—</span>
                    <span class="perf-chart-subtitle" id="perf-send_timing-sub"></span>
                </div>
                <div class="perf-chart-spark" id="perf-chart-send_timing"></div>
            </div>
        </div>`;
        case 'devices':
            return `
        <div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}">
            <div class="perf-chart-header">
                <span class="perf-chart-label">${t('dashboard.perf.devices') || 'Devices'} ${_colorWidget('devices')}</span>
            </div>
            <div class="perf-chart-body">
                <div class="perf-chart-value-block">
                    <span class="perf-chart-value">
                        <span id="perf-devices-online">0</span><span class="perf-patches-sep"> / </span><span id="perf-devices-total" class="perf-patches-total">0</span>
                    </span>
                    <span class="perf-chart-subtitle" id="perf-devices-sub"></span>
                </div>
                <div class="perf-devices-dots" id="perf-devices-dots"></div>
            </div>
        </div>`;
        case 'cpu':  return _sparkCardHtml('cpu',  'dashboard.perf.cpu',  false);
        case 'ram':  return _sparkCardHtml('ram',  'dashboard.perf.ram',  false);
        case 'gpu':  return _sparkCardHtml('gpu',  'dashboard.perf.gpu',  false);
        case 'temp': return _sparkCardHtml('temp', 'dashboard.perf.temp', true);
        default: return null;
    }
}

/** Re-register color-picker callbacks for every colorable cell. Idempotent
 *  — overwrites the previous handler keyed by id. Called whenever the
 *  perf section is (re)initialised so newly-created cells get wired up. */
function _registerPerfColorPickers(): void {
    for (const key of ALL_COLORABLE_KEYS) {
        registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
    }
}

/** Build a fresh `.perf-chart-card` element from a key. */
function _buildCellElement(key: string): HTMLElement | null {
    const html = _renderCellHtml(key);
    if (!html) return null;
    const tmp = document.createElement('div');
    tmp.innerHTML = html.trim();
    return tmp.firstElementChild as HTMLElement | null;
}

/** Returns the static HTML for the perf section. */
export function renderPerfSection(): string {
    _syncMode();
    _registerPerfColorPickers();

    let cellsHtml = '';
    for (const cell of getOrderedPerfCells()) {
        if (!cell.visible) continue;
        const html = _renderCellHtml(cell.key);
        if (html) cellsHtml += html;
    }

    return `<div class="perf-charts-grid">${cellsHtml}</div>`;
}

/** Externally-called from dashboard.ts whenever the running-target set
 *  is recomputed. Updates the Active Patches cell with count + a short
 *  list of running channels and their current FPS. */
export function updateActivePatches(
    running: { id: string; name: string; fps?: number }[],
    totalCount: number,
): void {
    _lastPatchesArgs = { running: running.map(r => ({ ...r })), totalCount };
    const rEl = document.getElementById('perf-patches-running');
    const tEl = document.getElementById('perf-patches-total');
    if (rEl) rEl.textContent = String(running.length).padStart(2, '0');
    if (tEl) tEl.textContent = String(totalCount).padStart(2, '0');

    const listEl = document.getElementById('perf-patches-list');
    if (!listEl) return;
    if (running.length === 0) {
        // Empty-state hint — "Ready to launch" when targets exist but
        // none are running, or "No patches yet" when the user hasn't
        // configured any. Keeps the cell from looking abandoned.
        const hintKey = totalCount === 0
            ? 'dashboard.perf.patches.empty.none'
            : 'dashboard.perf.patches.empty.idle';
        const hintText = t(hintKey) || (totalCount === 0 ? 'No patches yet' : 'Ready to launch');
        listEl.innerHTML = `<div class="perf-patches-empty">
            <span class="perf-patches-empty-dot" aria-hidden="true"></span>
            <span class="perf-patches-empty-text">${escapeText(hintText)}</span>
        </div>`;
        return;
    }
    const rows = running.slice(0, 4).map((r, i) => {
        const colors = ['--ch-signal', '--ch-cyan', '--ch-magenta', '--ch-amber'];
        const colorVar = colors[i % colors.length];
        const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
        return `<div class="perf-patches-row">
            <span class="perf-patches-stripe" style="background: var(${colorVar})"></span>
            <span class="perf-patches-name">${escapeText(r.name)}</span>
            <span class="perf-patches-fps">${fps}</span>
        </div>`;
    }).join('');
    const more = running.length > 4 ? `<div class="perf-patches-more">+${running.length - 4} more</div>` : '';
    listEl.innerHTML = rows + more;
}

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

/** Total FPS cell — pushed a new sample each dashboard refresh cycle.
 *  `totalFps` is the sum of fps_actual across running targets; `minFps`
 *  / `maxFps` are the live extremes shown as a subdued subtitle;
 *  `targetSum` is the sum of each running target's fps_target and is
 *  drawn as a dashed "max" reference line on the spark. */
export function updateTotalFps(
    totalFps: number,
    minFps: number | null,
    maxFps: number | null,
    targetSum: number = 0,
): void {
    _lastTotalFpsArgs = { totalFps, minFps, maxFps, targetSum };
    const fps = Math.max(0, totalFps);
    _history.fps.push(fps);
    if (_history.fps.length > MAX_SAMPLES) _history.fps.shift();
    if (fps > _fpsPeak) _fpsPeak = fps;
    _fpsTargetSum = Math.max(0, targetSum || 0);

    const valEl = document.getElementById('perf-fps-value');
    if (valEl) {
        const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
        const ceilingSuffix = _fpsTargetSum > 0
            ? `<span class="perf-fps-ceiling">/ ${Math.round(_fpsTargetSum)}</span>`
            : '';
        valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
    }
    const subEl = document.getElementById('perf-fps-sub');
    if (subEl) {
        if (minFps != null && maxFps != null && minFps !== maxFps) {
            subEl.textContent = `min ${minFps.toFixed(1)} · max ${maxFps.toFixed(1)}`;
        } else {
            subEl.textContent = '';
        }
    }
    _renderChartSvg('fps', /*animate=*/true);
}

/** Total Source FPS cell — sum of every running target's upstream
 *  color-strip-source `target_fps` (picture/audio/gradient/effect/...).
 *  This is the *requested* tick rate of the pipeline feeding LEDs, not
 *  the measured throughput of any external capture — a static-color
 *  stream still ticks at its idle rate and contributes here. The
 *  internal key stays `capture_fps` for layout-storage compatibility. */
export function updateTotalCaptureFps(
    totalFps: number,
    minFps: number | null,
    maxFps: number | null,
): void {
    _lastTotalCaptureFpsArgs = { totalFps, minFps, maxFps };
    const fps = Math.max(0, totalFps);
    _history.capture_fps.push(fps);
    if (_history.capture_fps.length > MAX_SAMPLES) _history.capture_fps.shift();
    if (fps > _captureFpsPeak) _captureFpsPeak = fps;

    const valEl = document.getElementById('perf-capture_fps-value');
    if (valEl) {
        const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
        valEl.innerHTML = `${fpsText}<span class="perf-fps-unit">fps</span>`;
    }
    const subEl = document.getElementById('perf-capture_fps-sub');
    if (subEl) {
        if (minFps != null && maxFps != null && minFps !== maxFps) {
            subEl.textContent = `min ${minFps.toFixed(1)} · max ${maxFps.toFixed(1)}`;
        } else {
            subEl.textContent = '';
        }
    }
    _renderChartSvg('capture_fps', /*animate=*/true);
}

/** Total Capture FPS cell — sum of *measured* new-frame rates from
 *  capture-backed streams (screen capture today; audio/api-input
 *  follow-up). Diverges from Total Source FPS when the upstream
 *  capture stalls — Source FPS reads "what was requested," Capture FPS
 *  reads "what actually arrived." `reportingCount` is how many running
 *  targets have a measured rate (i.e. are capture-backed); used as the
 *  subtitle so a synthetic-only setup reads "no captures" instead of
 *  silently sitting at 0. */
export function updateTotalCaptureFpsActual(
    totalFps: number,
    targetSum: number,
    reportingCount: number,
): void {
    _lastTotalCaptureFpsActualArgs = { totalFps, targetSum, reportingCount };
    const fps = Math.max(0, totalFps);
    _history.capture_fps_actual.push(fps);
    if (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift();
    if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;

    const valEl = document.getElementById('perf-capture_fps_actual-value');
    if (valEl) {
        if (reportingCount === 0) {
            valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
        } else {
            const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
            const ceilingSuffix = targetSum > 0
                ? `<span class="perf-fps-ceiling">/ ${Math.round(targetSum)}</span>`
                : '';
            valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
        }
    }
    const subEl = document.getElementById('perf-capture_fps_actual-sub');
    if (subEl) {
        if (reportingCount === 0) {
            subEl.textContent = '';
        } else if (targetSum > 0) {
            // Drop ratio reads "how far behind requested" — useful at-a-glance
            // diagnostic for capture saturation.
            const ratio = Math.max(0, Math.min(1, fps / targetSum));
            subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
        } else {
            subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
        }
    }
    _renderChartSvg('capture_fps_actual', /*animate=*/true);
}

/** Network throughput cell — bytes/sec the LED transport is moving
 *  across all running targets, plus a cumulative "X total" subtitle.
 *  Pairs with the Errors cell to triage where a slowdown lives:
 *  pipeline-side errors with low throughput → CPU/GPU bottleneck,
 *  pipeline running clean with throughput pinned → WiFi/wired
 *  saturated. The bytes counter approximates LED-payload only
 *  (protocol overhead is sub-5 % for any real LED count). */
export function updateNetworkThroughput(
    bytesPerSec: number,
    totalBytes: number,
): void {
    _lastNetworkArgs = { bytesPerSec, totalBytes };
    const bps = Math.max(0, bytesPerSec);
    _history.network.push(bps);
    if (_history.network.length > MAX_SAMPLES) _history.network.shift();
    if (bps > _networkPeak) _networkPeak = bps;
    _paintNetworkValue(bps, totalBytes);
    _renderChartSvg('network', /*animate=*/true);
}

/** Device-latency cell — average ping latency across *online*
 *  devices, with the worst-offender max as a subtitle. A leading
 *  indicator of WiFi degradation that fires before frames start
 *  dropping; pairs with the Devices cell to pinpoint which device
 *  is misbehaving. */
export function updateDeviceLatency(
    avgMs: number | null,
    maxMs: number | null,
    onlineCount: number,
    totalCount: number,
): void {
    _lastDeviceLatencyArgs = { avgMs, maxMs, onlineCount, totalCount };
    const sample = avgMs != null && Number.isFinite(avgMs) ? Math.max(0, avgMs) : 0;
    _history.device_latency.push(sample);
    if (_history.device_latency.length > MAX_SAMPLES) _history.device_latency.shift();
    if (sample > _deviceLatencyPeak) _deviceLatencyPeak = sample;
    _paintDeviceLatencyValue(avgMs, maxMs, onlineCount, totalCount);
    _renderChartSvg('device_latency', /*animate=*/true);
}

/** Send-timing cell — average and max time spent inside the LED
 *  client's send call across running targets. Climbs as a
 *  pre-failure signal when the network gets congested, several
 *  seconds before the Errors cell starts showing skipped frames. */
export function updateSendTiming(
    avgMs: number,
    maxMs: number,
    reportingCount: number,
): void {
    _lastSendTimingArgs = { avgMs, maxMs, reportingCount };
    const sample = Math.max(0, avgMs);
    _history.send_timing.push(sample);
    if (_history.send_timing.length > MAX_SAMPLES) _history.send_timing.shift();
    const peakSample = Math.max(sample, Math.max(0, maxMs));
    if (peakSample > _sendTimingPeak) _sendTimingPeak = peakSample;
    _paintSendTimingValue(avgMs, maxMs, reportingCount);
    _renderChartSvg('send_timing', /*animate=*/true);
}

function _formatBytesPerSec(bps: number): { value: string; unit: string } {
    if (bps >= 1024 * 1024) return { value: (bps / 1024 / 1024).toFixed(1), unit: 'MB/s' };
    if (bps >= 1024) return { value: (bps / 1024).toFixed(1), unit: 'KB/s' };
    return { value: bps.toFixed(0), unit: 'B/s' };
}

function _formatBytes(b: number): string {
    if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
    if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
    if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`;
    return `${b} B`;
}

function _paintNetworkValue(bytesPerSec: number, totalBytes: number): void {
    const valEl = document.getElementById('perf-network-value');
    if (valEl) {
        const { value, unit } = _formatBytesPerSec(bytesPerSec);
        valEl.innerHTML = `${value}<span class="perf-fps-unit">${unit}</span>`;
    }
    const subEl = document.getElementById('perf-network-sub');
    if (subEl) {
        subEl.textContent = totalBytes > 0 ? t('perf.total_bytes', { bytes: _formatBytes(totalBytes) }) : '';
    }
}

function _paintDeviceLatencyValue(
    avgMs: number | null,
    maxMs: number | null,
    onlineCount: number,
    totalCount: number,
): void {
    const valEl = document.getElementById('perf-device_latency-value');
    if (valEl) {
        if (totalCount === 0) {
            valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.no_devices')}</span>`;
        } else if (avgMs == null) {
            valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.offline')}</span>`;
        } else {
            const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
            valEl.innerHTML = `${txt}<span class="perf-fps-unit">ms</span>`;
        }
    }
    const subEl = document.getElementById('perf-device_latency-sub');
    if (subEl) {
        const parts: string[] = [];
        if (totalCount > 0) parts.push(`${onlineCount}/${totalCount} ${t('perf.online')}`);
        if (maxMs != null) parts.push(t('perf.max_ms', { ms: maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0) }));
        subEl.textContent = parts.join(' · ');
    }
}

function _paintSendTimingValue(avgMs: number, maxMs: number, reportingCount: number): void {
    const valEl = document.getElementById('perf-send_timing-value');
    if (valEl) {
        if (reportingCount === 0) {
            valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.idle')}</span>`;
        } else {
            const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
            valEl.innerHTML = `${txt}<span class="perf-fps-unit">ms</span>`;
        }
    }
    const subEl = document.getElementById('perf-send_timing-sub');
    if (subEl) {
        if (reportingCount === 0) {
            subEl.textContent = '';
        } else {
            const maxTxt = maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0);
            const targetsLabel = t('perf.targets_count', { count: reportingCount });
            subEl.textContent = `${t('perf.max_ms', { ms: maxTxt })} · ${targetsLabel}`;
        }
    }
}

/** Errors cell — converts the cumulative `errors_count` and
 *  `frames_skipped` totals (summed across running targets) into rates by
 *  taking per-poll deltas. The card stays at "0" / muted accent when
 *  things are healthy and tints coral the moment the rate goes
 *  non-zero, so it functions as a passive "is anything wrong?" indicator
 *  rather than demanding constant attention. */
export function updateTotalErrors(
    totalErrors: number,
    totalSkipped: number,
    pollMs: number,
): void {
    _lastErrorsArgs = { totalErrors, totalSkipped, pollMs };
    const safePollMs = pollMs > 0 ? pollMs : 1000;

    // Per-second deltas — guard against the first sample (no previous
    // baseline) and against counter resets when targets restart (delta
    // < 0 means "previous total no longer applies"; treat as 0).
    let errorsRate = 0;
    let skippedRate = 0;
    if (_prevErrorsTotal != null) {
        const delta = Math.max(0, totalErrors - _prevErrorsTotal);
        errorsRate = delta / (safePollMs / 1000);
    }
    if (_prevSkippedTotal != null) {
        const delta = Math.max(0, totalSkipped - _prevSkippedTotal);
        skippedRate = delta / (safePollMs / 1000);
    }
    _prevErrorsTotal = totalErrors;
    _prevSkippedTotal = totalSkipped;

    _history.errors.push(errorsRate);
    if (_history.errors.length > MAX_SAMPLES) _history.errors.shift();
    if (errorsRate > _errorsPeak) _errorsPeak = errorsRate;

    const card = document.querySelector('.perf-errors-cell') as HTMLElement | null;
    if (card) card.classList.toggle('has-errors', totalErrors > 0 || errorsRate > 0);

    const valEl = document.getElementById('perf-errors-value');
    if (valEl) {
        const rateText = errorsRate >= 10
            ? errorsRate.toFixed(0)
            : errorsRate >= 1
                ? errorsRate.toFixed(1)
                : '0';
        valEl.innerHTML = `${rateText}<span class="perf-fps-unit">/s</span>`;
    }
    const subEl = document.getElementById('perf-errors-sub');
    if (subEl) {
        const parts: string[] = [];
        if (totalErrors > 0) parts.push(`${totalErrors} total`);
        if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
        subEl.textContent = parts.join(' · ');
    }
    _renderChartSvg('errors', /*animate=*/true);
}

/** Devices cell — online / total count with a dot strip showing each
 *  device's connection state at a glance. */
export function updateDevices(
    states: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[],
): void {
    _lastDevicesArgs = states.map(s => ({ ...s }));
    const total = states.length;
    const online = states.filter(s => s.device_online).length;
    const offline = total - online;

    const onEl = document.getElementById('perf-devices-online');
    const totEl = document.getElementById('perf-devices-total');
    if (onEl) onEl.textContent = String(online).padStart(2, '0');
    if (totEl) totEl.textContent = String(total).padStart(2, '0');

    const subEl = document.getElementById('perf-devices-sub');
    if (subEl) {
        subEl.textContent = offline === 0
            ? t('perf.all_online')
            : t('perf.offline_count', { count: offline });
    }

    const dotsEl = document.getElementById('perf-devices-dots');
    if (!dotsEl) return;
    if (total === 0) {
        dotsEl.innerHTML = '';
        return;
    }
    // Cap visible dots to avoid wrapping weirdness; indicate overflow.
    const MAX_DOTS = 24;
    const shown = states.slice(0, MAX_DOTS);
    const overflow = total - shown.length;
    const dots = shown.map(s => {
        const name = s.device_name || s.device_id;
        const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
        const title = `${name} · ${s.device_online ? t('perf.online') : t('perf.offline')}${latency}`;
        return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
    }).join('');
    const more = overflow > 0
        ? `<span class="perf-devices-more">+${overflow}</span>`
        : '';
    dotsEl.innerHTML = dots + more;
}

/** Resolve the global animations preference once per render — read from
 *  the `data-layout-anim` attribute the dashboard puts on its root. */
function _animLevel(): 'full' | 'reduced' | 'off' {
    const c = document.getElementById('dashboard-content');
    const v = c?.dataset.layoutAnim;
    return v === 'off' || v === 'reduced' ? v : 'full';
}

/** Smoothly scroll the freshly-rendered spark left by one sample-step.
 *
 *  The chart already drew the new sample at the right edge, so without
 *  animation it appears to jump on every poll. To get a continuous
 *  left-scroll feel we:
 *    1. Snap the SVG +1 step to the right (no transition) — the
 *       previous frame's right-edge sample is now visually at the same
 *       pixel position it occupied last tick, so the user sees a
 *       1-frame "frozen" matching state.
 *    2. requestAnimationFrame → ease back to translateX(0) over the
 *       poll interval. Old samples slide off the left edge (clipped by
 *       `.perf-chart-spark { overflow:hidden }`); the new sample slides
 *       into view from the right.
 *
 *  Cost: a single GPU-composited `transform` per cell. The path strings
 *  themselves don't repaint mid-animation. */
function _scrollSpark(host: HTMLElement, sliceN: number): void {
    if (sliceN < 2) return;
    const level = _animLevel();
    if (level === 'off') return;
    const svg = host.querySelector('svg.perf-chart-svg') as SVGSVGElement | null;
    if (!svg) return;

    const stepPct = 100 / (sliceN - 1);
    // "reduced" cuts the duration to ~1/4 so motion is brief but the
    // chart still doesn't jump-cut. "full" matches the poll cadence so
    // each scroll lands exactly when the next sample arrives.
    const baseMs = dashboardPollInterval || 1000;
    const animMs = level === 'reduced' ? Math.min(220, baseMs / 4) : baseMs;

    svg.style.transition = 'none';
    svg.style.transform = `translateX(${stepPct.toFixed(3)}%)`;
    // Force the snap to commit before we install the easing transition,
    // otherwise the browser collapses both styles into one paint and
    // skips the scroll entirely.
    void svg.getBoundingClientRect();
    requestAnimationFrame(() => {
        svg.style.transition = `transform ${animMs}ms linear`;
        svg.style.transform = 'translateX(0)';
    });
}

/** Render the SVG sparkline into its container.
 *
 *  `animate` triggers the smooth left-scroll animation — only set on
 *  paths that just pushed a fresh sample. Non-sample paths (mode
 *  toggle, layout replay, color change, initial seed) pass false so
 *  the chart redraws instantly without a phantom scroll. */
function _renderChartSvg(key: string, animate: boolean = false): void {
    const host = document.getElementById(`perf-chart-${key}`);
    if (!host) return;
    // Effective window (in seconds) for this cell — global default
    // unless the cell pinned its own. With 1 sample/sec polling the
    // window in seconds equals the desired sample count; we trim the
    // module-level history arrays from the right (most recent N).
    const cfg = (() => { try { return getGlobalConfig(); } catch { return null; } })();
    const winSec = (() => { try { return effectivePerfWindow(key); } catch { return 120; } })();
    const samplesPerSec = cfg ? Math.max(0.5, 1000 / Math.max(50, cfg.pollMs)) : 1;
    const sliceN = Math.min(MAX_SAMPLES, Math.max(2, Math.round(winSec * samplesPerSec)));
    const sysFull = _history[key] || [];
    const appFull = _appHistory[key] || [];
    const sys = sysFull.slice(-sliceN);
    const app = appFull.slice(-sliceN);
    const color = _getColor(key);
    const isHostOnly = HOST_ONLY_KEYS.has(key);
    const showSystem = _mode === 'system' || _mode === 'both';
    const showApp = !isHostOnly && (_mode === 'app' || _mode === 'both');

    // Scale y per metric — temp varies 20..90°C; fps uses whichever is
    // larger of the session peak or the target-sum ceiling with some
    // headroom; capture_fps tracks the same shape but without a
    // target-sum reference; errors uses a small-number scale so a single
    // spike from 0 doesn't pin the line at the top forever; others are
    // 0..100 %.
    const yMin = key === 'temp' ? 20 : 0;
    const yMax = key === 'temp' ? 100
        : key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1)
        : key === 'capture_fps' ? Math.max(60, _captureFpsPeak * 1.1)
        : key === 'capture_fps_actual' ? Math.max(60, _captureFpsActualPeak * 1.1, _fpsTargetSum * 1.1)
        : key === 'errors' ? Math.max(5, _errorsPeak * 1.2)
        : key === 'network' ? Math.max(1024, _networkPeak * 1.1)
        : key === 'device_latency' ? Math.max(50, _deviceLatencyPeak * 1.2)
        : key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2)
        : 100;

    const paths: string[] = [];

    // FPS-only: dashed "target ceiling" reference line at the sum of
    // fps_target across running targets, so the spark reads as "live
    // throughput relative to max achievable."
    if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) {
        const span = yMax - yMin || 1;
        const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 1;
        paths.push(`<line x1="0" y1="${refY.toFixed(1)}" x2="${SPARK_W}" y2="${refY.toFixed(1)}" stroke="${color}" stroke-width="1" stroke-dasharray="5 4" opacity="0.4" />`);
    }

    if (showSystem && sys.length > 1) {
        paths.push(_pathFor(sys, yMin, yMax, color, 'sys', sliceN));
    }
    if (showApp && app.length > 1) {
        paths.push(_pathFor(app, yMin, yMax, color, 'app', sliceN));
    }

    host.innerHTML = `
        <svg class="perf-chart-svg" viewBox="0 0 ${SPARK_W} ${SPARK_H}" preserveAspectRatio="none" aria-hidden="true">
            <defs>
                <linearGradient id="perf-fade-${key}" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0%" stop-color="${color}" stop-opacity="0.32"/>
                    <stop offset="100%" stop-color="${color}" stop-opacity="0"/>
                </linearGradient>
            </defs>
            ${paths.join('')}
        </svg>`;

    if (animate) _scrollSpark(host, sliceN);
}

/** Build <path> elements (area + stroke) for one series. */
function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app', sliceN: number = MAX_SAMPLES): string {
    const n = history.length;
    if (n < 2) return '';
    // Right-align so the most recent sample sits at the right edge —
    // matches an instrument display where new values tick in from the
    // right. `sliceN` is the spark's logical sample-count "width" (set
    // by the user-configurable window): a fully-populated slice fills
    // the spark edge-to-edge; an early-history short array stays
    // right-aligned with empty space on the left.
    const step = SPARK_W / (Math.max(2, sliceN) - 1);
    const offset = (sliceN - n) * step;
    const span = yMax - yMin || 1;

    const points: string[] = [];
    for (let i = 0; i < n; i++) {
        const x = offset + i * step;
        const v = Math.min(yMax, Math.max(yMin, history[i]));
        const y = SPARK_H - ((v - yMin) / span) * (SPARK_H - 2) - 1;
        points.push(`${x.toFixed(1)},${y.toFixed(1)}`);
    }
    const line = `M${points.join(' L')}`;
    const firstX = offset;
    const lastX = offset + (n - 1) * step;
    const area = `M${firstX.toFixed(1)},${SPARK_H} L${points.join(' L')} L${lastX.toFixed(1)},${SPARK_H} Z`;

    if (kind === 'sys') {
        const gradientId = `perf-fade-${color.replace(/[^a-z0-9]/gi, '')}`;
        return `
            <path d="${area}" fill="${color}" opacity="0.14" />
            <path d="${line}" stroke="${color}" stroke-width="1.5" fill="none" stroke-linejoin="round" />`;
    }
    // App line: thinner, dashed, no fill
    return `<path d="${line}" stroke="${color}" stroke-width="1.1" fill="none" stroke-dasharray="4 3" stroke-linejoin="round" opacity="0.75" />`;
}

function _pushSample(key: string, sysValue: number, appValue: number | null): void {
    _history[key].push(sysValue);
    if (_history[key].length > MAX_SAMPLES) _history[key].shift();

    if (appValue != null) {
        _appHistory[key].push(appValue);
        if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
    }

    _renderChartSvg(key, /*animate=*/true);
}

/** Render the main + app values for a single perf card.
 *  - Main value ( `#perf-<key>-value` ) shows the value appropriate for the
 *    current mode (system value in 'system'/'both'; app value in 'app').
 *  - App tag ( `#perf-<key>-app` ) shows `APP <val>` in the top-right
 *    corner when mode is 'both' and an app value is available. */
function _renderValuePair(key: string, sysVal: string, appVal: string | null): void {
    const mainEl = document.getElementById(`perf-${key}-value`);
    if (mainEl) {
        if (_mode === 'app' && appVal != null) {
            mainEl.innerHTML = appVal;
        } else {
            mainEl.innerHTML = sysVal;
        }
    }
    const tagEl = document.getElementById(`perf-${key}-app`);
    if (tagEl) {
        if (_mode === 'both' && appVal != null) {
            tagEl.innerHTML = `<span class="perf-chart-app-k">App</span>${appVal}`;
            (tagEl as HTMLElement).style.display = '';
        } else {
            tagEl.textContent = '';
            (tagEl as HTMLElement).style.display = 'none';
        }
    }
}

async function _fetchPerformance(): Promise<void> {
    try {
        const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
        if (!resp.ok) return;
        const data = await resp.json();
        _lastFetchData = data;
        _applyPerfDataToDom(data, /*pushHistory=*/true);
    } catch {
        // Silently ignore transient fetch errors
    }
}

/** Apply a `/system/performance` payload to the perf-grid DOM.
 *
 * Split out from `_fetchPerformance` so that on layout changes we can
 * replay the cached data into a freshly-rendered grid without flashing
 * "—". `pushHistory=false` skips the per-metric history push (replay
 * mode); the chart sparks repaint from existing history via
 * `_renderChartSvg(key)` called by the rerender helper. */
function _applyPerfDataToDom(data: any, pushHistory: boolean): void {
        // CPU
        if (pushHistory) _pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
        _updateSidebarMeter(data.cpu_percent ?? 0, data.app_cpu_percent ?? 0);
        _renderValuePair('cpu',
            `${data.cpu_percent.toFixed(0)}%`,
            `${data.app_cpu_percent.toFixed(1)}%`);
        if (data.cpu_name) {
            const nameEl = document.getElementById('perf-cpu-name');
            if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name;
        }

        // RAM
        const appRamPct = data.ram_total_mb > 0
            ? (data.app_ram_mb / data.ram_total_mb) * 100
            : 0;
        if (pushHistory) _pushSample('ram', data.ram_percent, appRamPct);
        _updateTransportMem(data.app_ram_mb ?? 0);
        const usedGb = (data.ram_used_mb / 1024).toFixed(1);
        const totalGb = (data.ram_total_mb / 1024).toFixed(1);
        const appMb = data.app_ram_mb.toFixed(0);
        _renderValuePair('ram',
            `${usedGb}/${totalGb} GB`,
            `${appMb} MB`);

        // Temperature (host-only, no app variant — battery shown in app slot)
        if (data.cpu_temp_c != null) {
            if (_hasTemp !== true) {
                _hasTemp = true;
                const card = document.getElementById('perf-temp-card');
                if (card) {
                    card.removeAttribute('hidden');
                    card.classList.remove('perf-chart-card-hint');
                }
            }
            if (pushHistory) _pushSample('temp', data.cpu_temp_c, null);
            const batText = data.battery_temp_c != null
                ? `${data.battery_temp_c.toFixed(0)}°C`
                : null;
            _renderValuePair('temp',
                `${data.cpu_temp_c.toFixed(0)}°C`,
                batText);
        } else if (data.cpu_temp_hint_key) {
            // No live reading, but the server knows *why* — show an explainer
            // in place of the big number instead of silently hiding the card.
            // Primary use case: Windows without LibreHardwareMonitor, where
            // no userland API exposes real CPU die temperature.
            if (_hasTemp !== true) {
                _hasTemp = true;
                const card = document.getElementById('perf-temp-card');
                if (card) {
                    card.removeAttribute('hidden');
                    card.classList.add('perf-chart-card-hint');
                }
            }
            const valEl = document.getElementById('perf-temp-value');
            if (valEl) {
                valEl.innerHTML = `<span class="perf-chart-hint">${t(data.cpu_temp_hint_key)}</span>`;
            }
            const tagEl = document.getElementById('perf-temp-app');
            if (tagEl) {
                tagEl.textContent = '';
                (tagEl as HTMLElement).style.display = 'none';
            }
        } else if (_hasTemp === null) {
            _hasTemp = false;
        }

        // GPU
        if (data.gpu) {
            _hasGpu = true;
            const appGpuPct = (data.gpu.app_memory_mb != null && data.gpu.memory_total_mb)
                ? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100
                : null;
            if (pushHistory) _pushSample('gpu', data.gpu.utilization, appGpuPct);
            const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
            const appText = data.gpu.app_memory_mb != null
                ? `${data.gpu.app_memory_mb.toFixed(0)}MB`
                : null;
            _renderValuePair('gpu', sysText, appText);
            if (data.gpu.name) {
                const nameEl = document.getElementById('perf-gpu-name');
                if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;
            }
        } else if (_hasGpu === false) {
            // Cached "no GPU on this host" — hide the card after a re-render
            // recreates it without the hidden attr.
            const card = document.getElementById('perf-gpu-card');
            if (card) card.setAttribute('hidden', '');
        } else if (_hasGpu === null) {
            _hasGpu = false;
            const card = document.getElementById('perf-gpu-card');
            if (card) card.setAttribute('hidden', '');
        }
}

/** Push CPU + app-CPU share to the sidebar meter plate. Called from
 *  `_fetchPerformance` because the sidebar meter shares the same poll. */
function _updateSidebarMeter(cpuPercent: number, appCpuPercent: number): void {
    const cpuLabel = document.getElementById('sidebar-meter-cpu');
    const cpuBar = document.getElementById('sidebar-meter-cpu-bar');
    if (cpuLabel) cpuLabel.textContent = `${cpuPercent.toFixed(0)}%`;
    if (cpuBar) (cpuBar as HTMLElement).style.width = `${Math.min(100, Math.max(0, cpuPercent))}%`;

    const fpsLabel = document.getElementById('sidebar-meter-fps');
    const fpsBar = document.getElementById('sidebar-meter-fps-bar');
    const appPct = cpuPercent > 0 ? Math.min(100, (appCpuPercent / cpuPercent) * 100) : 0;
    if (fpsLabel) fpsLabel.textContent = `${appCpuPercent.toFixed(1)}%`;
    if (fpsBar) (fpsBar as HTMLElement).style.width = `${appPct}%`;

    // Transport-bar meta cells — show APP CPU share (what LedGrab is
    // consuming) rather than host CPU. System CPU stays on the sidebar
    // meter when present.
    const tCpu = document.getElementById('transport-cpu');
    if (tCpu) tCpu.textContent = `${appCpuPercent.toFixed(1)}%`;
}

function _updateTransportMem(appRamMb: number): void {
    const tMem = document.getElementById('transport-mem');
    if (!tMem) return;
    if (appRamMb >= 1024) {
        tMem.textContent = `${(appRamMb / 1024).toFixed(1)}G`;
    } else {
        tMem.textContent = `${appRamMb.toFixed(0)}M`;
    }
}

/** Seed history arrays from server-side ring buffer, then do an initial
 *  render so cards don't start empty on page load. */
async function _seedFromServer(): Promise<void> {
    try {
        const data = await fetchMetricsHistory();
        if (!data) return;
        const samples: any[] = (data.system as any[] | undefined) || [];
        _history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
        _history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
        _history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null);
        _history.temp = samples.map((s: any) => s.cpu_temp).filter((v: any) => v != null);

        // app_cpu is already a percent (0..100 per-core), plot as-is.
        _appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null);

        // app_ram + app_gpu_mem are absolute MB values in history — convert
        // to a percent of their respective totals so they plot against the
        // same 0..100 y-axis as the system line. If the total is missing
        // on a given sample (e.g. GPU total isn't in the history schema),
        // drop the point so it doesn't spike to the top.
        _appHistory.ram = samples
            .filter((s: any) => s.app_ram != null && s.ram_total > 0)
            .map((s: any) => (s.app_ram / s.ram_total) * 100);
        // Server history schema doesn't include gpu_memory_total — skip
        // seeding the app GPU series and let the live poll fill it from
        // the full /system/performance payload that does include totals.
        _appHistory.gpu = [];

        // FPS / Capture FPS / Errors aggregates — populated by the
        // server-side ring buffer so these sparks survive page reloads
        // (mirrors how CPU / RAM already work). Older payloads without
        // these fields produce empty arrays; live polling fills them in.
        _seedAggregateHistories(samples);

        if (_history.gpu.length > 0) {
            _hasGpu = true;
        } else if (samples.length > 0) {
            _hasGpu = false;
            const card = document.getElementById('perf-gpu-card');
            if (card) card.setAttribute('hidden', '');
        }
        if (_history.temp.length > 0) {
            _hasTemp = true;
            const card = document.getElementById('perf-temp-card');
            if (card) card.removeAttribute('hidden');
        }

        for (const key of CHART_KEYS) _renderChartSvg(key);
    } catch {
        // Charts will fill from polling instead
    }
}

/** Reconstruct the FPS / Capture FPS / Errors history + value labels
 *  from the server's ring buffer. Each system snapshot since v1.x
 *  carries the running-target aggregate so we can rehydrate without
 *  waiting for the dashboard's batch fetch (which is what populated
 *  these previously and lost everything on every reload). */
function _seedAggregateHistories(samples: any[]): void {
    if (samples.length === 0) return;

    const fpsSeries = samples
        .map((s: any) => s.total_fps)
        .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
    if (fpsSeries.length > 0) {
        _history.fps = fpsSeries.slice(-MAX_SAMPLES);
        _fpsPeak = Math.max(60, ..._history.fps);
    }

    const captureSeries = samples
        .map((s: any) => s.total_capture_fps)
        .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
    if (captureSeries.length > 0) {
        _history.capture_fps = captureSeries.slice(-MAX_SAMPLES);
        _captureFpsPeak = Math.max(60, ..._history.capture_fps);
    }

    const captureActualSeries = samples
        .map((s: any) => s.total_capture_fps_actual)
        .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
    if (captureActualSeries.length > 0) {
        _history.capture_fps_actual = captureActualSeries.slice(-MAX_SAMPLES);
        _captureFpsActualPeak = Math.max(60, ..._history.capture_fps_actual);
    }

    // Errors history is a per-second rate. The server already does
    // the delta math against its own running totals, so we just
    // pull `errors_per_sec` straight through. Set the cumulative
    // baseline so the dashboard's next live update doesn't synthesize
    // a phantom spike from a stale "0 → live count" comparison.
    const errorsSeries = samples
        .map((s: any) => s.errors_per_sec)
        .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
    if (errorsSeries.length > 0) {
        _history.errors = errorsSeries.slice(-MAX_SAMPLES);
        _errorsPeak = Math.max(1, ..._history.errors);
    }
    const lastSample = samples[samples.length - 1];
    if (typeof lastSample?.total_errors_count === 'number') {
        _prevErrorsTotal = lastSample.total_errors_count;
    }
    if (typeof lastSample?.total_frames_skipped === 'number') {
        _prevSkippedTotal = lastSample.total_frames_skipped;
    }

    // Latest target-sum is shown as a dashed reference line on the
    // FPS spark — pin it so the chart doesn't redraw without the
    // ceiling line for the brief window before dashboard.ts polls.
    if (typeof lastSample?.total_fps_target === 'number' && lastSample.total_fps_target > 0) {
        _fpsTargetSum = lastSample.total_fps_target;
    }

    // Paint value labels from the latest sample so the cards don't
    // sit on "—" / "0" until the next dashboard poll. Mirrors what
    // `_applyPerfDataToDom` does for CPU/RAM/GPU on the same load.
    if (typeof lastSample?.total_fps === 'number') {
        _paintFpsValue(lastSample.total_fps);
    }
    if (typeof lastSample?.total_capture_fps === 'number') {
        _paintCaptureFpsValue(lastSample.total_capture_fps);
    }
    if (typeof lastSample?.total_capture_fps_actual === 'number') {
        _paintCaptureFpsActualValue(
            lastSample.total_capture_fps_actual,
            typeof lastSample.total_fps_target === 'number' ? lastSample.total_fps_target : 0,
            typeof lastSample.capture_actual_count === 'number' ? lastSample.capture_actual_count : 0,
        );
    }

    // Network throughput history — direct passthrough from the
    // server's per-second rate (computed from cumulative byte counter
    // deltas, same shape as `errors_per_sec`).
    const networkSeries = samples
        .map((s: any) => s.bytes_per_sec)
        .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
    if (networkSeries.length > 0) {
        _history.network = networkSeries.slice(-MAX_SAMPLES);
        _networkPeak = Math.max(1024, ..._history.network);
    }
    if (typeof lastSample?.bytes_per_sec === 'number') {
        _paintNetworkValue(
            lastSample.bytes_per_sec,
            typeof lastSample.total_bytes_sent === 'number' ? lastSample.total_bytes_sent : 0,
        );
    }

    // Device latency history — sparkline plots the avg-across-online
    // series. `null` samples (no devices online) become 0 in the
    // history so the spark drops to floor instead of going jagged.
    const latencySeries = samples
        .map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0)
    if (latencySeries.length > 0) {
        _history.device_latency = latencySeries.slice(-MAX_SAMPLES);
        _deviceLatencyPeak = Math.max(50, ..._history.device_latency);
    }
    if (lastSample) {
        _paintDeviceLatencyValue(
            typeof lastSample.device_latency_avg_ms === 'number' ? lastSample.device_latency_avg_ms : null,
            typeof lastSample.device_latency_max_ms === 'number' ? lastSample.device_latency_max_ms : null,
            typeof lastSample.device_online_count === 'number' ? lastSample.device_online_count : 0,
            typeof lastSample.device_total_count === 'number' ? lastSample.device_total_count : 0,
        );
    }

    // Send-timing history — plots the avg series; max travels in
    // the subtitle/tooltip but isn't a separate spark line to avoid
    // adding visual noise.
    const sendSeries = samples
        .map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0)
    if (sendSeries.length > 0) {
        _history.send_timing = sendSeries.slice(-MAX_SAMPLES);
        const maxes = samples
            .map((s: any) => (typeof s.send_timing_max_ms === 'number' && Number.isFinite(s.send_timing_max_ms)) ? s.send_timing_max_ms : 0);
        _sendTimingPeak = Math.max(20, ..._history.send_timing, ...maxes);
    }
    if (lastSample) {
        _paintSendTimingValue(
            typeof lastSample.send_timing_avg_ms === 'number' ? lastSample.send_timing_avg_ms : 0,
            typeof lastSample.send_timing_max_ms === 'number' ? lastSample.send_timing_max_ms : 0,
            typeof lastSample.send_timing_count === 'number' ? lastSample.send_timing_count : 0,
        );
    }
    if (typeof lastSample?.errors_per_sec === 'number') {
        _paintErrorsValue(
            lastSample.errors_per_sec,
            lastSample.total_errors_count ?? 0,
            lastSample.skipped_per_sec ?? 0,
        );
    }
}

function _paintFpsValue(fps: number): void {
    const valEl = document.getElementById('perf-fps-value');
    if (!valEl) return;
    const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
    const ceilingSuffix = _fpsTargetSum > 0
        ? `<span class="perf-fps-ceiling">/ ${Math.round(_fpsTargetSum)}</span>`
        : '';
    valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
}

function _paintCaptureFpsValue(fps: number): void {
    const valEl = document.getElementById('perf-capture_fps-value');
    if (!valEl) return;
    const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
    valEl.innerHTML = `${fpsText}<span class="perf-fps-unit">fps</span>`;
}

function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCount: number): void {
    const valEl = document.getElementById('perf-capture_fps_actual-value');
    if (valEl) {
        if (reportingCount === 0) {
            valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
        } else {
            const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
            const ceilingSuffix = targetSum > 0
                ? `<span class="perf-fps-ceiling">/ ${Math.round(targetSum)}</span>`
                : '';
            valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
        }
    }
    const subEl = document.getElementById('perf-capture_fps_actual-sub');
    if (subEl) {
        if (reportingCount === 0) {
            subEl.textContent = '';
        } else if (targetSum > 0) {
            const ratio = Math.max(0, Math.min(1, fps / targetSum));
            subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
        } else {
            subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
        }
    }
}

function _paintErrorsValue(errorsRate: number, totalErrors: number, skippedRate: number): void {
    const card = document.querySelector('.perf-errors-cell') as HTMLElement | null;
    if (card) card.classList.toggle('has-errors', totalErrors > 0 || errorsRate > 0);

    const valEl = document.getElementById('perf-errors-value');
    if (valEl) {
        const rateText = errorsRate >= 10
            ? errorsRate.toFixed(0)
            : errorsRate >= 1
                ? errorsRate.toFixed(1)
                : '0';
        valEl.innerHTML = `${rateText}<span class="perf-fps-unit">/s</span>`;
    }
    const subEl = document.getElementById('perf-errors-sub');
    if (subEl) {
        const parts: string[] = [];
        if (totalErrors > 0) parts.push(`${totalErrors} total`);
        if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
        subEl.textContent = parts.join(' · ');
    }
}

/** Initialize perf section — paint from server-side history and wire up
 *  spark hover tooltips. Also fires one immediate `_fetchPerformance` so
 *  the value labels (CPU %, RAM GB, GPU °C, etc.) populate on page load
 *  without waiting for the first poll-interval tick — otherwise the
 *  cards display "—" for up to ~1 second after every reload. */
export async function initPerfCharts(): Promise<void> {
    await _seedFromServer();
    _initSparkTooltip();
    // If we have cached data from a prior session within this tab life
    // (e.g. layout-change re-render), replay it instantly. Otherwise
    // hit the network once. Both paths converge before the polling
    // loop starts so there's never a "—" state visible.
    if (_lastFetchData) {
        _applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false);
    } else {
        await _fetchPerformance();
    }
}

/** Re-render the perf grid in place after a layout change.
 *
 * Reconciles the existing `.perf-charts-grid` against the desired cell
 * set + order from the layout. Cells that already exist are kept in
 * place (or moved to a new index) — their DOM, listeners, color picker
 * state, hidden-by-env state, and label values all survive intact. Only
 * cells that were not visible before are freshly created; cells that
 * disappeared from the layout are removed.
 *
 * Replay of cached state targets only newly-created cells, so unchanged
 * cells don't get a phantom-scroll animation from a fresh
 * `update*FPS/Errors/Devices` call. Without this, every layout tweak
 * (mode toggle, window change, color reset) would visibly reset every
 * sparkline. */
export function rerenderPerfGrid(): void {
    const wrapper = document.querySelector('.dashboard-perf-persistent');
    if (!wrapper) return;
    const grid = wrapper.querySelector<HTMLElement>('.perf-charts-grid');
    if (!grid) return;

    _syncMode();
    _registerPerfColorPickers();

    // Index existing cells by metric key so we can keep / move them
    // instead of rebuilding their DOM.
    const existing = new Map<string, HTMLElement>();
    grid.querySelectorAll<HTMLElement>(':scope > .perf-chart-card[data-metric]').forEach(el => {
        const k = el.dataset.metric;
        if (k) existing.set(k, el);
    });

    // Compute desired keys (in order). Anything visible in the layout
    // makes the cut; env-detection hides cards via the `hidden` attr,
    // which is independent of layout visibility.
    const desiredKeys: string[] = [];
    for (const cell of getOrderedPerfCells()) {
        if (cell.visible) desiredKeys.push(cell.key);
    }

    // First pass: walk desired order, taking existing elements where
    // possible and creating new ones otherwise. Track which keys are
    // newly created — those are the only ones that need a value replay.
    const newKeys = new Set<string>();
    const ordered: HTMLElement[] = [];
    for (const key of desiredKeys) {
        let el = existing.get(key);
        if (el) {
            existing.delete(key);
        } else {
            el = _buildCellElement(key) ?? undefined;
            if (!el) continue;
            newKeys.add(key);
        }
        // Update mode + accent on every kept cell. Cheap and idempotent;
        // covers the common "mode toggle" path where cell set is unchanged.
        el.dataset.perfMode = _mode;
        el.style.setProperty('--perf-accent', _getColor(key));
        ordered.push(el);
    }

    // Remove cells that should no longer be visible.
    existing.forEach(el => el.remove());

    // Reorder via appendChild — moving an already-attached node preserves
    // its state (listeners, animation transforms, hidden attr). Skip the
    // append when the node is already at the correct position to avoid
    // gratuitous DOM mutations.
    let cursor: ChildNode | null = grid.firstChild;
    for (const el of ordered) {
        if (cursor === el) {
            cursor = el.nextSibling;
        } else {
            grid.insertBefore(el, cursor);
            // `el` is now positioned before `cursor`; cursor stays the same.
        }
    }

    // Re-render every spark (window / yScale may have changed). Pass
    // `animate=false` so we don't trigger phantom scrolls — only real
    // poll samples animate.
    for (const key of CHART_KEYS) _renderChartSvg(key);

    // Re-apply env-detection visibility for newly-created gpu/temp cells
    // (the HTML template renders them unhidden by default).
    if (newKeys.has('gpu') && _hasGpu === false) {
        const card = document.getElementById('perf-gpu-card');
        if (card) card.setAttribute('hidden', '');
    }
    if (newKeys.has('temp') && _hasTemp === true) {
        const card = document.getElementById('perf-temp-card');
        if (card) card.removeAttribute('hidden');
    }

    // Replay cached state only into newly-created cells. Existing cells
    // already have correct labels in their DOM.
    if (newKeys.size === 0) return;

    const needsFetchReplay = newKeys.has('cpu') || newKeys.has('ram')
        || newKeys.has('gpu') || newKeys.has('temp');
    if (needsFetchReplay && _lastFetchData) {
        _applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false);
    }
    if (newKeys.has('patches') && _lastPatchesArgs) {
        updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount);
    }
    if (newKeys.has('fps') && _lastTotalFpsArgs) {
        updateTotalFps(
            _lastTotalFpsArgs.totalFps,
            _lastTotalFpsArgs.minFps,
            _lastTotalFpsArgs.maxFps,
            _lastTotalFpsArgs.targetSum,
        );
    }
    if (newKeys.has('capture_fps') && _lastTotalCaptureFpsArgs) {
        updateTotalCaptureFps(
            _lastTotalCaptureFpsArgs.totalFps,
            _lastTotalCaptureFpsArgs.minFps,
            _lastTotalCaptureFpsArgs.maxFps,
        );
    }
    if (newKeys.has('capture_fps_actual') && _lastTotalCaptureFpsActualArgs) {
        updateTotalCaptureFpsActual(
            _lastTotalCaptureFpsActualArgs.totalFps,
            _lastTotalCaptureFpsActualArgs.targetSum,
            _lastTotalCaptureFpsActualArgs.reportingCount,
        );
    }
    if (newKeys.has('network') && _lastNetworkArgs) {
        updateNetworkThroughput(
            _lastNetworkArgs.bytesPerSec,
            _lastNetworkArgs.totalBytes,
        );
    }
    if (newKeys.has('device_latency') && _lastDeviceLatencyArgs) {
        updateDeviceLatency(
            _lastDeviceLatencyArgs.avgMs,
            _lastDeviceLatencyArgs.maxMs,
            _lastDeviceLatencyArgs.onlineCount,
            _lastDeviceLatencyArgs.totalCount,
        );
    }
    if (newKeys.has('send_timing') && _lastSendTimingArgs) {
        updateSendTiming(
            _lastSendTimingArgs.avgMs,
            _lastSendTimingArgs.maxMs,
            _lastSendTimingArgs.reportingCount,
        );
    }
    if (newKeys.has('errors') && _lastErrorsArgs) {
        // Replay must not synthesize a fake spike from delta against an
        // older baseline (e.g. layout-change re-render after a long
        // session). Pin the baseline to the cached totals so the call
        // computes a zero rate; the next real poll picks up from here.
        _prevErrorsTotal = _lastErrorsArgs.totalErrors;
        _prevSkippedTotal = _lastErrorsArgs.totalSkipped;
        updateTotalErrors(
            _lastErrorsArgs.totalErrors,
            _lastErrorsArgs.totalSkipped,
            _lastErrorsArgs.pollMs,
        );
    }
    if (newKeys.has('devices') && _lastDevicesArgs) {
        updateDevices(_lastDevicesArgs);
    }
}

// ─── Spark hover tooltip ─────────────────────────────────────────

/** Single shared tooltip + marker element lazy-created on first hover. */
let _tooltipEl: HTMLDivElement | null = null;
let _tooltipMarkerEl: HTMLDivElement | null = null;

function _ensureTooltip(): HTMLDivElement {
    if (_tooltipEl) return _tooltipEl;
    const el = document.createElement('div');
    el.className = 'perf-chart-tooltip';
    el.setAttribute('aria-hidden', 'true');
    document.body.appendChild(el);
    _tooltipEl = el;
    const marker = document.createElement('div');
    marker.className = 'perf-chart-tooltip-marker';
    marker.setAttribute('aria-hidden', 'true');
    document.body.appendChild(marker);
    _tooltipMarkerEl = marker;
    return el;
}

/** Format a sampled value per metric for the tooltip line. */
function _formatSampleValue(key: string, v: number): string {
    if (key === 'temp') return `${v.toFixed(1)}°C`;
    if (key === 'fps' || key === 'capture_fps' || key === 'capture_fps_actual') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
    if (key === 'network') {
        if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`;
        if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`;
        return `${v.toFixed(0)} B/s`;
    }
    if (key === 'device_latency' || key === 'send_timing') return `${v.toFixed(v < 10 ? 1 : 0)} ms`;
    if (key === 'errors') return `${v.toFixed(v < 1 ? 2 : v < 10 ? 1 : 0)}/s`;
    return `${v.toFixed(1)}%`;
}

function _metricLabel(key: string): string {
    if (key === 'cpu') return 'CPU';
    if (key === 'ram') return 'RAM';
    if (key === 'gpu') return 'GPU';
    if (key === 'temp') return 'Temp';
    if (key === 'fps') return 'Total FPS';
    if (key === 'capture_fps') return 'Total Source FPS';
    if (key === 'capture_fps_actual') return 'Total Capture FPS';
    if (key === 'network') return 'Network';
    if (key === 'device_latency') return 'Device Latency';
    if (key === 'send_timing') return 'Send Timing';
    if (key === 'errors') return 'Errors';
    return key.toUpperCase();
}

let _tooltipBound = false;
function _initSparkTooltip(): void {
    if (_tooltipBound) return;
    _tooltipBound = true;
    const intervalMs = dashboardPollInterval || 2000;
    // Bound on `document.body` instead of `.perf-charts-grid` so the
    // listener survives `rerenderPerfGrid()` replacing the grid element.
    // The handler bails out unless the cursor is actually over a spark,
    // so the hot-path cost is just one `closest()` call per mousemove.
    document.body.addEventListener('mousemove', (rawEv) => {
        const ev = rawEv as MouseEvent;
        const target = ev.target as HTMLElement;
        if (!target || !target.closest) { _hideTooltip(); return; }
        const spark = target.closest('.perf-chart-spark') as HTMLElement | null;
        if (!spark) { _hideTooltip(); return; }
        const card = spark.closest('.perf-chart-card') as HTMLElement | null;
        if (!card) { _hideTooltip(); return; }
        const key = card.dataset.metric;
        if (!key || !_history[key]) { _hideTooltip(); return; }

        const rect = spark.getBoundingClientRect();
        const sysFull = _history[key];
        const appFull = _appHistory[key];
        if (sysFull.length < 2) { _hideTooltip(); return; }

        // Tooltip must read from the same slice the spark draws — otherwise
        // the cursor x → sample mapping skews after the user changes the
        // window setting. Effective window in seconds × samples-per-sec
        // (clamped to MAX_SAMPLES) gives the visible slice length.
        const cfg = (() => { try { return getGlobalConfig(); } catch { return null; } })();
        const winSec = (() => { try { return effectivePerfWindow(key); } catch { return 120; } })();
        const samplesPerSec = cfg ? Math.max(0.5, 1000 / Math.max(50, cfg.pollMs)) : 1;
        const sliceN = Math.min(MAX_SAMPLES, Math.max(2, Math.round(winSec * samplesPerSec)));
        const sys = sysFull.slice(-sliceN);
        const app = appFull.slice(-sliceN);

        // Samples right-align in the spark (new tick arrives at the right
        // edge), so cursor x → index in the last-N window.
        const relX = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
        const fraction = rect.width > 0 ? relX / rect.width : 0;
        // The visible series maps to the rightmost `sys.length` samples in
        // a `sliceN`-wide spark — compute which actual sample the cursor x
        // corresponds to.
        const visibleStart = sliceN - sys.length;
        const globalIdx = Math.round(fraction * (sliceN - 1));
        const localIdx = Math.max(0, Math.min(sys.length - 1, globalIdx - visibleStart));
        const sysValue = sys[localIdx];
        const appValue = app && app.length > localIdx ? app[localIdx] : null;

        // "-Ns ago" based on sample age (newest is rightmost).
        const ageSecs = Math.round((sys.length - 1 - localIdx) * (intervalMs / 1000));

        const tip = _ensureTooltip();
        const color = _getColor(key);
        const sysLine = `<span class="perf-tip-dot" style="background:${color}"></span>
            <span class="perf-tip-k">${_metricLabel(key)}</span>
            <span class="perf-tip-v">${_formatSampleValue(key, sysValue)}</span>`;
        const appLine = (appValue != null)
            ? `<div class="perf-tip-row perf-tip-app">
                <span class="perf-tip-dot perf-tip-dot-app" style="border-color:${color}"></span>
                <span class="perf-tip-k">App</span>
                <span class="perf-tip-v">${_formatSampleValue(key, appValue)}</span>
            </div>`
            : '';
        const ageLine = `<div class="perf-tip-age">${ageSecs === 0 ? 'now' : `−${ageSecs}s`}</div>`;
        tip.innerHTML = `<div class="perf-tip-row">${sysLine}</div>${appLine}${ageLine}`;
        tip.style.display = 'block';

        // Position tip above cursor, clamped to viewport.
        const tipRect = tip.getBoundingClientRect();
        let tipLeft = ev.clientX - tipRect.width / 2;
        let tipTop = rect.top - tipRect.height - 10;
        if (tipTop < 6) tipTop = rect.bottom + 10;  // flip below if no room above
        tipLeft = Math.max(6, Math.min(window.innerWidth - tipRect.width - 6, tipLeft));
        tip.style.left = `${tipLeft}px`;
        tip.style.top = `${tipTop}px`;

        // Vertical marker line over the spark at cursor x.
        if (_tooltipMarkerEl) {
            _tooltipMarkerEl.style.display = 'block';
            _tooltipMarkerEl.style.left = `${ev.clientX}px`;
            _tooltipMarkerEl.style.top = `${rect.top}px`;
            _tooltipMarkerEl.style.height = `${rect.height}px`;
            _tooltipMarkerEl.style.setProperty('--marker-color', color);
        }
    });
    // Hide whenever the mouse leaves the body (out of viewport) — when
    // moving between sparks within the body, the mousemove handler above
    // re-positions the tip on the next sample.
    document.body.addEventListener('mouseleave', _hideTooltip);
}

function _hideTooltip(): void {
    if (_tooltipEl) _tooltipEl.style.display = 'none';
    if (_tooltipMarkerEl) _tooltipMarkerEl.style.display = 'none';
}

export function startPerfPolling(): void {
    if (_pollTimer) return;
    _fetchPerformance();
    _pollTimer = setInterval(_fetchPerformance, dashboardPollInterval);
}

export function stopPerfPolling(): void {
    if (_pollTimer) {
        clearInterval(_pollTimer);
        _pollTimer = null;
    }
}

document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        stopPerfPolling();
    } else {
        if (isActiveTab('dashboard')) startPerfPolling();
    }
});
