/**
 * Dashboard — real-time target status overview.
 */

import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import {
    ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
    ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
    ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
import { mountCardModeToggle } from './card-modes.ts';

function _applyGlobalLayoutAttrs(): void {
    const c = document.getElementById('dashboard-content');
    if (!c) return;
    const g = getGlobalConfig();
    c.dataset.layoutWidth = g.width;
    c.dataset.layoutAnim = g.animations;
}

/** Card-mode toggle teardown registry. Each render replaces the dashboard
 *  inner HTML, so any previously-mounted toggle becomes detached. We tear
 *  down old subscribers before mounting fresh so the module-level listener
 *  Set in card-modes.ts stays bounded. Keyed by the surface name (matches
 *  `data-dashboard-mode-slot`). */
const _dashboardModeTeardowns = new Map<string, () => void>();

function _mountDashboardCardModeToggles(): void {
    const container = document.getElementById('dashboard-content');
    if (!container) return;
    for (const [, teardown] of _dashboardModeTeardowns) {
        try { teardown(); } catch (e) { console.warn('card-mode teardown', e); }
    }
    _dashboardModeTeardowns.clear();
    const slots = container.querySelectorAll<HTMLElement>('[data-dashboard-mode-slot]');
    for (const slot of slots) {
        const surface = slot.dataset.dashboardModeSlot;
        if (!surface) continue;
        // Host = nearest [data-section] ancestor (either a .dashboard-section
        // or a .dashboard-subsection — both carry the attribute now).
        const host = slot.closest('[data-section]') as HTMLElement | null;
        if (!host) continue;
        const teardown = mountCardModeToggle({ container: slot, surface, host });
        _dashboardModeTeardowns.set(surface, teardown);
    }
}
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';

const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120;

interface UptimeBase { seconds: number; timestamp: number; }
interface MetricsRefs { fps: Element | null; errors: Element | null; row: Element | null; }

let _fpsHistory: Record<string, number[]> = {};
let _fpsCurrentHistory: Record<string, number[]> = {};
let _fpsCharts: Record<string, any> = {};
let _lastRunningIds: string[] = [];
let _lastSyncClockIds: string = '';
/** Previous cumulative `bytes_sent` summed across running targets.
 *  Used to convert the WLED transport byte counter into a per-poll
 *  delta that drives the Network throughput sparkline. `null` until
 *  the first poll so we don't emit a phantom spike on page load. */
let _prevTotalBytesSent: number | null = null;
let _uptimeBase: Record<string, UptimeBase> = {};
let _uptimeTimer: ReturnType<typeof setInterval> | null = null;
let _uptimeElements: Record<string, Element> = {};
let _metricsElements: Map<string, MetricsRefs> = new Map();

function _pushFps(targetId: string, actual: number, current: number): void {
    if (!_fpsHistory[targetId]) _fpsHistory[targetId] = [];
    _fpsHistory[targetId].push(actual);
    if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift();

    if (!_fpsCurrentHistory[targetId]) _fpsCurrentHistory[targetId] = [];
    _fpsCurrentHistory[targetId].push(current);
    if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift();
}

/** Update the transport status chip in the top bar to reflect how many
 *  targets are currently running. "Ready" when idle, "Armed · N live"
 *  when one or more targets are processing. Safe to call any time. */
function _updateTransportStatus(runningCount: number): void {
    const chip = document.getElementById('transport-status');
    if (!chip) return;
    const label = chip.querySelector('span:last-child');
    if (!label) return;
    if (runningCount > 0) {
        chip.classList.add('is-armed');
        const tmpl = t('transport.status.armed');
        label.textContent = tmpl.includes('{n}') ? tmpl.replace('{n}', String(runningCount)) : `${tmpl} · ${runningCount}`;
    } else {
        chip.classList.remove('is-armed');
        label.textContent = t('transport.status.ready');
    }
}

function _setUptimeBase(targetId: string, seconds: number): void {
    _uptimeBase[targetId] = { seconds, timestamp: Date.now() };
}

function _getInterpolatedUptime(targetId: string): number | null {
    const base = _uptimeBase[targetId];
    if (!base) return null;
    const elapsed = (Date.now() - base.timestamp) / 1000;
    return base.seconds + elapsed;
}

function _cacheUptimeElements(): void {
    _uptimeElements = {};
    for (const id of _lastRunningIds) {
        const el = document.querySelector(`[data-uptime-text="${CSS.escape(id)}"]`);
        if (el) _uptimeElements[id] = el;
    }
}

function _startUptimeTimer(): void {
    if (_uptimeTimer) return;
    _uptimeTimer = setInterval(() => {
        for (const id of _lastRunningIds) {
            const el = _uptimeElements[id];
            if (!el) continue;
            const seconds = _getInterpolatedUptime(id);
            if (seconds != null) {
                el.textContent = formatUptime(seconds);
            }
        }
    }, 1000);
}

function _stopUptimeTimer(): void {
    if (_uptimeTimer) {
        clearInterval(_uptimeTimer);
        _uptimeTimer = null;
    }
    _uptimeBase = {};
    _uptimeElements = {};
}

function _destroyFpsCharts(): void {
    for (const id of Object.keys(_fpsCharts)) {
        if (_fpsCharts[id]) { _fpsCharts[id].destroy(); }
    }
    _fpsCharts = {};
}

function _createFpsChart(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number): any {
    return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget);
}

async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
    _destroyFpsCharts();

    // Seed FPS history from server ring buffer (on first load and tab switches)
    if (runningTargetIds.length > 0) {
        const data = await fetchMetricsHistory();
        if (data) {
            const serverTargets = data.targets || {};
            for (const id of runningTargetIds) {
                const samples = serverTargets[id] || [];
                _fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
                _fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
            }
        }
    }

    // Clean up history for targets that are no longer running
    for (const id of Object.keys(_fpsHistory)) {
        if (!runningTargetIds.includes(id)) { delete _fpsHistory[id]; delete _fpsCurrentHistory[id]; }
    }
    for (const id of runningTargetIds) {
        const canvas = document.getElementById(`dashboard-fps-${id}`);
        if (!canvas) continue;
        const actualH = _fpsHistory[id] || [];
        const currentH = _fpsCurrentHistory[id] || [];
        const fpsTarget = parseFloat(canvas.dataset.fpsTarget ?? '30') || 30;
        _fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget);
    }

    _cacheMetricsElements(runningTargetIds);
}

