/**
 * Value Sources — CRUD for scalar value sources (static, animated, audio, adaptive_time, adaptive_scene).
 *
 * Value sources produce a float 0.0-1.0 used for dynamic brightness control
 * on LED targets. Five subtypes: static (constant), animated (waveform),
 * audio (audio-reactive), adaptive_time (time-of-day schedule),
 * adaptive_scene (scene brightness analysis).
 *
 * Card rendering is handled by streams.js (Value tab).
 * This module manages the editor modal and API operations.
 */

import {
    _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
    _cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
    _cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import {
    getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getColorStripIcon, getHAEntityIcon,
    ICON_CLONE, ICON_EDIT, ICON_TEST,
    ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
    ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
    ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import type { IconSelectItem } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { loadPictureSources } from './streams.ts';
import { hexToRgbArray, rgbArrayToHex } from './css-gradient-editor.ts';
import type { ValueSource } from '../types.ts';

export { getValueSourceIcon };

// ── EntitySelect instances for value source editor ──
let _vsAudioSourceEntitySelect: EntitySelect | null = null;
let _vsPictureSourceEntitySelect: EntitySelect | null = null;
let _vsHASourceEntitySelect: EntitySelect | null = null;
let _vsHAEntityEntitySelect: EntitySelect | null = null;
let _vsHAEntities: any[] = [];
let _vsGradientInputEntitySelect: EntitySelect | null = null;
let _vsGradientEntitySelect: EntitySelect | null = null;
let _vsCSSSourceEntitySelect: EntitySelect | null = null;
let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsBehaviorIconSelect: IconSelect | null = null;
let _vsMetricIconSelect: IconSelect | null = null;
let _vsTagsInput: TagInput | null = null;

class ValueSourceModal extends Modal {
    constructor() { super('value-source-modal'); }

    onForceClose() {
        if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
        if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.destroy(); _vsColorEasingIconSelect = null; }
        if (_vsHASourceEntitySelect) { _vsHASourceEntitySelect.destroy(); _vsHASourceEntitySelect = null; }
        if (_vsHAEntityEntitySelect) { _vsHAEntityEntitySelect.destroy(); _vsHAEntityEntitySelect = null; }
        if (_vsGradientInputEntitySelect) { _vsGradientInputEntitySelect.destroy(); _vsGradientInputEntitySelect = null; }
        if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; }
        if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
        if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
        if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
        if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
        if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
    }

    snapshotValues() {
        const type = (document.getElementById('value-source-type') as HTMLSelectElement).value;
        return {
            name: (document.getElementById('value-source-name') as HTMLInputElement).value,
            description: (document.getElementById('value-source-description') as HTMLInputElement).value,
            type,
            value: (document.getElementById('value-source-value') as HTMLInputElement).value,
            waveform: (document.getElementById('value-source-waveform') as HTMLSelectElement).value,
            speed: (document.getElementById('value-source-speed') as HTMLInputElement).value,
            minValue: (document.getElementById('value-source-min-value') as HTMLInputElement).value,
            maxValue: (document.getElementById('value-source-max-value') as HTMLInputElement).value,
            audioSource: (document.getElementById('value-source-audio-source') as HTMLSelectElement).value,
            mode: (document.getElementById('value-source-mode') as HTMLSelectElement).value,
            sensitivity: (document.getElementById('value-source-sensitivity') as HTMLInputElement).value,
            smoothing: (document.getElementById('value-source-smoothing') as HTMLInputElement).value,
            autoGain: (document.getElementById('value-source-auto-gain') as HTMLInputElement).checked,
            adaptiveMin: (document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value,
            adaptiveMax: (document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value,
            pictureSource: (document.getElementById('value-source-picture-source') as HTMLSelectElement).value,
            sceneBehavior: (document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value,
            sceneSensitivity: (document.getElementById('value-source-scene-sensitivity') as HTMLInputElement).value,
            sceneSmoothing: (document.getElementById('value-source-scene-smoothing') as HTMLInputElement).value,
            schedule: JSON.stringify(_getScheduleFromUI()),
            daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
            daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
            daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
            staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value,
            animatedColors: JSON.stringify(_animatedColors),
            animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value,
            animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
            colorSchedule: JSON.stringify(_colorSchedulePoints),
            tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
            metric: (document.getElementById('value-source-metric') as HTMLSelectElement).value,
            sysmetricMin: (document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value,
            sysmetricMax: (document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value,
            maxRate: (document.getElementById('value-source-max-rate') as HTMLInputElement).value,
            diskPath: (document.getElementById('value-source-disk-path') as HTMLInputElement).value,
            sensorLabel: (document.getElementById('value-source-sensor-label') as HTMLInputElement).value,
            pollInterval: (document.getElementById('value-source-poll-interval') as HTMLInputElement).value,
            sysmetricSmoothing: (document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value,
        };
    }
}

const valueSourceModal = new ValueSourceModal();

/* ── Name auto-generation ────────────────────────────────────── */

let _vsNameManuallyEdited = false;

function _autoGenerateVSName() {
    if (_vsNameManuallyEdited) return;
    if ((document.getElementById('value-source-id') as HTMLInputElement).value) return;
    const type = (document.getElementById('value-source-type') as HTMLSelectElement).value;
    const typeLabel = t(`value_source.type.${type}`);
    let detail = '';
    if (type === 'animated') {
        const wf = (document.getElementById('value-source-waveform') as HTMLSelectElement).value;
        detail = t(`value_source.waveform.${wf}`);
    } else if (type === 'audio') {
        const mode = (document.getElementById('value-source-mode') as HTMLSelectElement).value;
        detail = t(`value_source.mode.${mode}`);
    } else if (type === 'adaptive_scene') {
        const sel = document.getElementById('value-source-picture-source') as HTMLSelectElement;
        const name = sel?.selectedOptions[0]?.textContent?.trim();
        if (name) detail = name;
    } else if (type === 'system_metrics') {
        const metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value;
        detail = t(`value_source.metric.${metric}`);
    } else if (type === 'game_event') {
        const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value;
        if (eventType) detail = eventType;
    }
    (document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}

/* ── Icon-grid type selector ──────────────────────────────────── */

const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event'];
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];

let _vsTypeFilter: 'all' | 'float' | 'color' = 'all';

function _getFilteredTypeKeys(): string[] {
    if (_vsTypeFilter === 'float') return VS_FLOAT_TYPE_KEYS;
    if (_vsTypeFilter === 'color') return VS_COLOR_TYPE_KEYS;
    return VS_TYPE_KEYS;
}

function _buildVSTypeItems(): IconSelectItem[] {
    return _getFilteredTypeKeys().map(key => ({
        value: key,
        icon: getValueSourceIcon(key),
        label: t(`value_source.type.${key}`),
        desc: t(`value_source.type.${key}.desc`),
    }));
}

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

let _vsTypeIconSelect: IconSelect | null = null;
let _waveformIconSelect: IconSelect | null = null;
let _vsColorEasingIconSelect: IconSelect | null = null;

const _WAVEFORM_SVG = {
    sine:     '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 Q15 -4 30 12 Q45 28 60 12"/></svg>',
    triangle: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 L15 2 L45 22 L60 12"/></svg>',
    square:   '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 20 L0 4 L30 4 L30 20 L60 20 L60 4"/></svg>',
    sawtooth: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 20 L30 4 L30 20 L60 4"/></svg>',
};

function _ensureWaveformIconSelect() {
    const sel = document.getElementById('value-source-waveform');
    if (!sel) return;
    const items = [
        { value: 'sine',     icon: _WAVEFORM_SVG.sine,     label: t('value_source.waveform.sine') },
        { value: 'triangle', icon: _WAVEFORM_SVG.triangle, label: t('value_source.waveform.triangle') },
        { value: 'square',   icon: _WAVEFORM_SVG.square,   label: t('value_source.waveform.square') },
        { value: 'sawtooth', icon: _WAVEFORM_SVG.sawtooth, label: t('value_source.waveform.sawtooth') },
    ];
    if (_waveformIconSelect) { _waveformIconSelect.updateItems(items); return; }
    _waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 } as any);
}

function _ensureColorEasingIconSelect() {
    const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'linear', icon: _icon(P.activity), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
        { value: 'step',   icon: _icon(P.layoutDashboard), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
    ];
    if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; }
    _vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}

/* ── Waveform canvas preview ──────────────────────────────────── */

/**
 * Draw a waveform preview on the canvas element #value-source-waveform-preview.
 * Shows one full cycle of the selected waveform shape.
 */
function _drawWaveformPreview(waveformType: any) {
    const canvas = document.getElementById('value-source-waveform-preview') as HTMLCanvasElement;
    if (!canvas) return;

    const dpr = window.devicePixelRatio || 1;
    const cssW = canvas.offsetWidth || 200;
    const cssH = 60;
    canvas.width = cssW * dpr;
    canvas.height = cssH * dpr;
    canvas.style.height = cssH + 'px';

    const ctx = canvas.getContext('2d')!;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, cssW, cssH);

    const W = cssW;
    const H = cssH;
    const padX = 8;
    const padY = 8;
    const drawW = W - padX * 2;
    const drawH = H - padY * 2;
    const midY = padY + drawH / 2;

    // Draw zero line
    ctx.strokeStyle = 'rgba(255,255,255,0.12)';
    ctx.lineWidth = 1;
    ctx.setLineDash([3, 4]);
    ctx.beginPath();
    ctx.moveTo(padX, midY);
    ctx.lineTo(padX + drawW, midY);
    ctx.stroke();
    ctx.setLineDash([]);

    // Draw waveform
    const N = 120;
    ctx.beginPath();
    for (let i = 0; i <= N; i++) {
        const t = i / N;  // 0..1 over one cycle
        let v; // -1..1
        switch (waveformType) {
            case 'triangle':
                v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
                break;
            case 'square':
                v = t < 0.5 ? 1 : -1;
                break;
            case 'sawtooth':
                v = 2 * t - 1;
                break;
            case 'sine':
            default:
                v = Math.sin(2 * Math.PI * t);
                break;
        }
        const x = padX + t * drawW;
        const y = midY - v * (drawH / 2);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
    }

    // Glow effect: draw thick translucent line first
    ctx.strokeStyle = 'rgba(99,179,237,0.25)';
    ctx.lineWidth = 4;
    ctx.stroke();

    // Crisp line on top
    ctx.strokeStyle = '#63b3ed';
    ctx.lineWidth = 1.5;
    ctx.stroke();
}

export function updateWaveformPreview() {
    const wf = (document.getElementById('value-source-waveform') as HTMLSelectElement)?.value || 'sine';
    _drawWaveformPreview(wf);
}

/* ── Audio mode icon-grid selector ────────────────────────────── */

const _AUDIO_MODE_SVG = {
    rms:  '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="8" width="12" height="12" rx="2" fill="currentColor" opacity="0.3"/><rect x="24" y="4" width="12" height="16" rx="2" fill="currentColor" opacity="0.5"/><rect x="44" y="6" width="12" height="14" rx="2" fill="currentColor" opacity="0.4"/></svg>',
    peak: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 20 L10 14 L18 16 L26 4 L34 12 L42 18 L50 10 L58 20"/><circle cx="26" cy="4" r="2" fill="currentColor"/></svg>',
    beat: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 L12 12 L16 2 L22 22 L28 6 L32 12 L60 12"/></svg>',
};

let _audioModeIconSelect: IconSelect | null = null;

function _ensureAudioModeIconSelect() {
    const sel = document.getElementById('value-source-mode');
    if (!sel) return;
    const items = [
        { value: 'rms',  icon: _AUDIO_MODE_SVG.rms,  label: t('value_source.mode.rms'),  desc: t('value_source.mode.rms.desc') },
        { value: 'peak', icon: _AUDIO_MODE_SVG.peak, label: t('value_source.mode.peak'), desc: t('value_source.mode.peak.desc') },
        { value: 'beat', icon: _AUDIO_MODE_SVG.beat, label: t('value_source.mode.beat'), desc: t('value_source.mode.beat.desc') },
    ];
    if (_audioModeIconSelect) { _audioModeIconSelect.updateItems(items); return; }
    _audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
}

function _ensureBehaviorIconSelect() {
    const sel = document.getElementById('value-source-scene-behavior') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'complement', icon: _icon(P.moon), label: t('value_source.scene_behavior.complement'), desc: t('value_source.scene_behavior.hint') },
        { value: 'match',      icon: _icon(P.sun),  label: t('value_source.scene_behavior.match') },
    ];
    if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.updateItems(items); return; }
    _vsBehaviorIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}

