/**
 * Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
 */

import {
    apiKey,
    _targetEditorDevices, set_targetEditorDevices,
    _deviceBrightnessCache,
    kcWebSockets,
    ledPreviewWebSockets,
    _cachedValueSources, valueSourcesCache,
    streamsCache, audioSourcesCache, syncClocksCache,
    colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
    _cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import {
    getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
    ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
    ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
    ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
    ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH,
} from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
import type { OutputTarget } from '../types.ts';

// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)

// ── Bulk action handlers ──
async function _bulkStartTargets(ids: any) {
    const results = await Promise.allSettled(ids.map(id =>
        fetchWithAuth(`/output-targets/${id}/start`, { method: 'POST' })
    ));
    const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
    if (failed) showToast(`${ids.length - failed}/${ids.length} started`, 'warning');
    else showToast(t('device.started'), 'success');
}

async function _bulkStopTargets(ids: any) {
    const results = await Promise.allSettled(ids.map(id =>
        fetchWithAuth(`/output-targets/${id}/stop`, { method: 'POST' })
    ));
    const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
    if (failed) showToast(`${ids.length - failed}/${ids.length} stopped`, 'warning');
    else showToast(t('device.stopped'), 'success');
}

async function _bulkDeleteTargets(ids: any) {
    const results = await Promise.allSettled(ids.map(id =>
        fetchWithAuth(`/output-targets/${id}`, { method: 'DELETE' })
    ));
    const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
    if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
    else showToast(t('targets.deleted'), 'success');
    outputTargetsCache.invalidate();
    await loadTargetsTab();
}

async function _bulkDeleteDevices(ids: any) {
    const results = await Promise.allSettled(ids.map(id =>
        fetchWithAuth(`/devices/${id}`, { method: 'DELETE' })
    ));
    const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
    if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
    else showToast(t('device.removed'), 'success');
    devicesCache.invalidate();
    await loadTargetsTab();
}

async function _bulkDeletePatternTemplates(ids: any) {
    const results = await Promise.allSettled(ids.map(id =>
        fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' })
    ));
    const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
    if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
    else showToast(t('targets.deleted'), 'success');
    patternTemplatesCache.invalidate();
    await loadTargetsTab();
}

const _targetBulkActions = [
    { key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets },
    { key: 'stop', labelKey: 'bulk.stop', icon: ICON_STOP, handler: _bulkStopTargets },
    { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteTargets },
];

// ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices', bulkActions: [
    { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices },
] });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
    { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
] });

// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
    if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab();
});

// --- FPS sparkline history and chart instances for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory: Record<string, number[]> = {};        // fps_actual (rolling avg)
const _targetFpsCurrentHistory: Record<string, number[]> = {}; // fps_current (sends/sec)
const _targetFpsCharts: Record<string, any> = {};

function _pushTargetFps(targetId: any, actual: any, current: any) {
    if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
    const h = _targetFpsHistory[targetId];
    h.push(actual);
    if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift();

    if (!_targetFpsCurrentHistory[targetId]) _targetFpsCurrentHistory[targetId] = [];
    const c = _targetFpsCurrentHistory[targetId];
    c.push(current);
    if (c.length > _TARGET_MAX_FPS_SAMPLES) c.shift();
}

function _createTargetFpsChart(canvasId: any, actualHistory: any, currentHistory: any, fpsTarget: any, maxHwFps: any) {
    return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps, maxSamples: _TARGET_MAX_FPS_SAMPLES });
}

function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
    const chart = _targetFpsCharts[targetId];
    if (!chart) return;
    const actualH = _targetFpsHistory[targetId] || [];
    const currentH = _targetFpsCurrentHistory[targetId] || [];
    const ds0 = chart.data.datasets[0].data;
    ds0.length = 0;
    ds0.push(...actualH);
    const ds1 = chart.data.datasets[1].data;
    ds1.length = 0;
    ds1.push(...currentH);
    while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
    chart.data.labels.length = ds0.length;
    chart.options.scales.y.max = fpsTarget * 1.15;
    chart.update('none');
}

// --- Editor state ---
let _editorCssSources: any[] = [];   // populated when editor opens
let _targetTagsInput: TagInput | null = null;

class TargetEditorModal extends Modal {
    constructor() {
        super('target-editor-modal');
    }

    snapshotValues() {
        return {
            name: (document.getElementById('target-editor-name') as HTMLInputElement).value,
            device: (document.getElementById('target-editor-device') as HTMLSelectElement).value,
            protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
            css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
            brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
            brightness_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value,
            fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value,
            keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
            adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
            tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
        };
    }
}

const targetEditorModal = new TargetEditorModal();

