/**
 * Color Strip Sources — CRUD, card rendering, calibration bridge.
 */

import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import {
    getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon, getValueSourceIcon,
    ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY,
    ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
    ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
    ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
    ICON_SUN_DIM, ICON_WARNING, ICON_AUTOMATION,
} from '../core/icons.js';
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { attachProcessPicker } from '../core/process-picker.js';
import { IconSelect, showTypePicker } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import {
    rgbArrayToHex, hexToRgbArray,
    gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
    getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
    loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset,
} from './css-gradient-editor.js';

// Re-export for app.js window global bindings
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
export { saveCurrentAsCustomPreset, deleteCustomGradientPreset };

class CSSEditorModal extends Modal {
    constructor() {
        super('css-editor-modal');
    }

    onForceClose() {
        if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
        _compositeDestroyEntitySelects();
    }

    snapshotValues() {
        const type = document.getElementById('css-editor-type').value;
        return {
            name: document.getElementById('css-editor-name').value,
            type,
            picture_source: document.getElementById('css-editor-picture-source').value,
            interpolation: document.getElementById('css-editor-interpolation').value,
            smoothing: document.getElementById('css-editor-smoothing').value,
            color: document.getElementById('css-editor-color').value,
            led_count: document.getElementById('css-editor-led-count').value,
            gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
            animation_type: document.getElementById('css-editor-animation-type').value,
            cycle_colors: JSON.stringify(_colorCycleColors),
            effect_type: document.getElementById('css-editor-effect-type').value,
            effect_palette: document.getElementById('css-editor-effect-palette').value,
            effect_color: document.getElementById('css-editor-effect-color').value,
            effect_intensity: document.getElementById('css-editor-effect-intensity').value,
            effect_scale: document.getElementById('css-editor-effect-scale').value,
            effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
            composite_layers: JSON.stringify(_compositeLayers),
            mapped_zones: JSON.stringify(_mappedZones),
            audio_viz: document.getElementById('css-editor-audio-viz').value,
            audio_source: document.getElementById('css-editor-audio-source').value,
            audio_sensitivity: document.getElementById('css-editor-audio-sensitivity').value,
            audio_smoothing: document.getElementById('css-editor-audio-smoothing').value,
            audio_palette: document.getElementById('css-editor-audio-palette').value,
            audio_color: document.getElementById('css-editor-audio-color').value,
            audio_color_peak: document.getElementById('css-editor-audio-color-peak').value,
            audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
            api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
            api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
            notification_effect: document.getElementById('css-editor-notification-effect').value,
            notification_duration: document.getElementById('css-editor-notification-duration').value,
            notification_default_color: document.getElementById('css-editor-notification-default-color').value,
            notification_filter_mode: document.getElementById('css-editor-notification-filter-mode').value,
            notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
            notification_app_colors: JSON.stringify(_notificationAppColors),
            clock_id: document.getElementById('css-editor-clock').value,
            daylight_speed: document.getElementById('css-editor-daylight-speed').value,
            daylight_use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
            daylight_latitude: document.getElementById('css-editor-daylight-latitude').value,
            candlelight_color: document.getElementById('css-editor-candlelight-color').value,
            candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
            candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
            candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
            processed_input: document.getElementById('css-editor-processed-input').value,
            processed_template: document.getElementById('css-editor-processed-template').value,
            tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
        };
    }
}

const cssEditorModal = new CSSEditorModal();

let _cssTagsInput = null;

// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect = null;
let _cssAudioSourceEntitySelect = null;
let _cssClockEntitySelect = null;
let _processedInputEntitySelect = null;
let _processedTemplateEntitySelect = null;

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

const CSS_TYPE_KEYS = [
    'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
    'effect', 'composite', 'mapped', 'audio',
    'api_input', 'notification', 'daylight', 'candlelight', 'processed',
];

function _buildCSSTypeItems() {
    return CSS_TYPE_KEYS.map(key => ({
        value: key,
        icon: getColorStripIcon(key),
        label: t(`color_strip.type.${key}`),
        desc: t(`color_strip.type.${key}.desc`),
    }));
}

let _cssTypeIconSelect = null;

function _ensureCSSTypeIconSelect() {
    const sel = document.getElementById('css-editor-type');
    if (!sel) return;
    if (_cssTypeIconSelect) {
        // Refresh labels (language may have changed)
        _cssTypeIconSelect.updateItems(_buildCSSTypeItems());
        return;
    }
    _cssTypeIconSelect = new IconSelect({
        target: sel,
        items: _buildCSSTypeItems(),
        columns: 2,
    });
}

/* ── Type-switch helper ───────────────────────────────────────── */

export function onCSSTypeChange() {
    const type = document.getElementById('css-editor-type').value;
    // Sync icon-select trigger display
    if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
    const isPictureType = type === 'picture' || type === 'picture_advanced';
    document.getElementById('css-editor-picture-section').style.display = isPictureType ? '' : 'none';
    // Hide picture source dropdown for advanced (sources are per-line in calibration)
    const psGroup = document.getElementById('css-editor-picture-source-group');
    if (psGroup) psGroup.style.display = (type === 'picture') ? '' : 'none';
    document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
    document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
    document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
    document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none';
    document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
    document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none';
    document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
    document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
    document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
    document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
    document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
    document.getElementById('css-editor-processed-section').style.display = type === 'processed' ? '' : 'none';

    if (isPictureType) _ensureInterpolationIconSelect();
    if (type === 'processed') _populateProcessedSelectors();
    if (type === 'effect') {
        _ensureEffectTypeIconSelect();
        _ensureEffectPaletteIconSelect();
        onEffectTypeChange();
    }
    if (type === 'audio') {
        _ensureAudioVizIconSelect();
        _ensureAudioPaletteIconSelect();
        onAudioVizChange();
    }
    if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); }
    if (type === 'notification') {
        _ensureNotificationEffectIconSelect();
        _ensureNotificationFilterModeIconSelect();
    }

    // Animation section — shown for static/gradient only
    const animSection = document.getElementById('css-editor-animation-section');
    const animTypeSelect = document.getElementById('css-editor-animation-type');
    if (type === 'static' || type === 'gradient') {
        animSection.style.display = '';
        const opts = type === 'gradient'
            ? ['none','breathing','gradient_shift','wave','strobe','sparkle','pulse','candle','rainbow_fade']
            : ['none','breathing','strobe','sparkle','pulse','candle','rainbow_fade'];
        animTypeSelect.innerHTML = opts.map(v =>
            `<option value="${v}">${t('color_strip.animation.type.' + v)}</option>`
        ).join('');
        _ensureAnimationTypeIconSelect(type);
    } else {
        animSection.style.display = 'none';
    }
    _syncAnimationSpeedState();

    // LED count — only shown for picture, picture_advanced
    const hasLedCount = ['picture', 'picture_advanced'];
    document.getElementById('css-editor-led-count-group').style.display =
        hasLedCount.includes(type) ? '' : 'none';

    // Sync clock — shown for animated types
    const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
    document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
    if (clockTypes.includes(type)) _populateClockDropdown();

    if (type === 'audio') {
        _loadAudioSources();
    } else if (type === 'composite') {
        _compositeRenderList();
    } else if (type === 'mapped') {
        _mappedRenderList();
    } else if (type === 'gradient') {
        requestAnimationFrame(() => gradientRenderAll());
    }

    _autoGenerateCSSName();
}

function _populateClockDropdown(selectedId) {
    const sel = document.getElementById('css-editor-clock');
    const prev = selectedId !== undefined ? selectedId : sel.value;
    sel.innerHTML = `<option value="">${t('common.none_own_speed')}</option>` +
        _cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
    sel.value = prev || '';

    // Entity palette for clock
    if (_cssClockEntitySelect) _cssClockEntitySelect.destroy();
    _cssClockEntitySelect = new EntitySelect({
        target: sel,
        getItems: () => _cachedSyncClocks.map(c => ({
            value: c.id,
            label: c.name,
            icon: ICON_CLOCK,
            desc: `${c.speed}x`,
        })),
        placeholder: t('palette.search'),
        allowNone: true,
        noneLabel: t('common.none_own_speed'),
    });
}

export function onCSSClockChange() {
    // No-op: speed sliders removed; speed is now clock-only
}

function _populateProcessedSelectors() {
    const editingId = document.getElementById('css-editor-id').value;
    const allSources = colorStripSourcesCache.data || [];
    // Exclude self and other processed sources to prevent cycles
    const inputSources = allSources.filter(s => s.id !== editingId && s.source_type !== 'processed');
    const inputSel = document.getElementById('css-editor-processed-input');
    const prevInput = inputSel.value;
    inputSel.innerHTML = `<option value="">${t('common.none_no_input')}</option>` +
        inputSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
    inputSel.value = prevInput || '';
    if (_processedInputEntitySelect) _processedInputEntitySelect.destroy();
    if (inputSources.length > 0) {
        _processedInputEntitySelect = new EntitySelect({
            target: inputSel,
            getItems: () => (colorStripSourcesCache.data || [])
                .filter(s => s.id !== editingId && s.source_type !== 'processed')
                .map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
            placeholder: t('palette.search'),
        });
    }

    const templates = csptCache.data || [];
    const tplSel = document.getElementById('css-editor-processed-template');
    const prevTpl = tplSel.value;
    tplSel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` +
        templates.map(tp => `<option value="${tp.id}">${escapeHtml(tp.name)}</option>`).join('');
    tplSel.value = prevTpl || '';
    if (_processedTemplateEntitySelect) _processedTemplateEntitySelect.destroy();
    if (templates.length > 0) {
        _processedTemplateEntitySelect = new EntitySelect({
            target: tplSel,
            getItems: () => (csptCache.data || []).map(tp => ({
                value: tp.id, label: tp.name, icon: ICON_SPARKLES,
            })),
            placeholder: t('palette.search'),
        });
    }
}

function _getAnimationPayload() {
    const type = document.getElementById('css-editor-animation-type').value;
    return {
        enabled: type !== 'none',
        type: type !== 'none' ? type : 'breathing',
    };
}

function _loadAnimationState(anim) {
    // Set type after onCSSTypeChange() has populated the dropdown
    const val = (anim && anim.enabled && anim.type) ? anim.type : 'none';
    document.getElementById('css-editor-animation-type').value = val;
    if (_animationTypeIconSelect) _animationTypeIconSelect.setValue(val);
    _syncAnimationSpeedState();
}

export function onAnimationTypeChange() {
    if (_animationTypeIconSelect) _animationTypeIconSelect.setValue(document.getElementById('css-editor-animation-type').value);
    _syncAnimationSpeedState();
}

function _syncAnimationSpeedState() {
    const type = document.getElementById('css-editor-animation-type').value;
    const descEl = document.getElementById('css-editor-animation-type-desc');
    if (descEl) {
        const desc = t('color_strip.animation.type.' + type + '.desc') || '';
        descEl.textContent = desc;
        descEl.style.display = desc ? '' : 'none';
    }
}

/* ── Daylight real-time toggle helper ─────────────────────────── */

export function onDaylightRealTimeChange() {
    _syncDaylightSpeedVisibility();
}

function _syncDaylightSpeedVisibility() {
    const isRealTime = document.getElementById('css-editor-daylight-real-time').checked;
    document.getElementById('css-editor-daylight-speed-group').style.display = isRealTime ? 'none' : '';
}

/* ── Gradient strip preview helper ────────────────────────────── */

/**
 * Build a small inline CSS gradient preview from palette color points.
 * @param {Array<[number, string]>} pts  – [[position, 'r,g,b'], ...]
 * @param {number} [w=80] width in px
 * @param {number} [h=16] height in px
 * @returns {string} HTML string
 */
function _gradientStripHTML(pts, w = 80, h = 16) {
    const stops = pts.map(([pos, rgb]) => `rgb(${rgb}) ${(pos * 100).toFixed(0)}%`).join(', ');
    return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${stops});flex-shrink:0"></span>`;
}

/* gradientPresetStripHTML imported from css-gradient-editor.js */

/* ── Effect / audio palette IconSelect instances ─────────────── */

let _animationTypeIconSelect = null;
let _interpolationIconSelect = null;
let _effectTypeIconSelect = null;
let _effectPaletteIconSelect = null;
let _audioPaletteIconSelect = null;
let _audioVizIconSelect = null;
let _gradientPresetIconSelect = null;
let _notificationEffectIconSelect = null;
let _notificationFilterModeIconSelect = null;

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

function _ensureInterpolationIconSelect() {
    const sel = document.getElementById('css-editor-interpolation');
    if (!sel) return;
    const items = [
        { value: 'average',  icon: _icon(P.slidersHorizontal), label: t('color_strip.interpolation.average'),  desc: t('color_strip.interpolation.average.desc') },
        { value: 'median',   icon: _icon(P.activity),          label: t('color_strip.interpolation.median'),   desc: t('color_strip.interpolation.median.desc') },
        { value: 'dominant', icon: _icon(P.target),             label: t('color_strip.interpolation.dominant'), desc: t('color_strip.interpolation.dominant.desc') },
    ];
    if (_interpolationIconSelect) { _interpolationIconSelect.updateItems(items); return; }
    _interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}

function _ensureEffectTypeIconSelect() {
    const sel = document.getElementById('css-editor-effect-type');
    if (!sel) return;
    const items = [
        { value: 'fire',    icon: _icon(P.zap),      label: t('color_strip.effect.fire'),    desc: t('color_strip.effect.fire.desc') },
        { value: 'meteor',  icon: _icon(P.sparkles),  label: t('color_strip.effect.meteor'),  desc: t('color_strip.effect.meteor.desc') },
        { value: 'plasma',  icon: _icon(P.rainbow),   label: t('color_strip.effect.plasma'),  desc: t('color_strip.effect.plasma.desc') },
        { value: 'noise',   icon: _icon(P.activity),  label: t('color_strip.effect.noise'),   desc: t('color_strip.effect.noise.desc') },
        { value: 'aurora',  icon: _icon(P.sparkles),  label: t('color_strip.effect.aurora'),  desc: t('color_strip.effect.aurora.desc') },
    ];
    if (_effectTypeIconSelect) { _effectTypeIconSelect.updateItems(items); return; }
    _effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}