function _ensureMetricIconSelect() {
    const sel = document.getElementById('value-source-metric') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'cpu_load',      icon: _icon(P.activity),     label: t('value_source.metric.cpu_load') },
        { value: 'cpu_temp',      icon: _icon(P.thermometer),  label: t('value_source.metric.cpu_temp') },
        { value: 'gpu_load',      icon: _icon(P.zap),          label: t('value_source.metric.gpu_load') },
        { value: 'gpu_temp',      icon: _icon(P.flame),        label: t('value_source.metric.gpu_temp') },
        { value: 'ram_usage',     icon: _icon(P.cpu),          label: t('value_source.metric.ram_usage') },
        { value: 'disk_usage',    icon: _icon(P.hardDrive),    label: t('value_source.metric.disk_usage') },
        { value: 'network_rx',    icon: _icon(P.download),     label: t('value_source.metric.network_rx') },
        { value: 'network_tx',    icon: _icon(P.send),         label: t('value_source.metric.network_tx') },
        { value: 'battery_level', icon: _icon(P.batteryFull),  label: t('value_source.metric.battery_level') },
        { value: 'fan_speed',     icon: _icon(P.fan),          label: t('value_source.metric.fan_speed') },
    ];
    if (_vsMetricIconSelect) { _vsMetricIconSelect.updateItems(items); return; }
    _vsMetricIconSelect = new IconSelect({ target: sel, items, columns: 2, onChange: (v: string) => { _onMetricChange(v); _autoGenerateVSName(); } } as any);
}

function _onMetricChange(metric: string) {
    const rangeFields = document.getElementById('value-source-sysmetric-range') as HTMLElement | null;
    const networkFields = document.getElementById('value-source-sysmetric-network') as HTMLElement | null;
    const diskFields = document.getElementById('value-source-sysmetric-disk') as HTMLElement | null;
    const sensorFields = document.getElementById('value-source-sysmetric-sensor') as HTMLElement | null;
    const rangeMetrics = ['cpu_temp', 'gpu_temp', 'fan_speed'];
    const networkMetrics = ['network_rx', 'network_tx'];
    const sensorMetrics = ['cpu_temp', 'fan_speed'];
    if (rangeFields) rangeFields.style.display = rangeMetrics.includes(metric) ? '' : 'none';
    if (networkFields) networkFields.style.display = networkMetrics.includes(metric) ? '' : 'none';
    if (diskFields) diskFields.style.display = metric === 'disk_usage' ? '' : 'none';
    if (sensorFields) sensorFields.style.display = sensorMetrics.includes(metric) ? '' : 'none';
}

// ── Game Event Value Source helpers ──

let _vsGameIntegrationEntitySelect: EntitySelect | null = null;

function _populateVSGameIntegrationDropdown(selectedId: string = '') {
    const sel = document.getElementById('value-source-game-integration') as HTMLSelectElement;
    const integrations = _cachedGameIntegrations || [];
    const prev = selectedId || sel.value;
    sel.innerHTML = `<option value="">\u2014</option>` +
        integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).join('');
    sel.value = prev || '';

    if (_vsGameIntegrationEntitySelect) _vsGameIntegrationEntitySelect.destroy();
    _vsGameIntegrationEntitySelect = new EntitySelect({
        target: sel,
        getItems: () => integrations.map(gi => ({
            value: gi.id,
            label: gi.name,
            icon: ICON_GAMEPAD,
            desc: gi.adapter_type,
        })),
        placeholder: t('palette.search'),
    });

    // Update event type dropdown when integration changes
    sel.onchange = () => _populateVSGameEventTypeDropdown('');
}

function _populateVSGameEventTypeDropdown(selectedType: string = '') {
    const eventTypeSel = document.getElementById('value-source-game-event-type') as HTMLSelectElement;
    const giId = (document.getElementById('value-source-game-integration') as HTMLSelectElement)?.value;

    // Get continuous events from the selected integration's adapter
    const CONTINUOUS_EVENTS = ['health', 'armor', 'mana', 'ammo', 'stamina', 'shield', 'score', 'gold', 'xp', 'level'];
    let eventTypes = CONTINUOUS_EVENTS;

    if (giId) {
        const gi = (_cachedGameIntegrations || []).find(g => g.id === giId);
        if (gi) {
            const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type);
            if (adapter && adapter.supported_events.length > 0) {
                // Filter to continuous events only
                eventTypes = adapter.supported_events.filter(e => CONTINUOUS_EVENTS.includes(e));
                if (eventTypes.length === 0) eventTypes = adapter.supported_events;
            }
        }
    }

    const prev = selectedType || eventTypeSel.value;
    eventTypeSel.innerHTML = eventTypes.map(et =>
        `<option value="${et}"${et === prev ? ' selected' : ''}>${et}</option>`
    ).join('');
    if (prev && eventTypes.includes(prev)) eventTypeSel.value = prev;
}

function _ensureVSTypeIconSelect() {
    const sel = document.getElementById('value-source-type');
    if (!sel) return;
    if (_vsTypeIconSelect) { _vsTypeIconSelect.updateItems(_buildVSTypeItems()); return; }
    _vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 } as any);
}

// ── Modal ─────────────────────────────────────────────────────