function _cacheMetricsElements(runningIds: string[]): void {
    _metricsElements.clear();
    for (const id of runningIds) {
        _metricsElements.set(id, {
            fps: document.querySelector(`[data-fps-text="${CSS.escape(id)}"]`),
            errors: document.querySelector(`[data-errors-text="${CSS.escape(id)}"]`),
            row: document.querySelector(`[data-target-id="${CSS.escape(id)}"]`),
        });
    }
}

/** Update running target metrics in-place (no HTML rebuild). */
function _updateRunningMetrics(enrichedRunning: any[]): void {
    for (const target of enrichedRunning) {
        const state = target.state || {};
        const metrics = target.metrics || {};
        const isHA = target.target_type === 'ha_light';
        const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
        const fpsCurrent = isHA ? fpsTarget : (state.fps_current ?? 0);
        const fpsActual = isHA ? String(fpsTarget) : (state.fps_actual != null ? state.fps_actual.toFixed(1) : '-');
        const errors = metrics.errors_count || 0;

        // Push FPS and update chart
        if (state.fps_actual != null) {
            _pushFps(target.id, state.fps_actual, fpsCurrent);
        }
        const chart = _fpsCharts[target.id];
        if (chart) {
            const actualH = _fpsHistory[target.id] || [];
            const currentH = _fpsCurrentHistory[target.id] || [];
            // Left-pad with nulls so all charts span full width
            const pad0 = MAX_FPS_SAMPLES - actualH.length;
            const pad1 = MAX_FPS_SAMPLES - currentH.length;
            const ds0 = chart.data.datasets[0].data;
            ds0.length = 0;
            if (pad0 > 0) for (let i = 0; i < pad0; i++) ds0.push(null);
            ds0.push(...actualH);
            const ds1 = chart.data.datasets[1].data;
            ds1.length = 0;
            if (pad1 > 0) for (let i = 0; i < pad1; i++) ds1.push(null);
            ds1.push(...currentH);
            chart.data.labels.length = MAX_FPS_SAMPLES;
            chart.update('none');
        }

        // Refresh uptime base for interpolation
        if (metrics.uptime_seconds != null) {
            _setUptimeBase(target.id, metrics.uptime_seconds);
        }

        // Update text values (use cached refs, fallback to querySelector)
        const cached = _metricsElements.get(target.id);
        const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${CSS.escape(target.id)}"]`);
        if (fpsEl) {
            const effFps = state.fps_effective;
            const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
                ? `${fpsCurrent}<span class="dashboard-fps-target">/${effFps}↓${fpsTarget}</span>`
                : `${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
            const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
            fpsEl.innerHTML = `<span class="${unreachableClass}">${fpsTargetLabel}</span>`
                + `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
        }

        const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`);
        if (errorsEl) {
            // Plain numeric in the big value — cleaner at display-font
            // size. The status glyph (✓ / ⚠) sits next to the small
            // label at the top of the cell; swap it here too so it
            // reflects the live error count without flicker.
            errorsEl.textContent = formatCompact(errors);
            errorsEl.classList.toggle('has-errors', errors > 0);
            (errorsEl as HTMLElement).title = String(errors);
            const cell = document.querySelector(`[data-errors-cell="${CSS.escape(target.id)}"]`);
            if (cell) {
                const labelEl = cell.querySelector('.k');
                if (labelEl) {
                    const labelText = labelEl.querySelector('[data-i18n]')?.textContent || t('dashboard.errors');
                    labelEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} <span data-i18n="dashboard.errors">${labelText}</span>`;
                }
                cell.classList.toggle('has-errors', errors > 0);
            }
        }

        // Update health dot — prefer streaming reachability when processing
        const isLed = target.target_type === 'led' || target.target_type === 'wled';
        if (isLed) {
            const row = cached?.row || document.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
            if (row) {
                const dot = row.querySelector('.health-dot');
                if (dot) {
                    const streamReachable = state.device_streaming_reachable;
                    if (state.processing && streamReachable != null) {
                        dot.className = `health-dot ${streamReachable ? 'health-online' : 'health-offline'}`;
                    } else if (state.device_last_checked != null) {
                        dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`;
                    }
                }
            }
        }
    }

}

function _updateAutomationsInPlace(automations: Automation[]): void {
    for (const a of automations) {
        const card = document.querySelector(`[data-automation-id="${CSS.escape(a.id)}"]`);
        if (!card) continue;
        const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
        if (badge) {
            if (!a.enabled) {
                badge.className = 'dashboard-badge-stopped';
                badge.textContent = t('automations.status.disabled');
            } else if (a.is_active) {
                badge.className = 'dashboard-badge-active';
                badge.textContent = t('automations.status.active');
            } else {
                badge.className = 'dashboard-badge-stopped';
                badge.textContent = t('automations.status.inactive');
            }
        }
        const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn');
        if (btn) {
            btn.className = `dashboard-action-btn ${a.enabled ? 'stop' : 'start'}`;
            btn.setAttribute('onclick', `dashboardToggleAutomation('${a.id}', ${!a.enabled})`);
            btn.innerHTML = a.enabled ? ICON_STOP_PLAIN : ICON_START;
        }
    }
}

function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
    for (const c of syncClocks) {
        const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
        if (!card) continue;
        const speedEl = card.querySelector('.dashboard-clock-speed');
        if (speedEl) speedEl.textContent = `${c.speed}×`;
        card.classList.toggle('is-running', c.is_running);
        const led = card.querySelector('.mod-leds .led');
        if (led) led.className = c.is_running ? 'led on blink' : 'led';
        const patch = card.querySelector('.mod-patch');
        if (patch) {
            const dot = patch.querySelector('.patch-dot');
            if (dot) dot.className = c.is_running ? 'patch-dot is-live' : 'patch-dot';
            const label = patch.querySelector('span:last-child');
            if (label) label.textContent = c.is_running ? 'TICKING' : 'PAUSED';
        }
        const btn = card.querySelector('.mod-foot .mod-btn');
        if (btn) {
            btn.className = c.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
            btn.setAttribute('onclick', `event.stopPropagation(); ${c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`}`);
            const label = c.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
            btn.innerHTML = `${c.is_running ? ICON_PAUSE : ICON_START} <span>${label}</span>`;
        }
    }
}

function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
    const healthClass = conn.connected ? 'health-online' : 'health-offline';
    const healthTitle = conn.connected
        ? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}`
        : t('ha_source.disconnected');
    const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
    const host = (conn.host || '').trim();
    const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
    const subtitle = conn.connected
        ? (host ? `${host} · ${entitiesPart}` : entitiesPart)
        : (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
    const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
    const ledCls = conn.connected ? 'led on blink' : 'led';
    const patchLabel = conn.connected ? t('patch.online') : t('patch.offline');
    const patchLive = conn.connected ? ' is-live' : '';

    return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
        <div class="mod-head">
            <div class="mod-id">
                <span class="mod-badge">HA · ${escapeHtml(short)}</span>
                <div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
                <div class="mod-meta">${escapeHtml(subtitle)}</div>
            </div>
            <div class="mod-leds" aria-hidden="true">
                <span class="${ledCls}"></span>
            </div>
        </div>
        <div class="mod-foot">
            <div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
        </div>
    </div>`;
}