function _ensureEffectPaletteIconSelect() {
    const sel = document.getElementById('css-editor-effect-palette');
    if (!sel) return;
    const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({
        value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`),
    }));
    if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
    _effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}

function _ensureAudioPaletteIconSelect() {
    const sel = document.getElementById('css-editor-audio-palette');
    if (!sel) return;
    const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({
        value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`),
    }));
    if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
    _audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}

function _ensureAudioVizIconSelect() {
    const sel = document.getElementById('css-editor-audio-viz');
    if (!sel) return;
    const items = [
        { value: 'spectrum',   icon: _icon(P.activity),   label: t('color_strip.audio.viz.spectrum'),   desc: t('color_strip.audio.viz.spectrum.desc') },
        { value: 'beat_pulse', icon: _icon(P.zap),        label: t('color_strip.audio.viz.beat_pulse'), desc: t('color_strip.audio.viz.beat_pulse.desc') },
        { value: 'vu_meter',   icon: _icon(P.trendingUp), label: t('color_strip.audio.viz.vu_meter'),   desc: t('color_strip.audio.viz.vu_meter.desc') },
    ];
    if (_audioVizIconSelect) { _audioVizIconSelect.updateItems(items); return; }
    _audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}

function _buildGradientPresetItems() {
    const builtIn = [
        { value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
        ...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
            value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
        })),
    ];
    const custom = loadCustomGradientPresets().map(p => ({
        value: `__custom__${p.name}`,
        icon: gradientPresetStripHTML(p.stops),
        label: p.name,
        isCustom: true,
    }));
    return [...builtIn, ...custom];
}

function _ensureGradientPresetIconSelect() {
    const sel = document.getElementById('css-editor-gradient-preset');
    if (!sel) return;
    const items = _buildGradientPresetItems();
    if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
    _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}

/** Rebuild the preset picker after adding/removing custom presets. */
export function refreshGradientPresetPicker() {
    if (_gradientPresetIconSelect) {
        _gradientPresetIconSelect.updateItems(_buildGradientPresetItems());
        _gradientPresetIconSelect.setValue('');
    }
    _renderCustomPresetList();
}

/** Render the custom preset list below the save button. */
function _renderCustomPresetList() {
    const container = document.getElementById('css-editor-custom-presets-list');
    if (!container) return;
    const presets = loadCustomGradientPresets();
    if (presets.length === 0) {
        container.innerHTML = '';
        return;
    }
    container.innerHTML = presets.map(p => {
        const strip = gradientPresetStripHTML(p.stops, 60, 14);
        const safeName = escapeHtml(p.name);
        return `<div class="custom-preset-row">
            ${strip}
            <span class="custom-preset-name">${safeName}</span>
            <button type="button" class="btn btn-icon btn-sm btn-secondary"
                onclick="applyCustomGradientPreset(${JSON.stringify(p.name)})"
                title="${t('color_strip.gradient.preset.apply')}">&#x2713;</button>
            <button type="button" class="btn btn-icon btn-sm btn-danger"
                onclick="deleteAndRefreshGradientPreset(${JSON.stringify(p.name)})"
                title="${t('common.delete')}">&#x2715;</button>
        </div>`;
    }).join('');
}

function _ensureNotificationEffectIconSelect() {
    const sel = document.getElementById('css-editor-notification-effect');
    if (!sel) return;
    const items = [
        { value: 'flash', icon: _icon(P.zap),         label: t('color_strip.notification.effect.flash'), desc: t('color_strip.notification.effect.flash.desc') },
        { value: 'pulse', icon: _icon(P.activity),     label: t('color_strip.notification.effect.pulse'), desc: t('color_strip.notification.effect.pulse.desc') },
        { value: 'sweep', icon: _icon(P.fastForward),  label: t('color_strip.notification.effect.sweep'), desc: t('color_strip.notification.effect.sweep.desc') },
    ];
    if (_notificationEffectIconSelect) { _notificationEffectIconSelect.updateItems(items); return; }
    _notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}

function _ensureNotificationFilterModeIconSelect() {
    const sel = document.getElementById('css-editor-notification-filter-mode');
    if (!sel) return;
    const items = [
        { value: 'off',       icon: _icon(P.globe),    label: t('color_strip.notification.filter_mode.off'),       desc: t('color_strip.notification.filter_mode.off.desc') },
        { value: 'whitelist', icon: _icon(P.circleCheck), label: t('color_strip.notification.filter_mode.whitelist'), desc: t('color_strip.notification.filter_mode.whitelist.desc') },
        { value: 'blacklist', icon: _icon(P.eyeOff),   label: t('color_strip.notification.filter_mode.blacklist'), desc: t('color_strip.notification.filter_mode.blacklist.desc') },
    ];
    if (_notificationFilterModeIconSelect) { _notificationFilterModeIconSelect.updateItems(items); return; }
    _notificationFilterModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}

function _buildAnimationTypeItems(cssType) {
    const items = [
        { value: 'none',         icon: _icon(P.square),    label: t('color_strip.animation.type.none'),         desc: t('color_strip.animation.type.none.desc') },
        { value: 'breathing',    icon: _icon(P.activity),  label: t('color_strip.animation.type.breathing'),    desc: t('color_strip.animation.type.breathing.desc') },
    ];
    if (cssType === 'gradient') {
        items.push(
            { value: 'gradient_shift', icon: _icon(P.fastForward), label: t('color_strip.animation.type.gradient_shift'), desc: t('color_strip.animation.type.gradient_shift.desc') },
            { value: 'wave',           icon: _icon(P.rainbow),     label: t('color_strip.animation.type.wave'),           desc: t('color_strip.animation.type.wave.desc') },
        );
    }
    items.push(
        { value: 'strobe',       icon: _icon(P.zap),       label: t('color_strip.animation.type.strobe'),       desc: t('color_strip.animation.type.strobe.desc') },
        { value: 'sparkle',      icon: _icon(P.sparkles),  label: t('color_strip.animation.type.sparkle'),      desc: t('color_strip.animation.type.sparkle.desc') },
        { value: 'pulse',        icon: _icon(P.trendingUp),label: t('color_strip.animation.type.pulse'),        desc: t('color_strip.animation.type.pulse.desc') },
        { value: 'candle',       icon: _icon(P.flame),     label: t('color_strip.animation.type.candle'),       desc: t('color_strip.animation.type.candle.desc') },
        { value: 'rainbow_fade', icon: _icon(P.rainbow),   label: t('color_strip.animation.type.rainbow_fade'), desc: t('color_strip.animation.type.rainbow_fade.desc') },
    );
    return items;
}

/** Handles the gradient preset selector change — routes to built-in or custom preset. */
export function onGradientPresetChange(value) {
    if (!value) return;  // "— Custom —" selected
    if (value.startsWith('__custom__')) {
        applyCustomGradientPreset(value.slice('__custom__'.length));
    } else {
        applyGradientPreset(value);
    }
}

/** Called from inline onclick in the HTML save button. Prompts for a name and saves. */
export function promptAndSaveGradientPreset() {
    const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
    if (!name || !name.trim()) return;
    saveCurrentAsCustomPreset(name.trim());
    showToast(t('color_strip.gradient.preset.saved'), 'success');
    refreshGradientPresetPicker();
}

/** Apply a custom preset by name. */
export function applyCustomGradientPreset(name) {
    const presets = loadCustomGradientPresets();
    const preset = presets.find(p => p.name === name);
    if (!preset) return;
    gradientInit(preset.stops);
}

/** Delete a custom preset and refresh the picker. */
export function deleteAndRefreshGradientPreset(name) {
    deleteCustomGradientPreset(name);
    showToast(t('color_strip.gradient.preset.deleted'), 'success');
    refreshGradientPresetPicker();
}

function _ensureAnimationTypeIconSelect(cssType) {
    const sel = document.getElementById('css-editor-animation-type');
    if (!sel) return;
    const items = _buildAnimationTypeItems(cssType);
    // Destroy and recreate — options change between static/gradient
    if (_animationTypeIconSelect) { _animationTypeIconSelect.destroy(); _animationTypeIconSelect = null; }
    _animationTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}

/* ── Effect type helpers ──────────────────────────────────────── */

// Palette color control points — mirrors _PALETTE_DEFS in effect_stream.py
const _PALETTE_COLORS = {
    fire:    [[0,'0,0,0'],[0.33,'200,24,0'],[0.66,'255,160,0'],[1,'255,255,200']],
    ocean:   [[0,'0,0,32'],[0.33,'0,16,128'],[0.66,'0,128,255'],[1,'128,224,255']],
    lava:    [[0,'0,0,0'],[0.25,'128,0,0'],[0.5,'255,32,0'],[0.75,'255,160,0'],[1,'255,255,128']],
    forest:  [[0,'0,16,0'],[0.33,'0,80,0'],[0.66,'32,160,0'],[1,'128,255,64']],
    rainbow: [[0,'255,0,0'],[0.17,'255,255,0'],[0.33,'0,255,0'],[0.5,'0,255,255'],[0.67,'0,0,255'],[0.83,'255,0,255'],[1,'255,0,0']],
    aurora:  [[0,'0,16,32'],[0.2,'0,80,64'],[0.4,'0,200,100'],[0.6,'64,128,255'],[0.8,'128,0,200'],[1,'0,16,32']],
    sunset:  [[0,'32,0,64'],[0.25,'128,0,128'],[0.5,'255,64,0'],[0.75,'255,192,64'],[1,'255,255,192']],
    ice:     [[0,'0,0,64'],[0.33,'0,64,192'],[0.66,'128,192,255'],[1,'240,248,255']],
};

// Default palette per effect type
export function onEffectTypeChange() {
    const et = document.getElementById('css-editor-effect-type').value;
    // palette: all except meteor
    document.getElementById('css-editor-effect-palette-group').style.display = et !== 'meteor' ? '' : 'none';
    // color picker: meteor only
    document.getElementById('css-editor-effect-color-group').style.display = et === 'meteor' ? '' : 'none';
    // intensity: fire, meteor, aurora
    document.getElementById('css-editor-effect-intensity-group').style.display =
        ['fire', 'meteor', 'aurora'].includes(et) ? '' : 'none';
    // scale: plasma, noise, aurora
    document.getElementById('css-editor-effect-scale-group').style.display =
        ['plasma', 'noise', 'aurora'].includes(et) ? '' : 'none';
    // mirror: meteor only
    document.getElementById('css-editor-effect-mirror-group').style.display = et === 'meteor' ? '' : 'none';
    // description
    const descEl = document.getElementById('css-editor-effect-type-desc');
    if (descEl) {
        const desc = t('color_strip.effect.' + et + '.desc') || '';
        descEl.textContent = desc;
        descEl.style.display = desc ? '' : 'none';
    }
    _autoGenerateCSSName();
}

/* ── Color Cycle helpers ──────────────────────────────────────── */

const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];

function _syncColorCycleFromDom() {
    const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]');
    if (inputs.length > 0) {
        _colorCycleColors = Array.from(inputs).map(el => el.value);
    }
}