export async function showValueSourceModal(editData: any, presetType: any = null) {
    // When creating new: show type picker first, then re-enter with presetType
    if (!editData && !presetType) {
        _vsTypeFilter = 'all';
        showTypePicker({
            title: t('value_source.select_type'),
            items: _buildVSTypeItems(),
            onPick: (type) => showValueSourceModal(null, type),
            filterTabs: [
                { key: 'all', label: t('value_source.filter.all') },
                { key: 'float', label: t('value_source.filter.float') },
                { key: 'color', label: t('value_source.filter.color') },
            ],
            onFilterChange: (key) => {
                _vsTypeFilter = key as any;
                return _buildVSTypeItems();
            },
        });
        return;
    }

    const hasId = editData?.id;
    const isEdit = !!hasId;

    const sourceType = editData?.source_type || presetType || 'static';
    const titleIcon = getValueSourceIcon(sourceType);
    const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
    const typeName = t(`value_source.type.${sourceType}`);
    (document.getElementById('value-source-modal-title') as HTMLElement).innerHTML = isEdit
        ? `${titleIcon} ${t(titleKey)}`
        : `${titleIcon} ${t(titleKey)}: ${typeName}`;
    (document.getElementById('value-source-id') as HTMLInputElement).value = isEdit ? editData.id : '';
    (document.getElementById('value-source-error') as HTMLElement).style.display = 'none';

    _vsNameManuallyEdited = !!(isEdit || editData);
    (document.getElementById('value-source-name') as HTMLInputElement).oninput = () => { _vsNameManuallyEdited = true; };

    _ensureVSTypeIconSelect();
    const typeSelect = document.getElementById('value-source-type') as HTMLSelectElement;
    // Type is chosen before the modal opens — always hide selector
    (document.getElementById('value-source-type-group') as HTMLElement).style.display = 'none';

    if (editData) {
        (document.getElementById('value-source-name') as HTMLInputElement).value = editData.name || '';
        (document.getElementById('value-source-description') as HTMLInputElement).value = editData.description || '';
        typeSelect.value = editData.source_type || 'static';
        onValueSourceTypeChange();

        if (editData.source_type === 'static') {
            _setSlider('value-source-value', editData.value ?? 1.0);
        } else if (editData.source_type === 'animated') {
            (document.getElementById('value-source-waveform') as HTMLSelectElement).value = editData.waveform || 'sine';
            if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
            _drawWaveformPreview(editData.waveform || 'sine');
            _setSlider('value-source-speed', editData.speed ?? 10);
            _setSlider('value-source-min-value', editData.min_value ?? 0);
            _setSlider('value-source-max-value', editData.max_value ?? 1);
        } else if (editData.source_type === 'audio') {
            _populateAudioSourceDropdown(editData.audio_source_id || '');
            (document.getElementById('value-source-mode') as HTMLSelectElement).value = editData.mode || 'rms';
            if (_audioModeIconSelect) _audioModeIconSelect.setValue(editData.mode || 'rms');
            (document.getElementById('value-source-auto-gain') as HTMLInputElement).checked = !!editData.auto_gain;
            _setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
            _setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
            _setSlider('value-source-audio-min-value', editData.min_value ?? 0);
            _setSlider('value-source-audio-max-value', editData.max_value ?? 1);
        } else if (editData.source_type === 'adaptive_time') {
            _populateScheduleUI(editData.schedule);
            _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
            _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
        } else if (editData.source_type === 'adaptive_scene') {
            _populatePictureSourceDropdown(editData.picture_source_id || '');
            (document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value = editData.scene_behavior || 'complement';
            _setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0);
            _setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3);
            _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
            _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
        } else if (editData.source_type === 'daylight') {
            _setSlider('value-source-daylight-speed', editData.speed ?? 1.0);
            (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = !!editData.use_real_time;
            _setSlider('value-source-daylight-latitude', editData.latitude ?? 50);
            _syncDaylightVSSpeedVisibility();
            _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
            _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
        } else if (editData.source_type === 'static_color') {
            const rgb = editData.color || [255, 255, 255];
            (document.getElementById('value-source-static-color') as HTMLInputElement).value = rgbArrayToHex(rgb);
        } else if (editData.source_type === 'animated_color') {
            _animatedColors = (editData.colors || [[255, 0, 0], [0, 255, 0], [0, 0, 255]]).map((c: number[]) => rgbArrayToHex(c));
            _renderAnimatedColorList();
            _setSlider('value-source-animated-color-speed', editData.speed ?? 10.0);
            (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear';
            _ensureColorEasingIconSelect();
        } else if (editData.source_type === 'adaptive_time_color') {
            _colorSchedulePoints = (editData.schedule || []).map((p: any) => ({
                time: p.time,
                color: rgbArrayToHex(p.color || [255, 255, 255]),
            }));
            _renderColorScheduleList();
        } else if (editData.source_type === 'ha_entity') {
            _populateHASourceDropdown(editData.ha_source_id || '');
            await _fetchVSHAEntities(editData.ha_source_id || '');
            _populateHAEntityDropdown(editData.entity_id || '');
            (document.getElementById('value-source-attribute') as HTMLInputElement).value = editData.attribute || '';
            (document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = String(editData.min_ha_value ?? 0);
            (document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = String(editData.max_ha_value ?? 100);
            _setSlider('value-source-ha-smoothing', editData.smoothing ?? 0);
        } else if (editData.source_type === 'gradient_map') {
            _populateGradientInputDropdown(editData.value_source_id || '');
            _populateGradientEntityDropdown(editData.gradient_id || '');
            (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = editData.easing || 'linear';
            _ensureGradientEasingIconSelect();
        } else if (editData.source_type === 'css_extract') {
            _populateCSSSourceDropdown(editData.color_strip_source_id || '');
            (document.getElementById('value-source-led-start') as HTMLInputElement).value = String(editData.led_start ?? 0);
            (document.getElementById('value-source-led-end') as HTMLInputElement).value = String(editData.led_end ?? -1);
        } else if (editData.source_type === 'system_metrics') {
            (document.getElementById('value-source-metric') as HTMLSelectElement).value = editData.metric || 'cpu_load';
            _ensureMetricIconSelect();
            (document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value = String(editData.min_value ?? 0);
            (document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value = String(editData.max_value ?? 100);
            (document.getElementById('value-source-max-rate') as HTMLInputElement).value = String(editData.max_rate ?? 125000000);
            (document.getElementById('value-source-disk-path') as HTMLInputElement).value = editData.disk_path || '';
            (document.getElementById('value-source-sensor-label') as HTMLInputElement).value = editData.sensor_label || '';
            _setSlider('value-source-poll-interval', editData.poll_interval ?? 1.0);
            _setSlider('value-source-sysmetric-smoothing', editData.smoothing ?? 0);
            _onMetricChange(editData.metric || 'cpu_load');
        } else if (editData.source_type === 'game_event') {
            _populateVSGameIntegrationDropdown(editData.game_integration_id || '');
            _populateVSGameEventTypeDropdown(editData.event_type || 'health');
            (document.getElementById('value-source-ge-min') as HTMLInputElement).value = String(editData.min_game_value ?? 0);
            (document.getElementById('value-source-ge-max') as HTMLInputElement).value = String(editData.max_game_value ?? 100);
            _setSlider('value-source-ge-smoothing', editData.smoothing ?? 0);
            _setSlider('value-source-ge-default', editData.default_value ?? 0.5);
            _setSlider('value-source-ge-timeout', editData.timeout ?? 5.0);
        }
    } else {
        (document.getElementById('value-source-name') as HTMLInputElement).value = '';
        (document.getElementById('value-source-description') as HTMLInputElement).value = '';
        typeSelect.value = presetType || 'static';
        onValueSourceTypeChange();
        _setSlider('value-source-value', 1.0);
        _setSlider('value-source-speed', 10);
        _setSlider('value-source-min-value', 0);
        _setSlider('value-source-max-value', 1);
        (document.getElementById('value-source-waveform') as HTMLSelectElement).value = 'sine';
        _drawWaveformPreview('sine');
        _populateAudioSourceDropdown('');
        (document.getElementById('value-source-mode') as HTMLSelectElement).value = 'rms';
        if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
        (document.getElementById('value-source-auto-gain') as HTMLInputElement).checked = false;
        _setSlider('value-source-sensitivity', 1.0);
        _setSlider('value-source-smoothing', 0.3);
        _setSlider('value-source-audio-min-value', 0);
        _setSlider('value-source-audio-max-value', 1);
        // Adaptive defaults
        _populateScheduleUI([]);
        _populatePictureSourceDropdown('');
        (document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value = 'complement';
        _setSlider('value-source-scene-sensitivity', 1.0);
        _setSlider('value-source-scene-smoothing', 0.3);
        _setSlider('value-source-adaptive-min-value', 0);
        _setSlider('value-source-adaptive-max-value', 1);
        // Daylight defaults
        _setSlider('value-source-daylight-speed', 1.0);
        (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
        _setSlider('value-source-daylight-latitude', 50);
        _syncDaylightVSSpeedVisibility();
        // Color type defaults
        (document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff';
        _animatedColors = ['#ff0000', '#00ff00', '#0000ff'];
        _renderAnimatedColorList();
        _setSlider('value-source-animated-color-speed', 10.0);
        (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear';
        _colorSchedulePoints = [];
        _renderColorScheduleList();
        // HA entity defaults
        (document.getElementById('value-source-entity-id') as HTMLInputElement).value = '';
        (document.getElementById('value-source-attribute') as HTMLInputElement).value = '';
        (document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = '0';
        (document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = '100';
        _setSlider('value-source-ha-smoothing', 0);
        // Gradient map defaults
        (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = 'linear';
        // CSS extract defaults
        (document.getElementById('value-source-led-start') as HTMLInputElement).value = '0';
        (document.getElementById('value-source-led-end') as HTMLInputElement).value = '-1';
        // System metrics defaults
        (document.getElementById('value-source-metric') as HTMLSelectElement).value = 'cpu_load';
        (document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value = '0';
        (document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value = '100';
        (document.getElementById('value-source-max-rate') as HTMLInputElement).value = '125000000';
        (document.getElementById('value-source-disk-path') as HTMLInputElement).value = '';
        (document.getElementById('value-source-sensor-label') as HTMLInputElement).value = '';
        _setSlider('value-source-poll-interval', 1.0);
        _setSlider('value-source-sysmetric-smoothing', 0);
        _autoGenerateVSName();
    }

    // Wire up auto-name triggers
    (document.getElementById('value-source-waveform') as HTMLSelectElement).onchange = () => { _autoGenerateVSName(); _drawWaveformPreview((document.getElementById('value-source-waveform') as HTMLSelectElement).value); };
    (document.getElementById('value-source-mode') as HTMLSelectElement).onchange = () => _autoGenerateVSName();
    (document.getElementById('value-source-picture-source') as HTMLSelectElement).onchange = () => _autoGenerateVSName();

    // Tags
    if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
    _vsTagsInput = new TagInput(document.getElementById('value-source-tags-container'), { placeholder: t('tags.placeholder') });
    _vsTagsInput.setValue(editData ? (editData.tags || []) : []);

    valueSourceModal.open();
    valueSourceModal.snapshot();
}

export async function closeValueSourceModal() {
    await valueSourceModal.close();
}

export function onValueSourceTypeChange() {
    const type = (document.getElementById('value-source-type') as HTMLSelectElement).value;
    if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
    (document.getElementById('value-source-static-section') as HTMLElement).style.display = type === 'static' ? '' : 'none';
    (document.getElementById('value-source-animated-section') as HTMLElement).style.display = type === 'animated' ? '' : 'none';
    if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview((document.getElementById('value-source-waveform') as HTMLSelectElement).value); }
    (document.getElementById('value-source-audio-section') as HTMLElement).style.display = type === 'audio' ? '' : 'none';
    if (type === 'audio') _ensureAudioModeIconSelect();
    (document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none';
    (document.getElementById('value-source-adaptive-scene-section') as HTMLElement).style.display = type === 'adaptive_scene' ? '' : 'none';
    if (type === 'adaptive_scene') _ensureBehaviorIconSelect();
    (document.getElementById('value-source-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none';
    (document.getElementById('value-source-static-color-section') as HTMLElement).style.display = type === 'static_color' ? '' : 'none';
    (document.getElementById('value-source-animated-color-section') as HTMLElement).style.display = type === 'animated_color' ? '' : 'none';
    if (type === 'animated_color') _ensureColorEasingIconSelect();
    (document.getElementById('value-source-adaptive-time-color-section') as HTMLElement).style.display = type === 'adaptive_time_color' ? '' : 'none';
    (document.getElementById('value-source-ha-entity-section') as HTMLElement).style.display = type === 'ha_entity' ? '' : 'none';
    (document.getElementById('value-source-gradient-map-section') as HTMLElement).style.display = type === 'gradient_map' ? '' : 'none';
    (document.getElementById('value-source-css-extract-section') as HTMLElement).style.display = type === 'css_extract' ? '' : 'none';
    (document.getElementById('value-source-system-metrics-section') as HTMLElement).style.display = type === 'system_metrics' ? '' : 'none';
    if (type === 'system_metrics') {
        _ensureMetricIconSelect();
        _onMetricChange((document.getElementById('value-source-metric') as HTMLSelectElement).value);
    }
    (document.getElementById('value-source-game-event-section') as HTMLElement).style.display = type === 'game_event' ? '' : 'none';
    if (type === 'game_event') {
        _populateVSGameIntegrationDropdown('');
    }
    (document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
        (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';

    // Populate audio dropdown when switching to audio type
    if (type === 'audio') {
        const select = document.getElementById('value-source-audio-source') as HTMLSelectElement;
        if (select && select.options.length === 0) {
            _populateAudioSourceDropdown('');
        }
    }

    // Populate picture source dropdown when switching to scene type
    if (type === 'adaptive_scene') {
        _populatePictureSourceDropdown('');
    }

    // Populate HA source dropdown when switching to ha_entity type
    if (type === 'ha_entity') {
        _populateHASourceDropdown('');
        // Auto-fetch entities for the pre-selected HA source
        const haSelect = document.getElementById('value-source-ha-source') as HTMLSelectElement;
        if (haSelect?.value) {
            _fetchVSHAEntities(haSelect.value).then(() => _populateHAEntityDropdown(''));
        }
    }

    // Populate gradient input and gradient entity dropdowns when switching to gradient_map type
    if (type === 'gradient_map') {
        _populateGradientInputDropdown('');
        _populateGradientEntityDropdown('');
        _ensureGradientEasingIconSelect();
    }

    // Populate CSS source dropdown when switching to css_extract type
    if (type === 'css_extract') {
        _populateCSSSourceDropdown('');
    }

    _autoGenerateVSName();
}

// ── Daylight helpers ──────────────────────────────────────────

export function onDaylightVSRealTimeChange() {
    _syncDaylightVSSpeedVisibility();
}

function _syncDaylightVSSpeedVisibility() {
    const rt = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
    (document.getElementById('value-source-daylight-speed-group') as HTMLElement).style.display = rt ? 'none' : '';
}

// ── Save ──────────────────────────────────────────────────────

export async function saveValueSource() {
    const id = (document.getElementById('value-source-id') as HTMLInputElement).value;
    const name = (document.getElementById('value-source-name') as HTMLInputElement).value.trim();
    const sourceType = (document.getElementById('value-source-type') as HTMLSelectElement).value;
    const description = (document.getElementById('value-source-description') as HTMLInputElement).value.trim() || null;
    const errorEl = document.getElementById('value-source-error') as HTMLElement;

    if (!name) {
        errorEl.textContent = t('value_source.error.name_required');
        errorEl.style.display = '';
        return;
    }

    const payload: any = { name, source_type: sourceType, description, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] };

    if (sourceType === 'static') {
        payload.value = parseFloat((document.getElementById('value-source-value') as HTMLInputElement).value);
    } else if (sourceType === 'animated') {
        payload.waveform = (document.getElementById('value-source-waveform') as HTMLSelectElement).value;
        payload.speed = parseFloat((document.getElementById('value-source-speed') as HTMLInputElement).value);
        payload.min_value = parseFloat((document.getElementById('value-source-min-value') as HTMLInputElement).value);
        payload.max_value = parseFloat((document.getElementById('value-source-max-value') as HTMLInputElement).value);
    } else if (sourceType === 'audio') {
        payload.audio_source_id = (document.getElementById('value-source-audio-source') as HTMLSelectElement).value;
        payload.mode = (document.getElementById('value-source-mode') as HTMLSelectElement).value;
        payload.auto_gain = (document.getElementById('value-source-auto-gain') as HTMLInputElement).checked;
        payload.sensitivity = parseFloat((document.getElementById('value-source-sensitivity') as HTMLInputElement).value);
        payload.smoothing = parseFloat((document.getElementById('value-source-smoothing') as HTMLInputElement).value);
        payload.min_value = parseFloat((document.getElementById('value-source-audio-min-value') as HTMLInputElement).value);
        payload.max_value = parseFloat((document.getElementById('value-source-audio-max-value') as HTMLInputElement).value);
    } else if (sourceType === 'adaptive_time') {
        payload.schedule = _getScheduleFromUI();
        if (payload.schedule.length < 2) {
            errorEl.textContent = t('value_source.error.schedule_min');
            errorEl.style.display = '';
            return;
        }
        payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
        payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
    } else if (sourceType === 'adaptive_scene') {
        payload.picture_source_id = (document.getElementById('value-source-picture-source') as HTMLSelectElement).value;
        payload.scene_behavior = (document.getElementById('value-source-scene-behavior') as HTMLSelectElement).value;
        payload.sensitivity = parseFloat((document.getElementById('value-source-scene-sensitivity') as HTMLInputElement).value);
        payload.smoothing = parseFloat((document.getElementById('value-source-scene-smoothing') as HTMLInputElement).value);
        payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
        payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
    } else if (sourceType === 'daylight') {
        payload.speed = parseFloat((document.getElementById('value-source-daylight-speed') as HTMLInputElement).value);
        payload.use_real_time = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
        payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
        payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
        payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
    } else if (sourceType === 'static_color') {
        payload.color = hexToRgbArray((document.getElementById('value-source-static-color') as HTMLInputElement).value);
    } else if (sourceType === 'animated_color') {
        payload.colors = _getAnimatedColorsPayload();
        payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value);
        payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value;
    } else if (sourceType === 'adaptive_time_color') {
        payload.schedule = _getColorSchedulePayload();
    } else if (sourceType === 'ha_entity') {
        payload.ha_source_id = (document.getElementById('value-source-ha-source') as HTMLSelectElement).value;
        payload.entity_id = (document.getElementById('value-source-entity-id') as HTMLSelectElement).value;
        payload.attribute = (document.getElementById('value-source-attribute') as HTMLInputElement).value.trim();
        payload.min_ha_value = parseFloat((document.getElementById('value-source-min-ha-value') as HTMLInputElement).value);
        payload.max_ha_value = parseFloat((document.getElementById('value-source-max-ha-value') as HTMLInputElement).value);
        payload.smoothing = parseFloat((document.getElementById('value-source-ha-smoothing') as HTMLInputElement).value);
        if (!payload.ha_source_id) {
            errorEl.textContent = t('value_source.ha_source') + ' required';
            errorEl.style.display = '';
            return;
        }
        if (!payload.entity_id) {
            errorEl.textContent = t('value_source.entity_id') + ' required';
            errorEl.style.display = '';
            return;
        }
    } else if (sourceType === 'gradient_map') {
        payload.value_source_id = (document.getElementById('value-source-gradient-input') as HTMLSelectElement).value;
        payload.gradient_id = (document.getElementById('value-source-gradient-id') as HTMLSelectElement).value;
        payload.easing = (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value;
        if (!payload.value_source_id) {
            errorEl.textContent = t('value_source.input_source') + ' required';
            errorEl.style.display = '';
            return;
        }
        if (!payload.gradient_id) {
            errorEl.textContent = t('value_source.gradient_stops') + ' required';
            errorEl.style.display = '';
            return;
        }
    } else if (sourceType === 'css_extract') {
        payload.color_strip_source_id = (document.getElementById('value-source-css-source') as HTMLSelectElement).value;
        payload.led_start = parseInt((document.getElementById('value-source-led-start') as HTMLInputElement).value) || 0;
        payload.led_end = parseInt((document.getElementById('value-source-led-end') as HTMLInputElement).value);
        if (isNaN(payload.led_end)) payload.led_end = -1;
        if (!payload.color_strip_source_id) {
            errorEl.textContent = t('value_source.css_source') + ' required';
            errorEl.style.display = '';
            return;
        }
    } else if (sourceType === 'system_metrics') {
        payload.metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value;
        payload.min_value = parseFloat((document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value) || 0;
        payload.max_value = parseFloat((document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value) || 100;
        payload.max_rate = parseFloat((document.getElementById('value-source-max-rate') as HTMLInputElement).value) || 125000000;
        payload.disk_path = (document.getElementById('value-source-disk-path') as HTMLInputElement).value;
        payload.sensor_label = (document.getElementById('value-source-sensor-label') as HTMLInputElement).value;
        payload.poll_interval = parseFloat((document.getElementById('value-source-poll-interval') as HTMLInputElement).value) || 1.0;
        payload.smoothing = parseFloat((document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value) || 0;
    } else if (sourceType === 'game_event') {
        payload.game_integration_id = (document.getElementById('value-source-game-integration') as HTMLSelectElement).value;
        payload.event_type = (document.getElementById('value-source-game-event-type') as HTMLSelectElement).value;
        payload.min_game_value = parseFloat((document.getElementById('value-source-ge-min') as HTMLInputElement).value) || 0;
        payload.max_game_value = parseFloat((document.getElementById('value-source-ge-max') as HTMLInputElement).value) || 100;
        payload.smoothing = parseFloat((document.getElementById('value-source-ge-smoothing') as HTMLInputElement).value) || 0;
        payload.default_value = parseFloat((document.getElementById('value-source-ge-default') as HTMLInputElement).value) || 0.5;
        payload.timeout = parseFloat((document.getElementById('value-source-ge-timeout') as HTMLInputElement).value) || 5.0;
        if (!payload.game_integration_id) {
            errorEl.textContent = t('value_source.game_event.integration') + ' required';
            errorEl.style.display = '';
            return;
        }
    }

    try {
        const method = id ? 'PUT' : 'POST';
        const url = id ? `/value-sources/${id}` : '/value-sources';
        const resp = await fetchWithAuth(url, {
            method,
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload),
        });
        if (!resp.ok) {
            const err = await resp.json().catch(() => ({}));
            throw new Error(err.detail || `HTTP ${resp.status}`);
        }
        showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
        valueSourceModal.forceClose();
        valueSourcesCache.invalidate();
        await loadPictureSources();
    } catch (e: any) {
        errorEl.textContent = e.message;
        errorEl.style.display = '';
    }
}

// ── Edit ──────────────────────────────────────────────────────

export async function editValueSource(sourceId: any) {
    try {
        const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
        if (!resp.ok) throw new Error(t('value_source.error.load'));
        const data = await resp.json();
        await showValueSourceModal(data);
    } catch (e: any) {
        if (e.isAuth) return;
        showToast(e.message, 'error');
    }
}

// ── Clone ─────────────────────────────────────────────────────

export async function cloneValueSource(sourceId: any) {
    try {
        const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
        if (!resp.ok) throw new Error(t('value_source.error.load'));
        const data = await resp.json();
        delete data.id;
        data.name = data.name + ' (copy)';
        await showValueSourceModal(data);
    } catch (e: any) {
        if (e.isAuth) return;
        showToast(e.message, 'error');
    }
}

// ── Delete ────────────────────────────────────────────────────

export async function deleteValueSource(sourceId: any) {
    const confirmed = await showConfirm(t('value_source.delete.confirm'));
    if (!confirmed) return;

    try {
        const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
        if (!resp.ok) {
            const err = await resp.json().catch(() => ({}));
            throw new Error(err.detail || `HTTP ${resp.status}`);
        }
        showToast(t('value_source.deleted'), 'success');
        valueSourcesCache.invalidate();
        await loadPictureSources();
    } catch (e: any) {
        showToast(e.message, 'error');
    }
}

// ── Value Source Test (real-time output chart) ────────────────

const VS_HISTORY_SIZE = 200;

let _testVsWs: WebSocket | null = null;
let _testVsAnimFrame: number | null = null;
let _testVsLatest: any = null;
let _testVsHistory: number[] = [];
let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
let _testVsRawLatest: number | null = null;
let _testVsRawRange: [number, number] | null = null;
let _testVsColorLatest: [number, number, number] | null = null;
let _testVsColorHistory: [number, number, number][] = [];

const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });

export function testValueSource(sourceId: any) {
    const statusEl = document.getElementById('vs-test-status');
    if (statusEl) {
        statusEl.textContent = t('value_source.test.connecting');
        statusEl.style.display = '';
    }

    // Reset state
    _testVsLatest = null;
    _testVsHistory = [];
    _testVsMinObserved = Infinity;
    _testVsMaxObserved = -Infinity;
    _testVsRawLatest = null;
    _testVsRawRange = null;
    _testVsColorLatest = null;
    _testVsColorHistory = [];

    // Hide color swatch until color data arrives
    const swatchEl = document.getElementById('vs-test-color-swatch');
    if (swatchEl) swatchEl.style.display = 'none';

    const currentEl = document.getElementById('vs-test-current');
    const minEl = document.getElementById('vs-test-min');
    const maxEl = document.getElementById('vs-test-max');
    if (currentEl) { currentEl.textContent = '---'; currentEl.style.color = ''; }
    if (minEl) minEl.textContent = '---';
    if (maxEl) maxEl.textContent = '---';

    testVsModal.open();

    // Size canvas to container
    const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
    _sizeVsCanvas(canvas);

    // Connect WebSocket
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}${API_BASE}/value-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey || '')}`;

    try {
        _testVsWs = new WebSocket(wsUrl);

        _testVsWs.onopen = () => {
            if (statusEl) statusEl.style.display = 'none';
        };

        _testVsWs.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);
                // For color sources with an input value (e.g. gradient_map),
                // chart the input interpolation factor instead of luminance
                const chartValue = data.input_value !== undefined ? data.input_value : data.value;
                _testVsLatest = chartValue;
                _testVsHistory.push(chartValue);
                if (_testVsHistory.length > VS_HISTORY_SIZE) {
                    _testVsHistory.shift();
                }
                if (chartValue < _testVsMinObserved) _testVsMinObserved = chartValue;
                if (chartValue > _testVsMaxObserved) _testVsMaxObserved = chartValue;
                if (data.raw_value !== undefined) {
                    _testVsRawLatest = data.raw_value;
                    if (data.raw_range) _testVsRawRange = data.raw_range;
                }
                if (data.color) {
                    _testVsColorLatest = data.color;
                    _testVsColorHistory.push(data.color);
                    if (_testVsColorHistory.length > VS_HISTORY_SIZE) {
                        _testVsColorHistory.shift();
                    }
                }
            } catch (e) {
                console.error('Value source test WS parse error:', e);
                return;
            }
        };

        _testVsWs.onclose = () => {
            _testVsWs = null;
        };

        _testVsWs.onerror = () => {
            showToast(t('value_source.test.error'), 'error');
            _cleanupVsTest();
        };
    } catch {
        showToast(t('value_source.test.error'), 'error');
        _cleanupVsTest();
        return;
    }

    // Start render loop
    _testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
}

export function closeTestValueSourceModal() {
    _cleanupVsTest();
    testVsModal.forceClose();
}

function _cleanupVsTest() {
    if (_testVsAnimFrame) {
        cancelAnimationFrame(_testVsAnimFrame);
        _testVsAnimFrame = null;
    }
    if (_testVsWs) {
        _testVsWs.onclose = null;
        _testVsWs.close();
        _testVsWs = null;
    }
    _testVsLatest = null;
}

function _sizeVsCanvas(canvas: HTMLCanvasElement) {
    const rect = canvas.parentElement!.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    canvas.width = rect.width * dpr;
    canvas.height = 200 * dpr;
    canvas.style.height = '200px';
    canvas.getContext('2d')!.scale(dpr, dpr);
}

function _renderVsTestLoop() {
    _renderVsChart();
    _renderVsColorSwatch();
    if (testVsModal.isOpen) {
        _testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
    }
}

function _renderVsChart() {
    const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
    if (!canvas) return;

    const ctx = canvas.getContext('2d')!;
    const dpr = window.devicePixelRatio || 1;
    const w = canvas.width / dpr;
    const h = canvas.height / dpr;

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);

    // Draw horizontal guide lines at 0.0, 0.5, 1.0
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
    ctx.setLineDash([4, 4]);
    ctx.lineWidth = 1;
    for (const frac of [0, 0.5, 1.0]) {
        const y = h - frac * h;
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(w, y);
        ctx.stroke();
    }
    ctx.setLineDash([]);

    // Draw Y-axis labels
    ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
    ctx.font = '10px monospace';
    ctx.textAlign = 'left';
    ctx.fillText('1.0', 4, 12);
    ctx.fillText('0.5', 4, h / 2 - 2);
    ctx.fillText('0.0', 4, h - 4);

    const history = _testVsHistory;
    if (history.length < 2) return;

    // Draw filled area under the line
    ctx.beginPath();
    const stepX = w / (VS_HISTORY_SIZE - 1);
    const startOffset = VS_HISTORY_SIZE - history.length;

    ctx.moveTo(startOffset * stepX, h);
    for (let i = 0; i < history.length; i++) {
        const x = (startOffset + i) * stepX;
        const y = h - history[i] * h;
        ctx.lineTo(x, y);
    }
    ctx.lineTo((startOffset + history.length - 1) * stepX, h);
    ctx.closePath();
    ctx.fillStyle = _testVsColorLatest
        ? `rgba(${_testVsColorLatest[0]},${_testVsColorLatest[1]},${_testVsColorLatest[2]},0.15)`
        : 'rgba(76, 175, 80, 0.15)';
    ctx.fill();

    // Draw the line
    ctx.beginPath();
    for (let i = 0; i < history.length; i++) {
        const x = (startOffset + i) * stepX;
        const y = h - history[i] * h;
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
    }
    const lineColor = _testVsColorLatest
        ? `rgb(${_testVsColorLatest[0]},${_testVsColorLatest[1]},${_testVsColorLatest[2]})`
        : '#4caf50';
    ctx.strokeStyle = lineColor;
    ctx.lineWidth = 2;
    ctx.stroke();

    // Update stats
    const curEl = document.getElementById('vs-test-current');
    const mnEl = document.getElementById('vs-test-min');
    const mxEl = document.getElementById('vs-test-max');
    if (_testVsLatest !== null && curEl) {
        curEl.textContent = (_testVsLatest * 100).toFixed(1) + '%';
    }
    if (_testVsMinObserved !== Infinity && mnEl) {
        mnEl.textContent = (_testVsMinObserved * 100).toFixed(1) + '%';
    }
    if (_testVsMaxObserved !== -Infinity && mxEl) {
        mxEl.textContent = (_testVsMaxObserved * 100).toFixed(1) + '%';
    }

    // Draw right-side Y-axis labels for raw HA values
    if (_testVsRawRange) {
        const [rawMin, rawMax] = _testVsRawRange;
        const rawMid = (rawMin + rawMax) / 2;
        ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
        ctx.font = '10px monospace';
        ctx.textAlign = 'right';
        ctx.fillText(_fmtRaw(rawMax), w - 4, 12);
        ctx.fillText(_fmtRaw(rawMid), w - 4, h / 2 - 2);
        ctx.fillText(_fmtRaw(rawMin), w - 4, h - 4);

        // Draw current raw value marker on right edge
        if (_testVsRawLatest !== null) {
            const rawRange = rawMax - rawMin;
            const frac = rawRange !== 0 ? (_testVsRawLatest - rawMin) / rawRange : 0.5;
            const clampedFrac = Math.max(0, Math.min(1, frac));
            const yRaw = h - clampedFrac * h;

            ctx.fillStyle = '#4caf50';
            ctx.font = 'bold 10px monospace';
            ctx.textAlign = 'right';
            // Offset label to avoid overlap with top/bottom labels
            const labelY = Math.max(20, Math.min(h - 12, yRaw + 4));
            ctx.fillText(_fmtRaw(_testVsRawLatest), w - 4, labelY);

            // Small tick mark
            ctx.strokeStyle = '#4caf50';
            ctx.lineWidth = 1;
            ctx.setLineDash([]);
            ctx.beginPath();
            ctx.moveTo(w - 2, yRaw);
            ctx.lineTo(w, yRaw);
            ctx.stroke();
        }
    }
}

function _fmtRaw(v: number): string {
    return Number.isInteger(v) ? String(v) : v.toFixed(1);
}

function _renderVsColorSwatch() {
    const swatchEl = document.getElementById('vs-test-color-swatch');
    const canvas = document.getElementById('vs-test-color-canvas') as HTMLCanvasElement | null;
    if (!canvas || !swatchEl) return;

    if (_testVsColorHistory.length === 0) {
        swatchEl.style.display = 'none';
        return;
    }

    swatchEl.style.display = '';

    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.parentElement!.getBoundingClientRect();
    const w = rect.width;
    const h = 32;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    canvas.style.height = h + 'px';

    const ctx = canvas.getContext('2d')!;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    const colors = _testVsColorHistory;
    const stepX = w / VS_HISTORY_SIZE;
    const barW = Math.max(stepX, 1);
    const startOffset = VS_HISTORY_SIZE - colors.length;

    for (let i = 0; i < colors.length; i++) {
        const [r, g, b] = colors[i];
        ctx.fillStyle = `rgb(${r},${g},${b})`;
        ctx.fillRect((startOffset + i) * stepX, 0, barW + 0.5, h);
    }

    // Update current color stat display
    if (_testVsColorLatest) {
        const [r, g, b] = _testVsColorLatest;
        const curEl = document.getElementById('vs-test-current');
        if (curEl) {
            curEl.textContent = `rgb(${r}, ${g}, ${b})`;
            curEl.style.color = `rgb(${r},${g},${b})`;
        }
    }
}

// ── Card rendering (used by streams.js) ───────────────────────

export function createValueSourceCard(src: ValueSource) {
    const icon = getValueSourceIcon(src.source_type);

    let propsHtml = '';
    if (src.source_type === 'static') {
        propsHtml = `<span class="stream-card-prop">${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
    } else if (src.source_type === 'animated') {
        const waveLabel = src.waveform || 'sine';
        propsHtml = `
            <span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(waveLabel)}</span>
            <span class="stream-card-prop">${ICON_TIMER} ${src.speed ?? 10} cpm</span>
            <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1}</span>
        `;
    } else if (src.source_type === 'audio') {
        const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
        const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
        const audioSection = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture';
        const audioTab = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
        const modeLabel = src.mode || 'rms';
        const audioBadge = audioSrc
            ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','${audioTab}','${audioSection}','data-id','${src.audio_source_id}')">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`
            : `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`;
        propsHtml = `
            ${audioBadge}
            <span class="stream-card-prop">${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}</span>
            <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1}</span>
        `;
    } else if (src.source_type === 'adaptive_time') {
        const pts = (src.schedule || []).length;
        propsHtml = `
            <span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>
            <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1}</span>
        `;
    } else if (src.source_type === 'daylight') {
        if (src.use_real_time) {
            propsHtml = `<span class="stream-card-prop">${ICON_CLOCK} ${t('value_source.daylight.real_time')}</span>`;
        } else {
            propsHtml = `<span class="stream-card-prop">${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x</span>`;
        }
        propsHtml += `<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${src.max_value ?? 1}</span>`;
    } else if (src.source_type === 'adaptive_scene') {
        const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
        const psName = ps ? ps.name : (src.picture_source_id || '-');
        let psSubTab = 'raw', psSection = 'raw-streams';
        if (ps) {
            if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
            else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
        }
        const psBadge = ps
            ? `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')" title="${escapeHtml(t('value_source.picture_source'))}">${ICON_MONITOR} ${escapeHtml(psName)}</span>`
            : `<span class="stream-card-prop">${ICON_MONITOR} ${escapeHtml(psName)}</span>`;
        propsHtml = `
            ${psBadge}
            <span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
        `;
    } else if (src.source_type === 'static_color') {
        const rgb = (src as any).color || [255, 255, 255];
        const hex = rgbArrayToHex(rgb);
        propsHtml = `<span class="stream-card-prop"><span style="display:inline-block;width:12px;height:12px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle"></span> ${hex}</span>`;
    } else if (src.source_type === 'animated_color') {
        const colors = (src as any).colors || [];
        propsHtml = `
            <span class="stream-card-prop">${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}</span>
            <span class="stream-card-prop">${ICON_TIMER} ${(src as any).speed ?? 10} cpm</span>
        `;
    } else if (src.source_type === 'adaptive_time_color') {
        const pts = ((src as any).schedule || []).length;
        propsHtml = `<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>`;
    } else if (src.source_type === 'ha_entity') {
        const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id);
        const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-');
        const entityId = (src as any).entity_id || '';
        const attr = (src as any).attribute;
        const haBadge = haSrc
            ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.ha_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')">${ICON_HOME} ${escapeHtml(haName)}</span>`
            : `<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>`;
        propsHtml = `
            ${haBadge}
            <span class="stream-card-prop">${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}</span>
            <span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}</span>
        `;
    } else if (src.source_type === 'gradient_map') {
        const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id);
        const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-');
        const gradients = gradientsCache.data || [];
        const grad = gradients.find(g => g.id === (src as any).gradient_id);
        const gradName = grad ? grad.name : ((src as any).gradient_id || '-');
        const stops = grad?.stops || [];
        const gradientCss = stops.length >= 2
            ? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})`
            : '#333';
        const inputBadge = inputVs
            ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.gradient_map.input'))}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${(src as any).value_source_id}')">${ICON_LINK} ${escapeHtml(inputName)}</span>`
            : `<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(inputName)}</span>`;
        const gradBadge = grad
            ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.gradient_map.gradient'))}" onclick="event.stopPropagation(); navigateToCard('streams','gradients','gradients','data-id','${(src as any).gradient_id}')">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>`
            : `<span class="stream-card-prop">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>`;
        propsHtml = `
            ${inputBadge}
            ${gradBadge}
            <div style="height:8px;border-radius:4px;margin:4px 0;background:${gradientCss};"></div>
        `;
    } else if (src.source_type === 'css_extract') {
        const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id);
        const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-');
        const ledStart = (src as any).led_start ?? 0;
        const ledEnd = (src as any).led_end ?? -1;
        const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`;
        const cssBadge = cssSrc
            ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.css_extract.source'))}" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${(src as any).color_strip_source_id}')">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>`
            : `<span class="stream-card-prop">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>`;
        propsHtml = `
            ${cssBadge}
            <span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
        `;
    } else if (src.source_type === 'system_metrics') {
        const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric;
        propsHtml = `<span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(metricLabel)}</span>`;
    }

    return wrapCard({
        type: 'template-card',
        dataAttr: 'data-id',
        id: src.id,
        removeOnclick: `deleteValueSource('${src.id}')`,
        removeTitle: t('common.delete'),
        content: `
            <div class="template-card-header">
                <div class="template-name">${icon} ${escapeHtml(src.name)}</div>
            </div>
            <div class="stream-card-props">${propsHtml}</div>
            ${renderTagChips(src.tags)}
            ${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
        actions: `
                <button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
                <button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
                <button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
    });
}

// ── Helpers ───────────────────────────────────────────────────

function _setSlider(id: string, value: any) {
    const slider = document.getElementById(id) as HTMLInputElement;
    if (slider) {
        slider.value = value;
        const display = document.getElementById(id + '-display');
        if (display) display.textContent = value;
    }
}

function _populateAudioSourceDropdown(selectedId: any) {
    const select = document.getElementById('value-source-audio-source') as HTMLSelectElement;
    if (!select) return;
    select.innerHTML = _cachedAudioSources.map((s: any) => {
        const badge = s.source_type === 'capture' ? ' [capture]' : ' [processed]';
        return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
    }).join('');

    if (_vsAudioSourceEntitySelect) _vsAudioSourceEntitySelect.destroy();
    if (_cachedAudioSources.length > 0) {
        _vsAudioSourceEntitySelect = new EntitySelect({
            target: select,
            getItems: () => _cachedAudioSources.map(s => ({
                value: s.id,
                label: s.name,
                icon: getAudioSourceIcon(s.source_type),
                desc: s.source_type,
            })),
            placeholder: t('palette.search'),
        });
    }
}

// ── Adaptive helpers ──────────────────────────────────────────

function _populatePictureSourceDropdown(selectedId: any) {
    const select = document.getElementById('value-source-picture-source') as HTMLSelectElement;
    if (!select) return;
    select.innerHTML = _cachedStreams.map((s: any) =>
        `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
    ).join('');

    if (_vsPictureSourceEntitySelect) _vsPictureSourceEntitySelect.destroy();
    if (_cachedStreams.length > 0) {
        _vsPictureSourceEntitySelect = new EntitySelect({
            target: select,
            getItems: () => _cachedStreams.map(s => ({
                value: s.id,
                label: s.name,
                icon: getPictureSourceIcon(s.stream_type),
            })),
            placeholder: t('palette.search'),
        });
    }
}

export function addSchedulePoint(time: string = '', value: number = 1.0) {
    const list = document.getElementById('value-source-schedule-list');
    if (!list) return;
    const timeStr = time || '12:00';
    const [h, m] = timeStr.split(':').map(Number);
    const pad = (n: number) => String(n).padStart(2, '0');

    const row = document.createElement('div');
    row.className = 'schedule-row';
    row.innerHTML = `
        <div class="schedule-time-wrap">
            <input type="number" class="schedule-h" min="0" max="23" value="${pad(h)}" data-role="hour">
            <span class="schedule-time-colon">:</span>
            <input type="number" class="schedule-m" min="0" max="59" value="${pad(m)}" data-role="minute">
        </div>
        <input type="hidden" class="schedule-time" value="${timeStr}">
        <input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
            oninput="this.nextElementSibling.textContent = this.value">
        <span class="schedule-value-display">${value}</span>
        <button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">${ICON_TRASH}</button>
    `;
    list.appendChild(row);
    _wireScheduleTimePicker(row);
}

function _wireScheduleTimePicker(row: HTMLElement) {
    const hInput = row.querySelector('.schedule-h') as HTMLInputElement;
    const mInput = row.querySelector('.schedule-m') as HTMLInputElement;
    const hidden = row.querySelector('.schedule-time') as HTMLInputElement;
    if (!hInput || !mInput || !hidden) return;

    const pad = (n: number) => String(n).padStart(2, '0');

    function clamp(input: HTMLInputElement, min: number, max: number) {
        let v = parseInt(input.value, 10);
        if (isNaN(v)) v = min;
        if (v < min) v = min;
        if (v > max) v = max;
        input.value = pad(v);
        return v;
    }

    function sync() {
        const hv = clamp(hInput, 0, 23);
        const mv = clamp(mInput, 0, 59);
        hidden.value = `${pad(hv)}:${pad(mv)}`;
    }

    [hInput, mInput].forEach(inp => {
        inp.addEventListener('focus', () => inp.select());
        inp.addEventListener('input', sync);
        inp.addEventListener('blur', sync);
        inp.addEventListener('keydown', (e) => {
            const isHour = inp.dataset.role === 'hour';
            const max = isHour ? 23 : 59;
            if (e.key === 'ArrowUp') {
                e.preventDefault();
                let v = parseInt(inp.value, 10) || 0;
                inp.value = pad(v >= max ? 0 : v + 1);
                sync();
            } else if (e.key === 'ArrowDown') {
                e.preventDefault();
                let v = parseInt(inp.value, 10) || 0;
                inp.value = pad(v <= 0 ? max : v - 1);
                sync();
            }
        });
    });

    sync();
}

function _getScheduleFromUI() {
    const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
    const schedule: { time: string; value: number }[] = [];
    rows.forEach(row => {
        const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
        const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);
        if (time) schedule.push({ time, value });
    });
    return schedule;
}

function _populateScheduleUI(schedule: any) {
    const list = document.getElementById('value-source-schedule-list');
    if (!list) return;
    list.innerHTML = '';
    if (!schedule || schedule.length === 0) {
        // Default: morning bright, night dim
        addSchedulePoint('08:00', 1.0);
        addSchedulePoint('22:00', 0.3);
    } else {
        schedule.forEach(p => addSchedulePoint(p.time, p.value));
    }
}

// ── Animated Color helpers ──────────────────────────────────

let _animatedColors: string[] = ['#ff0000', '#00ff00', '#0000ff'];

export function addAnimatedColor(color: string = '#ffffff') {
    _animatedColors = [..._animatedColors, color];
    _renderAnimatedColorList();
}

export function removeAnimatedColor(idx: number) {
    _animatedColors = _animatedColors.filter((_, i) => i !== idx);
    _renderAnimatedColorList();
}

function _renderAnimatedColorList() {
    const list = document.getElementById('value-source-animated-color-list');
    if (!list) return;
    list.innerHTML = _animatedColors.map((c, i) => `
        <div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
            <input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
                onchange="_animatedColors[${i}] = this.value">
            <button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeAnimatedColor(${i})">${ICON_TRASH}</button>
        </div>
    `).join('');
    // Wire up color inputs to update state immutably
    list.querySelectorAll('.animated-color-input').forEach((el) => {
        const input = el as HTMLInputElement;
        const idx = parseInt(input.dataset.idx || '0', 10);
        input.addEventListener('input', () => {
            _animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c);
        });
    });
}

function _getAnimatedColorsPayload(): number[][] {
    return _animatedColors.map(c => hexToRgbArray(c));
}

// ── Color Schedule helpers ──────────────────────────────────

let _colorSchedulePoints: { time: string; color: string }[] = [];

export function addColorSchedulePoint(time: string = '12:00', color: string = '#ffffff') {
    _colorSchedulePoints = [..._colorSchedulePoints, { time, color }];
    _renderColorScheduleList();
}

export function removeColorSchedulePoint(idx: number) {
    _colorSchedulePoints = _colorSchedulePoints.filter((_, i) => i !== idx);
    _renderColorScheduleList();
}

function _renderColorScheduleList() {
    const list = document.getElementById('value-source-color-schedule-list');
    if (!list) return;
    list.innerHTML = _colorSchedulePoints.map((p, i) => `
        <div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
            <input type="time" class="color-schedule-time" value="${p.time}" data-idx="${i}">
            <input type="color" class="color-schedule-color" value="${p.color}" data-idx="${i}">
            <button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeColorSchedulePoint(${i})">${ICON_TRASH}</button>
        </div>
    `).join('');
    list.querySelectorAll('.color-schedule-time').forEach((el) => {
        const input = el as HTMLInputElement;
        const idx = parseInt(input.dataset.idx || '0', 10);
        input.addEventListener('input', () => {
            _colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, time: input.value } : p);
        });
    });
    list.querySelectorAll('.color-schedule-color').forEach((el) => {
        const input = el as HTMLInputElement;
        const idx = parseInt(input.dataset.idx || '0', 10);
        input.addEventListener('input', () => {
            _colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, color: input.value } : p);
        });
    });
}

function _getColorSchedulePayload(): { time: string; color: number[] }[] {
    return _colorSchedulePoints.map(p => ({ time: p.time, color: hexToRgbArray(p.color) }));
}

// ── HA Entity helpers ──────────────────────────────────────

function _populateHASourceDropdown(selectedId: string) {
    const select = document.getElementById('value-source-ha-source') as HTMLSelectElement;
    if (!select) return;
    select.innerHTML = _cachedHASources.map((s: any) =>
        `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
    ).join('');

    if (_vsHASourceEntitySelect) _vsHASourceEntitySelect.destroy();
    if (_cachedHASources.length > 0) {
        _vsHASourceEntitySelect = new EntitySelect({
            target: select,
            getItems: () => _cachedHASources.map(s => ({
                value: s.id,
                label: s.name,
                icon: ICON_HOME,
                desc: s.host || '',
            })),
            placeholder: t('palette.search'),
            onChange: (value) => {
                _fetchVSHAEntities(value).then(() => _populateHAEntityDropdown(''));
            },
        });
    }
}

async function _fetchVSHAEntities(haSourceId: string): Promise<void> {
    if (!haSourceId) { _vsHAEntities = []; return; }
    try {
        const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
        if (!resp.ok) { _vsHAEntities = []; return; }
        const data = await resp.json();
        _vsHAEntities = data.entities || [];
    } catch {
        _vsHAEntities = [];
    }
}

function _populateHAEntityDropdown(selectedId: string) {
    const select = document.getElementById('value-source-entity-id') as HTMLSelectElement;
    if (!select) return;
    select.innerHTML = _vsHAEntities.map((e: any) =>
        `<option value="${e.entity_id}"${e.entity_id === selectedId ? ' selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
    ).join('');

    if (_vsHAEntityEntitySelect) _vsHAEntityEntitySelect.destroy();
    if (_vsHAEntities.length > 0) {
        _vsHAEntityEntitySelect = new EntitySelect({
            target: select,
            getItems: () => _vsHAEntities.map((e: any) => ({
                value: e.entity_id,
                label: e.friendly_name || e.entity_id,
                icon: getHAEntityIcon(e),
                desc: e.entity_id,
            })),
            placeholder: t('palette.search'),
        });
    }
}

// ── Gradient Map helpers ───────────────────────────────────

function _populateGradientInputDropdown(selectedId: string) {
    const select = document.getElementById('value-source-gradient-input') as HTMLSelectElement;
    if (!select) return;
    const floatSources = _cachedValueSources.filter(v => v.return_type === 'float');
    select.innerHTML = floatSources.map((s: any) =>
        `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
    ).join('');

    if (_vsGradientInputEntitySelect) _vsGradientInputEntitySelect.destroy();
    if (floatSources.length > 0) {
        _vsGradientInputEntitySelect = new EntitySelect({
            target: select,
            getItems: () => floatSources.map(s => ({
                value: s.id,
                label: s.name,
                icon: getValueSourceIcon(s.source_type),
                desc: s.source_type,
            })),
            placeholder: t('palette.search'),
        });
    }
}

function _ensureGradientEasingIconSelect() {
    const sel = document.getElementById('value-source-gradient-easing') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'linear', icon: _icon(P.activity), label: 'Linear', desc: 'Smooth blend' },
        { value: 'step', icon: _icon(P.layoutDashboard), label: 'Step', desc: 'Hard edges' },
    ];
    if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.updateItems(items); return; }
    _vsGradientEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}

function _populateGradientEntityDropdown(selectedId: string) {
    const select = document.getElementById('value-source-gradient-id') as HTMLSelectElement;
    if (!select) return;
    const gradients = gradientsCache.data || [];
    select.innerHTML = gradients.map((g: GradientEntity) =>
        `<option value="${g.id}"${g.id === selectedId ? ' selected' : ''}>${escapeHtml(g.name)}</option>`
    ).join('');

    if (_vsGradientEntitySelect) _vsGradientEntitySelect.destroy();
    if (gradients.length > 0) {
        _vsGradientEntitySelect = new EntitySelect({
            target: select,
            getItems: () => (gradientsCache.data || []).map((g: GradientEntity) => {
                const stops = g.stops || [];
                const stripHtml = stops.length >= 2
                    ? `<span style="display:inline-block;width:80px;height:16px;border-radius:3px;background:linear-gradient(to right,${stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')});flex-shrink:0"></span>`
                    : ICON_RAINBOW;
                return {
                    value: g.id,
                    label: g.name,
                    icon: stripHtml,
                    desc: `${stops.length} stops`,
                };
            }),
            placeholder: t('palette.search'),
        });
    }

}

// ── CSS Extract helpers ────────────────────────────────────

function _populateCSSSourceDropdown(selectedId: string) {
    const select = document.getElementById('value-source-css-source') as HTMLSelectElement;
    if (!select) return;
    select.innerHTML = _cachedColorStripSources.map((s: any) =>
        `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
    ).join('');

    if (_vsCSSSourceEntitySelect) _vsCSSSourceEntitySelect.destroy();
    if (_cachedColorStripSources.length > 0) {
        _vsCSSSourceEntitySelect = new EntitySelect({
            target: select,
            getItems: () => _cachedColorStripSources.map(s => ({
                value: s.id,
                label: s.name,
                icon: getColorStripIcon(s.source_type),
                desc: s.source_type,
            })),
            placeholder: t('palette.search'),
        });
    }
}