function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
    const healthClass = conn.connected ? 'health-online' : 'health-offline';
    const healthTitle = conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected');
    const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
    const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
    const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'MQ';
    const ledCls = conn.connected ? 'led on blink' : 'led';
    const patchLabel = conn.connected ? t('patch.online') : t('patch.offline');
    const patchLive = conn.connected ? ' is-live' : '';

    return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
        <div class="mod-head">
            <div class="mod-id">
                <span class="mod-badge">MQTT · ${escapeHtml(short)}</span>
                <div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
                <div class="mod-meta">${subtitle}</div>
            </div>
            <div class="mod-leds" aria-hidden="true">
                <span class="${ledCls}"></span>
            </div>
        </div>
        <div class="mod-foot">
            <div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
        </div>
    </div>`;
}

function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttStatus?: MQTTStatusResponse): void {
    const applyState = (card: Element, connected: boolean, patchLabel: string): void => {
        card.classList.toggle('is-running', connected);
        const led = card.querySelector('.mod-leds .led');
        if (led) {
            led.className = connected ? 'led on blink' : 'led';
        }
        const patch = card.querySelector('.mod-patch');
        if (patch) {
            const dot = patch.querySelector('.patch-dot');
            if (dot) dot.className = connected ? 'patch-dot is-live' : 'patch-dot';
            const label = patch.querySelector('span:last-child');
            if (label) label.textContent = patchLabel;
        }
    };

    for (const conn of haStatus.connections) {
        const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
        if (!card) continue;
        const dot = card.querySelector('.health-dot');
        if (dot) {
            dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`;
            dot.setAttribute('title', conn.connected
                ? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}`
                : t('ha_source.disconnected'));
        }
        const meta = card.querySelector('.mod-meta');
        if (meta) {
            const host = (conn.host || '').trim();
            const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
            meta.textContent = conn.connected
                ? (host ? `${host} · ${entitiesPart}` : entitiesPart)
                : (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
        }
        applyState(card, conn.connected, conn.connected ? t('patch.online') : t('patch.offline'));
    }
    if (mqttStatus) {
        for (const conn of mqttStatus.connections) {
            const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
            if (!card) continue;
            const dot = card.querySelector('.health-dot');
            if (dot) {
                dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`;
                dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'));
            }
            const meta = card.querySelector('.mod-meta');
            if (meta) {
                meta.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
            }
            applyState(card, conn.connected, conn.connected ? t('patch.online') : t('patch.offline'));
        }
    }
    // Update section count badge
    const totalSources = haStatus.total_sources + (mqttStatus?.total_sources || 0);
    const totalConnected = haStatus.connected_count + (mqttStatus?.connected_count || 0);
    const header = document.querySelector('[data-dashboard-section="integrations"]');
    if (header) {
        const countEl = header.querySelector('.dashboard-section-count');
        if (countEl) countEl.textContent = `${totalConnected}/${totalSources}`;
    }
}