function _colorCycleRenderList() {
    const list = document.getElementById('color-cycle-colors-list');
    if (!list) return;
    const canRemove = _colorCycleColors.length > 2;
    list.innerHTML = _colorCycleColors.map((hex, i) => `
        <div class="color-cycle-item">
            <input type="color" value="${hex}">
            ${canRemove
                ? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
                           onclick="colorCycleRemoveColor(${i})">&#x2715;</button>`
                : `<div style="height:14px"></div>`}
        </div>
    `).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
}

export function colorCycleAddColor() {
    _syncColorCycleFromDom();
    _colorCycleColors.push('#ffffff');
    _colorCycleRenderList();
}

export function colorCycleRemoveColor(i) {
    _syncColorCycleFromDom();
    if (_colorCycleColors.length <= 2) return;
    _colorCycleColors.splice(i, 1);
    _colorCycleRenderList();
}

function _colorCycleGetColors() {
    const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]');
    return Array.from(inputs).map(el => hexToRgbArray(el.value));
}

function _loadColorCycleState(css) {
    const raw = css && css.colors;
    _colorCycleColors = (raw && raw.length >= 2)
        ? raw.map(c => rgbArrayToHex(c))
        : [..._DEFAULT_CYCLE_COLORS];
    _colorCycleRenderList();
}

/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
/* rgbArrayToHex / hexToRgbArray imported from css-gradient-editor.js */

/* ── Composite layer helpers ──────────────────────────────────── */

let _compositeLayers = [];
let _compositeAvailableSources = [];  // non-composite sources for layer dropdowns
let _compositeSourceEntitySelects = [];
let _compositeBrightnessEntitySelects = [];
let _compositeBlendIconSelects = [];
let _compositeCSPTEntitySelects = [];

function _compositeDestroyEntitySelects() {
    _compositeSourceEntitySelects.forEach(es => es.destroy());
    _compositeSourceEntitySelects = [];
    _compositeBrightnessEntitySelects.forEach(es => es.destroy());
    _compositeBrightnessEntitySelects = [];
    _compositeBlendIconSelects.forEach(is => is.destroy());
    _compositeBlendIconSelects = [];
    _compositeCSPTEntitySelects.forEach(es => es.destroy());
    _compositeCSPTEntitySelects = [];
}

function _getCompositeBlendItems() {
    return [
        { value: 'normal',   icon: _icon(P.square),   label: t('color_strip.composite.blend_mode.normal'),   desc: t('color_strip.composite.blend_mode.normal.desc') },
        { value: 'add',      icon: _icon(P.sun),      label: t('color_strip.composite.blend_mode.add'),      desc: t('color_strip.composite.blend_mode.add.desc') },
        { value: 'multiply', icon: _icon(P.eye),      label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
        { value: 'screen',   icon: _icon(P.monitor),  label: t('color_strip.composite.blend_mode.screen'),   desc: t('color_strip.composite.blend_mode.screen.desc') },
    ];
}

function _getCompositeSourceItems() {
    return _compositeAvailableSources.map(s => ({
        value: s.id,
        label: s.name,
        icon: getColorStripIcon(s.source_type),
    }));
}

function _getCompositeBrightnessItems() {
    return (_cachedValueSources || []).map(v => ({
        value: v.id,
        label: v.name,
        icon: getValueSourceIcon(v.source_type),
    }));
}

function _getCompositeCSPTItems() {
    return (_cachedCSPTemplates || []).map(tmpl => ({
        value: tmpl.id,
        label: tmpl.name,
        icon: ICON_SPARKLES,
    }));
}

function _compositeRenderList() {
    const list = document.getElementById('composite-layers-list');
    if (!list) return;
    _compositeDestroyEntitySelects();
    const vsList = _cachedValueSources || [];
    list.innerHTML = _compositeLayers.map((layer, i) => {
        const srcOptions = _compositeAvailableSources.map(s =>
            `<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
        ).join('');
        const vsOptions = `<option value="">${t('color_strip.composite.brightness.none')}</option>` +
            vsList.map(v =>
                `<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
            ).join('');
        const csptList = _cachedCSPTemplates || [];
        const csptOptions = `<option value="">${t('common.none_no_cspt')}</option>` +
            csptList.map(tmpl =>
                `<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
            ).join('');
        const canRemove = _compositeLayers.length > 1;
        return `
            <div class="composite-layer-item" data-layer-index="${i}">
                <div class="composite-layer-row">
                    <span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
                    <select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
                    <select class="composite-layer-blend" data-idx="${i}">
                        <option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
                        <option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
                        <option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
                        <option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
                    </select>
                </div>
                <div class="composite-layer-row">
                    <label class="composite-layer-opacity-label">
                        <span>${t('color_strip.composite.opacity')}:</span>
                        <span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
                    </label>
                    <input type="range" class="composite-layer-opacity" data-idx="${i}"
                           min="0" max="1" step="0.05" value="${layer.opacity}">
                    <label class="settings-toggle composite-layer-toggle">
                        <input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
                        <span class="settings-toggle-slider"></span>
                    </label>
                    ${canRemove
                        ? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
                                   onclick="compositeRemoveLayer(${i})">&#x2715;</button>`
                        : ''}
                </div>
                <div class="composite-layer-row">
                    <label class="composite-layer-brightness-label">
                        <span>${t('color_strip.composite.brightness')}:</span>
                    </label>
                    <select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
                </div>
                <div class="composite-layer-row">
                    <label class="composite-layer-brightness-label">
                        <span>${t('color_strip.composite.processing')}:</span>
                    </label>
                    <select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
                </div>
            </div>
        `;
    }).join('');

    // Wire up live opacity display
    list.querySelectorAll('.composite-layer-opacity').forEach(el => {
        el.addEventListener('input', () => {
            const val = parseFloat(el.value);
            el.closest('.composite-layer-row').querySelector('.composite-opacity-val').textContent = val.toFixed(2);
        });
    });

    // Attach IconSelect to each layer's blend mode dropdown
    const blendItems = _getCompositeBlendItems();
    list.querySelectorAll('.composite-layer-blend').forEach(sel => {
        const is = new IconSelect({ target: sel, items: blendItems, columns: 2 });
        _compositeBlendIconSelects.push(is);
    });

    // Attach EntitySelect to each layer's source dropdown
    list.querySelectorAll('.composite-layer-source').forEach(sel => {
        _compositeSourceEntitySelects.push(new EntitySelect({
            target: sel,
            getItems: _getCompositeSourceItems,
            placeholder: t('palette.search'),
        }));
    });

    // Attach EntitySelect to each layer's brightness dropdown
    list.querySelectorAll('.composite-layer-brightness').forEach(sel => {
        _compositeBrightnessEntitySelects.push(new EntitySelect({
            target: sel,
            getItems: _getCompositeBrightnessItems,
            placeholder: t('palette.search'),
            allowNone: true,
            noneLabel: t('color_strip.composite.brightness.none'),
        }));
    });

    // Attach EntitySelect to each layer's CSPT dropdown
    list.querySelectorAll('.composite-layer-cspt').forEach(sel => {
        _compositeCSPTEntitySelects.push(new EntitySelect({
            target: sel,
            getItems: _getCompositeCSPTItems,
            placeholder: t('palette.search'),
            allowNone: true,
            noneLabel: t('common.none_no_cspt'),
        }));
    });

    _initCompositeLayerDrag(list);
}

export function compositeAddLayer() {
    _compositeLayersSyncFromDom();
    _compositeLayers.push({
        source_id: _compositeAvailableSources.length > 0 ? _compositeAvailableSources[0].id : '',
        blend_mode: 'normal',
        opacity: 1.0,
        enabled: true,
        brightness_source_id: null,
        processing_template_id: null,
    });
    _compositeRenderList();
}

export function compositeRemoveLayer(i) {
    _compositeLayersSyncFromDom();
    if (_compositeLayers.length <= 1) return;
    _compositeLayers.splice(i, 1);
    _compositeRenderList();
}

function _compositeLayersSyncFromDom() {
    const list = document.getElementById('composite-layers-list');
    if (!list) return;
    const srcs = list.querySelectorAll('.composite-layer-source');
    const blends = list.querySelectorAll('.composite-layer-blend');
    const opacities = list.querySelectorAll('.composite-layer-opacity');
    const enableds = list.querySelectorAll('.composite-layer-enabled');
    const briSrcs = list.querySelectorAll('.composite-layer-brightness');
    const csptSels = list.querySelectorAll('.composite-layer-cspt');
    if (srcs.length === _compositeLayers.length) {
        for (let i = 0; i < srcs.length; i++) {
            _compositeLayers[i].source_id = srcs[i].value;
            _compositeLayers[i].blend_mode = blends[i].value;
            _compositeLayers[i].opacity = parseFloat(opacities[i].value);
            _compositeLayers[i].enabled = enableds[i].checked;
            _compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
            _compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
        }
    }
}

/* ── Composite layer drag-to-reorder ── */

const _COMPOSITE_DRAG_THRESHOLD = 5;
let _compositeLayerDragState = null;

function _initCompositeLayerDrag(list) {
    // Guard against stacking listeners across re-renders (the list DOM node persists).
    if (list._compositeDragBound) return;
    list._compositeDragBound = true;

    list.addEventListener('pointerdown', (e) => {
        const handle = e.target.closest('.composite-layer-drag-handle');
        if (!handle) return;
        const item = handle.closest('.composite-layer-item');
        if (!item) return;
        e.preventDefault();
        e.stopPropagation();

        const fromIndex = parseInt(item.dataset.layerIndex, 10);
        _compositeLayerDragState = {
            item,
            list,
            startY: e.clientY,
            started: false,
            clone: null,
            placeholder: null,
            offsetY: 0,
            fromIndex,
            scrollRaf: null,
        };

        const onMove = (ev) => _onCompositeLayerDragMove(ev);
        const onUp = () => {
            document.removeEventListener('pointermove', onMove);
            document.removeEventListener('pointerup', onUp);
            _onCompositeLayerDragEnd();
        };
        document.addEventListener('pointermove', onMove);
        document.addEventListener('pointerup', onUp);
    }, { capture: false });
}

function _onCompositeLayerDragMove(e) {
    const ds = _compositeLayerDragState;
    if (!ds) return;

    if (!ds.started) {
        if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
        _startCompositeLayerDrag(ds, e);
    }

    ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';

    const items = ds.list.querySelectorAll('.composite-layer-item');
    for (const it of items) {
        if (it.style.display === 'none') continue;
        const r = it.getBoundingClientRect();
        if (e.clientY >= r.top && e.clientY <= r.bottom) {
            const before = e.clientY < r.top + r.height / 2;
            if (it === ds.lastTarget && before === ds.lastBefore) break;
            ds.lastTarget = it;
            ds.lastBefore = before;
            if (before) {
                ds.list.insertBefore(ds.placeholder, it);
            } else {
                ds.list.insertBefore(ds.placeholder, it.nextSibling);
            }
            break;
        }
    }

    // Auto-scroll near modal edges
    if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
    const modal = ds.list.closest('.modal-body');
    if (modal) {
        const EDGE = 60, SPEED = 12;
        const mr = modal.getBoundingClientRect();
        let speed = 0;
        if (e.clientY < mr.top + EDGE) speed = -SPEED;
        else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
        if (speed !== 0) {
            const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
            ds.scrollRaf = requestAnimationFrame(scroll);
        }
    }
}

function _startCompositeLayerDrag(ds, e) {
    ds.started = true;
    const rect = ds.item.getBoundingClientRect();

    const clone = ds.item.cloneNode(true);
    clone.className = ds.item.className + ' composite-layer-drag-clone';
    clone.style.width = rect.width + 'px';
    clone.style.left = rect.left + 'px';
    clone.style.top = rect.top + 'px';
    document.body.appendChild(clone);
    ds.clone = clone;
    ds.offsetY = e.clientY - rect.top;

    const placeholder = document.createElement('div');
    placeholder.className = 'composite-layer-drag-placeholder';
    placeholder.style.height = rect.height + 'px';
    ds.item.parentNode.insertBefore(placeholder, ds.item);
    ds.placeholder = placeholder;

    ds.item.style.display = 'none';
    document.body.classList.add('composite-layer-dragging');
}

function _onCompositeLayerDragEnd() {
    const ds = _compositeLayerDragState;
    _compositeLayerDragState = null;
    if (!ds || !ds.started) return;

    if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);

    // Determine new index from placeholder position
    let toIndex = 0;
    for (const child of ds.list.children) {
        if (child === ds.placeholder) break;
        if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
            toIndex++;
        }
    }

    // Cleanup DOM
    ds.item.style.display = '';
    ds.placeholder.remove();
    ds.clone.remove();
    document.body.classList.remove('composite-layer-dragging');

    // Sync current DOM values before reordering
    _compositeLayersSyncFromDom();

    // Reorder array and re-render
    if (toIndex !== ds.fromIndex) {
        const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
        _compositeLayers.splice(toIndex, 0, moved);
        _compositeRenderList();
    }
}

function _compositeGetLayers() {
    _compositeLayersSyncFromDom();
    return _compositeLayers.map(l => {
        const layer = {
            source_id: l.source_id,
            blend_mode: l.blend_mode,
            opacity: l.opacity,
            enabled: l.enabled,
        };
        if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
        if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
        return layer;
    });
}

function _loadCompositeState(css) {
    const raw = css && css.layers;
    _compositeLayers = (raw && raw.length > 0)
        ? raw.map(l => ({
            source_id: l.source_id || '',
            blend_mode: l.blend_mode || 'normal',
            opacity: l.opacity != null ? l.opacity : 1.0,
            enabled: l.enabled != null ? l.enabled : true,
            brightness_source_id: l.brightness_source_id || null,
            processing_template_id: l.processing_template_id || null,
        }))
        : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
    _compositeRenderList();
}

/* ── Mapped zone helpers ──────────────────────────────────────── */

let _mappedZones = [];
let _mappedAvailableSources = [];  // non-mapped sources for zone dropdowns
let _mappedZoneEntitySelects = [];

function _getMappedSourceItems() {
    return _mappedAvailableSources.map(s => ({
        value: s.id,
        label: s.name,
        icon: getColorStripIcon(s.source_type),
    }));
}

function _mappedDestroyEntitySelects() {
    _mappedZoneEntitySelects.forEach(es => es.destroy());
    _mappedZoneEntitySelects = [];
}

function _mappedRenderList() {
    const list = document.getElementById('mapped-zones-list');
    if (!list) return;
    _mappedDestroyEntitySelects();
    list.innerHTML = _mappedZones.map((zone, i) => {
        const srcOptions = _mappedAvailableSources.map(s =>
            `<option value="${s.id}"${zone.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
        ).join('');
        return `
            <div class="segment-row">
                <div class="segment-row-header">
                    <span class="segment-index-label">#${i + 1}</span>
                    <button type="button" class="btn-icon-inline btn-danger-text"
                            onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">&times;</button>
                </div>
                <div class="segment-row-fields">
                    <select class="mapped-zone-source" data-idx="${i}">${srcOptions}</select>
                    <div class="segment-range-fields">
                        <label>${t('color_strip.mapped.zone_start')}</label>
                        <input type="number" class="mapped-zone-start" data-idx="${i}"
                               min="0" value="${zone.start}" placeholder="0">
                        <label>${t('color_strip.mapped.zone_end')}</label>
                        <input type="number" class="mapped-zone-end" data-idx="${i}"
                               min="0" value="${zone.end}" placeholder="0 = auto">
                    </div>
                    <label class="segment-reverse-label">
                        <input type="checkbox" class="mapped-zone-reverse" data-idx="${i}"${zone.reverse ? ' checked' : ''}>
                        <span>${t('color_strip.mapped.zone_reverse')}</span>
                    </label>
                </div>
            </div>
        `;
    }).join('');

    // Attach EntitySelect to each zone's source dropdown
    list.querySelectorAll('.mapped-zone-source').forEach(sel => {
        _mappedZoneEntitySelects.push(new EntitySelect({
            target: sel,
            getItems: _getMappedSourceItems,
            placeholder: t('color_strip.mapped.select_source'),
        }));
    });
}

export function mappedAddZone() {
    _mappedZonesSyncFromDom();
    _mappedZones.push({
        source_id: _mappedAvailableSources.length > 0 ? _mappedAvailableSources[0].id : '',
        start: 0,
        end: 0,
        reverse: false,
    });
    _mappedRenderList();
}

export function mappedRemoveZone(i) {
    _mappedZonesSyncFromDom();
    _mappedZones.splice(i, 1);
    _mappedRenderList();
}

function _mappedZonesSyncFromDom() {
    const list = document.getElementById('mapped-zones-list');
    if (!list) return;
    const srcs = list.querySelectorAll('.mapped-zone-source');
    const starts = list.querySelectorAll('.mapped-zone-start');
    const ends = list.querySelectorAll('.mapped-zone-end');
    const reverses = list.querySelectorAll('.mapped-zone-reverse');
    if (srcs.length === _mappedZones.length) {
        for (let i = 0; i < srcs.length; i++) {
            _mappedZones[i].source_id = srcs[i].value;
            _mappedZones[i].start = parseInt(starts[i].value) || 0;
            _mappedZones[i].end = parseInt(ends[i].value) || 0;
            _mappedZones[i].reverse = reverses[i].checked;
        }
    }
}