function _protocolBadge(device: any, target: any) {
    const dt = device?.device_type;
    if (!dt || dt === 'wled') {
        const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
        return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`;
    }
    const map = {
        openrgb:  [ICON_PALETTE, 'OpenRGB SDK'],
        adalight: [ICON_PLUG, t('targets.protocol.serial')],
        ambiled:  [ICON_PLUG, t('targets.protocol.serial')],
        mqtt:     [ICON_GLOBE, 'MQTT'],
        ws:       [ICON_GLOBE, 'WebSocket'],
        mock:     [ICON_WRENCH, 'Mock'],
    };
    const [icon, label] = map[dt] || [ICON_PLUG, dt];
    return `${icon} ${label}`;
}

let _targetNameManuallyEdited = false;

function _autoGenerateTargetName() {
    if (_targetNameManuallyEdited) return;
    if ((document.getElementById('target-editor-id') as HTMLInputElement).value) return;
    const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
    const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
    const cssSelect = document.getElementById('target-editor-css-source') as HTMLSelectElement;
    const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || '';
    if (!deviceName || !cssName) return;
    (document.getElementById('target-editor-name') as HTMLInputElement).value = `${deviceName} \u00b7 ${cssName}`;
}

function _updateFpsRecommendation() {
    const el = document.getElementById('target-editor-fps-rec') as HTMLElement;
    const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
    const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
    if (!device || !device.led_count) {
        el.style.display = 'none';
        return;
    }
    const fps = _computeMaxFps(device.baud_rate, device.led_count, device.device_type);
    if (fps !== null) {
        el.textContent = t('targets.fps.rec', { fps, leds: device.led_count });
        el.style.display = '';
    } else {
        el.style.display = 'none';
    }
}

function _updateDeviceInfo() {
    const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
    const el = document.getElementById('target-editor-device-info') as HTMLElement;
    const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
    if (device && device.led_count) {
        el.textContent = `${device.led_count} LEDs`;
        el.style.display = '';
    } else {
        el.style.display = 'none';
    }
}

function _updateKeepaliveVisibility() {
    const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
    const keepaliveGroup = document.getElementById('target-editor-keepalive-group') as HTMLElement;
    const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
    const caps = selectedDevice?.capabilities || [];
    keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}

function _updateSpecificSettingsVisibility() {
    const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
    const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
    const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
    // Hide WLED-only controls (protocol + keepalive) for non-WLED devices
    const protocolGroup = document.getElementById('target-editor-protocol-group');
    if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none';
    // keepalive is controlled further by _updateKeepaliveVisibility
    const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
    if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none';
}

function _updateBrightnessThresholdVisibility() {
    // Always visible — threshold considers both brightness source and pixel content
    (document.getElementById('target-editor-brightness-threshold-group') as HTMLElement).style.display = '';
}

// ── EntitySelect instances for target editor ──
let _deviceEntitySelect: EntitySelect | null = null;
let _cssEntitySelect: EntitySelect | null = null;
let _brightnessVsEntitySelect: EntitySelect | null = null;
let _protocolIconSelect: IconSelect | null = null;

function _populateCssDropdown(selectedId = '') {
    const select = document.getElementById('target-editor-css-source') as HTMLSelectElement;
    select.innerHTML = _editorCssSources.map(s =>
        `<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
    ).join('');
}

function _populateBrightnessVsDropdown(selectedId = '') {
    const select = document.getElementById('target-editor-brightness-vs') as HTMLSelectElement;
    let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
    _cachedValueSources.forEach(vs => {
        html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
    });
    select.innerHTML = html;
}

function _ensureTargetEntitySelects() {
    // Device
    if (_deviceEntitySelect) _deviceEntitySelect.destroy();
    _deviceEntitySelect = new EntitySelect({
        target: document.getElementById('target-editor-device') as HTMLSelectElement,
        getItems: () => _targetEditorDevices.map(d => ({
            value: d.id,
            label: d.name,
            icon: getDeviceTypeIcon(d.device_type),
            desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''),
        })),
        placeholder: t('palette.search'),
    });

    // CSS source
    if (_cssEntitySelect) _cssEntitySelect.destroy();
    _cssEntitySelect = new EntitySelect({
        target: document.getElementById('target-editor-css-source') as HTMLSelectElement,
        getItems: () => _editorCssSources.map(s => ({
            value: s.id,
            label: s.name,
            icon: getColorStripIcon(s.source_type),
            desc: s.source_type,
        })),
        placeholder: t('palette.search'),
    });

    // Brightness value source
    if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
    _brightnessVsEntitySelect = new EntitySelect({
        target: document.getElementById('target-editor-brightness-vs') as HTMLSelectElement,
        getItems: () => _cachedValueSources.map(vs => ({
            value: vs.id,
            label: vs.name,
            icon: getValueSourceIcon(vs.source_type),
            desc: vs.source_type,
        })),
        placeholder: t('palette.search'),
        allowNone: true,
        noneLabel: t('targets.brightness_vs.none'),
    });
}

const _pIcon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;