function renderDashboardSyncClock(clock: SyncClock): string {
    const toggleAction = clock.is_running
        ? `dashboardPauseClock('${clock.id}')`
        : `dashboardResumeClock('${clock.id}')`;
    const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
    const metaParts = [
        `<span class="dashboard-clock-speed">${clock.speed}×</span>`,
        clock.description ? escapeHtml(clock.description) : '',
    ].filter(Boolean);
    const short = (clock.id || '').replace(/^sc_/, '').slice(0, 2).toUpperCase() || 'CK';
    const ledCls = clock.is_running ? 'led on blink' : 'led';
    const patchLabel = clock.is_running ? t('patch.ticking') : t('patch.paused');
    const patchLive = clock.is_running ? ' is-live' : '';
    const btnCls = clock.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
    const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');

    const scStyle = cardColorStyle(clock.id);
    const iconPlate = _dashboardIconPlate(clock as any);
    const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
    return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${clock.is_running ? 'is-running' : ''}" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
        <div class="${headCls}">
            ${iconPlate}
            <div class="mod-id">
                <span class="mod-badge">CLK · ${escapeHtml(short)}</span>
                <div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
                ${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
            </div>
            <div class="mod-leds" aria-hidden="true">
                <span class="${ledCls}"></span>
            </div>
        </div>
        <div class="mod-foot">
            <div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
            <button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
            <button class="mod-btn" onclick="event.stopPropagation(); dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
        </div>
    </div>`;
}

let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
/** Called from the transport-bar poll cycler (and any legacy callers
 *  that might still reference `window.changeDashboardPollInterval`). */
export function changeDashboardPollInterval(value: string | number): void {
    clearTimeout(_pollDebounce);
    _pollDebounce = setTimeout(() => {
        const ms = parseInt(String(value), 10) * 1000;
        setDashboardPollInterval(ms);
        startAutoRefresh();
        stopPerfPolling();
        startPerfPolling();
    }, 300);
}

function _getCollapsedSections(): Record<string, boolean> {
    let userOverrides: Record<string, boolean> = {};
    try { userOverrides = JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
    catch { /* ignore */ }
    // Layered: layout's `collapsedDefault` is the floor; the user's
    // per-session toggle overrides it. Lets users start every section
    // collapsed via Customize without losing in-session expand/collapse.
    const merged: Record<string, boolean> = {};
    for (const s of getOrderedSections()) {
        merged[s.key] = userOverrides[s.key] ?? s.collapsedDefault;
    }
    // Subsections like 'running' / 'stopped' aren't in the layout — preserve
    // user overrides as-is.
    for (const k of Object.keys(userOverrides)) {
        if (!(k in merged)) merged[k] = userOverrides[k];
    }
    return merged;
}

export function toggleDashboardSection(sectionKey: string): void {
    const collapsed = _getCollapsedSections();
    collapsed[sectionKey] = !collapsed[sectionKey];
    localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
    const header = document.querySelector(`[data-dashboard-section="${CSS.escape(sectionKey)}"]`);
    if (!header) return;
    const content = header.nextElementSibling;
    const chevron = header.querySelector('.dashboard-section-chevron');
    const nowCollapsed = collapsed[sectionKey];
    if (chevron) (chevron as HTMLElement).style.transform = nowCollapsed ? '' : 'rotate(90deg)';

    // Animate collapse/expand unless reduced motion
    const contentEl = content as any;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        contentEl.style.display = nowCollapsed ? 'none' : '';
        return;
    }
    if (contentEl._dsAnim) { contentEl._dsAnim.cancel(); contentEl._dsAnim = null; }
    if (nowCollapsed) {
        const h = contentEl.offsetHeight;
        contentEl.style.overflow = 'hidden';
        const anim = contentEl.animate(
            [{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
            { duration: 200, easing: 'ease-in-out' }
        );
        contentEl._dsAnim = anim;
        anim.onfinish = () => { contentEl.style.display = 'none'; contentEl.style.overflow = ''; contentEl._dsAnim = null; };
    } else {
        contentEl.style.display = '';
        contentEl.style.overflow = 'hidden';
        const h = contentEl.scrollHeight;
        const anim = contentEl.animate(
            [{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }],
            { duration: 200, easing: 'ease-in-out' }
        );
        contentEl._dsAnim = anim;
        anim.onfinish = () => { contentEl.style.overflow = ''; contentEl._dsAnim = null; };
    }
}

function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = '', cardModeSurface: string = ''): string {
    const collapsed = _getCollapsedSections();
    const isCollapsed = !!collapsed[sectionKey];
    const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
    // Only render the count pill when there's an actual count to show.
    // The Performance header passes '' (no item count makes sense here)
    // and was rendering an empty grey badge next to the title.
    const countHtml = (count !== '' && count != null)
        ? `<span class="dashboard-section-count">${count}</span>`
        : '';
    // Optional card-mode toggle slot — mounted post-render in
    // _mountDashboardCardModeToggles(). Sections that don't display a
    // card grid (e.g. 'perf') pass an empty string and get no slot.
    const modeSlot = cardModeSurface
        ? `<span class="dashboard-mode-slot" data-dashboard-mode-slot="${cardModeSurface}"></span>`
        : '';
    return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
        <span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
            <span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
            ${label}
            ${countHtml}
        </span>
        ${modeSlot}
        ${extraHtml}
    </div>`;
}

function _sectionContent(sectionKey: string, itemsHtml: string): string {
    const collapsed = _getCollapsedSections();
    const isCollapsed = !!collapsed[sectionKey];
    return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
}

export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
    if (_dashboardLoading) return;
    set_dashboardLoading(true);
    const container = document.getElementById('dashboard-content');
    if (!container) { set_dashboardLoading(false); return; }
    if (!container.children.length) setTabRefreshing('dashboard-content', true);

    try {
        // Fire all requests in a single batch to avoid sequential RTTs
        const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
            outputTargetsCache.fetch().catch((): any[] => []),
            fetchWithAuth('/automations').catch(() => null),
            devicesCache.fetch().catch((): any[] => []),
            colorStripSourcesCache.fetch().catch((): any[] => []),
            fetchWithAuth('/output-targets/batch/states').catch(() => null),
            fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
            loadScenePresets(),
            fetchWithAuth('/sync-clocks').catch(() => null),
            fetchWithAuth('/home-assistant/status').catch(() => null),
            fetchWithAuth('/mqtt/status').catch(() => null),
            fetchWithAuth('/devices/batch/states').catch(() => null),
        ]);

        // Devices cell — online/offline count + dot strip. Independent of
        // the running-target set: shows every configured device regardless
        // of whether any target is currently streaming to it.
        if (deviceStatesResp && deviceStatesResp.ok) {
            try {
                const payload = await deviceStatesResp.json();
                const statesObj = payload.states || {};
                const deviceStateList = Object.values(statesObj) as any[];
                updateDevices(deviceStateList);

                // Device-latency cell — avg / max ping latency across
                // online devices. Already deduplicated by device_id
                // (this list is keyed on device, not target). Offline
                // devices contribute to the total count but not the
                // latency aggregate.
                let onlineCount = 0;
                let latencyMax = 0;
                let latencySum = 0;
                let latencyN = 0;
                for (const ds of deviceStateList) {
                    if (ds?.device_online) onlineCount++;
                    const l = ds?.device_latency_ms;
                    if (typeof l === 'number' && Number.isFinite(l) && l >= 0) {
                        latencySum += l;
                        latencyN += 1;
                        if (l > latencyMax) latencyMax = l;
                    }
                }
                const latencyAvg = latencyN > 0 ? latencySum / latencyN : null;
                updateDeviceLatency(
                    latencyAvg,
                    latencyN > 0 ? latencyMax : null,
                    onlineCount,
                    deviceStateList.length,
                );
            } catch { /* ignore parse errors */ }
        }

        const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
        const automations = automationsData.automations || [];
        const devicesMap = {};
        for (const d of devicesArr) { devicesMap[d.id] = d; }
        const cssSourceMap = {};
        for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; }
        const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
        const syncClocks = syncClocksData.clocks || [];
        const haStatus: HomeAssistantStatusResponse = haStatusResp && haStatusResp.ok
            ? await haStatusResp.json()
            : { connections: [], total_sources: 0, connected_count: 0 };
        const mqttStatus: MQTTStatusResponse = mqttStatusResp && mqttStatusResp.ok
            ? await mqttStatusResp.json()
            : { connections: [], total_sources: 0, connected_count: 0 };

        const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
        const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};

        // Build dynamic HTML (targets, automations)
        let dynamicHtml = '';
        let runningIds: any[] = [];
        if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
            dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
        } else {
            const enriched = targets.map(target => ({
                ...target,
                state: allStates[target.id] || {},
                metrics: allMetrics[target.id] || {},
            }));

            const running = enriched.filter(t => t.state && t.state.processing);
            const stopped = enriched.filter(t => !t.state || !t.state.processing);
            updateTabBadge('targets', running.length);
            _updateTransportStatus(running.length);
            updateActivePatches(
                running.map(r => ({
                    id: r.id,
                    name: r.name,
                    fps: r.state?.fps_actual != null ? r.state.fps_actual
                        : r.state?.fps_current != null ? r.state.fps_current
                        : undefined,
                })),
                enriched.length,
            );
            // Aggregate throughput across all running targets — fills the
            // Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
            // a dashed reference line ("max achievable throughput").
            const fpsValues: number[] = [];
            const captureFpsValues: number[] = [];
            let fpsSum = 0;
            let fpsTargetSum = 0;
            let captureFpsSum = 0;
            // Capture-actual aggregates: only count targets whose stream
            // reports a measured rate (capture-backed, e.g. screen capture).
            // Synthetic streams report null and are excluded so the cell
            // can read "no captures" instead of "0 fps".
            let captureFpsActualSum = 0;
            let captureActualReportingCount = 0;
            let captureFpsActualTargetSum = 0;
            for (const r of running) {
                const fps = r.state?.fps_actual != null ? r.state.fps_actual
                    : r.state?.fps_current != null ? r.state.fps_current
                    : null;
                if (fps != null) {
                    fpsValues.push(fps);
                    fpsSum += fps;
                }
                const tgt = r.state?.fps_target
                    ?? (r.settings || {}).fps
                    ?? r.update_rate;
                if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
                const captureFps = r.state?.fps_capture;
                if (typeof captureFps === 'number' && captureFps > 0) {
                    captureFpsValues.push(captureFps);
                    captureFpsSum += captureFps;
                }
                const captureFpsActual = r.state?.fps_capture_actual;
                if (typeof captureFpsActual === 'number') {
                    captureFpsActualSum += captureFpsActual;
                    captureActualReportingCount += 1;
                    // Use this target's source-side rate as the per-capture
                    // ceiling so the "% of requested" subtitle matches the
                    // captures actually being measured.
                    if (typeof captureFps === 'number' && captureFps > 0) {
                        captureFpsActualTargetSum += captureFps;
                    }
                }
            }
            const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
            const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
            updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
            const captureFpsMin = captureFpsValues.length > 0 ? Math.min(...captureFpsValues) : null;
            const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null;
            updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax);
            updateTotalCaptureFpsActual(captureFpsActualSum, captureFpsActualTargetSum, captureActualReportingCount);

            // Errors / dropped frames — fed cumulative totals; the perf
            // cell turns them into per-second rates by tracking deltas
            // across polls. Computed across running targets only so a
            // long-stopped target's historical errors don't keep the
            // counter elevated forever.
            let totalErrors = 0;
            let totalSkipped = 0;
            let totalBytesSent = 0;
            let sendTimingSum = 0;
            let sendTimingMax = 0;
            let sendTimingCount = 0;
            for (const r of running) {
                const e = r.metrics?.errors_count;
                if (typeof e === 'number' && e > 0) totalErrors += e;
                const s = r.state?.frames_skipped;
                if (typeof s === 'number' && s > 0) totalSkipped += s;
                const b = r.state?.bytes_sent;
                if (typeof b === 'number' && b > 0) totalBytesSent += b;
                const t = r.state?.timing_send_ms;
                if (typeof t === 'number' && Number.isFinite(t) && t >= 0) {
                    sendTimingSum += t;
                    sendTimingCount += 1;
                    if (t > sendTimingMax) sendTimingMax = t;
                }
            }
            updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval);

            // Network throughput — convert cumulative byte counter into
            // a per-second rate via deltas, same shape as the errors
            // cell. Counter resets (target stop/restart) leave the
            // total unchanged or smaller; rate is clamped non-negative
            // inside the perf-charts module.
            const pollSec = Math.max(0.05, dashboardPollInterval / 1000);
            const bytesDelta = _prevTotalBytesSent != null
                ? Math.max(0, totalBytesSent - _prevTotalBytesSent)
                : 0;
            const bytesPerSec = _prevTotalBytesSent != null ? bytesDelta / pollSec : 0;
            _prevTotalBytesSent = totalBytesSent;
            updateNetworkThroughput(bytesPerSec, totalBytesSent);

            // Send-timing — already an instantaneous "ms last frame"
            // value, so we just average / max across running targets.
            const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
            updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);

            // Check if we can do an in-place metrics update (same targets, not first load)
            const newRunningIds = running.map(t => t.id).sort().join(',');
            const prevRunningIds = [..._lastRunningIds].sort().join(',');
            const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
            const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
            const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
            if (structureUnchanged && !forceFullRender && running.length > 0) {
                _updateRunningMetrics(running);
                _updateSyncClocksInPlace(syncClocks);
                _updateIntegrationsInPlace(haStatus, mqttStatus);
                _cacheUptimeElements();
                _startUptimeTimer();
                startPerfPolling();
                set_dashboardLoading(false);
                return;
            }
            if (structureUnchanged && forceFullRender) {
                if (running.length > 0) _updateRunningMetrics(running);
                _updateAutomationsInPlace(automations);
                _updateSyncClocksInPlace(syncClocks);
                _updateIntegrationsInPlace(haStatus, mqttStatus);
                _cacheUptimeElements();
                _startUptimeTimer();
                startPerfPolling();
                set_dashboardLoading(false);
                return;
            }

            // Build each section's HTML into a map so we can render in
            // user-defined order (layout-driven). Sections with no content
            // (e.g. `automations` when there are zero automations) produce
            // null and are skipped, unless the user explicitly toggled
            // them to show via Customize (we don't yet plumb a "show
            // empty CTA" mode here; that's a v1.1 follow-up).
            const sectionFragments: Record<string, string> = {};

            // Integrations section (HA + MQTT sources)
            const totalIntSources = haStatus.total_sources + mqttStatus.total_sources;
            const totalIntConnected = haStatus.connected_count + mqttStatus.connected_count;
            if (totalIntSources > 0) {
                const haCards = haStatus.connections.map(c => _renderIntegrationCard(c)).join('');
                const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
                const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
                sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
                    ${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`, '', 'dashboard-integrations')}
                    ${_sectionContent('integrations', intGrid)}
                </div>`;
            }

            if (automations.length > 0) {
                const activeAutomations = automations.filter(a => a.is_active);
                const inactiveAutomations = automations.filter(a => !a.is_active);
                updateTabBadge('automations', activeAutomations.length);
                const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
                const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
                const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;

                sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
                    ${_sectionHeader('automations', t('dashboard.section.automations'), automations.length, '', 'dashboard-automations')}
                    ${_sectionContent('automations', automationGrid)}
                </div>`;
            }

            // Scene Presets section
            if (scenePresets.length > 0) {
                const sceneSec = renderScenePresetsSection(scenePresets);
                if (sceneSec && typeof sceneSec === 'object') {
                    sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
                        ${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra, 'dashboard-scenes')}
                        ${_sectionContent('scenes', sceneSec.content)}
                    </div>`;
                }
            }

            // Sync Clocks section
            if (syncClocks.length > 0) {
                const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
                const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
                sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
                    ${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length, '', 'dashboard-sync-clocks')}
                    ${_sectionContent('sync-clocks', clockGrid)}
                </div>`;
            }

            if (targets.length > 0) {
                let targetsInner = '';

                if (running.length > 0) {
                    runningIds = running.map(t => t.id);
                    const stopAllBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">${ICON_STOP} ${t('dashboard.stop_all')}</button>`;
                    const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join('');

                    targetsInner += `<div class="dashboard-subsection" data-section="running">
                        ${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn, 'dashboard-running')}
                        ${_sectionContent('running', runningItems)}
                    </div>`;
                }

                if (stopped.length > 0) {
                    const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join('');

                    targetsInner += `<div class="dashboard-subsection" data-section="stopped">
                        ${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length, '', 'dashboard-stopped')}
                        ${_sectionContent('stopped', stoppedItems)}
                    </div>`;
                }

                sectionFragments['targets'] = `<div class="dashboard-section" data-section="targets">
                    ${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)}
                    ${_sectionContent('targets', targetsInner)}
                </div>`;
            }

            // Now assemble in layout-driven order, skipping invisible
            // sections and the perf section (which is always rendered
            // separately at the top for chart-persistence reasons).
            for (const section of getOrderedSections()) {
                if (section.key === 'perf') continue;
                if (!section.visible) continue;
                const html = sectionFragments[section.key];
                if (html) dynamicHtml += html;
            }
        }

        // First load: build everything in one innerHTML to avoid flicker.
        // Poll-interval control was moved to the transport bar (it's global,
        // not dashboard-specific) — toolbar now keeps the tutorial help
        // button + the new "Customize" gear that opens the layout panel.
        const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
        const perfVisible = isSectionVisible('perf');
        const customizeBtn = `<button class="tutorial-trigger-btn" onclick="openDashboardCustomize()" title="${t('dashboard.customize.title')}" aria-label="${t('dashboard.customize.title')}">${ICON_SETTINGS}</button>`;
        const tutorialBtn = `<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button>`;
        const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${customizeBtn}${tutorialBtn}</span></div>`;
        if (isFirstLoad) {
            const perfBlock = perfVisible
                ? `<div class="dashboard-perf-persistent dashboard-section" data-section="perf">
                    ${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
                    ${_sectionContent('perf', renderPerfSection())}
                </div>`
                : '';
            container.innerHTML = `${toolbar}${perfBlock}<div class="dashboard-dynamic">${dynamicHtml}</div>`;
            _applyGlobalLayoutAttrs();
            if (perfVisible) await initPerfCharts();
            // Event delegation for scene preset cards (attached once, works across innerHTML refreshes)
            initScenePresetDelegation(container);
        } else {
            // Toggle perf visibility on subsequent renders without
            // destroying its DOM (charts persist).
            const existingPerf = container.querySelector('.dashboard-perf-persistent') as HTMLElement | null;
            if (existingPerf) {
                existingPerf.style.display = perfVisible ? '' : 'none';
            }
            const dynamic = container.querySelector('.dashboard-dynamic');
            if (dynamic && dynamic.innerHTML !== dynamicHtml) {
                dynamic.innerHTML = dynamicHtml;
            }
            _applyGlobalLayoutAttrs();
        }
        // Apply per-section density tags so CSS selectors like
        // `.dashboard-section[data-density="dense"]` can take effect.
        for (const s of getOrderedSections()) {
            const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null;
            if (el) el.dataset.density = s.density;
        }
        _mountDashboardCardModeToggles();
        _lastRunningIds = runningIds;
        _lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
        _cacheUptimeElements();
        await _initFpsCharts(runningIds);
        _startUptimeTimer();
        startPerfPolling();

    } catch (error) {
        if (error.isAuth) return;
        console.error('Failed to load dashboard:', error);
        container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
    } finally {
        set_dashboardLoading(false);
        setTabRefreshing('dashboard-content', false);
    }
}

/** Render an icon plate (`.mod-icon`) for a dashboard card from the
 *  entity's own ``icon`` / ``icon_color`` fields. Returns empty string
 *  when no icon is set, so the head reverts to its legacy badge-only
 *  layout. */
function _dashboardIconPlate(entity: { icon?: string; icon_color?: string }): string {
    const iconId = entity.icon || '';
    if (!iconId) return '';
    const iconColor = entity.icon_color || '';
    const styleAttr = iconColor
        ? ` style="--ch:${escapeHtml(iconColor)};color:${escapeHtml(iconColor)}"`
        : '';
    return `<div class="mod-icon"${styleAttr}>${renderDeviceIconSvg(iconId, { size: 24 })}</div>`;
}

/** Resolve the effective custom icon for a dashboard target card.
 *  LED targets inherit from their referenced device when no own icon is
 *  set; HA-light targets have no inheritance source. Returns the HTML
 *  for the icon plate (empty string when no icon is available). */
function _dashboardTargetIconPlate(target: any, devicesMap: Record<string, Device>): string {
    const isLed = target.target_type === 'led' || target.target_type === 'wled';
    const isHALight = target.target_type === 'ha_light';
    const ownIconId = (target.icon as string | undefined) || '';
    const ownColor = (target.icon_color as string | undefined) || '';

    let iconId = ownIconId;
    let iconColor = ownColor;
    if (!iconId && isLed) {
        const device = target.device_id ? devicesMap[target.device_id] : null;
        const inheritedId = (device as any)?.icon || '';
        if (inheritedId) {
            iconId = inheritedId;
            iconColor = ownColor || (device as any)?.icon_color || '';
        }
    }
    if (!iconId) return '';
    const styleAttr = iconColor
        ? ` style="--ch:${escapeHtml(iconColor)};color:${escapeHtml(iconColor)}"`
        : '';
    void isHALight;
    return `<div class="mod-icon"${styleAttr}>${renderDeviceIconSvg(iconId, { size: 24 })}</div>`;
}

function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Record<string, Device> = {}, cssSourceMap: Record<string, ColorStripSource> = {}): string {
    const state = target.state || {};
    const metrics = target.metrics || {};
    const isLed = target.target_type === 'led' || target.target_type === 'wled';
    const isHALight = target.target_type === 'ha_light';
    const iconPlate = _dashboardTargetIconPlate(target, devicesMap);
    const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
    const icon = ICON_TARGET;
    const typeLabel = isLed ? t('dashboard.type.led') : isHALight ? t('ha_light.section.title') : t('dashboard.type.kc');
    const navSubTab = isHALight ? 'ha-light-targets' : 'led-targets';
    const navSection = isHALight ? 'ha-light-targets' : 'led-targets';
    const navAttr = isHALight ? 'data-ha-target-id' : 'data-target-id';
    const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`;

    let subtitleParts = [typeLabel];
    if (isLed || isHALight) {
        if (isLed) {
            const device = target.device_id ? devicesMap[target.device_id] : null;
            if (device) {
                subtitleParts.push((device.device_type || '').toUpperCase());
                if (device.led_count) {
                    subtitleParts.push(`${device.led_count} LED`);
                }
            }
        }
        const cssId = target.color_strip_source_id || '';
        if (cssId) {
            const css = cssSourceMap[cssId];
            if (css) {
                subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
            }
        }
    }

    // Short channel label for the badge — first 2 chars of id hash after the
    // `ot_` prefix, uppercased. Stable per target, consistent with the
    // "CH·XX" convention in the mockup without needing a position counter.
    const rawId = (target.id || '').replace(/^ot_/, '');
    const chLabel = (rawId.slice(0, 2) || 'XX').toUpperCase();
    const typeLabel2 = isLed
        ? ((target.device_id && devicesMap[target.device_id]?.device_type) || 'LED').toUpperCase()
        : isHALight ? 'HA'
        : 'KC';
    const badgeText = `CH·${chLabel} · ${typeLabel2}`;

    if (isRunning) {
        const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
        const fpsCurrent = isHALight ? fpsTarget : (state.fps_current ?? 0);
        const fpsActual = isHALight ? String(fpsTarget) : (state.fps_actual != null ? state.fps_actual.toFixed(1) : '-');
        const uptime = formatUptime(metrics.uptime_seconds);
        const errors = metrics.errors_count || 0;

        // Set uptime base for interpolation
        if (metrics.uptime_seconds != null) {
            _setUptimeBase(target.id, metrics.uptime_seconds);
        }

        // Push FPS samples to history
        if (state.fps_actual != null) {
            _pushFps(target.id, state.fps_actual, fpsCurrent);
        }

        let healthDot = '';
        if (isLed && state.device_last_checked != null) {
            const cls = state.device_online ? 'health-online' : 'health-offline';
            const statusLabel = state.device_online ? t('device.health.online') : t('device.health.offline');
            healthDot = `<span class="health-dot ${cls}" role="status" aria-label="${statusLabel}"></span>`;
        }

        const cStyle = cardColorStyle(target.id);
        return `<div class="dashboard-target dashboard-card-link is-running" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
            <div class="${headCls}">
                ${iconPlate}
                <div class="mod-id">
                    <span class="mod-badge">${escapeHtml(badgeText)}</span>
                    <div class="mod-name"><span>${escapeHtml(target.name)}</span>${healthDot}</div>
                    ${subtitleParts.length ? `<div class="mod-meta">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
                </div>
                <div class="mod-leds" aria-hidden="true">
                    <span class="led on blink"></span>
                    <span class="led on blink"></span>
                    <span class="led on blink"></span>
                </div>
            </div>
            <div class="mod-metrics">
                <div class="mod-metric" title="${t('dashboard.fps') || 'FPS'}">
                    <span class="k">FPS</span>
                    <span class="v signal" data-fps-text="${target.id}">${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span><span class="dashboard-fps-avg">avg ${fpsActual}</span></span>
                    <canvas class="mod-metric-spark-canvas" id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
                </div>
                <div class="mod-metric" title="${t('dashboard.uptime')}">
                    <span class="k">${ICON_CLOCK} <span data-i18n="dashboard.uptime">Uptime</span></span>
                    <span class="v" data-uptime-text="${target.id}">${uptime}</span>
                </div>
                <div class="mod-metric" title="${t('dashboard.errors')}" data-errors-cell="${target.id}">
                    <span class="k">${errors > 0 ? ICON_WARNING : ICON_OK} <span data-i18n="dashboard.errors">Errors</span></span>
                    <span class="v${errors > 0 ? ' has-errors' : ''}" data-errors-text="${target.id}" title="${errors}">${formatCompact(errors)}</span>
                </div>
            </div>
            <div class="mod-foot">
                <div class="mod-patch"><span class="patch-dot is-live"></span><span>${escapeHtml(t('patch.patched'))}</span></div>
                <button class="mod-btn mod-btn-stop" onclick="event.stopPropagation(); dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN} <span>${t('device.button.stop') || 'Stop'}</span></button>
            </div>
        </div>`;
    } else {
        const cStyle2 = cardColorStyle(target.id);
        return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
            <div class="${headCls}">
                ${iconPlate}
                <div class="mod-id">
                    <span class="mod-badge">${escapeHtml(badgeText)}</span>
                    <div class="mod-name"><span>${escapeHtml(target.name)}</span></div>
                    ${subtitleParts.length ? `<div class="mod-meta">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
                </div>
                <div class="mod-leds" aria-hidden="true">
                    <span class="led"></span>
                </div>
            </div>
            <div class="mod-foot">
                <div class="mod-patch"><span class="patch-dot"></span><span>${escapeHtml(t('patch.standby'))}</span></div>
                <button class="mod-btn mod-btn-go" onclick="event.stopPropagation(); dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START} <span>${t('device.button.start') || 'Start'}</span></button>
            </div>
        </div>`;
    }
}

function renderDashboardAutomation(automation: Automation, sceneMap: Map<string, ScenePreset> = new Map()): string {
    const isActive = automation.is_active;
    const isDisabled = !automation.enabled;

    let condSummary = '';
    if (automation.rules.length > 0) {
        const parts = automation.rules.map(r => {
            if (r.rule_type === 'application') {
                const apps = (r.apps || []).join(', ');
                const matchLabel = r.match_type === 'topmost' ? t('automations.rule.application.match_type.topmost') : t('automations.rule.application.match_type.running');
                return `${apps} (${matchLabel})`;
            }
            if (r.rule_type === 'startup') return t('automations.rule.startup');
            if (r.rule_type === 'time_of_day') return t('automations.rule.time_of_day');
            return t(`automations.rule.${r.rule_type}`) || r.rule_type;
        });
        const logic = automation.rule_logic === 'and' ? ' & ' : ' | ';
        condSummary = parts.join(logic);
    }

    // Scene info
    const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
    const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');

    const short = (automation.id || '').replace(/^auto_/, '').slice(0, 2).toUpperCase() || 'AU';
    const ledCls = isActive ? 'led on blink' : (isDisabled ? 'led' : 'led on');
    const patchLabel = isDisabled
        ? (t('automations.status.disabled') || 'DISABLED').toUpperCase()
        : isActive
            ? (t('automations.status.active') || 'ACTIVE').toUpperCase()
            : (t('automations.status.inactive') || 'STANDBY').toUpperCase();
    const patchLive = isActive ? ' is-live' : '';
    const btnCls = automation.enabled ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
    const btnLabel = automation.enabled
        ? (t('automations.action.disable') || 'Disable')
        : (t('automations.action.enable') || t('automations.status.active') || 'Enable');
    const metaLines: string[] = [];
    if (condSummary) metaLines.push(escapeHtml(condSummary));
    metaLines.push(`${ICON_SCENE} ${sceneName}`);

    const aStyle = cardColorStyle(automation.id);
    const iconPlate = _dashboardIconPlate(automation as any);
    const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
    return `<div class="dashboard-target dashboard-automation dashboard-card-link ${isActive ? 'is-running' : ''}" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
        <div class="${headCls}">
            ${iconPlate}
            <div class="mod-id">
                <span class="mod-badge">AUTO · ${escapeHtml(short)}</span>
                <div class="mod-name"><span>${escapeHtml(automation.name)}</span></div>
                <div class="mod-meta">${metaLines.join(' · ')}</div>
            </div>
            <div class="mod-leds" aria-hidden="true">
                <span class="${ledCls}"></span>
            </div>
        </div>
        <div class="mod-foot">
            <div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
            <button class="${btnCls}" onclick="event.stopPropagation(); dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">${automation.enabled ? ICON_STOP_PLAIN : ICON_START} <span>${btnLabel}</span></button>
        </div>
    </div>`;
}

export async function dashboardToggleAutomation(automationId: string, enable: boolean): Promise<void> {
    try {
        const endpoint = enable ? 'enable' : 'disable';
        const response = await fetchWithAuth(`/automations/${automationId}/${endpoint}`, {
            method: 'POST',
        });
        if (response.ok) {
            loadDashboard();
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('dashboard.error.automation_toggle_failed'), 'error');
    }
}

export async function dashboardStartTarget(targetId: string): Promise<void> {
    try {
        const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('device.started'), 'success');
            loadDashboard();
        } else {
            const error = await response.json();
            showToast(error.detail || t('dashboard.error.start_failed'), 'error');
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('dashboard.error.start_failed'), 'error');
    }
}

export async function dashboardStopTarget(targetId: string): Promise<void> {
    try {
        const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('device.stopped'), 'success');
            loadDashboard();
        } else {
            const error = await response.json();
            showToast(error.detail || t('dashboard.error.stop_failed'), 'error');
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('dashboard.error.stop_failed'), 'error');
    }
}

export async function dashboardStopAll(): Promise<void> {
    const confirmed = await showConfirm(t('confirm.stop_all'));
    if (!confirmed) return;
    try {
        const [allTargets, statesResp] = await Promise.all([
            outputTargetsCache.fetch().catch((): any[] => []),
            fetchWithAuth('/output-targets/batch/states'),
        ]);
        const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
        const states = statesData.states || {};
        const running = allTargets.filter(t => states[t.id]?.processing);
        await Promise.all(running.map(t =>
            fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
        ));
        loadDashboard();
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('dashboard.error.stop_all'), 'error');
    }
}

export async function dashboardPauseClock(clockId: string): Promise<void> {
    try {
        const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        showToast(t('sync_clock.paused'), 'success');
        loadDashboard(true);
    } catch (e) {
        if (e.isAuth) return;
        showToast(e.message, 'error');
    }
}

export async function dashboardResumeClock(clockId: string): Promise<void> {
    try {
        const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        showToast(t('sync_clock.resumed'), 'success');
        loadDashboard(true);
    } catch (e) {
        if (e.isAuth) return;
        showToast(e.message, 'error');
    }
}

export async function dashboardResetClock(clockId: string): Promise<void> {
    try {
        const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        showToast(t('sync_clock.reset_done'), 'success');
        loadDashboard(true);
    } catch (e) {
        if (e.isAuth) return;
        showToast(e.message, 'error');
    }
}

export function stopUptimeTimer(): void {
    _stopUptimeTimer();
}

// React to global server events when dashboard tab is active
function _isDashboardActive(): boolean {
    return isActiveTab('dashboard');
}

let _eventDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
function _debouncedDashboardReload(forceFullRender: boolean = false): void {
    if (!_isDashboardActive()) return;
    clearTimeout(_eventDebounceTimer);
    _eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300);
}

document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());

const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device', 'home_assistant_source']);
document.addEventListener('server:entity_changed', (e: Event) => {
    const { entity_type } = (e as CustomEvent).detail || {};
    if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
});

// Re-render dashboard when language changes
document.addEventListener('languageChanged', () => {
    if (!apiKey) return;
    // Force perf section rebuild with new locale
    const perfEl = document.querySelector('.dashboard-perf-persistent');
    if (perfEl) perfEl.remove();
    loadDashboard();
});

// Live-preview: re-render the dashboard whenever the customize panel
// changes the saved layout. Uses a debounce so dragging or rapid
// toggling doesn't thrash the DOM. The perf strip is preserved across
// re-renders (DOM persistence), so toggling its visibility is the
// only re-init path that needs `forceFullRender`.
let _layoutChangeRenderTimer: ReturnType<typeof setTimeout> | undefined;
subscribeDashboardLayout(() => {
    if (!apiKey) return;
    if (!_isDashboardActive()) return;
    clearTimeout(_layoutChangeRenderTimer);
    _layoutChangeRenderTimer = setTimeout(() => {
        // Invalidate the in-place-update optimization in `loadDashboard`
        // — section HTML must be rebuilt when sections reorder, change
        // density, or toggle visibility. Without this reset the
        // optimization would skip the rebuild entirely when the running-
        // target set hasn't changed.
        _lastRunningIds = [];
        _lastSyncClockIds = '';

        const perfInDom = !!document.querySelector('.dashboard-perf-persistent');
        const perfShouldBe = isSectionVisible('perf');

        if (perfShouldBe !== perfInDom) {
            // Visibility flipped — full rebuild needed (charts re-init from
            // server ring buffer + immediate fetch in `initPerfCharts`).
            const container = document.getElementById('dashboard-content');
            if (container) container.innerHTML = '';
        } else if (perfShouldBe) {
            // Perf still visible: in-place re-render of just the
            // `.perf-charts-grid` so cell visibility / order / mode /
            // window / yScale changes paint immediately without the
            // full-dashboard innerHTML wipe (which previously caused a
            // frame of jump and a window of "—" / "0" values).
            rerenderPerfGrid();
        }

        loadDashboard(true);
    }, 60);
});

// Pause uptime timer when browser tab is hidden, resume when visible
document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        _stopUptimeTimer();
    } else if (_isDashboardActive() && _lastRunningIds.length > 0) {
        _cacheUptimeElements();
        _startUptimeTimer();
    }
});