function _mappedGetZones() {
    _mappedZonesSyncFromDom();
    return _mappedZones.map(z => ({
        source_id: z.source_id,
        start: z.start,
        end: z.end,
        reverse: z.reverse,
    }));
}

function _loadMappedState(css) {
    const raw = css && css.zones;
    _mappedZones = (raw && raw.length > 0)
        ? raw.map(z => ({
            source_id: z.source_id || '',
            start: z.start || 0,
            end: z.end || 0,
            reverse: z.reverse || false,
        }))
        : [];
    _mappedRenderList();
}

function _resetMappedState() {
    _mappedZones = [];
    _mappedRenderList();
}

/* ── Audio visualization helpers ──────────────────────────────── */

export function onAudioVizChange() {
    const viz = document.getElementById('css-editor-audio-viz').value;
    // Palette: spectrum / beat_pulse
    document.getElementById('css-editor-audio-palette-group').style.display =
        (viz === 'spectrum' || viz === 'beat_pulse') ? '' : 'none';
    // Base color + Peak color: vu_meter only
    document.getElementById('css-editor-audio-color-group').style.display = viz === 'vu_meter' ? '' : 'none';
    document.getElementById('css-editor-audio-color-peak-group').style.display = viz === 'vu_meter' ? '' : 'none';
    // Mirror: spectrum only
    document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
    _autoGenerateCSSName();
}

async function _loadAudioSources() {
    const select = document.getElementById('css-editor-audio-source');
    if (!select) return;
    try {
        const sources = await audioSourcesCache.fetch();
        select.innerHTML = sources.map(s => {
            const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
            return `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`;
        }).join('');
        if (sources.length === 0) {
            select.innerHTML = '';
        }
        // Entity palette for audio source
        if (_cssAudioSourceEntitySelect) _cssAudioSourceEntitySelect.destroy();
        if (sources.length > 0) {
            _cssAudioSourceEntitySelect = new EntitySelect({
                target: select,
                getItems: () => sources.map(s => ({
                    value: s.id,
                    label: s.name,
                    icon: getAudioSourceIcon(s.source_type),
                    desc: s.source_type,
                })),
                placeholder: t('palette.search'),
            });
        }
    } catch {
        select.innerHTML = '';
    }
}

function _loadAudioState(css) {
    document.getElementById('css-editor-audio-viz').value = css.visualization_mode || 'spectrum';
    if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
    onAudioVizChange();

    const sensitivity = css.sensitivity ?? 1.0;
    document.getElementById('css-editor-audio-sensitivity').value = sensitivity;
    document.getElementById('css-editor-audio-sensitivity-val').textContent = parseFloat(sensitivity).toFixed(1);

    const smoothing = css.smoothing ?? 0.3;
    document.getElementById('css-editor-audio-smoothing').value = smoothing;
    document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(smoothing).toFixed(2);

    document.getElementById('css-editor-audio-palette').value = css.palette || 'rainbow';
    if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(css.palette || 'rainbow');
    document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]);
    document.getElementById('css-editor-audio-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
    document.getElementById('css-editor-audio-mirror').checked = css.mirror || false;

    // Set audio source selector
    const select = document.getElementById('css-editor-audio-source');
    if (select && css.audio_source_id) {
        select.value = css.audio_source_id;
    }
}

function _resetAudioState() {
    document.getElementById('css-editor-audio-viz').value = 'spectrum';
    if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
    document.getElementById('css-editor-audio-sensitivity').value = 1.0;
    document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0';
    document.getElementById('css-editor-audio-smoothing').value = 0.3;
    document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30';
    document.getElementById('css-editor-audio-palette').value = 'rainbow';
    if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('rainbow');
    document.getElementById('css-editor-audio-color').value = '#00ff00';
    document.getElementById('css-editor-audio-color-peak').value = '#ff0000';
    document.getElementById('css-editor-audio-mirror').checked = false;
}

/* ── Notification helpers ────────────────────────────────────── */

let _notificationAppColors = [];  // [{app: '', color: '#...'}]

export function onNotificationFilterModeChange() {
    const mode = document.getElementById('css-editor-notification-filter-mode').value;
    document.getElementById('css-editor-notification-filter-list-group').style.display = mode === 'off' ? 'none' : '';
}

function _notificationAppColorsRenderList() {
    const list = document.getElementById('notification-app-colors-list');
    if (!list) return;
    list.innerHTML = _notificationAppColors.map((entry, i) => `
        <div class="notif-app-color-row">
            <input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
            <input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
            <button type="button" class="btn btn-secondary notif-app-color-remove"
                    onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
        </div>
    `).join('');
}

export function notificationAddAppColor() {
    _notificationAppColorsSyncFromDom();
    _notificationAppColors.push({ app: '', color: '#ffffff' });
    _notificationAppColorsRenderList();
}

export function notificationRemoveAppColor(i) {
    _notificationAppColorsSyncFromDom();
    _notificationAppColors.splice(i, 1);
    _notificationAppColorsRenderList();
}

export async function testNotification(sourceId) {
    try {
        const resp = await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' });
        if (!resp.ok) {
            const err = await resp.json().catch(() => ({}));
            showToast(err.detail || t('color_strip.notification.test.error'), 'error');
            return;
        }
        const data = await resp.json();
        if (data.streams_notified > 0) {
            showToast(t('color_strip.notification.test.ok'), 'success');
        } else {
            showToast(t('color_strip.notification.test.no_streams'), 'warning');
        }
    } catch {
        showToast(t('color_strip.notification.test.error'), 'error');
    }
}

// ── OS Notification History Modal ─────────────────────────────────────────

export function showNotificationHistory() {
    const modal = document.getElementById('notification-history-modal');
    if (!modal) return;
    modal.style.display = 'flex';
    modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
    _loadNotificationHistory();
}

export function closeNotificationHistory() {
    const modal = document.getElementById('notification-history-modal');
    if (modal) modal.style.display = 'none';
}

export async function refreshNotificationHistory() {
    await _loadNotificationHistory();
}

async function _loadNotificationHistory() {
    const list = document.getElementById('notification-history-list');
    const status = document.getElementById('notification-history-status');
    if (!list) return;

    try {
        const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const data = await resp.json();

        if (!data.available) {
            list.innerHTML = '';
            if (status) {
                status.textContent = t('color_strip.notification.history.unavailable');
                status.style.display = '';
            }
            return;
        }

        if (status) status.style.display = 'none';

        const history = data.history || [];
        if (history.length === 0) {
            list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
            return;
        }

        list.innerHTML = history.map(entry => {
            const appName = entry.app || t('color_strip.notification.history.unknown_app');
            const timeStr = new Date(entry.time * 1000).toLocaleString();
            const fired = entry.fired ?? 0;
            const filtered = entry.filtered ?? 0;
            const firedBadge = fired > 0
                ? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
                : '';
            const filteredBadge = filtered > 0
                ? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
                : '';
            return `<div class="notif-history-row">
                <div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
                <div class="notif-history-time">${timeStr}</div>
                <div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
            </div>`;
        }).join('');
    } catch (err) {
        console.error('Failed to load notification history:', err);
        if (status) {
            status.textContent = t('color_strip.notification.history.error');
            status.style.display = '';
        }
        list.innerHTML = '';
    }
}

function _notificationAppColorsSyncFromDom() {
    const list = document.getElementById('notification-app-colors-list');
    if (!list) return;
    const names = list.querySelectorAll('.notif-app-name');
    const colors = list.querySelectorAll('.notif-app-color');
    if (names.length === _notificationAppColors.length) {
        for (let i = 0; i < names.length; i++) {
            _notificationAppColors[i].app = names[i].value;
            _notificationAppColors[i].color = colors[i].value;
        }
    }
}

function _notificationGetAppColorsDict() {
    _notificationAppColorsSyncFromDom();
    const dict = {};
    for (const entry of _notificationAppColors) {
        if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
    }
    return dict;
}

function _loadNotificationState(css) {
    document.getElementById('css-editor-notification-effect').value = css.notification_effect || 'flash';
    if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
    const dur = css.duration_ms ?? 1500;
    document.getElementById('css-editor-notification-duration').value = dur;
    document.getElementById('css-editor-notification-duration-val').textContent = dur;
    document.getElementById('css-editor-notification-default-color').value = css.default_color || '#ffffff';
    document.getElementById('css-editor-notification-filter-mode').value = css.app_filter_mode || 'off';
    if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
    document.getElementById('css-editor-notification-filter-list').value = (css.app_filter_list || []).join('\n');
    onNotificationFilterModeChange();
    _attachNotificationProcessPicker();

    // App colors dict → list
    const ac = css.app_colors || {};
    _notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
    _notificationAppColorsRenderList();

    _showNotificationEndpoint(css.id);
}

function _resetNotificationState() {
    document.getElementById('css-editor-notification-effect').value = 'flash';
    if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
    document.getElementById('css-editor-notification-duration').value = 1500;
    document.getElementById('css-editor-notification-duration-val').textContent = '1500';
    document.getElementById('css-editor-notification-default-color').value = '#ffffff';
    document.getElementById('css-editor-notification-filter-mode').value = 'off';
    if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
    document.getElementById('css-editor-notification-filter-list').value = '';
    onNotificationFilterModeChange();
    _attachNotificationProcessPicker();
    _notificationAppColors = [];
    _notificationAppColorsRenderList();
    _showNotificationEndpoint(null);
}

function _attachNotificationProcessPicker() {
    const container = document.getElementById('css-editor-notification-filter-picker-container');
    const textarea = document.getElementById('css-editor-notification-filter-list');
    if (container && textarea) attachProcessPicker(container, textarea);
}

function _showNotificationEndpoint(cssId) {
    const el = document.getElementById('css-editor-notification-endpoint');
    if (!el) return;
    if (!cssId) {
        el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
        return;
    }
    const base = `${window.location.origin}/api/v1`;
    const url = `${base}/color-strip-sources/${cssId}/notify`;
    el.innerHTML = `
        <small class="endpoint-label">POST</small>
        <div class="ws-url-row"><input type="text" value="${url}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
    `;
}

/* ── Card ─────────────────────────────────────────────────────── */