function _ensureProtocolIconSelect() {
    const sel = document.getElementById('target-editor-protocol');
    if (!sel) return;
    const items = [
        { value: 'ddp',  icon: _pIcon(P.radio), label: t('targets.protocol.ddp'),  desc: t('targets.protocol.ddp.desc') },
        { value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
    ];
    if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
    _protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
}

export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
    try {
        // Load devices, CSS sources, and value sources for dropdowns
        const [devices, cssSources] = await Promise.all([
            devicesCache.fetch().catch((): any[] => []),
            colorStripSourcesCache.fetch().catch((): any[] => []),
            valueSourcesCache.fetch(),
        ]);

        set_targetEditorDevices(devices);
        _editorCssSources = cssSources;

        // Populate device select
        const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement;
        deviceSelect.innerHTML = '';
        devices.forEach(d => {
            const opt = document.createElement('option');
            opt.value = d.id;
            opt.dataset.name = d.name;
            const shortUrl = d.url && d.url.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : '';
            const devType = (d.device_type || 'wled').toUpperCase();
            opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
            deviceSelect.appendChild(opt);
        });

        let _editorTags: string[] = [];
        if (targetId) {
            // Editing existing target
            const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
            if (!resp.ok) throw new Error('Failed to load target');
            const target = await resp.json();
            _editorTags = target.tags || [];

            (document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
            (document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
            deviceSelect.value = target.device_id || '';
            const fps = target.fps ?? 30;
            (document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
            (document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
            (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
            (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
            (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;

            const thresh = target.min_brightness_threshold ?? 0;
            (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh;
            (document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh;

            (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
            (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';

            _populateCssDropdown(target.color_strip_source_id || '');
            _populateBrightnessVsDropdown(target.brightness_value_source_id || '');
        } else if (cloneData) {
            // Cloning — create mode but pre-filled from clone data
            _editorTags = cloneData.tags || [];
            (document.getElementById('target-editor-id') as HTMLInputElement).value = '';
            (document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
            deviceSelect.value = cloneData.device_id || '';
            const fps = cloneData.fps ?? 30;
            (document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
            (document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
            (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
            (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
            (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;

            const cloneThresh = cloneData.min_brightness_threshold ?? 0;
            (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh;
            (document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh;

            (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
            (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';

            _populateCssDropdown(cloneData.color_strip_source_id || '');
            _populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
        } else {
            // Creating new target
            (document.getElementById('target-editor-id') as HTMLInputElement).value = '';
            (document.getElementById('target-editor-name') as HTMLInputElement).value = '';
            (document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any;
            (document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
            (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
            (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;

            (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any;
            (document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';

            (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
            (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';

            _populateCssDropdown('');
            _populateBrightnessVsDropdown('');
        }

        // Entity palette selectors
        _ensureTargetEntitySelects();
        _ensureProtocolIconSelect();
        if (_protocolIconSelect) _protocolIconSelect.setValue((document.getElementById('target-editor-protocol') as HTMLSelectElement).value);

        // Auto-name generation
        _targetNameManuallyEdited = !!(targetId || cloneData);
        (document.getElementById('target-editor-name') as HTMLInputElement).oninput = () => { _targetNameManuallyEdited = true; };
        window._targetAutoName = _autoGenerateTargetName;
        deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
        (document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _autoGenerateTargetName(); };
        (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).onchange = () => { _updateBrightnessThresholdVisibility(); };
        if (!targetId && !cloneData) _autoGenerateTargetName();

        // Show/hide conditional fields
        _updateDeviceInfo();
        _updateKeepaliveVisibility();
        _updateSpecificSettingsVisibility();
        _updateFpsRecommendation();
        _updateBrightnessThresholdVisibility();

        // Tags
        if (_targetTagsInput) _targetTagsInput.destroy();
        _targetTagsInput = new TagInput(document.getElementById('target-tags-container'), {
            placeholder: t('tags.placeholder'),
        });
        _targetTagsInput.setValue(_editorTags);

        targetEditorModal.snapshot();
        targetEditorModal.open();

        (document.getElementById('target-editor-error') as HTMLElement).style.display = 'none';
        setTimeout(() => desktopFocus(document.getElementById('target-editor-name')), 100);
    } catch (error) {
        console.error('Failed to open target editor:', error);
        showToast(t('target.error.editor_open_failed'), 'error');
    }
}

export function isTargetEditorDirty() {
    return targetEditorModal.isDirty();
}

export async function closeTargetEditorModal() {
    await targetEditorModal.close();
}

export function forceCloseTargetEditorModal() {
    if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
    targetEditorModal.forceClose();
}

export async function saveTargetEditor() {
    const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value;
    const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim();
    const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value;
    const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value);

    if (!name) {
        targetEditorModal.showError(t('targets.error.name_required'));
        return;
    }

    const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30;
    const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;

    const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
    const minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0;

    const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
    const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;

    const payload: any = {
        name,
        device_id: deviceId,
        color_strip_source_id: colorStripSourceId,
        brightness_value_source_id: brightnessVsId,
        min_brightness_threshold: minBrightnessThreshold,
        fps,
        keepalive_interval: standbyInterval,
        adaptive_fps: adaptiveFps,
        protocol,
        tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
    };

    try {
        let response;
        if (targetId) {
            response = await fetchWithAuth(`/output-targets/${targetId}`, {
                method: 'PUT',
                body: JSON.stringify(payload),
            });
        } else {
            payload.target_type = 'led';
            response = await fetchWithAuth('/output-targets', {
                method: 'POST',
                body: JSON.stringify(payload),
            });
        }

        if (!response.ok) {
            const err = await response.json();
            throw new Error(err.detail || 'Failed to save');
        }

        showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
        outputTargetsCache.invalidate();
        targetEditorModal.forceClose();
        await loadTargetsTab();
    } catch (error) {
        if (error.isAuth) return;
        console.error('Error saving target:', error);
        targetEditorModal.showError(error.message);
    }
}

// ===== TARGETS TAB (WLED devices + targets combined) =====

let _treeTriggered = false;

const _targetsTree = new TreeNav('targets-tree-nav', {
    onSelect: (key: any) => {
        _treeTriggered = true;
        switchTargetSubTab(key);
        _treeTriggered = false;
    }
});

export function switchTargetSubTab(tabKey: any) {
    document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
        (panel as HTMLElement).classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
    );
    localStorage.setItem('activeTargetSubTab', tabKey);
    updateSubTabHash('targets', tabKey);
    // Update tree active state (unless the tree triggered this switch)
    if (!_treeTriggered) {
        _targetsTree.setActive(tabKey);
    }
}

const _targetSectionMap = {
    'led-devices': [csDevices],
    'led-targets': [csLedTargets],
    'kc-targets': [csKCTargets],
    'kc-patterns': [csPatternTemplates],
};

let _loadTargetsLock = false;
let _actionInFlight = false;

export async function loadTargetsTab() {
    const container = document.getElementById('targets-panel-content');
    if (!container) return;
    // Skip if another loadTargetsTab or a button action is already running
    if (_loadTargetsLock || _actionInFlight) return;
    _loadTargetsLock = true;
    if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);

    try {
        // Fetch all entities via DataCache
        const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
            devicesCache.fetch().catch((): any[] => []),
            outputTargetsCache.fetch().catch((): any[] => []),
            colorStripSourcesCache.fetch().catch((): any[] => []),
            patternTemplatesCache.fetch().catch((): any[] => []),
            streamsCache.fetch().catch((): any[] => []),
            valueSourcesCache.fetch().catch((): any[] => []),
            audioSourcesCache.fetch().catch((): any[] => []),
            syncClocksCache.fetch().catch((): any[] => []),
            haSourcesCache.fetch().catch((): any[] => []),
        ]);

        const colorStripSourceMap = {};
        cssArr.forEach(s => { colorStripSourceMap[s.id] = s; });

        let pictureSourceMap = {};
        psArr.forEach(s => { pictureSourceMap[s.id] = s; });

        let patternTemplateMap = {};
        patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });

        let valueSourceMap = {};
        valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });

        let audioSourceMap = {};
        asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });

        // Fetch all device states, target states, and target metrics in batch
        const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([
            fetchWithAuth('/devices/batch/states'),
            fetchWithAuth('/output-targets/batch/states'),
            fetchWithAuth('/output-targets/batch/metrics'),
        ]);
        const allDeviceStates = batchDevStatesResp.ok ? (await batchDevStatesResp.json()).states : {};
        const allTargetStates = batchTgtStatesResp.ok ? (await batchTgtStatesResp.json()).states : {};
        const allTargetMetrics = batchTgtMetricsResp.ok ? (await batchTgtMetricsResp.json()).metrics : {};

        const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} }));

        // Enrich targets with state/metrics; fetch colors only for running KC targets
        const targetsWithState = await Promise.all(
            targets.map(async (target) => {
                const state = allTargetStates[target.id] || {};
                const metrics = allTargetMetrics[target.id] || {};
                let latestColors = null;
                if (target.target_type === 'key_colors' && state.processing) {
                    try {
                        const colorsResp = await fetch(`${API_BASE}/output-targets/${target.id}/colors`, { headers: getHeaders() });
                        if (colorsResp.ok) latestColors = await colorsResp.json();
                    } catch {}
                }
                return { ...target, state, metrics, latestColors };
            })
        );

        // Build device map for target name resolution
        const deviceMap = {};
        devicesWithState.forEach(d => { deviceMap[d.id] = d; });

        // Group by type
        const ledDevices = devicesWithState;
        const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
        const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
        const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');

        // Update tab badge with running target count
        const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
        updateTabBadge('targets', runningCount);

        const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';

        // Build tree navigation structure
        const treeGroups = [
            {
                key: 'led_group', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led',
                children: [
                    { key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length },
                    { key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length },
                ]
            },
            {
                key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
                children: [
                    { key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
                    { key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
                ]
            },
            {
                key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
                children: [
                    { key: 'ha-light-targets', titleKey: 'ha_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: haLightTargets.length },
                ]
            }
        ];
        // Determine which tree leaf is active — migrate old values
        const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
        const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
            : activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';

        // Use window.createPatternTemplateCard to avoid circular import
        const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');

        // Build items arrays for each section (apply saved drag order)
        const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
        const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
        const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
        const haSourceMap: Record<string, any> = {};
        _cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
        const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
        const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));

        // Track which target cards were replaced/added (need chart re-init)
        let changedTargetIds: Set<string> | null = null;

        if (csDevices.isMounted()) {
            // ── Incremental update: reconcile cards in-place ──
            _targetsTree.updateCounts({
                'led-devices': ledDevices.length,
                'led-targets': ledTargets.length,
                'kc-targets': kcTargets.length,
                'kc-patterns': patternTemplates.length,
                'ha-light-targets': haLightTargets.length,
            });
            csDevices.reconcile(deviceItems);
            const ledResult = csLedTargets.reconcile(ledTargetItems);
            const kcResult = csKCTargets.reconcile(kcTargetItems);
            csPatternTemplates.reconcile(patternItems);
            csHALightTargets.reconcile(haLightTargetItems);
            changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
                                        ...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);

            // Restore LED preview state on replaced cards (panel hidden by default in HTML)
            for (const id of Array.from(ledResult.replaced) as any[]) {
                if (ledPreviewWebSockets[id]) {
                    _restoreLedPreviewState(id);
                }
            }
        } else {
            // ── First render: build full HTML ──
            const panels = [
                { key: 'led-devices', html: csDevices.render(deviceItems) },
                { key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
                { key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
                { key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
                { key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
            ].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
            container.innerHTML = panels;
            CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
            initHALightTargetDelegation(container);

            // Render tree sidebar with expand/collapse buttons
            _targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
            _targetsTree.update(treeGroups, activeLeaf);
            _targetsTree.observeSections('targets-panel-content');
        }

        // Show/hide stop-all buttons based on running state
        const ledRunning = ledTargets.some(t => t.state && t.state.processing);
        const kcRunning = kcTargets.some(t => t.state && t.state.processing);
        const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null;
        const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null;
        if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
        if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }

        // Patch volatile metrics in-place (avoids full card replacement on polls)
        for (const tgt of ledTargets) {
            if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
        }
        for (const tgt of kcTargets) {
            if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt);
        }

        // Attach event listeners and fetch brightness for device cards
        devicesWithState.forEach(device => {
            attachDeviceListeners(device.id);
            if ((device.capabilities || []).includes('brightness_control')) {
                if (device.id in _deviceBrightnessCache) {
                    const bri = _deviceBrightnessCache[device.id];
                    const slider = document.querySelector(`[data-device-brightness="${CSS.escape(device.id)}"]`) as HTMLInputElement | null;
                    if (slider) {
                        slider.value = String(bri);
                        slider.title = Math.round(bri / 255 * 100) + '%';
                        slider.disabled = false;
                    }
                    const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(device.id)}"]`);
                    if (wrap) wrap.classList.remove('brightness-loading');
                } else {
                    fetchDeviceBrightness(device.id);
                }
            }
            // Enrich OpenRGB zone badges with per-zone LED counts
            if (device.device_type === 'openrgb') {
                enrichOpenrgbZoneBadges(device.id, device.url);
            }
        });

        // Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
        for (const device of devicesWithState) {
            const el = container.querySelector(`[data-last-seen="${CSS.escape(device.id)}"]`) as HTMLElement | null;
            if (el) {
                const ts = device.state?.device_last_checked;
                const label = ts ? formatRelativeTime(ts) : null;
                el.textContent = label ? `\u23F1 ${t('device.last_seen.label')}: ${label}` : '';
                if (ts) el.title = ts;
            }
        }

        // Manage KC WebSockets: connect for processing, disconnect for stopped
        const processingKCIds = new Set();
        kcTargets.forEach(target => {
            if (target.state && target.state.processing) {
                processingKCIds.add(target.id);
                if (!kcWebSockets[target.id]) {
                    connectKCWebSocket(target.id);
                }
            }
        });
        Object.keys(kcWebSockets).forEach(id => {
            if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
        });

        // Auto-disconnect LED preview WebSockets for targets that stopped
        const processingLedIds = new Set();
        ledTargets.forEach(target => {
            if (target.state && target.state.processing) processingLedIds.add(target.id);
        });
        Object.keys(ledPreviewWebSockets).forEach(id => {
            if (!processingLedIds.has(id)) disconnectLedPreviewWS(id);
        });

        // FPS charts: only destroy charts for replaced/removed cards (or all on first render)
        if (changedTargetIds) {
            // Incremental: destroy only charts whose cards were replaced or removed
            for (const id of changedTargetIds) {
                if (_targetFpsCharts[id]) {
                    _targetFpsCharts[id].destroy();
                    delete _targetFpsCharts[id];
                }
            }
        } else {
            // First render: destroy all old charts
            for (const id of Object.keys(_targetFpsCharts)) {
                _targetFpsCharts[id].destroy();
                delete _targetFpsCharts[id];
            }
        }

        // Push FPS samples and create/update charts for running targets
        const allTargets = [...ledTargets, ...kcTargets];
        const runningIds = new Set();
        const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id);

        // Seed FPS history from server if empty (first load / page reload)
        if (runningTargetIds.length > 0 && runningTargetIds.some(id => !_targetFpsHistory[id]?.length)) {
            const data = await fetchMetricsHistory();
            if (data) {
                const serverTargets = data.targets || {};
                for (const id of runningTargetIds) {
                    if (!_targetFpsHistory[id]?.length) {
                        const samples = serverTargets[id] || [];
                        const actual = samples.map((s: any) => s.fps).filter((v: any) => v != null);
                        const current = samples.map((s: any) => s.fps_current).filter((v: any) => v != null);
                        _targetFpsHistory[id] = actual.slice(-_TARGET_MAX_FPS_SAMPLES);
                        _targetFpsCurrentHistory[id] = current.slice(-_TARGET_MAX_FPS_SAMPLES);
                    }
                }
            }
        }

        allTargets.forEach(target => {
            if (target.state && target.state.processing) {
                runningIds.add(target.id);
                if (target.state.fps_actual != null) {
                    _pushTargetFps(target.id, target.state.fps_actual, target.state.fps_current ?? 0);
                }
                // Create chart if it doesn't exist (new or replaced card)
                if (!_targetFpsCharts[target.id]) {
                    const actualH = _targetFpsHistory[target.id] || [];
                    const currentH = _targetFpsCurrentHistory[target.id] || [];
                    const fpsTarget = target.state.fps_target || 30;
                    const device = devices.find(d => d.id === target.device_id);
                    const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
                    const chart = _createTargetFpsChart(`target-fps-${target.id}`, actualH, currentH, fpsTarget, maxHwFps);
                    if (chart) _targetFpsCharts[target.id] = chart;
                } else {
                    // Chart survived reconcile — just update data
                    _updateTargetFpsChart(target.id, target.state.fps_target || 30);
                }
            }
        });
        // Clean up history and charts for targets no longer running
        Object.keys(_targetFpsHistory).forEach(id => {
            if (!runningIds.has(id)) { delete _targetFpsHistory[id]; delete _targetFpsCurrentHistory[id]; }
        });
        Object.keys(_targetFpsCharts).forEach(id => {
            if (!runningIds.has(id)) {
                _targetFpsCharts[id].destroy();
                delete _targetFpsCharts[id];
            }
        });

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

function _cssSourceName(cssId: any, colorStripSourceMap: any) {
    if (!cssId) return t('targets.no_css');
    const css = colorStripSourceMap[cssId];
    return css ? escapeHtml(css.name) : escapeHtml(cssId);
}

// ─── In-place metric patching (avoids full card replacement on polls) ───

function _buildLedTimingHTML(state: any) {
    const isAudio = state.timing_audio_read_ms != null;
    return `
        <div class="timing-header">
            <div class="metric-label">${t('device.metrics.timing')}</div>
            <div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
        </div>
        <div class="timing-bar">
            ${isAudio ? `
            <span class="timing-seg timing-audio-read" style="flex:${state.timing_audio_read_ms}" title="read ${state.timing_audio_read_ms}ms"></span>
            <span class="timing-seg timing-audio-fft" style="flex:${state.timing_audio_fft_ms}" title="fft ${state.timing_audio_fft_ms}ms"></span>
            <span class="timing-seg timing-audio-render" style="flex:${state.timing_audio_render_ms || 0.1}" title="render ${state.timing_audio_render_ms}ms"></span>
            ` : `
            ${state.timing_extract_ms != null ? `<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>` : ''}
            ${state.timing_map_leds_ms != null ? `<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>` : ''}
            ${state.timing_smooth_ms != null ? `<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>` : ''}
            `}
            <span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
        </div>
        <div class="timing-legend">
            ${isAudio ? `
            <span class="timing-legend-item"><span class="timing-dot timing-audio-read"></span>read ${state.timing_audio_read_ms}ms</span>
            <span class="timing-legend-item"><span class="timing-dot timing-audio-fft"></span>fft ${state.timing_audio_fft_ms}ms</span>
            <span class="timing-legend-item"><span class="timing-dot timing-audio-render"></span>render ${state.timing_audio_render_ms}ms</span>
            ` : `
            ${state.timing_extract_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>` : ''}
            ${state.timing_map_leds_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>` : ''}
            ${state.timing_smooth_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>` : ''}
            `}
            <span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
        </div>`;
}

function _patchTargetMetrics(target: any) {
    const container = document.getElementById('targets-panel-content');
    if (!container) return;
    const card = container.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
    if (!card) return;
    const state = target.state || {};
    const metrics = target.metrics || {};

    const fps = card.querySelector('[data-tm="fps"]') as HTMLElement | null;
    if (fps) {
        const effFps = state.fps_effective;
        const tgtFps = state.fps_target || 0;
        const fpsLabel = (effFps != null && effFps < tgtFps)
            ? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}↓${tgtFps}</span>`
            : `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
        const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
        fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
            + `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
    }

    // Update health dot to reflect streaming reachability when processing
    const healthDot = card.querySelector('.health-dot') as HTMLElement | null;
    if (healthDot && state.processing) {
        const reachable = state.device_streaming_reachable;
        if (reachable === false) {
            healthDot.className = 'health-dot health-offline';
            healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming';
        } else if (reachable === true) {
            healthDot.className = 'health-dot health-online';
            healthDot.title = t('device.health.online');
        }
    }

    const timing = card.querySelector('[data-tm="timing"]') as HTMLElement | null;
    if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);

    const frames = card.querySelector('[data-tm="frames"]') as HTMLElement | null;
    if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }

    const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement | null;
    if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); }

    const errors = card.querySelector('[data-tm="errors"]') as HTMLElement | null;
    if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); }

    // Error indicator near target name
    const errorIndicator = card.querySelector('.target-error-indicator');
    if (errorIndicator) {
        const hasErrors = (metrics.errors_count || 0) > 0;
        errorIndicator.classList.toggle('visible', hasErrors);
    }

    const uptime = card.querySelector('[data-tm="uptime"]') as HTMLElement | null;
    if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
}

export function createTargetCard(target: OutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
    const state = target.state || {};

    const isProcessing = state.processing || false;

    const device = deviceMap[target.device_id!];
    const deviceName = device ? device.name : (target.device_id || 'No device');

    const cssId = target.color_strip_source_id || '';
    const cssSummary = _cssSourceName(cssId, colorStripSourceMap);

    const bvsId = target.brightness_value_source_id || '';
    const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;

    // Determine if overlay is available (picture-based CSS)
    const css = cssId ? colorStripSourceMap[cssId] : null;
    const overlayAvailable = !css || css.source_type === 'picture';

    // Health info from target state (forwarded from device)
    const devOnline = state.device_online || false;
    let healthClass = 'health-unknown';
    let healthTitle = '';
    if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
        healthClass = devOnline ? 'health-online' : 'health-offline';
        healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
    }

    return wrapCard({
        dataAttr: 'data-target-id',
        id: target.id,
        classes: isProcessing ? 'card-running' : '',
        removeOnclick: `deleteTarget('${target.id}')`,
        removeTitle: t('common.delete'),
        content: `
            <div class="card-header">
                <div class="card-title" title="${escapeHtml(target.name)}">
                    <span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
                    <span class="card-title-text">${escapeHtml(target.name)}</span>
                    <span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
                </div>
            </div>
            <div class="stream-card-props">
                <span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
                <span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
                <span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
                <span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
                ${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
                ${(target.min_brightness_threshold ?? 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${target.min_brightness_threshold} → off</span>` : ''}
            </div>
            ${renderTagChips(target.tags)}
            <div class="card-content">
                ${isProcessing ? `
                <div class="metrics-grid">
                    <div class="target-fps-row">
                        <div class="target-fps-sparkline">
                            <canvas id="target-fps-${target.id}" data-fps-target="${state.fps_target || 30}"></canvas>
                        </div>
                        <div class="target-fps-label">
                            <span class="metric-value" data-tm="fps">---</span>
                        </div>
                    </div>
                </div>
                <div class="target-metrics-collapse">
                    <button type="button" class="target-metrics-toggle" onclick="this.parentElement.classList.toggle('open')">${t('targets.metrics.pipeline')}</button>
                    <div class="target-metrics-animate">
                        <div class="metrics-grid target-metrics-expanded">
                            <div class="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div>
                            <div class="metric">
                                <div class="metric-label">${t('device.metrics.frames')}</div>
                                <div class="metric-value" data-tm="frames">---</div>
                            </div>
                            ${state.needs_keepalive !== false ? `
                            <div class="metric">
                                <div class="metric-label">${t('device.metrics.keepalive')}</div>
                                <div class="metric-value" data-tm="keepalive">---</div>
                            </div>
                            ` : ''}
                            <div class="metric">
                                <div class="metric-label">${t('device.metrics.errors')}</div>
                                <div class="metric-value" data-tm="errors">---</div>
                            </div>
                            <div class="metric">
                                <div class="metric-label">${t('device.metrics.uptime')}</div>
                                <div class="metric-value" data-tm="uptime">---</div>
                            </div>
                        </div>
                    </div>
                </div>
                ` : ''}
            </div>
            ${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`,
        actions: `
                ${isProcessing ? `
                    <button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
                        ${ICON_STOP}
                    </button>
                ` : `
                    <button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
                        ${ICON_START}
                    </button>
                `}
                ${isProcessing ? `
                    <button class="btn btn-icon btn-secondary" data-led-preview-btn="${target.id}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
                        ${ICON_LED_PREVIEW}
                    </button>
                ` : ''}
                <button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
                    ${ICON_CLONE}
                </button>
                <button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
                    ${ICON_EDIT}
                </button>
`,
    });
}

async function _targetAction(action: any) {
    _actionInFlight = true;
    try {
        await action();
    } finally {
        _actionInFlight = false;
        _loadTargetsLock = false;  // ensure next poll can run
        loadTargetsTab();
    }
}

export async function startTargetProcessing(targetId: any) {
    await _targetAction(async () => {
        const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('device.started'), 'success');
        } else {
            const error = await response.json();
            showToast(error.detail || t('target.error.start_failed'), 'error');
        }
    });
}

export async function stopTargetProcessing(targetId: any) {
    await _targetAction(async () => {
        const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('device.stopped'), 'success');
        } else {
            const error = await response.json();
            showToast(error.detail || t('target.error.stop_failed'), 'error');
        }
    });
}

export async function stopAllLedTargets() {
    const confirmed = await showConfirm(t('confirm.stop_all'));
    if (!confirmed) return;
    await _stopAllByType('led');
}

export async function stopAllKCTargets() {
    const confirmed = await showConfirm(t('confirm.stop_all'));
    if (!confirmed) return;
    await _stopAllByType('key_colors');
}

async function _stopAllByType(targetType: any) {
    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 typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
        const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing);
        if (!running.length) {
            showToast(t('targets.stop_all.none_running'), 'info');
            return;
        }
        await Promise.all(running.map(t =>
            fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
        ));
        showToast(t('targets.stop_all.stopped', { count: running.length }), 'success');
        loadTargetsTab();
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('targets.stop_all.error'), 'error');
    }
}

export async function startTargetOverlay(targetId: any) {
    await _targetAction(async () => {
        const response = await fetchWithAuth(`/output-targets/${targetId}/overlay/start`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('overlay.started'), 'success');
        } else {
            const error = await response.json();
            showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
        }
    });
}

export async function stopTargetOverlay(targetId: any) {
    await _targetAction(async () => {
        const response = await fetchWithAuth(`/output-targets/${targetId}/overlay/stop`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('overlay.stopped'), 'success');
        } else {
            const error = await response.json();
            showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
        }
    });
}

export async function cloneTarget(targetId: any) {
    try {
        const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
        if (!resp.ok) throw new Error('Failed to load target');
        const target = await resp.json();
        showTargetEditor(null, target);
    } catch (error) {
        console.error('Failed to clone target:', error);
        showToast(t('target.error.clone_failed'), 'error');
    }
}

export async function deleteTarget(targetId: any) {
    const confirmed = await showConfirm(t('targets.delete.confirm'));
    if (!confirmed) return;

    await _targetAction(async () => {
        const response = await fetchWithAuth(`/output-targets/${targetId}`, {
            method: 'DELETE',
        });
        if (response.ok) {
            showToast(t('targets.deleted'), 'success');
            outputTargetsCache.invalidate();
        } else {
            const error = await response.json();
            showToast(error.detail || t('target.error.delete_failed'), 'error');
        }
    });
}


/* ── LED Strip Preview ────────────────────────────────────────── */

const _ledPreviewLastFrame = {};

/**
 * Build the LED preview panel HTML for a target card.
 * For OpenRGB devices in "separate" zone mode with 2+ zones, renders
 * one canvas per zone with labels. Otherwise, a single canvas.
 */
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
    // Always render hidden — JS toggles visibility. This keeps card HTML stable
    // so reconciliation doesn't replace the card when preview is toggled.
    const visible = 'none';
    const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';

    // Check for per-zone preview
    if (device && isOpenrgbDevice(device.device_type) && device.zone_mode === 'separate') {
        const { baseUrl, zones } = _splitOpenrgbZone(device.url);
        if (zones.length > 1) {
            const zoneCanvases = zones.map(z =>
                `<div class="led-preview-zone">` +
                `<canvas class="led-preview-canvas led-preview-zone-canvas" data-zone-name="${escapeHtml(z)}"></canvas>` +
                `<span class="led-preview-zone-label">${escapeHtml(z)}</span>` +
                `</div>`
            ).join('');
            return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-zones" data-zone-mode="separate" data-zone-base-url="${escapeHtml(baseUrl)}" style="display:${visible}">` +
                zoneCanvases +
                `<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
                `</div>`;
        }
    }

    // Check for composite source with per-layer preview
    if (cssSource && cssSource.source_type === 'composite' && cssSource.layers && cssSource.layers.length > 1) {
        const layerCanvases = cssSource.layers.filter(l => l.enabled !== false).map((l, i) => {
            const layerSrc = colorStripSourceMap ? colorStripSourceMap[l.source_id] : null;
            const layerName = layerSrc ? layerSrc.name : l.source_id;
            return `<div class="led-preview-layer">` +
                `<canvas class="led-preview-canvas led-preview-layer-canvas" data-layer-idx="${i}"></canvas>` +
                `<span class="led-preview-layer-label">${escapeHtml(layerName)}</span>` +
                `</div>`;
        }).join('');
        return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-layers" data-composite="1" style="display:${visible}">` +
            `<div class="led-preview-layer led-preview-layer-composite">` +
                `<canvas class="led-preview-canvas led-preview-layer-canvas" data-layer-idx="composite"></canvas>` +
                `<span class="led-preview-layer-label">${escapeHtml(cssSource.name || 'Composite')}</span>` +
            `</div>` +
            layerCanvases +
            `<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
            `</div>`;
    }

    // Default: single canvas
    return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
        `<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
        `<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
        `</div>`;
}

/**
 * Resample an RGB byte array from srcCount pixels to dstCount pixels
 * using linear interpolation (matches backend np.interp behavior).
 */
function _resampleStrip(srcBytes: any, srcCount: any, dstCount: any) {
    if (dstCount === srcCount) return srcBytes;
    const dst = new Uint8Array(dstCount * 3);
    for (let i = 0; i < dstCount; i++) {
        const t = dstCount > 1 ? i / (dstCount - 1) : 0;
        const srcPos = t * (srcCount - 1);
        const lo = Math.floor(srcPos);
        const hi = Math.min(lo + 1, srcCount - 1);
        const frac = srcPos - lo;
        for (let ch = 0; ch < 3; ch++) {
            dst[i * 3 + ch] = Math.round(
                srcBytes[lo * 3 + ch] * (1 - frac) + srcBytes[hi * 3 + ch] * frac
            );
        }
    }
    return dst;
}

/**
 * Render per-zone LED previews: resample the full frame independently
 * for each zone canvas (matching the backend's separate-mode behavior).
 */
function _renderLedStripZones(panel: any, rgbBytes: any) {
    const baseUrl = panel.dataset.zoneBaseUrl;
    const cache = baseUrl ? getZoneCountCache(baseUrl) : null;
    const srcCount = Math.floor(rgbBytes.length / 3);
    if (srcCount < 1) return;

    const zoneCanvases = panel.querySelectorAll('.led-preview-zone-canvas');
    if (!cache) {
        // Zone sizes unknown — render full frame to all canvases
        for (const canvas of zoneCanvases) {
            _renderLedStrip(canvas, rgbBytes);
        }
        return;
    }

    // Separate mode: each zone gets the full source frame resampled to its LED count
    // (matches backend OpenRGB separate-mode behavior)
    for (const canvas of zoneCanvases) {
        const zoneName = canvas.dataset.zoneName;
        const zoneSize = cache[zoneName.toLowerCase()];
        if (!zoneSize || zoneSize < 1) continue;
        const resampled = _resampleStrip(rgbBytes, srcCount, zoneSize);
        _renderLedStrip(canvas, resampled);
    }
}

function _renderLedStrip(canvas: any, rgbBytes: any) {
    const ledCount = rgbBytes.length / 3;
    if (ledCount <= 0) return;

    // Set canvas resolution to match LED count (1px per LED)
    canvas.width = ledCount;
    canvas.height = 1;

    const ctx = canvas.getContext('2d')!;
    const imageData = ctx.createImageData(ledCount, 1);
    const data = imageData.data;

    for (let i = 0; i < ledCount; i++) {
        const si = i * 3;
        const di = i * 4;
        data[di] = rgbBytes[si];
        data[di + 1] = rgbBytes[si + 1];
        data[di + 2] = rgbBytes[si + 2];
        data[di + 3] = 255;
    }

    ctx.putImageData(imageData, 0, 0);
}

function connectLedPreviewWS(targetId: any) {
    // Close existing WS without touching DOM (caller manages panel/button state)
    const oldWs = ledPreviewWebSockets[targetId];
    if (oldWs) {
        oldWs.onclose = null;
        oldWs.close();
        delete ledPreviewWebSockets[targetId];
    }

    const key = localStorage.getItem('wled_api_key');
    if (!key) return;

    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/led-preview/ws?token=${encodeURIComponent(key)}`;

    try {
        const ws = new WebSocket(wsUrl);
        ws.binaryType = 'arraybuffer';

        ws.onmessage = (event) => {
            if (event.data instanceof ArrayBuffer) {
                const raw = new Uint8Array(event.data);
                const brightness = raw[0];

                const panel = document.getElementById(`led-preview-panel-${targetId}`);

                // Detect composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
                const isCompositeWire = raw.length > 4 && raw[1] === 0xFE;

                if (isCompositeWire) {
                    const layerCount = raw[2];
                    const ledCount = (raw[3] << 8) | raw[4];
                    const rgbSize = ledCount * 3;
                    let offset = 5;

                    // Render per-layer canvases if panel supports it
                    if (panel && panel.dataset.composite === '1') {
                        const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]');
                        for (let i = 0; i < layerCount; i++) {
                            const layerRgb = raw.subarray(offset, offset + rgbSize);
                            offset += rgbSize;
                            // layer canvases: idx 0 = "composite", idx 1..N = individual layers
                            const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first
                            if (canvas) _renderLedStrip(canvas, layerRgb);
                        }
                    } else {
                        // Skip layer data (panel doesn't have layer canvases)
                        offset += layerCount * rgbSize;
                    }

                    // Final composite result
                    const compositeRgb = raw.subarray(offset, offset + rgbSize);
                    _ledPreviewLastFrame[targetId] = compositeRgb;

                    if (panel) {
                        if (panel.dataset.composite === '1') {
                            const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]');
                            if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb);
                        } else if (panel.dataset.zoneMode === 'separate') {
                            _renderLedStripZones(panel, compositeRgb);
                        } else {
                            const canvas = panel.querySelector('.led-preview-canvas');
                            if (canvas) _renderLedStrip(canvas, compositeRgb);
                        }
                    }
                } else {
                    // Standard wire format: [brightness_byte] [R G B R G B ...]
                    const frame = raw.subarray(1);
                    _ledPreviewLastFrame[targetId] = frame;

                    if (panel) {
                        if (panel.dataset.zoneMode === 'separate') {
                            _renderLedStripZones(panel, frame);
                        } else {
                            const canvas = panel.querySelector('.led-preview-canvas');
                            if (canvas) _renderLedStrip(canvas, frame);
                        }
                    }
                }

                // Show brightness label: always when a brightness source is set, otherwise only below 100%
                const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
                if (bLabel) {
                    const pct = Math.round(brightness / 255 * 100);
                    if (pct < 100 || bLabel.dataset.hasBvs) {
                        bLabel.innerHTML = `${ICON_SUN_DIM} ${pct}%`;
                        bLabel.style.display = '';
                    } else {
                        bLabel.style.display = 'none';
                    }
                }
            }
        };

        ws.onclose = () => {
            delete ledPreviewWebSockets[targetId];
        };

        ws.onerror = (error) => {
            console.error(`LED preview WebSocket error for ${targetId}:`, error);
        };

        ledPreviewWebSockets[targetId] = ws;
    } catch (error) {
        console.error(`Failed to connect LED preview WebSocket for ${targetId}:`, error);
    }
}

function _setPreviewButtonState(targetId: any, active: boolean) {
    const btn = document.querySelector(`[data-led-preview-btn="${CSS.escape(targetId)}"]`);
    if (btn) {
        btn.classList.toggle('btn-warning', active);
        btn.classList.toggle('btn-secondary', !active);
    }
}

/** Restore preview panel visibility, button state, and last frame after card replacement. */
function _restoreLedPreviewState(targetId: any) {
    const panel = document.getElementById(`led-preview-panel-${targetId}`);
    if (panel) panel.style.display = '';
    _setPreviewButtonState(targetId, true);
    // Re-render cached frame onto the new canvas
    const frame = _ledPreviewLastFrame[targetId];
    if (frame && panel) {
        if (panel.dataset.zoneMode === 'separate') {
            _renderLedStripZones(panel, frame);
        } else {
            const canvas = panel.querySelector('.led-preview-canvas');
            if (canvas) _renderLedStrip(canvas, frame);
        }
    }
}

function disconnectLedPreviewWS(targetId: any) {
    const ws = ledPreviewWebSockets[targetId];
    if (ws) {
        ws.onclose = null;
        ws.close();
        delete ledPreviewWebSockets[targetId];
    }
    delete _ledPreviewLastFrame[targetId];
    const panel = document.getElementById(`led-preview-panel-${targetId}`);
    if (panel) panel.style.display = 'none';
    _setPreviewButtonState(targetId, false);
}

export function disconnectAllLedPreviewWS() {
    Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
}

export function toggleLedPreview(targetId: any) {
    const panel = document.getElementById(`led-preview-panel-${targetId}`);
    if (!panel) return;

    if (ledPreviewWebSockets[targetId]) {
        disconnectLedPreviewWS(targetId);
    } else {
        panel.style.display = '';
        _setPreviewButtonState(targetId, true);
        connectLedPreviewWS(targetId);
    }
}