export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
    const isStatic = source.source_type === 'static';
    const isGradient = source.source_type === 'gradient';
    const isColorCycle = source.source_type === 'color_cycle';
    const isEffect = source.source_type === 'effect';
    const isComposite = source.source_type === 'composite';
    const isMapped = source.source_type === 'mapped';
    const isAudio = source.source_type === 'audio';
    const isApiInput = source.source_type === 'api_input';
    const isNotification = source.source_type === 'notification';
    const isPictureAdvanced = source.source_type === 'picture_advanced';

    // Clock crosslink badge (replaces speed badge when clock is assigned)
    const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
    const clockBadge = clockObj
        ? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
        : source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';

    const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
    const animBadge = anim
        ? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
        : '';

    let propsHtml;
    if (isStatic) {
        const hexColor = rgbArrayToHex(source.color);
        propsHtml = `
            <span class="stream-card-prop" title="${t('color_strip.static_color')}">
                <span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
            </span>
            ${animBadge}
            ${clockBadge}
        `;
    } else if (isColorCycle) {
        const colors = source.colors || [];
        const swatches = colors.slice(0, 8).map(c =>
            `<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
        ).join('');
        propsHtml = `
            <span class="stream-card-prop">${swatches}</span>
            ${clockBadge}
        `;
    } else if (isGradient) {
        const stops = source.stops || [];
        const sortedStops = [...stops].sort((a, b) => a.position - b.position);
        let cssGradient = '';
        if (sortedStops.length >= 2) {
            // Build CSS stops that mirror the interpolation algorithm:
            // for each stop emit its primary color, then immediately emit color_right
            // at the same position to produce a hard edge (bidirectional stop).
            const parts = [];
            sortedStops.forEach(s => {
                const pct = Math.round(s.position * 100);
                parts.push(`${rgbArrayToHex(s.color)} ${pct}%`);
                if (s.color_right) parts.push(`${rgbArrayToHex(s.color_right)} ${pct}%`);
            });
            cssGradient = `linear-gradient(to right, ${parts.join(', ')})`;
        }
        propsHtml = `
            ${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
            <span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
            ${animBadge}
            ${clockBadge}
        `;
    } else if (isEffect) {
        const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
        const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
        propsHtml = `
            <span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
            ${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
            ${clockBadge}
        `;
    } else if (isComposite) {
        const layerCount = (source.layers || []).length;
        const enabledCount = (source.layers || []).filter(l => l.enabled !== false).length;
        propsHtml = `
            <span class="stream-card-prop">${ICON_LINK} ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
            ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
        `;
    } else if (isMapped) {
        const zoneCount = (source.zones || []).length;
        propsHtml = `
            <span class="stream-card-prop">${ICON_MAP_PIN} ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
            ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
        `;
    } else if (isAudio) {
        const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
        const vizMode = source.visualization_mode || 'spectrum';
        const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
        const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
        propsHtml = `
            <span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
            ${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
            ${source.audio_source_id ? (() => {
                const as = audioSourceMap && audioSourceMap[source.audio_source_id];
                const asName = as ? as.name : source.audio_source_id;
                const asSection = as && as.source_type === 'mono' ? 'audio-mono' : 'audio-multi';
                return `<span class="stream-card-prop${as ? ' stream-card-link' : ''}" title="${t('color_strip.audio.source')}"${as ? ` onclick="event.stopPropagation(); navigateToCard('streams','audio','${asSection}','data-id','${source.audio_source_id}')"` : ''}>${ICON_AUDIO_LOOPBACK} ${escapeHtml(asName)}</span>`;
            })() : ''}
            ${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
        `;
    } else if (isApiInput) {
        const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
        const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
        propsHtml = `
            <span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
                <span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
            </span>
            <span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
        `;
    } else if (isNotification) {
        const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
        const durationVal = source.duration_ms || 1500;
        const defColor = source.default_color || '#FFFFFF';
        const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
        propsHtml = `
            <span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
            <span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
            <span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
                <span style="display:inline-block;width:14px;height:14px;background:${defColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColor.toUpperCase()}
            </span>
            ${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
        `;
    } else if (source.source_type === 'daylight') {
        const useRealTime = source.use_real_time;
        const speedVal = (source.speed ?? 1.0).toFixed(1);
        propsHtml = `
            <span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
            ${clockBadge}
        `;
    } else if (source.source_type === 'candlelight') {
        const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
        const numCandles = source.num_candles ?? 3;
        propsHtml = `
            <span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
                <span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
            </span>
            <span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
            ${clockBadge}
        `;
    } else if (source.source_type === 'processed') {
        const inputSrc = (colorStripSourcesCache.data || []).find(s => s.id === source.input_source_id);
        const inputName = inputSrc?.name || source.input_source_id || '—';
        const tplName = source.processing_template_id
            ? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id)
            : '—';
        propsHtml = `
            <span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
            <span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
        `;
    } else if (isPictureAdvanced) {
        const cal = source.calibration || {};
        const lines = cal.lines || [];
        const totalLeds = lines.reduce((s, l) => s + (l.led_count || 0), 0);
        const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
        // Collect unique picture source names
        const psIds = [...new Set(lines.map(l => l.picture_source_id).filter(Boolean))];
        const psNames = psIds.map(id => {
            const ps = pictureSourceMap && pictureSourceMap[id];
            return ps ? ps.name : id;
        });
        propsHtml = `
            <span class="stream-card-prop" title="${t('calibration.advanced.lines_title')}">${ICON_MAP_PIN} ${lines.length} ${t('calibration.advanced.lines_title').toLowerCase()}</span>
            ${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
            ${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
        `;
    } else {
        const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id];
        const srcName = ps ? ps.name : source.picture_source_id || '—';
        const cal = source.calibration || {};
        const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
        const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
        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'; }
        }
        propsHtml = `
            <span class="stream-card-prop stream-card-prop-full${ps ? ' stream-card-link' : ''}" title="${t('color_strip.picture_source')}"${ps ? ` onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${source.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(srcName)}</span>
            ${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
        `;
    }

    const icon = getColorStripIcon(source.source_type);
    const isDaylight = source.source_type === 'daylight';
    const isCandlelight = source.source_type === 'candlelight';
    const isProcessed = source.source_type === 'processed';
    const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight && !isProcessed);
    const calibrationBtn = isPictureKind
        ? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
        : '';
    const overlayBtn = isPictureKind
        ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
        : '';
    const testNotifyBtn = isNotification
        ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
        : '';
    const notifHistoryBtn = isNotification
        ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
        : '';
    const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;

    return wrapCard({
        dataAttr: 'data-css-id',
        id: source.id,
        removeOnclick: `deleteColorStrip('${source.id}')`,
        removeTitle: t('common.delete'),
        content: `
            <div class="card-header">
                <div class="card-title" title="${escapeHtml(source.name)}">
                    ${icon} ${escapeHtml(source.name)}
                </div>
            </div>
            <div class="stream-card-props">
                ${propsHtml}
            </div>
            ${renderTagChips(source.tags)}`,
        actions: `
                <button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
                <button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
                ${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
    });
}

/* ── Auto-name generation ─────────────────────────────────────── */

let _cssNameManuallyEdited = false;

function _autoGenerateCSSName() {
    if (_cssNameManuallyEdited) return;
    if (document.getElementById('css-editor-id').value) return; // edit mode
    const type = document.getElementById('css-editor-type').value;
    const typeLabel = t(`color_strip.type.${type}`);
    let detail = '';
    if (type === 'picture') {
        const sel = document.getElementById('css-editor-picture-source');
        const name = sel?.selectedOptions[0]?.textContent?.trim();
        if (name) detail = name;
    } else if (type === 'effect') {
        const eff = document.getElementById('css-editor-effect-type').value;
        if (eff) detail = t(`color_strip.effect.${eff}`);
    } else if (type === 'audio') {
        const viz = document.getElementById('css-editor-audio-viz').value;
        if (viz) detail = t(`color_strip.audio.viz.${viz}`);
    } else if (type === 'notification') {
        const eff = document.getElementById('css-editor-notification-effect').value;
        if (eff) detail = t(`color_strip.notification.effect.${eff}`);
    }
    document.getElementById('css-editor-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}

/* ── Per-type handler registry ─────────────────────────────────
 * Each handler has:
 *   load(css)        — populate editor fields from a saved/cloned source object
 *   reset()          — set editor fields to default values for "new" mode
 *   getPayload(name) — read editor fields and return the API payload (or null to signal validation error)
 * The handlers delegate to existing _loadXState / _resetXState helpers where available.
 */

const _typeHandlers = {
    static: {
        load(css) {
            document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
            _loadAnimationState(css.animation);
        },
        reset() {
            document.getElementById('css-editor-color').value = '#ffffff';
            _loadAnimationState(null);
        },
        getPayload(name) {
            return {
                name,
                color: hexToRgbArray(document.getElementById('css-editor-color').value),
                animation: _getAnimationPayload(),
            };
        },
    },
    color_cycle: {
        load(css) {
            _loadColorCycleState(css);
        },
        reset() {
            _loadColorCycleState(null);
        },
        getPayload(name) {
            const cycleColors = _colorCycleGetColors();
            if (cycleColors.length < 2) {
                cssEditorModal.showError(t('color_strip.color_cycle.min_colors'));
                return null;
            }
            return { name, colors: cycleColors };
        },
    },
    gradient: {
        load(css) {
            document.getElementById('css-editor-gradient-preset').value = '';
            if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue('');
            gradientInit(css.stops || [
                { position: 0.0, color: [255, 0, 0] },
                { position: 1.0, color: [0, 0, 255] },
            ]);
            _loadAnimationState(css.animation);
        },
        reset() {
            document.getElementById('css-editor-gradient-preset').value = '';
            gradientInit([
                { position: 0.0, color: [255, 0, 0] },
                { position: 1.0, color: [0, 0, 255] },
            ]);
            _loadAnimationState(null);
        },
        getPayload(name) {
            const gStops = getGradientStops();
            if (gStops.length < 2) {
                cssEditorModal.showError(t('color_strip.gradient.min_stops'));
                return null;
            }
            return {
                name,
                stops: gStops.map(s => ({
                    position: s.position,
                    color: s.color,
                    ...(s.colorRight ? { color_right: s.colorRight } : {}),
                })),
                animation: _getAnimationPayload(),
            };
        },
    },
    effect: {
        load(css) {
            document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
            if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire');
            onEffectTypeChange();
            document.getElementById('css-editor-effect-palette').value = css.palette || 'fire';
            if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(css.palette || 'fire');
            document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
            document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.0;
            document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
            document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
            document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
            document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
        },
        reset() {
            document.getElementById('css-editor-effect-type').value = 'fire';
            document.getElementById('css-editor-effect-palette').value = 'fire';
            document.getElementById('css-editor-effect-color').value = '#ff5000';
            document.getElementById('css-editor-effect-intensity').value = 1.0;
            document.getElementById('css-editor-effect-intensity-val').textContent = '1.0';
            document.getElementById('css-editor-effect-scale').value = 1.0;
            document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
            document.getElementById('css-editor-effect-mirror').checked = false;
        },
        getPayload(name) {
            const payload = {
                name,
                effect_type: document.getElementById('css-editor-effect-type').value,
                palette: document.getElementById('css-editor-effect-palette').value,
                intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
                scale: parseFloat(document.getElementById('css-editor-effect-scale').value),
                mirror: document.getElementById('css-editor-effect-mirror').checked,
            };
            // Meteor uses a color picker
            if (payload.effect_type === 'meteor') {
                const hex = document.getElementById('css-editor-effect-color').value;
                payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
            }
            return payload;
        },
    },
    audio: {
        async load(css) {
            await _loadAudioSources();
            _loadAudioState(css);
        },
        reset() {
            _resetAudioState();
        },
        getPayload(name) {
            return {
                name,
                visualization_mode: document.getElementById('css-editor-audio-viz').value,
                audio_source_id: document.getElementById('css-editor-audio-source').value || null,
                sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
                smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
                palette: document.getElementById('css-editor-audio-palette').value,
                color: hexToRgbArray(document.getElementById('css-editor-audio-color').value),
                color_peak: hexToRgbArray(document.getElementById('css-editor-audio-color-peak').value),
                mirror: document.getElementById('css-editor-audio-mirror').checked,
            };
        },
    },
    composite: {
        load(css) {
            _loadCompositeState(css);
        },
        reset() {
            _loadCompositeState(null);
        },
        getPayload(name) {
            const layers = _compositeGetLayers();
            if (layers.length < 1) {
                cssEditorModal.showError(t('color_strip.composite.error.min_layers'));
                return null;
            }
            const hasEmpty = layers.some(l => !l.source_id);
            if (hasEmpty) {
                cssEditorModal.showError(t('color_strip.composite.error.no_source'));
                return null;
            }
            return { name, layers };
        },
    },
    mapped: {
        load(css) {
            _loadMappedState(css);
        },
        reset() {
            _resetMappedState();
        },
        getPayload(name) {
            const zones = _mappedGetZones();
            const hasEmpty = zones.some(z => !z.source_id);
            if (hasEmpty) {
                cssEditorModal.showError(t('color_strip.mapped.error.no_source'));
                return null;
            }
            return { name, zones };
        },
    },
    api_input: {
        load(css) {
            document.getElementById('css-editor-api-input-fallback-color').value =
                rgbArrayToHex(css.fallback_color || [0, 0, 0]);
            document.getElementById('css-editor-api-input-timeout').value = css.timeout ?? 5.0;
            document.getElementById('css-editor-api-input-timeout-val').textContent =
                parseFloat(css.timeout ?? 5.0).toFixed(1);
            _showApiInputEndpoints(css.id);
        },
        reset() {
            document.getElementById('css-editor-api-input-fallback-color').value = '#000000';
            document.getElementById('css-editor-api-input-timeout').value = 5.0;
            document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
            _showApiInputEndpoints(null);
        },
        getPayload(name) {
            const fbHex = document.getElementById('css-editor-api-input-fallback-color').value;
            return {
                name,
                fallback_color: hexToRgbArray(fbHex),
                timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value),
            };
        },
    },
    notification: {
        load(css) {
            _loadNotificationState(css);
        },
        reset() {
            _resetNotificationState();
        },
        getPayload(name) {
            const filterList = document.getElementById('css-editor-notification-filter-list').value
                .split('\n').map(s => s.trim()).filter(Boolean);
            return {
                name,
                notification_effect: document.getElementById('css-editor-notification-effect').value,
                duration_ms: parseInt(document.getElementById('css-editor-notification-duration').value) || 1500,
                default_color: document.getElementById('css-editor-notification-default-color').value,
                app_filter_mode: document.getElementById('css-editor-notification-filter-mode').value,
                app_filter_list: filterList,
                app_colors: _notificationGetAppColorsDict(),
            };
        },
    },
    daylight: {
        load(css) {
            document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
            document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
            document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
            document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
            document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
            _syncDaylightSpeedVisibility();
        },
        reset() {
            document.getElementById('css-editor-daylight-speed').value = 1.0;
            document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
            document.getElementById('css-editor-daylight-real-time').checked = false;
            document.getElementById('css-editor-daylight-latitude').value = 50.0;
            document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
        },
        getPayload(name) {
            return {
                name,
                speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
                use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
                latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
            };
        },
    },
    candlelight: {
        load(css) {
            document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
            document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
            document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
            document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
            document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
            document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
        },
        reset() {
            document.getElementById('css-editor-candlelight-color').value = '#ff9329';
            document.getElementById('css-editor-candlelight-intensity').value = 1.0;
            document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
            document.getElementById('css-editor-candlelight-num-candles').value = 3;
            document.getElementById('css-editor-candlelight-speed').value = 1.0;
            document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
        },
        getPayload(name) {
            return {
                name,
                color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
                intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
                num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
                speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
            };
        },
    },
    processed: {
        async load(css) {
            await csptCache.fetch();
            await colorStripSourcesCache.fetch();
            _populateProcessedSelectors();
            document.getElementById('css-editor-processed-input').value = css.input_source_id || '';
            document.getElementById('css-editor-processed-template').value = css.processing_template_id || '';
        },
        async reset(presetType) {
            if (presetType === 'processed') {
                await csptCache.fetch();
                await colorStripSourcesCache.fetch();
                _populateProcessedSelectors();
            }
        },
        getPayload(name) {
            const inputId = document.getElementById('css-editor-processed-input').value;
            const templateId = document.getElementById('css-editor-processed-template').value;
            if (!inputId) {
                cssEditorModal.showError(t('color_strip.processed.error.no_input'));
                return null;
            }
            return {
                name,
                input_source_id: inputId,
                processing_template_id: templateId || null,
            };
        },
    },
    picture_advanced: {
        load(css) {
            document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
            const smoothing = css.smoothing ?? 0.3;
            document.getElementById('css-editor-smoothing').value = smoothing;
            document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
        },
        reset() {
            document.getElementById('css-editor-interpolation').value = 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
            document.getElementById('css-editor-smoothing').value = 0.3;
            document.getElementById('css-editor-smoothing-value').textContent = '0.30';
        },
        getPayload(name) {
            return {
                name,
                interpolation_mode: document.getElementById('css-editor-interpolation').value,
                smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
                led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
            };
        },
    },
    picture: {
        load(css, sourceSelect) {
            sourceSelect.value = css.picture_source_id || '';
            document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
            const smoothing = css.smoothing ?? 0.3;
            document.getElementById('css-editor-smoothing').value = smoothing;
            document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
        },
        reset() {
            document.getElementById('css-editor-interpolation').value = 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
            document.getElementById('css-editor-smoothing').value = 0.3;
            document.getElementById('css-editor-smoothing-value').textContent = '0.30';
        },
        getPayload(name) {
            return {
                name,
                picture_source_id: document.getElementById('css-editor-picture-source').value,
                interpolation_mode: document.getElementById('css-editor-interpolation').value,
                smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
                led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
            };
        },
    },
};

/* ── Editor open/close ────────────────────────────────────────── */

export async function showCSSEditor(cssId = null, cloneData = null, presetType = null) {
    // When creating new: show type picker first, then re-enter with presetType
    if (!cssId && !cloneData && !presetType) {
        showTypePicker({
            title: t('color_strip.select_type'),
            items: _buildCSSTypeItems(),
            onPick: (type) => showCSSEditor(null, null, type),
        });
        return;
    }
    try {
        const sources = await streamsCache.fetch();

        // Fetch all color strip sources for composite layer dropdowns
        await valueSourcesCache.fetch().catch(() => []);
        const allCssSources = await colorStripSourcesCache.fetch().catch(() => []);
        _compositeAvailableSources = allCssSources.filter(s =>
            s.source_type !== 'composite' && (!cssId || s.id !== cssId)
        );
        _mappedAvailableSources = allCssSources.filter(s =>
            s.source_type !== 'mapped' && (!cssId || s.id !== cssId)
        );

        const sourceSelect = document.getElementById('css-editor-picture-source');
        sourceSelect.innerHTML = '';
        sources.forEach(s => {
            const opt = document.createElement('option');
            opt.value = s.id;
            opt.dataset.name = s.name;
            opt.textContent = s.name;
            sourceSelect.appendChild(opt);
        });

        // Entity palette for picture source
        if (_cssPictureSourceEntitySelect) _cssPictureSourceEntitySelect.destroy();
        _cssPictureSourceEntitySelect = new EntitySelect({
            target: sourceSelect,
            getItems: () => sources.map(s => ({
                value: s.id,
                label: s.name,
                icon: getPictureSourceIcon(s.stream_type),
            })),
            placeholder: t('palette.search'),
        });

        // Helper: populate editor fields from a CSS source object
        const _populateFromCSS = async (css) => {
            const sourceType = css.source_type || 'picture';
            document.getElementById('css-editor-type').value = sourceType;
            onCSSTypeChange();

            // Set clock dropdown value (must be after onCSSTypeChange populates it)
            if (css.clock_id) {
                _populateClockDropdown(css.clock_id);
                onCSSClockChange();
            }

            const handler = _typeHandlers[sourceType] || _typeHandlers.picture;
            await handler.load(css, sourceSelect);

            document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
        };

        // Initialize icon-grid type selector (idempotent)
        _ensureCSSTypeIconSelect();

        // Hide type selector — type is chosen before the modal opens (or immutable in edit)
        document.getElementById('css-editor-type-group').style.display = 'none';

        if (cssId) {
            const cssSources = await colorStripSourcesCache.fetch();
            const css = cssSources.find(s => s.id === cssId);
            if (!css) throw new Error('Failed to load color strip source');

            document.getElementById('css-editor-id').value = css.id;
            document.getElementById('css-editor-name').value = css.name;

            // Exclude self from composite/mapped sources when editing
            if (css.source_type === 'composite') {
                _compositeAvailableSources = allCssSources.filter(s =>
                    s.source_type !== 'composite' && s.id !== css.id
                );
            } else if (css.source_type === 'mapped') {
                _mappedAvailableSources = allCssSources.filter(s =>
                    s.source_type !== 'mapped' && s.id !== css.id
                );
            }

            await _populateFromCSS(css);
            document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.edit')}`;
        } else if (cloneData) {
            document.getElementById('css-editor-id').value = '';
            document.getElementById('css-editor-name').value = (cloneData.name || '') + ' (Copy)';
            await _populateFromCSS(cloneData);
            document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
        } else {
            document.getElementById('css-editor-id').value = '';
            document.getElementById('css-editor-name').value = '';
            const effectiveType = presetType || 'picture';
            document.getElementById('css-editor-type').value = effectiveType;
            onCSSTypeChange();
            document.getElementById('css-editor-led-count').value = 0;

            // Reset all type handlers to defaults
            for (const handler of Object.values(_typeHandlers)) {
                await handler.reset(effectiveType);
            }

            const typeIcon = getColorStripIcon(effectiveType);
            document.getElementById('css-editor-title').innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${effectiveType}`)}`;
            _autoGenerateCSSName();
        }

        // Auto-name wiring
        _cssNameManuallyEdited = !!(cssId || cloneData);
        document.getElementById('css-editor-name').oninput = () => { _cssNameManuallyEdited = true; };
        document.getElementById('css-editor-picture-source').onchange = () => _autoGenerateCSSName();
        document.getElementById('css-editor-notification-effect').onchange = () => _autoGenerateCSSName();

        document.getElementById('css-editor-error').style.display = 'none';

        // Tags
        if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
        const _cssTags = cssId
            ? ((await colorStripSourcesCache.fetch()).find(s => s.id === cssId)?.tags || [])
            : (cloneData ? (cloneData.tags || []) : []);
        _cssTagsInput = new TagInput(document.getElementById('css-tags-container'), { placeholder: t('tags.placeholder') });
        _cssTagsInput.setValue(_cssTags);

        cssEditorModal.snapshot();
        cssEditorModal.open();
        setTimeout(() => desktopFocus(document.getElementById('css-editor-name')), 100);
    } catch (error) {
        if (error.isAuth) return;
        console.error('Failed to open CSS editor:', error);
        showToast(t('color_strip.error.editor_open_failed'), 'error');
    }
}

export function closeCSSEditorModal() { cssEditorModal.close(); }
export function forceCSSEditorClose() { cssEditorModal.forceClose(); }
export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }

/* ── Save ─────────────────────────────────────────────────────── */

export async function saveCSSEditor() {
    const cssId = document.getElementById('css-editor-id').value;
    const name = document.getElementById('css-editor-name').value.trim();
    const sourceType = document.getElementById('css-editor-type').value;

    if (!name) {
        cssEditorModal.showError(t('color_strip.error.name_required'));
        return;
    }

    const knownType = sourceType in _typeHandlers;
    const handler = knownType ? _typeHandlers[sourceType] : _typeHandlers.picture;
    const payload = handler.getPayload(name);
    if (payload === null) return; // validation error already shown

    if (!cssId) payload.source_type = knownType ? sourceType : 'picture';

    // Attach clock_id for animated types
    const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
    if (clockTypes.includes(sourceType)) {
        const clockVal = document.getElementById('css-editor-clock').value;
        payload.clock_id = clockVal || null;
    }

    // Tags
    payload.tags = _cssTagsInput ? _cssTagsInput.getValue() : [];

    try {
        let response;
        if (cssId) {
            response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
                method: 'PUT',
                body: JSON.stringify(payload),
            });
        } else {
            response = await fetchWithAuth('/color-strip-sources', {
                method: 'POST',
                body: JSON.stringify(payload),
            });
        }

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

        showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
        colorStripSourcesCache.invalidate();
        cssEditorModal.forceClose();
        if (window.loadPictureSources) window.loadPictureSources();
        if (window.loadTargetsTab) await window.loadTargetsTab();
    } catch (error) {
        if (error.isAuth) return;
        console.error('Error saving CSS:', error);
        cssEditorModal.showError(error.message);
    }
}

/* ── API Input helpers ────────────────────────────────────────── */

function _showApiInputEndpoints(cssId) {
    const el = document.getElementById('css-editor-api-input-endpoints');
    const group = document.getElementById('css-editor-api-input-endpoints-group');
    if (!el || !group) return;
    if (!cssId) {
        el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`;
        return;
    }
    const base = `${window.location.origin}/api/v1`;
    const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsBase = `${wsProto}//${window.location.host}/api/v1`;
    const restUrl = `${base}/color-strip-sources/${cssId}/colors`;
    const apiKey = localStorage.getItem('wled_api_key') || '';
    const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
    el.innerHTML = `
        <small class="endpoint-label">REST POST</small>
        <div class="ws-url-row" style="margin-bottom:6px"><input type="text" value="${restUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
        <small class="endpoint-label">WebSocket</small>
        <div class="ws-url-row"><input type="text" value="${wsUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
    `;
}

export function copyEndpointUrl(btn) {
    const input = btn.parentElement.querySelector('input');
    if (!input || !input.value) return;
    // navigator.clipboard requires secure context (HTTPS or localhost)
    if (navigator.clipboard && window.isSecureContext) {
        navigator.clipboard.writeText(input.value).then(() => {
            showToast(t('settings.copied') || 'Copied!', 'success');
        });
    } else {
        // Fallback for non-secure contexts (HTTP on LAN)
        input.select();
        document.execCommand('copy');
        showToast(t('settings.copied') || 'Copied!', 'success');
    }
}

/* ── Clone ────────────────────────────────────────────────────── */

export async function cloneColorStrip(cssId) {
    try {
        const sources = await colorStripSourcesCache.fetch();
        const css = sources.find(s => s.id === cssId);
        if (!css) throw new Error('Color strip source not found');
        showCSSEditor(null, css);
    } catch (error) {
        if (error.isAuth) return;
        console.error('Failed to clone color strip:', error);
        showToast(t('color_strip.error.clone_failed'), 'error');
    }
}

/* ── Delete ───────────────────────────────────────────────────── */

export async function deleteColorStrip(cssId) {
    const confirmed = await showConfirm(t('color_strip.delete.confirm'));
    if (!confirmed) return;

    try {
        const response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
            method: 'DELETE',
        });
        if (response.ok) {
            showToast(t('color_strip.deleted'), 'success');
            colorStripSourcesCache.invalidate();
            if (window.loadPictureSources) window.loadPictureSources();
            if (window.loadTargetsTab) await window.loadTargetsTab();
        } else {
            const err = await response.json();
            const msg = err.detail || 'Failed to delete';
            const isReferenced = response.status === 409;
            showToast(isReferenced ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error');
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('color_strip.error.delete_failed'), 'error');
    }
}

/* ── Overlay ──────────────────────────────────────────────────── */

export async function startCSSOverlay(cssId) {
    try {
        const response = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/start`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('overlay.started'), 'success');
            if (window.loadTargetsTab) window.loadTargetsTab();
        } else {
            const error = await response.json();
            showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('overlay.error.start'), 'error');
    }
}

export async function toggleCSSOverlay(cssId) {
    try {
        const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
        if (!resp.ok) return;
        const { active } = await resp.json();
        if (active) {
            await stopCSSOverlay(cssId);
        } else {
            await startCSSOverlay(cssId);
        }
    } catch (err) {
        if (err.isAuth) return;
        console.error('Failed to toggle CSS overlay:', err);
    }
}

export async function stopCSSOverlay(cssId) {
    try {
        const response = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/stop`, {
            method: 'POST',
        });
        if (response.ok) {
            showToast(t('overlay.stopped'), 'success');
            if (window.loadTargetsTab) window.loadTargetsTab();
        } else {
            const error = await response.json();
            showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
        }
    } catch (error) {
        if (error.isAuth) return;
        showToast(t('overlay.error.stop'), 'error');
    }
}

/* ── Editor Preview (opens existing test modal with transient WS) ──── */

const _PREVIEW_TYPES = new Set(['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight']);

/** Collect current editor form state into a source config for transient preview. */
function _collectPreviewConfig() {
    const sourceType = document.getElementById('css-editor-type').value;
    if (!_PREVIEW_TYPES.has(sourceType)) return null;
    let config;
    if (sourceType === 'static') {
        config = { source_type: 'static', color: hexToRgbArray(document.getElementById('css-editor-color').value), animation: _getAnimationPayload() };
    } else if (sourceType === 'gradient') {
        const stops = getGradientStops();
        if (stops.length < 2) return null;
        config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() };
    } else if (sourceType === 'color_cycle') {
        const colors = _colorCycleGetColors();
        if (colors.length < 2) return null;
        config = { source_type: 'color_cycle', colors };
    } else if (sourceType === 'effect') {
        config = { source_type: 'effect', effect_type: document.getElementById('css-editor-effect-type').value, palette: document.getElementById('css-editor-effect-palette').value, intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value), scale: parseFloat(document.getElementById('css-editor-effect-scale').value), mirror: document.getElementById('css-editor-effect-mirror').checked };
        if (config.effect_type === 'meteor') { const hex = document.getElementById('css-editor-effect-color').value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
    } else if (sourceType === 'daylight') {
        config = { source_type: 'daylight', speed: parseFloat(document.getElementById('css-editor-daylight-speed').value), use_real_time: document.getElementById('css-editor-daylight-real-time').checked, latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value) };
    } else if (sourceType === 'candlelight') {
        config = { source_type: 'candlelight', color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value), intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value), num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3, speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value) };
    }
    const clockEl = document.getElementById('css-editor-clock');
    if (clockEl && clockEl.value) config.clock_id = clockEl.value;
    return config;
}

/**
 * Open the existing Test Preview modal from the CSS editor.
 * For saved sources, uses the normal test endpoint.
 * For unsaved/self-contained types, uses the transient preview endpoint.
 */
export function previewCSSFromEditor() {
    // Always use transient preview with current form values
    const config = _collectPreviewConfig();
    if (!config) {
        // Non-previewable type (picture, composite, etc.) — fall back to saved source test
        const cssId = document.getElementById('css-editor-id').value;
        if (cssId) { testColorStrip(cssId); return; }
        showToast(t('color_strip.preview.unsupported'), 'info');
        return;
    }

    _cssTestCSPTMode = false;
    _cssTestCSPTId = null;
    _cssTestTransientConfig = config;
    const csptGroup = document.getElementById('css-test-cspt-input-group');
    if (csptGroup) csptGroup.style.display = 'none';
    _openTestModal('__preview__');
}

/** Store transient config so _cssTestConnect and applyCssTestSettings can use it. */
let _cssTestTransientConfig = null;

/* ── Test / Preview ───────────────────────────────────────────── */

const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
let _cssTestWs = null;
let _cssTestRaf = null;
let _cssTestLatestRgb = null;
let _cssTestMeta = null;
let _cssTestSourceId = null;
let _cssTestIsComposite = false;
let _cssTestLayerData = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
let _cssTestGeneration = 0;   // bumped on each connect to ignore stale WS messages
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode = false;  // true when testing a CSPT template
let _cssTestCSPTId = null;     // CSPT template ID when in CSPT mode
let _cssTestIsApiInput = false;
let _cssTestFpsTimestamps = [];    // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory = []; // rolling FPS samples for sparkline
let _cssTestFpsChart = null;
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
let _csptTestInputEntitySelect = null;

function _getCssTestLedCount() {
    const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY), 10);
    return (stored > 0 && stored <= 2000) ? stored : 100;
}

function _getCssTestFps() {
    const stored = parseInt(localStorage.getItem(_CSS_TEST_FPS_KEY), 10);
    return (stored >= 1 && stored <= 60) ? stored : 20;
}

function _populateCssTestSourceSelector(preselectId) {
    const sources = colorStripSourcesCache.data || [];
    const nonProcessed = sources.filter(s => s.source_type !== 'processed');
    const sel = document.getElementById('css-test-cspt-input-select');
    sel.innerHTML = nonProcessed.map(s =>
        `<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
    ).join('');
    if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
    _csptTestInputEntitySelect = new EntitySelect({
        target: sel,
        getItems: () => (colorStripSourcesCache.data || [])
            .filter(s => s.source_type !== 'processed')
            .map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
        placeholder: t('palette.search'),
    });
}

export function testColorStrip(sourceId) {
    _cssTestCSPTMode = false;
    _cssTestCSPTId = null;
    // Detect api_input type
    const sources = colorStripSourcesCache.data || [];
    const src = sources.find(s => s.id === sourceId);
    _cssTestIsApiInput = src?.source_type === 'api_input';
    // Populate input source selector with current source preselected
    _populateCssTestSourceSelector(sourceId);
    _openTestModal(sourceId);
}

export async function testCSPT(templateId) {
    _cssTestCSPTMode = true;
    _cssTestCSPTId = templateId;

    // Populate input source selector
    await colorStripSourcesCache.fetch();
    _populateCssTestSourceSelector(null);

    const sel = document.getElementById('css-test-cspt-input-select');
    const inputId = sel.value;
    if (!inputId) {
        showToast(t('color_strip.processed.error.no_input'), 'error');
        return;
    }
    _openTestModal(inputId);
}

function _openTestModal(sourceId) {
    // Clean up any previous session fully
    if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
    if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
    _cssTestLatestRgb = null;
    _cssTestMeta = null;
    _cssTestIsComposite = false;
    _cssTestLayerData = null;

    const modal = document.getElementById('test-css-source-modal');
    if (!modal) return;
    modal.style.display = 'flex';
    modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
    _cssTestSourceId = sourceId;

    // Reset views
    document.getElementById('css-test-strip-view').style.display = 'none';
    document.getElementById('css-test-rect-view').style.display = 'none';
    document.getElementById('css-test-layers-view').style.display = 'none';
    document.getElementById('css-test-led-group').style.display = '';
    // Input source selector: shown for both CSS test and CSPT test, hidden for api_input
    const csptGroup = document.getElementById('css-test-cspt-input-group');
    if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
    const layersContainer = document.getElementById('css-test-layers');
    if (layersContainer) layersContainer.innerHTML = '';
    document.getElementById('css-test-status').style.display = '';
    document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');

    // Reset FPS tracking
    _cssTestFpsHistory = [];

    // For api_input: hide LED/FPS controls, show FPS chart
    const ledControlGroup = document.getElementById('css-test-led-fps-group');
    const fpsChartGroup = document.getElementById('css-test-fps-chart-group');
    if (_cssTestIsApiInput) {
        if (ledControlGroup) ledControlGroup.style.display = 'none';
        if (fpsChartGroup) fpsChartGroup.style.display = '';
        _cssTestStartFpsSampling();
        // Use large LED count (buffer auto-sizes) and high poll FPS
        _cssTestConnect(sourceId, 1000, 60);
    } else {
        if (ledControlGroup) ledControlGroup.style.display = '';
        if (fpsChartGroup) fpsChartGroup.style.display = 'none';
        // Restore LED count + FPS + Enter key handlers
        const ledCount = _getCssTestLedCount();
        const ledInput = document.getElementById('css-test-led-input');
        ledInput.value = ledCount;
        ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };

        const fpsVal = _getCssTestFps();
        const fpsInput = document.getElementById('css-test-fps-input');
        fpsInput.value = fpsVal;
        fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };

        _cssTestConnect(sourceId, ledCount, fpsVal);
    }
}

function _cssTestConnect(sourceId, ledCount, fps) {
    // Close existing connection if any
    if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }

    // Bump generation so any late messages from old WS are ignored
    const gen = ++_cssTestGeneration;

    if (!fps) fps = _getCssTestFps();
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const apiKey = localStorage.getItem('wled_api_key') || '';
    const isTransient = sourceId === '__preview__' && _cssTestTransientConfig;
    let wsUrl;
    if (isTransient) {
        wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
    } else if (_cssTestCSPTMode && _cssTestCSPTId) {
        wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
    } else {
        wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
    }

    _cssTestWs = new WebSocket(wsUrl);
    _cssTestWs.binaryType = 'arraybuffer';

    if (isTransient) {
        _cssTestWs.onopen = () => {
            if (gen !== _cssTestGeneration) return;
            _cssTestWs.send(JSON.stringify(_cssTestTransientConfig));
        };
    }

    _cssTestWs.onmessage = (event) => {
        // Ignore messages from a stale connection
        if (gen !== _cssTestGeneration) return;

        if (typeof event.data === 'string') {
            const msg = JSON.parse(event.data);

            // Handle brightness updates for composite layers
            if (msg.type === 'brightness') {
                _cssTestUpdateBrightness(msg.values);
                return;
            }

            // Handle frame dimensions — render border-width overlay
            if (msg.type === 'frame_dims' && _cssTestMeta) {
                _cssTestRenderBorderOverlay(msg.width, msg.height);
                return;
            }

            // Initial metadata
            _cssTestMeta = msg;
            const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
            _cssTestIsComposite = _cssTestMeta.layers && _cssTestMeta.layers.length > 0;

            // Show correct view
            document.getElementById('css-test-strip-view').style.display = (isPicture || _cssTestIsComposite) ? 'none' : '';
            document.getElementById('css-test-rect-view').style.display = isPicture ? '' : 'none';
            document.getElementById('css-test-layers-view').style.display = _cssTestIsComposite ? '' : 'none';
            document.getElementById('css-test-status').style.display = 'none';

            // Widen modal for picture sources to show the screen rectangle larger
            const modalContent = document.querySelector('#test-css-source-modal .modal-content');
            if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';

            // Hide LED count control for picture sources (LED count is fixed by calibration)
            document.getElementById('css-test-led-group').style.display = isPicture ? 'none' : '';

            // Show fire button for notification sources (direct only; composite has per-layer buttons)
            const isNotify = _cssTestMeta.source_type === 'notification';
            const layerInfos = _cssTestMeta.layer_infos || [];
            _cssTestNotificationIds = isNotify
                ? [_cssTestSourceId]
                : layerInfos.filter(li => li.is_notification).map(li => li.id);
            const fireBtn = document.getElementById('css-test-fire-btn');
            if (fireBtn) fireBtn.style.display = (isNotify && !_cssTestIsComposite) ? '' : 'none';

            // Populate rect screen labels for picture sources
            if (isPicture) {
                const nameEl = document.getElementById('css-test-rect-name');
                const ledsEl = document.getElementById('css-test-rect-leds');
                if (nameEl) nameEl.textContent = _cssTestMeta.source_name || '';
                if (ledsEl) ledsEl.textContent = `${_cssTestMeta.led_count} LEDs`;
                // Render tick marks after layout settles
                requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta.edges));
            }

            // Build composite layer canvases
            if (_cssTestIsComposite) {
                _cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos);
                requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-layers-axis', _cssTestMeta.led_count));
            }

            // Render strip axis for non-picture, non-composite views
            if (!isPicture && !_cssTestIsComposite) {
                requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-strip-axis', _cssTestMeta.led_count));
            }
        } else {
            const raw = new Uint8Array(event.data);
            // Check JPEG frame preview: [0xFD] [jpeg_bytes]
            if (raw.length > 1 && raw[0] === 0xFD) {
                const jpegBlob = new Blob([raw.subarray(1)], { type: 'image/jpeg' });
                const url = URL.createObjectURL(jpegBlob);
                const screen = document.getElementById('css-test-rect-screen');
                if (screen) {
                    // Preload image to avoid flicker on swap
                    const img = new Image();
                    img.onload = () => {
                        const oldUrl = screen._blobUrl;
                        screen._blobUrl = url;
                        screen.style.backgroundImage = `url(${url})`;
                        screen.style.backgroundSize = 'cover';
                        screen.style.backgroundPosition = 'center';
                        if (oldUrl) URL.revokeObjectURL(oldUrl);
                        // Set aspect ratio from first decoded frame
                        const rect = document.getElementById('css-test-rect');
                        if (rect && !rect._aspectSet && img.naturalWidth && img.naturalHeight) {
                            rect.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
                            rect.style.height = 'auto';
                            rect._aspectSet = true;
                            requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta?.edges));
                        }
                    };
                    img.onerror = () => URL.revokeObjectURL(url);
                    img.src = url;
                }
                return;
            }
            // Check composite wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
            if (raw.length > 3 && raw[0] === 0xFE && _cssTestIsComposite) {
                const layerCount = raw[1];
                const ledCount = (raw[2] << 8) | raw[3];
                const rgbSize = ledCount * 3;
                let offset = 4;
                const layers = [];
                for (let i = 0; i < layerCount; i++) {
                    layers.push(raw.subarray(offset, offset + rgbSize));
                    offset += rgbSize;
                }
                const composite = raw.subarray(offset, offset + rgbSize);
                _cssTestLayerData = { layerCount, ledCount, layers, composite };
                _cssTestLatestRgb = composite;
            } else {
                // Standard format: raw RGB
                _cssTestLatestRgb = raw;
            }

            // Track FPS for api_input sources
            if (_cssTestIsApiInput) {
                _cssTestFpsTimestamps.push(performance.now());
            }
        }
    };

    _cssTestWs.onerror = () => {
        if (gen !== _cssTestGeneration) return;
        document.getElementById('css-test-status').textContent = t('color_strip.test.error');
    };

    _cssTestWs.onclose = () => {
        if (gen === _cssTestGeneration) _cssTestWs = null;
    };

    // Start render loop (only once)
    if (!_cssTestRaf) _cssTestRenderLoop();
}

const _BELL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>';

function _cssTestBuildLayers(layerNames, sourceType, layerInfos) {
    const container = document.getElementById('css-test-layers');
    if (!container) return;
    // Composite result first, then individual layers
    let html = `<div class="css-test-layer css-test-layer-composite">` +
        `<canvas class="css-test-layer-canvas" data-layer-idx="composite"></canvas>` +
        `<span class="css-test-layer-label">${sourceType === 'composite' ? t('color_strip.test.composite') : ''}</span>` +
        `</div>`;
    for (let i = 0; i < layerNames.length; i++) {
        const info = layerInfos && layerInfos[i];
        const isNotify = info && info.is_notification;
        const hasBri = info && info.has_brightness;
        const fireBtn = isNotify
            ? `<button class="css-test-fire-btn" onclick="event.stopPropagation(); fireCssTestNotificationLayer('${info.id}')" title="${t('color_strip.notification.test')}">${_BELL_SVG}</button>`
            : '';
        const briLabel = hasBri
            ? `<span class="css-test-layer-brightness" data-layer-idx="${i}" style="display:none"></span>`
            : '';
        let calLabel = '';
        if (info && info.is_picture && info.calibration_led_count) {
            const mismatch = _cssTestMeta.led_count !== info.calibration_led_count;
            calLabel = `<span class="css-test-layer-cal${mismatch ? ' css-test-layer-cal-warn' : ''}" data-layer-idx="${i}">${mismatch ? ICON_WARNING + ' ' : ''}${_cssTestMeta.led_count}/${info.calibration_led_count}</span>`;
        }
        html += `<div class="css-test-layer css-test-strip-wrap">` +
            `<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
            `<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
            calLabel +
            briLabel +
            fireBtn +
            `</div>`;
    }
    container.innerHTML = html;
}

function _cssTestUpdateBrightness(values) {
    if (!values) return;
    const container = document.getElementById('css-test-layers');
    if (!container) return;
    for (let i = 0; i < values.length; i++) {
        const el = container.querySelector(`.css-test-layer-brightness[data-layer-idx="${i}"]`);
        if (!el) continue;
        const v = values[i];
        if (v != null) {
            el.innerHTML = `${ICON_SUN_DIM} ${v}%`;
            el.style.display = '';
        } else {
            el.style.display = 'none';
        }
    }
}

export function applyCssTestSettings() {
    if (!_cssTestSourceId) return;

    const ledInput = document.getElementById('css-test-led-input');
    let leds = parseInt(ledInput?.value, 10);
    if (isNaN(leds) || leds < 1) leds = 1;
    if (leds > 2000) leds = 2000;
    if (ledInput) ledInput.value = leds;
    localStorage.setItem(_CSS_TEST_LED_KEY, String(leds));

    const fpsInput = document.getElementById('css-test-fps-input');
    let fps = parseInt(fpsInput?.value, 10);
    if (isNaN(fps) || fps < 1) fps = 1;
    if (fps > 60) fps = 60;
    if (fpsInput) fpsInput.value = fps;
    localStorage.setItem(_CSS_TEST_FPS_KEY, String(fps));

    // Clear frame data but keep views/layout intact to avoid size jump
    _cssTestLatestRgb = null;
    _cssTestMeta = null;
    _cssTestLayerData = null;

    // Read selected input source from selector (both CSS and CSPT modes)
    const inputSel = document.getElementById('css-test-cspt-input-select');
    if (inputSel && inputSel.value) {
        _cssTestSourceId = inputSel.value;
        // Re-detect api_input when source changes
        const sources = colorStripSourcesCache.data || [];
        const src = sources.find(s => s.id === _cssTestSourceId);
        _cssTestIsApiInput = src?.source_type === 'api_input';
    }

    // Reconnect (generation counter ignores stale frames from old WS)
    _cssTestConnect(_cssTestSourceId, leds, fps);
}

function _cssTestRenderLoop() {
    _cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
    if (!_cssTestMeta) return;

    const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;

    if (_cssTestIsComposite && _cssTestLayerData) {
        _cssTestRenderLayers(_cssTestLayerData);
    } else if (isPicture && _cssTestLatestRgb) {
        _cssTestRenderRect(_cssTestLatestRgb, _cssTestMeta.edges);
    } else if (_cssTestLatestRgb) {
        _cssTestRenderStrip(_cssTestLatestRgb);
    }
}

function _cssTestRenderStrip(rgbBytes) {
    const canvas = document.getElementById('css-test-strip-canvas');
    if (!canvas) return;
    const ledCount = rgbBytes.length / 3;
    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 _cssTestRenderLayers(data) {
    const container = document.getElementById('css-test-layers');
    if (!container) return;
    const canvases = container.querySelectorAll('.css-test-layer-canvas');

    // Composite canvas is first
    const compositeCanvas = container.querySelector('[data-layer-idx="composite"]');
    if (compositeCanvas) _cssTestRenderStripCanvas(compositeCanvas, data.composite);

    // Individual layer canvases
    for (let i = 0; i < data.layers.length; i++) {
        const canvas = container.querySelector(`[data-layer-idx="${i}"]`);
        if (canvas) _cssTestRenderStripCanvas(canvas, data.layers[i]);
    }
}

function _cssTestRenderStripCanvas(canvas, rgbBytes) {
    const ledCount = rgbBytes.length / 3;
    if (ledCount <= 0) return;
    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 _cssTestRenderRect(rgbBytes, edges) {
    // edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...]
    // indices are pre-computed on server: reverse + offset already applied
    const edgeMap = { top: [], right: [], bottom: [], left: [] };
    for (const e of edges) {
        if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
    }

    for (const [edge, indices] of Object.entries(edgeMap)) {
        const canvas = document.getElementById(`css-test-edge-${edge}`);
        if (!canvas) continue;
        const count = indices.length;
        if (count === 0) { canvas.width = 0; continue; }

        const isH = edge === 'top' || edge === 'bottom';
        canvas.width = isH ? count : 1;
        canvas.height = isH ? 1 : count;
        const ctx = canvas.getContext('2d');
        const imageData = ctx.createImageData(canvas.width, canvas.height);
        const px = imageData.data;
        for (let i = 0; i < count; i++) {
            const si = indices[i] * 3;
            const di = i * 4;
            px[di] = rgbBytes[si] || 0;
            px[di + 1] = rgbBytes[si + 1] || 0;
            px[di + 2] = rgbBytes[si + 2] || 0;
            px[di + 3] = 255;
        }
        ctx.putImageData(imageData, 0, 0);
    }
}

function _cssTestRenderBorderOverlay(frameW, frameH) {
    const screen = document.getElementById('css-test-rect-screen');
    if (!screen || !_cssTestMeta) return;

    // Remove any previous border overlay
    screen.querySelectorAll('.css-test-border-overlay').forEach(el => el.remove());

    const bw = _cssTestMeta.border_width;
    if (!bw || bw <= 0) return;

    const edges = _cssTestMeta.edges || [];
    const activeEdges = new Set(edges.map(e => e.edge));

    // Compute border as percentage of frame dimensions
    const bwPctH = (bw / frameH * 100).toFixed(2);  // % for top/bottom
    const bwPctW = (bw / frameW * 100).toFixed(2);   // % for left/right

    const overlayStyle = 'position:absolute;pointer-events:none;background:rgba(var(--primary-color-rgb, 76,175,80),0.18);border:1px solid rgba(var(--primary-color-rgb, 76,175,80),0.4);';

    if (activeEdges.has('top')) {
        const el = document.createElement('div');
        el.className = 'css-test-border-overlay';
        el.style.cssText = `${overlayStyle}top:0;left:0;right:0;height:${bwPctH}%;`;
        el.title = `${t('calibration.border_width')} ${bw}px`;
        screen.appendChild(el);
    }
    if (activeEdges.has('bottom')) {
        const el = document.createElement('div');
        el.className = 'css-test-border-overlay';
        el.style.cssText = `${overlayStyle}bottom:0;left:0;right:0;height:${bwPctH}%;`;
        el.title = `${t('calibration.border_width')} ${bw}px`;
        screen.appendChild(el);
    }
    if (activeEdges.has('left')) {
        const el = document.createElement('div');
        el.className = 'css-test-border-overlay';
        el.style.cssText = `${overlayStyle}top:0;bottom:0;left:0;width:${bwPctW}%;`;
        el.title = `${t('calibration.border_width')} ${bw}px`;
        screen.appendChild(el);
    }
    if (activeEdges.has('right')) {
        const el = document.createElement('div');
        el.className = 'css-test-border-overlay';
        el.style.cssText = `${overlayStyle}top:0;bottom:0;right:0;width:${bwPctW}%;`;
        el.title = `${t('calibration.border_width')} ${bw}px`;
        screen.appendChild(el);
    }

    // Show border width label
    const label = document.createElement('div');
    label.className = 'css-test-border-overlay css-test-border-label';
    label.textContent = `${bw}px`;
    screen.appendChild(label);
}

function _cssTestRenderTicks(edges) {
    const canvas = document.getElementById('css-test-rect-ticks');
    const rectEl = document.getElementById('css-test-rect');
    if (!canvas || !rectEl) return;

    const outer = canvas.parentElement;
    const outerRect = outer.getBoundingClientRect();
    const gridRect = rectEl.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;

    canvas.width = outerRect.width * dpr;
    canvas.height = outerRect.height * dpr;
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);
    ctx.clearRect(0, 0, outerRect.width, outerRect.height);

    // Grid offset within outer container (the padding area)
    const gx = gridRect.left - outerRect.left;
    const gy = gridRect.top - outerRect.top;
    const gw = gridRect.width;
    const gh = gridRect.height;
    const edgeThick = 14; // matches CSS grid-template

    // Build edge map with indices
    const edgeMap = { top: [], right: [], bottom: [], left: [] };
    for (const e of edges) {
        if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
    }

    const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
    const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
    const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
    ctx.strokeStyle = tickStroke;
    ctx.fillStyle = tickFill;
    ctx.lineWidth = 1;
    ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';

    const edgeGeom = {
        top:    { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy, dir: -1, horizontal: true },
        bottom: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy + gh, dir: 1, horizontal: true },
        left:   { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx, dir: -1, horizontal: false },
        right:  { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx + gw, dir: 1, horizontal: false },
    };

    for (const [edge, indices] of Object.entries(edgeMap)) {
        const count = indices.length;
        if (count === 0) continue;
        const geo = edgeGeom[edge];

        // Determine which ticks to label
        const labelsToShow = new Set([0]);
        if (count > 1) labelsToShow.add(count - 1);

        if (count > 2) {
            const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
            const maxDigits = String(Math.max(...indices)).length;
            const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 20;
            const niceSteps = [5, 10, 25, 50, 100, 250, 500];
            let step = niceSteps[niceSteps.length - 1];
            for (const s of niceSteps) {
                if (Math.floor(count / s) <= 4) { step = s; break; }
            }

            const tickPx = i => (i / (count - 1)) * edgeLen;
            const placed = [];
            // Place boundary ticks first
            labelsToShow.forEach(i => placed.push(tickPx(i)));

            for (let i = 1; i < count - 1; i++) {
                if (indices[i] % step === 0) {
                    const px = tickPx(i);
                    if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
                        labelsToShow.add(i);
                        placed.push(px);
                    }
                }
            }
        }

        const tickLen = 6;
        labelsToShow.forEach(idx => {
            const label = String(indices[idx]);
            const fraction = count > 1 ? idx / (count - 1) : 0.5;

            if (geo.horizontal) {
                const tx = geo.x1 + fraction * (geo.x2 - geo.x1);
                const ty = geo.y;
                const tickDir = geo.dir; // -1 for top (tick goes up), +1 for bottom (tick goes down)
                ctx.beginPath();
                ctx.moveTo(tx, ty);
                ctx.lineTo(tx, ty + tickDir * tickLen);
                ctx.stroke();
                ctx.textAlign = 'center';
                ctx.textBaseline = tickDir < 0 ? 'bottom' : 'top';
                ctx.fillText(label, tx, ty + tickDir * (tickLen + 2));
            } else {
                const ty = geo.y1 + fraction * (geo.y2 - geo.y1);
                const tx = geo.x;
                const tickDir = geo.dir; // -1 for left (tick goes left), +1 for right (tick goes right)
                ctx.beginPath();
                ctx.moveTo(tx, ty);
                ctx.lineTo(tx + tickDir * tickLen, ty);
                ctx.stroke();
                ctx.textBaseline = 'middle';
                ctx.textAlign = tickDir < 0 ? 'right' : 'left';
                ctx.fillText(label, tx + tickDir * (tickLen + 2), ty);
            }
        });
    }
}

function _cssTestRenderStripAxis(canvasId, ledCount) {
    const canvas = document.getElementById(canvasId);
    if (!canvas || ledCount <= 0) return;

    const dpr = window.devicePixelRatio || 1;
    const w = canvas.clientWidth;
    const h = canvas.clientHeight;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);
    ctx.clearRect(0, 0, w, h);

    const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
    const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
    const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
    ctx.strokeStyle = tickStroke;
    ctx.fillStyle = tickFill;
    ctx.lineWidth = 1;
    ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
    ctx.textBaseline = 'top';

    const tickLen = 5;

    // Determine which ticks to label
    const labelsToShow = new Set([0]);
    if (ledCount > 1) labelsToShow.add(ledCount - 1);

    if (ledCount > 2) {
        const maxDigits = String(ledCount - 1).length;
        const minSpacing = maxDigits * 7 + 8;
        const niceSteps = [5, 10, 25, 50, 100, 250, 500];
        let step = niceSteps[niceSteps.length - 1];
        for (const s of niceSteps) {
            if (Math.floor(ledCount / s) <= Math.floor(w / minSpacing)) { step = s; break; }
        }

        const tickPx = i => (i / (ledCount - 1)) * w;
        const placed = [];
        labelsToShow.forEach(i => placed.push(tickPx(i)));

        for (let i = 1; i < ledCount - 1; i++) {
            if (i % step === 0) {
                const px = tickPx(i);
                if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
                    labelsToShow.add(i);
                    placed.push(px);
                }
            }
        }
    }

    labelsToShow.forEach(idx => {
        const fraction = ledCount > 1 ? idx / (ledCount - 1) : 0.5;
        const tx = fraction * w;
        ctx.beginPath();
        ctx.moveTo(tx, 0);
        ctx.lineTo(tx, tickLen);
        ctx.stroke();
        // Align first tick left, last tick right, others center
        if (idx === 0) ctx.textAlign = 'left';
        else if (idx === ledCount - 1) ctx.textAlign = 'right';
        else ctx.textAlign = 'center';
        ctx.fillText(String(idx), tx, tickLen + 1);
    });
}

export function fireCssTestNotification() {
    for (const id of _cssTestNotificationIds) {
        testNotification(id);
    }
}

export function fireCssTestNotificationLayer(sourceId) {
    testNotification(sourceId);
}

let _cssTestFpsSampleInterval = null;

function _cssTestStartFpsSampling() {
    _cssTestStopFpsSampling();
    _cssTestFpsTimestamps = [];
    _cssTestFpsActualHistory = [];
    if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }

    // Sample FPS every 1 second
    _cssTestFpsSampleInterval = setInterval(() => {
        const now = performance.now();
        // Count frames in the last 1 second
        const cutoff = now - 1000;
        _cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
        const fps = _cssTestFpsTimestamps.length;

        _cssTestFpsActualHistory.push(fps);
        if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
            _cssTestFpsActualHistory.shift();

        // Update numeric display (match target card format)
        const valueEl = document.getElementById('css-test-fps-value');
        if (valueEl) valueEl.textContent = fps;
        const avgEl = document.getElementById('css-test-fps-avg');
        if (avgEl && _cssTestFpsActualHistory.length > 1) {
            const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
            avgEl.textContent = `avg ${avg.toFixed(1)}`;
        }

        // Create or update chart
        if (!_cssTestFpsChart) {
            _cssTestFpsChart = createFpsSparkline(
                'css-test-fps-chart',
                _cssTestFpsActualHistory,
                [],  // no "current" dataset, just actual
                60,  // y-axis max
            );
        }
        if (_cssTestFpsChart) {
            const ds = _cssTestFpsChart.data.datasets[0].data;
            ds.length = 0;
            ds.push(..._cssTestFpsActualHistory);
            while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
            _cssTestFpsChart.data.labels.length = ds.length;
            _cssTestFpsChart.update('none');
        }
    }, 1000);
}

function _cssTestStopFpsSampling() {
    if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
    if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
}

export function closeTestCssSourceModal() {
    if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
    if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
    _cssTestLatestRgb = null;
    _cssTestMeta = null;
    _cssTestSourceId = null;
    _cssTestIsComposite = false;
    _cssTestLayerData = null;
    _cssTestNotificationIds = [];
    _cssTestIsApiInput = false;
    _cssTestStopFpsSampling();
    _cssTestFpsTimestamps = [];
    _cssTestFpsActualHistory = [];
    // Revoke blob URL for frame preview
    const screen = document.getElementById('css-test-rect-screen');
    if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
    // Reset aspect ratio for next open
    const rect = document.getElementById('css-test-rect');
    if (rect) { rect.style.aspectRatio = ''; rect.style.height = ''; rect._aspectSet = false; }
    // Reset modal width
    const modalContent = document.querySelector('#test-css-source-modal .modal-content');
    if (modalContent) modalContent.style.maxWidth = '';
    const modal = document.getElementById('test-css-source-modal');
    if (modal) { modal.style.display = 'none'; }
}

/* Gradient editor moved to css-gradient-editor.js */
