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

import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import {
    getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon,
    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_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
import {
    rgbArrayToHex, hexToRgbArray,
    gradientInit, gradientRenderAll, gradientAddStop,
    getGradientStops, gradientSetIdPrefix,
} from './css-gradient-editor.ts';
import {
    compositeDestroyEntitySelects, compositeSetAvailableSources, compositeGetAvailableSources,
    compositeRenderList, compositeAddLayer, compositeRemoveLayer, compositeGetLayers, loadCompositeState,
    compositeGetRawLayers,
} from './color-strips-composite.ts';
import {
    ensureNotificationEffectIconSelect, ensureNotificationFilterModeIconSelect,
    onNotificationFilterModeChange,
    notificationAddAppOverride, notificationRemoveAppOverride,
    notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
    testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
    loadNotificationState, resetNotificationState, showNotificationEndpoint,
} from './color-strips-notification.ts';

// Re-export for app.js window global bindings
export { gradientInit, gradientRenderAll, gradientAddStop };
export { compositeAddLayer, compositeRemoveLayer };
export {
    onNotificationFilterModeChange,
    notificationAddAppOverride, notificationRemoveAppOverride,
    testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
};
export { _getAnimationPayload, _colorCycleGetColors };

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

const cssEditorModal = new CSSEditorModal();

let _cssTagsInput: any = null;

// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null;
let _cssAudioSourceEntitySelect: any = null;
let _cssClockEntitySelect: any = null;
let _processedInputEntitySelect: any = null;
let _processedTemplateEntitySelect: any = 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', 'weather', '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: any = null;

function _ensureCSSTypeIconSelect() {
    const sel = document.getElementById('css-editor-type') as HTMLSelectElement;
    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 ───────────────────────────────────────── */

const CSS_SECTION_MAP: Record<string, string> = {
    'picture': 'css-editor-picture-section',
    'picture_advanced': 'css-editor-picture-section',
    'static': 'css-editor-static-section',
    'color_cycle': 'css-editor-color-cycle-section',
    'gradient': 'css-editor-gradient-section',
    'effect': 'css-editor-effect-section',
    'composite': 'css-editor-composite-section',
    'mapped': 'css-editor-mapped-section',
    'audio': 'css-editor-audio-section',
    'api_input': 'css-editor-api-input-section',
    'notification': 'css-editor-notification-section',
    'daylight': 'css-editor-daylight-section',
    'candlelight': 'css-editor-candlelight-section',
    'weather': 'css-editor-weather-section',
    'processed': 'css-editor-processed-section',
};

const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];

const CSS_TYPE_SETUP: Record<string, () => void> = {
    processed: () => _populateProcessedSelectors(),
    effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); onEffectTypeChange(); },
    audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteIconSelect(); onAudioVizChange(); _loadAudioSources(); },
    gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
    notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
    candlelight: () => _ensureCandleTypeIconSelect(),
    weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
    composite: () => compositeRenderList(),
    mapped: () => _mappedRenderList(),
};

export function onCSSTypeChange() {
    const type = (document.getElementById('css-editor-type') as HTMLInputElement).value;
    // Sync icon-select trigger display
    if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);

    // Hide all type-specific sections, then show the active one
    CSS_ALL_SECTION_IDS.forEach(id => {
        const el = document.getElementById(id);
        if (el) el.style.display = 'none';
    });
    const activeSection = CSS_SECTION_MAP[type];
    if (activeSection) {
        const el = document.getElementById(activeSection);
        if (el) el.style.display = '';
    }

    // Hide picture source dropdown for advanced (sources are per-line in calibration)
    const psGroup = document.getElementById('css-editor-picture-source-group') as HTMLElement | null;
    if (psGroup) psGroup.style.display = (type === 'picture') ? '' : 'none';

    const isPictureType = type === 'picture' || type === 'picture_advanced';
    if (isPictureType) _ensureInterpolationIconSelect();

    // Run type-specific setup
    const setupFn = CSS_TYPE_SETUP[type];
    if (setupFn) setupFn();

    // Animation section — shown for static/gradient only
    const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
    const animTypeSelect = document.getElementById('css-editor-animation-type') as HTMLSelectElement;
    if (type === 'static' || type === 'gradient') {
        animSection.style.display = '';
        const opts = type === 'gradient'
            ? ['none','breathing','gradient_shift','wave','noise_perturb','hue_rotate','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') as HTMLElement).style.display =
        hasLedCount.includes(type) ? '' : 'none';

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

    _autoGenerateCSSName();
}

function _populateClockDropdown(selectedId?: any) {
    const sel = document.getElementById('css-editor-clock') as HTMLSelectElement;
    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
}

let _weatherSourceEntitySelect: any = null;

function _populateWeatherSourceDropdown() {
    const sources = _cachedWeatherSources || [];
    const sel = document.getElementById('css-editor-weather-source') as HTMLSelectElement;
    const prev = sel.value;
    sel.innerHTML = `<option value="">\u2014</option>` +
        sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
    sel.value = prev || '';
    if (_weatherSourceEntitySelect) _weatherSourceEntitySelect.destroy();
    if (sources.length > 0) {
        _weatherSourceEntitySelect = new EntitySelect({
            target: sel,
            getItems: () => (_cachedWeatherSources || []).map(s => ({
                value: s.id,
                label: s.name,
                icon: getColorStripIcon('weather'),
            })),
            placeholder: t('palette.search'),
        });
    }
}

function _populateProcessedSelectors() {
    const editingId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
    const allSources = (colorStripSourcesCache.data || []) as any[];
    // 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') as HTMLSelectElement;
    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 || []) as any[])
                .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 || []) as any[];
    const tplSel = document.getElementById('css-editor-processed-template') as HTMLSelectElement;
    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 || []) as any[]).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') as HTMLInputElement).value;
    return {
        enabled: type !== 'none',
        type: type !== 'none' ? type : 'breathing',
    };
}

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

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

function _syncAnimationSpeedState() {
    const type = (document.getElementById('css-editor-animation-type') as HTMLInputElement).value;
    const descEl = document.getElementById('css-editor-animation-type-desc') as HTMLElement | null;
    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') as HTMLInputElement).checked;
    (document.getElementById('css-editor-daylight-speed-group') as HTMLElement).style.display = isRealTime ? 'none' : '';
}

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

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

let _animationTypeIconSelect: any = null;
let _interpolationIconSelect: any = null;
let _effectTypeIconSelect: any = null;
let _effectPaletteIconSelect: any = null;
let _audioPaletteIconSelect: any = null;
let _audioVizIconSelect: any = null;
let _gradientPresetIconSelect: any = null;
let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null;
let _apiInputInterpolationIconSelect: any = null;
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;

function _ensureInterpolationIconSelect() {
    const sel = document.getElementById('css-editor-interpolation') as HTMLSelectElement | null;
    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 _ensureApiInputInterpolationIconSelect() {
    const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'linear',  icon: _icon(P.activity),  label: t('color_strip.api_input.interpolation.linear'),  desc: t('color_strip.api_input.interpolation.linear.desc') },
        { value: 'nearest', icon: _icon(P.hash),       label: t('color_strip.api_input.interpolation.nearest'), desc: t('color_strip.api_input.interpolation.nearest.desc') },
        { value: 'none',    icon: _icon(P.circleOff),  label: t('color_strip.api_input.interpolation.none'),    desc: t('color_strip.api_input.interpolation.none.desc') },
    ];
    if (_apiInputInterpolationIconSelect) { _apiInputInterpolationIconSelect.updateItems(items); return; }
    _apiInputInterpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}

function _ensureEffectTypeIconSelect() {
    const sel = document.getElementById('css-editor-effect-type') as HTMLSelectElement | null;
    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') },
        { value: 'rain',             icon: _icon(P.cloudSun),  label: t('color_strip.effect.rain'),             desc: t('color_strip.effect.rain.desc') },
        { value: 'comet',            icon: _icon(P.rocket),    label: t('color_strip.effect.comet'),            desc: t('color_strip.effect.comet.desc') },
        { value: 'bouncing_ball',    icon: _icon(P.activity),  label: t('color_strip.effect.bouncing_ball'),    desc: t('color_strip.effect.bouncing_ball.desc') },
        { value: 'fireworks',        icon: _icon(P.sparkles),  label: t('color_strip.effect.fireworks'),        desc: t('color_strip.effect.fireworks.desc') },
        { value: 'sparkle_rain',     icon: _icon(P.star),      label: t('color_strip.effect.sparkle_rain'),     desc: t('color_strip.effect.sparkle_rain.desc') },
        { value: 'lava_lamp',        icon: _icon(P.flame),     label: t('color_strip.effect.lava_lamp'),        desc: t('color_strip.effect.lava_lamp.desc') },
        { value: 'wave_interference',icon: _icon(P.rainbow),   label: t('color_strip.effect.wave_interference'),desc: t('color_strip.effect.wave_interference.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') as HTMLSelectElement | null;
    if (!sel) return;
    const items = _buildGradientEntityItems();
    _syncSelectOptions(sel, items);
    if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
    _effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
}

function _ensureGradientEasingIconSelect() {
    const sel = document.getElementById('css-editor-gradient-easing') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'linear',      icon: _icon(P.trendingUp),         label: t('color_strip.gradient.easing.linear'),      desc: t('color_strip.gradient.easing.linear.desc') },
        { value: 'ease_in_out', icon: _icon(P.activity),           label: t('color_strip.gradient.easing.ease_in_out'), desc: t('color_strip.gradient.easing.ease_in_out.desc') },
        { value: 'step',        icon: _icon(P.layoutDashboard),    label: t('color_strip.gradient.easing.step'),        desc: t('color_strip.gradient.easing.step.desc') },
        { value: 'cubic',       icon: _icon(P.slidersHorizontal), label: t('color_strip.gradient.easing.cubic'),       desc: t('color_strip.gradient.easing.cubic.desc') },
    ];
    if (_gradientEasingIconSelect) { _gradientEasingIconSelect.updateItems(items); return; }
    _gradientEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}

function _ensureCandleTypeIconSelect() {
    const sel = document.getElementById('css-editor-candlelight-type') as HTMLSelectElement | null;
    if (!sel) return;
    const items = [
        { value: 'default',  icon: _icon(P.flame),    label: t('color_strip.candlelight.type.default'),  desc: t('color_strip.candlelight.type.default.desc') },
        { value: 'taper',    icon: _icon(P.activity),  label: t('color_strip.candlelight.type.taper'),    desc: t('color_strip.candlelight.type.taper.desc') },
        { value: 'votive',   icon: _icon(P.lightbulb), label: t('color_strip.candlelight.type.votive'),   desc: t('color_strip.candlelight.type.votive.desc') },
        { value: 'bonfire',  icon: _icon(P.zap),       label: t('color_strip.candlelight.type.bonfire'),  desc: t('color_strip.candlelight.type.bonfire.desc') },
    ];
    if (_candleTypeIconSelect) { _candleTypeIconSelect.updateItems(items); return; }
    _candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}

function _ensureAudioPaletteIconSelect() {
    const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
    if (!sel) return;
    const items = _buildGradientEntityItems();
    _syncSelectOptions(sel, items);
    if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
    _audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
}

function _ensureAudioVizIconSelect() {
    const sel = document.getElementById('css-editor-audio-viz') as HTMLSelectElement | null;
    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 _buildGradientEntityItems() {
    const gradients = _getGradients();
    return gradients.map(g => ({
        value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name,
    }));
}

/** Sync <option> elements in a <select> from items array. */
function _syncSelectOptions(sel: HTMLSelectElement, items: Array<{ value: string; label: string }>) {
    sel.innerHTML = '';
    for (const item of items) {
        const opt = document.createElement('option');
        opt.value = item.value;
        opt.textContent = item.label;
        sel.appendChild(opt);
    }
}

function _ensureGradientPresetIconSelect() {
    const sel = document.getElementById('css-editor-gradient-preset') as HTMLSelectElement | null;
    if (!sel) return;
    const items = _buildGradientEntityItems();
    _syncSelectOptions(sel, items);
    if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
    _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') });
}

/** Rebuild the gradient picker after entity changes. */
export function refreshGradientPresetPicker() {
    const items = _buildGradientEntityItems();
    if (_gradientPresetIconSelect) _gradientPresetIconSelect.updateItems(items);
    if (_effectPaletteIconSelect) _effectPaletteIconSelect.updateItems(items);
    if (_audioPaletteIconSelect) _audioPaletteIconSelect.updateItems(items);
}

/** Render the user-created gradient list below the save button. */
function _renderCustomPresetList() {
    const container = document.getElementById('css-editor-custom-presets-list') as HTMLElement | null;
    if (!container) return;
    const userGradients = _getGradients().filter(g => !g.is_builtin);
    if (userGradients.length === 0) {
        container.innerHTML = '';
        return;
    }
    container.innerHTML = userGradients.map(g => {
        const strip = _gradientEntityStripHTML(g.stops, 60, 14);
        const safeName = escapeHtml(g.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="onGradientPresetChange('${g.id}')"
                title="${t('color_strip.gradient.preset.apply')}">&#x2713;</button>
            <button type="button" class="btn btn-icon btn-sm btn-danger"
                onclick="deleteAndRefreshGradientPreset('${g.id}')"
                title="${t('common.delete')}">&#x2715;</button>
        </div>`;
    }).join('');
}

function _buildAnimationTypeItems(cssType: any) {
    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') },
            { value: 'noise_perturb',  icon: _icon(P.activity),    label: t('color_strip.animation.type.noise_perturb'),  desc: t('color_strip.animation.type.noise_perturb.desc') },
            { value: 'hue_rotate',     icon: _icon(P.rotateCw),    label: t('color_strip.animation.type.hue_rotate'),     desc: t('color_strip.animation.type.hue_rotate.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 — loads stops from gradient entity. */
export function onGradientPresetChange(value: any) {
    if (!value) return;  // "— Custom —" selected
    const g = _findGradient(value);
    if (g) {
        gradientInit(g.stops);
    }
}

/** Called from inline onclick in the HTML save button. Saves current gradient to server as new entity. */
export async function promptAndSaveGradientPreset() {
    const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
    if (!name || !name.trim()) return;
    const stops = getGradientStops().map(s => ({
        position: s.position,
        color: s.color,
    }));
    try {
        await fetchWithAuth('/gradients', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ name: name.trim(), stops }),
        });
        await gradientsCache.fetch({ force: true });
        showToast(t('color_strip.gradient.preset.saved'), 'success');
        refreshGradientPresetPicker();
    } catch (e: any) {
        showToast(e.message || 'Failed to save gradient', 'error');
    }
}

/** Delete a gradient entity and refresh the picker. */
export async function deleteAndRefreshGradientPreset(gradientId: any) {
    try {
        await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
        await gradientsCache.fetch({ force: true });
        showToast(t('color_strip.gradient.preset.deleted'), 'success');
        refreshGradientPresetPicker();
    } catch (e: any) {
        showToast(e.message || 'Failed to delete gradient', 'error');
    }
}

function _ensureAnimationTypeIconSelect(cssType: any) {
    const sel = document.getElementById('css-editor-animation-type') as HTMLSelectElement | null;
    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 ──────────────────────────────────────── */

/**
 * Build a gradient strip preview HTML from a GradientEntity's stops.
 * Accepts [{position, color: [R,G,B]}, ...] format from the API.
 */
function _gradientEntityStripHTML(stops: Array<{ position: number; color: number[] }>, w = 80, h = 16) {
    const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
    return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
}

/** Get cached gradient entities (or empty array if not yet loaded). */
function _getGradients(): GradientEntity[] {
    return gradientsCache.data || [];
}

/** Find a gradient entity by ID. */
function _findGradient(id: string): GradientEntity | undefined {
    return _getGradients().find(g => g.id === id);
}

// Default palette per effect type
export function onEffectTypeChange() {
    const et = (document.getElementById('css-editor-effect-type') as HTMLInputElement).value;
    // palette: all except meteor, comet, bouncing_ball (which use color picker)
    const usesColorPicker = ['meteor', 'comet', 'bouncing_ball'].includes(et);
    (document.getElementById('css-editor-effect-palette-group') as HTMLElement).style.display = !usesColorPicker ? '' : 'none';
    // color picker: meteor, comet, bouncing_ball
    (document.getElementById('css-editor-effect-color-group') as HTMLElement).style.display = usesColorPicker ? '' : 'none';
    // intensity: most effects use it
    const usesIntensity = ['fire', 'meteor', 'aurora', 'rain', 'comet', 'bouncing_ball', 'fireworks', 'sparkle_rain'].includes(et);
    (document.getElementById('css-editor-effect-intensity-group') as HTMLElement).style.display = usesIntensity ? '' : 'none';
    // scale: effects with spatial parameters
    const usesScale = ['plasma', 'noise', 'aurora', 'rain', 'lava_lamp', 'wave_interference'].includes(et);
    (document.getElementById('css-editor-effect-scale-group') as HTMLElement).style.display = usesScale ? '' : 'none';
    // mirror: meteor, comet
    (document.getElementById('css-editor-effect-mirror-group') as HTMLElement).style.display =
        ['meteor', 'comet'].includes(et) ? '' : 'none';
    // custom palette visibility
    if (!usesColorPicker) onEffectPaletteChange();
    else (document.getElementById('css-editor-effect-custom-palette-group') as HTMLElement).style.display = 'none';
    // description
    const descEl = document.getElementById('css-editor-effect-type-desc') as HTMLElement | null;
    if (descEl) {
        const desc = t('color_strip.effect.' + et + '.desc') || '';
        descEl.textContent = desc;
        descEl.style.display = desc ? '' : 'none';
    }
    _autoGenerateCSSName();
}

export function onEffectPaletteChange() {
    // No-op — kept for HTML onclick compatibility
}

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

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

function _syncColorCycleFromDom() {
    const inputs = document.querySelectorAll<HTMLInputElement>('#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') as HTMLElement | null;
    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: number) {
    _syncColorCycleFromDom();
    if (_colorCycleColors.length <= 2) return;
    _colorCycleColors.splice(i, 1);
    _colorCycleRenderList();
}

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

function _loadColorCycleState(css: any) {
    const raw = css && css.colors;
    _colorCycleColors = (raw && raw.length >= 2)
        ? raw.map((c: any) => 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 (extracted to color-strips-composite.ts) ── */
/* ── Mapped zone helpers ──────────────────────────────────────── */

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

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') as HTMLElement | null;
    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<HTMLSelectElement>('.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: number) {
    _mappedZonesSyncFromDom();
    _mappedZones.splice(i, 1);
    _mappedRenderList();
}

function _mappedZonesSyncFromDom() {
    const list = document.getElementById('mapped-zones-list') as HTMLElement | null;
    if (!list) return;
    const srcs = list.querySelectorAll<HTMLSelectElement>('.mapped-zone-source');
    const starts = list.querySelectorAll<HTMLInputElement>('.mapped-zone-start');
    const ends = list.querySelectorAll<HTMLInputElement>('.mapped-zone-end');
    const reverses = list.querySelectorAll<HTMLInputElement>('.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: any) {
    const raw = css && css.zones;
    _mappedZones = (raw && raw.length > 0)
        ? raw.map((z: any) => ({
            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') as HTMLInputElement).value;
    // Palette: spectrum / beat_pulse
    (document.getElementById('css-editor-audio-palette-group') as HTMLElement).style.display =
        (viz === 'spectrum' || viz === 'beat_pulse') ? '' : 'none';
    // Base color + Peak color: vu_meter only
    (document.getElementById('css-editor-audio-color-group') as HTMLElement).style.display = viz === 'vu_meter' ? '' : 'none';
    (document.getElementById('css-editor-audio-color-peak-group') as HTMLElement).style.display = viz === 'vu_meter' ? '' : 'none';
    // Mirror: spectrum only
    (document.getElementById('css-editor-audio-mirror-group') as HTMLElement).style.display = viz === 'spectrum' ? '' : 'none';
    _autoGenerateCSSName();
}

async function _loadAudioSources() {
    const select = document.getElementById('css-editor-audio-source') as HTMLSelectElement | null;
    if (!select) return;
    try {
        const sources: any[] = await audioSourcesCache.fetch();
        select.innerHTML = sources.map(s => {
            const badge = s.source_type === 'multichannel' ? ' [multichannel]' : s.source_type === 'band_extract' ? ' [band]' : ' [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: any) {
    (document.getElementById('css-editor-audio-viz') as HTMLInputElement).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') as HTMLInputElement).value = sensitivity;
    (document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = parseFloat(sensitivity).toFixed(1);

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

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

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

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

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

type CardPropsRenderer = (source: ColorStripSource, opts: {
    clockBadge: string;
    animBadge: string;
    audioSourceMap: Record<string, any>;
    pictureSourceMap: Record<string, any>;
}) => string;

const NON_PICTURE_TYPES = new Set([
    'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
    'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
]);

const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
    static: (source, { clockBadge, animBadge }) => {
        const hexColor = rgbArrayToHex(source.color!);
        return `
            <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}
        `;
    },
    color_cycle: (source, { clockBadge }) => {
        const colors = source.colors || [];
        const swatches = colors.slice(0, 8).map((c: any) =>
            `<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
        ).join('');
        return `
            <span class="stream-card-prop">${swatches}</span>
            ${clockBadge}
        `;
    },
    gradient: (source, { clockBadge, animBadge }) => {
        const stops = source.stops || [];
        const sortedStops = [...stops].sort((a, b) => a.position - b.position);
        let cssGradient = '';
        if (sortedStops.length >= 2) {
            const parts: string[] = [];
            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(', ')})`;
        }
        return `
            ${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}
        `;
    },
    effect: (source, { clockBadge }) => {
        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) : '';
        return `
            <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}
        `;
    },
    composite: (source) => {
        const layerCount = (source.layers || []).length;
        const enabledCount = (source.layers || []).filter((l: any) => l.enabled !== false).length;
        return `
            <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>` : ''}
        `;
    },
    mapped: (source) => {
        const zoneCount = (source.zones || []).length;
        return `
            <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>` : ''}
        `;
    },
    audio: (source, { audioSourceMap }) => {
        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) : '';
        return `
            <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' : as.source_type === 'band_extract' ? 'audio-band-extract' : 'audio-multi') : 'audio-multi';
                const asTab = as ? (as.source_type === 'mono' ? 'audio_mono' : as.source_type === 'band_extract' ? 'audio_band_extract' : 'audio_multi') : 'audio_multi';
                return `<span class="stream-card-prop${as ? ' stream-card-link' : ''}" title="${t('color_strip.audio.source')}"${as ? ` onclick="event.stopPropagation(); navigateToCard('streams','${asTab}','${asSection}','data-id','${source.audio_source_id}')"` : ''}>${ICON_AUDIO_LOOPBACK} ${escapeHtml(asName)}</span>`;
            })() : ''}
            ${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
        `;
    },
    api_input: (source) => {
        const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
        const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
        const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
        return `
            <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>
            <span class="stream-card-prop" title="${t('color_strip.api_input.interpolation')}">${escapeHtml(interpLabel)}</span>
        `;
    },
    notification: (source) => {
        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;
        return `
            <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>` : ''}
        `;
    },
    daylight: (source, { clockBadge }) => {
        const useRealTime = source.use_real_time;
        const speedVal = (source.speed ?? 1.0).toFixed(1);
        return `
            <span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
            ${clockBadge}
        `;
    },
    candlelight: (source, { clockBadge }) => {
        const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
        const numCandles = source.num_candles ?? 3;
        return `
            <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}
        `;
    },
    weather: (source, { clockBadge }) => {
        const speedVal = (source.speed ?? 1.0).toFixed(1);
        const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
        const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
        const wsName = ws?.name || '—';
        const wsLink = ws
            ? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','weather','weather-sources','data-id','${source.weather_source_id}')`
            : '';
        return `
            <span class="stream-card-prop${wsLink}" title="${t('color_strip.weather.source')}">${ICON_LINK_SOURCE} ${escapeHtml(wsName)}</span>
            <span class="stream-card-prop">${ICON_FAST_FORWARD} ${speedVal}x</span>
            <span class="stream-card-prop">${ICON_THERMOMETER} ${tempInfl}</span>
            ${clockBadge}
        `;
    },
    processed: (source) => {
        const inputSrc = ((colorStripSourcesCache.data || []) as any[]).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)
            : '—';
        return `
            <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>
        `;
    },
    picture_advanced: (source, { pictureSourceMap }) => {
        const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
        const lines = cal.lines || [];
        const totalLeds = lines.reduce((s: any, l: any) => s + (l.led_count || 0), 0);
        const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
        const psIds: any[] = [...new Set(lines.map((l: any) => l.picture_source_id).filter(Boolean))];
        const psNames = psIds.map((id: any) => {
            const ps = pictureSourceMap && pictureSourceMap[id];
            return ps ? ps.name : id;
        });
        return `
            <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>` : ''}
        `;
    },
};

/** Fallback renderer for picture-type sources (plain picture). */
function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Record<string, any>): string {
    const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id!];
    const srcName = ps ? ps.name : source.picture_source_id || '—';
    const cal = source.calibration ?? {} as Partial<import('../types.ts').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'; }
    }
    return `
        <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>` : ''}
    `;
}

export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
    // 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 isAnimatable = source.source_type === 'static' || source.source_type === 'gradient';
    const anim = isAnimatable && 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>`
        : '';

    const renderer = CSS_CARD_RENDERERS[source.source_type];
    const propsHtml = renderer
        ? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap })
        : _renderPictureCardProps(source, pictureSourceMap);

    const icon = getColorStripIcon(source.source_type);
    const isNotification = source.source_type === 'notification';
    const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type);
    const calibrationBtn = isPictureKind
        ? `<button class="btn btn-icon btn-secondary" onclick="${source.source_type === 'picture_advanced' ? `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} <span class="card-title-text">${escapeHtml(source.name)}</span>
                </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: boolean = false;

function _autoGenerateCSSName() {
    if (_cssNameManuallyEdited) return;
    if ((document.getElementById('css-editor-id') as HTMLInputElement).value) return; // edit mode
    const type = (document.getElementById('css-editor-type') as HTMLInputElement).value;
    const typeLabel = t(`color_strip.type.${type}`);
    let detail = '';
    if (type === 'picture') {
        const sel = document.getElementById('css-editor-picture-source') as HTMLSelectElement | null;
        const name = sel?.selectedOptions[0]?.textContent?.trim();
        if (name) detail = name;
    } else if (type === 'effect') {
        const eff = (document.getElementById('css-editor-effect-type') as HTMLInputElement).value;
        if (eff) detail = t(`color_strip.effect.${eff}`);
    } else if (type === 'audio') {
        const viz = (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value;
        if (viz) detail = t(`color_strip.audio.viz.${viz}`);
    } else if (type === 'notification') {
        const eff = (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value;
        if (eff) detail = t(`color_strip.notification.effect.${eff}`);
    }
    (document.getElementById('css-editor-name') as HTMLInputElement).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: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
    static: {
        load(css) {
            (document.getElementById('css-editor-color') as HTMLInputElement).value = rgbArrayToHex(css.color);
            _loadAnimationState(css.animation);
        },
        reset() {
            (document.getElementById('css-editor-color') as HTMLInputElement).value = '#ffffff';
            _loadAnimationState(null);
        },
        getPayload(name) {
            return {
                name,
                color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).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) {
            const gradientId = css.gradient_id || '';
            _ensureGradientPresetIconSelect();
            (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = gradientId;
            if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(gradientId);
            _loadAnimationState(css.animation);
            (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
            if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
        },
        reset() {
            _ensureGradientPresetIconSelect();
            // Default to first gradient
            const gradients = _getGradients();
            const defaultId = gradients.length > 0 ? gradients[0].id : '';
            (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = defaultId;
            if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(defaultId);
            _loadAnimationState(null);
            (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
            if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
        },
        getPayload(name) {
            const gradientId = (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value;
            if (!gradientId) {
                cssEditorModal.showError(t('color_strip.gradient.error.no_gradient'));
                return null;
            }
            return {
                name,
                gradient_id: gradientId,
                animation: _getAnimationPayload(),
                easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value,
            };
        },
    },
    effect: {
        load(css) {
            (document.getElementById('css-editor-effect-type') as HTMLInputElement).value = css.effect_type || 'fire';
            if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire');
            onEffectTypeChange();
            const gradientId = css.gradient_id || 'gr_builtin_fire';
            (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
            if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
            (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
            (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
            (document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
            (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
            (document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
            (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
            onEffectPaletteChange();
        },
        reset() {
            (document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
            (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
            if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
            (document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
            (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0';
            (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
            (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
        },
        getPayload(name) {
            const payload: any = {
                name,
                effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
                gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
                intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value),
                scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
                mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
            };
            // Meteor/comet/bouncing_ball use a color picker
            if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
                const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).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') as HTMLInputElement).value,
                audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
                sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
                smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
                gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
                color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
                color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
                mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).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') as HTMLInputElement).value =
                rgbArrayToHex(css.fallback_color || [0, 0, 0]);
            (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0;
            (document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
                parseFloat(css.timeout ?? 5.0).toFixed(1);
            (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
            _ensureApiInputInterpolationIconSelect();
            _showApiInputEndpoints(css.id);
        },
        reset() {
            (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
            (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any;
            (document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
            (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
            _ensureApiInputInterpolationIconSelect();
            _showApiInputEndpoints(null);
        },
        getPayload(name) {
            const fbHex = (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value;
            return {
                name,
                fallback_color: hexToRgbArray(fbHex),
                timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value),
                interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
            };
        },
    },
    notification: {
        load(css) {
            loadNotificationState(css);
        },
        reset() {
            resetNotificationState();
        },
        getPayload(name) {
            const filterList = (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value
                .split('\n').map(s => s.trim()).filter(Boolean);
            return {
                name,
                os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
                notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
                duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
                default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
                app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
                app_filter_list: filterList,
                app_colors: notificationGetAppColorsDict(),
                sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
                sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
                app_sounds: notificationGetAppSoundsDict(),
            };
        },
    },
    daylight: {
        load(css) {
            (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
            (document.getElementById('css-editor-daylight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
            (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked = css.use_real_time || false;
            (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value = css.latitude ?? 50.0;
            (document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
            (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = css.longitude ?? 0.0;
            (document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = parseFloat(css.longitude ?? 0.0).toFixed(0);
            _syncDaylightSpeedVisibility();
        },
        reset() {
            (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('css-editor-daylight-speed-val') as HTMLElement).textContent = '1.0';
            (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked = false;
            (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value = 50.0 as any;
            (document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
            (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
            (document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
        },
        getPayload(name) {
            return {
                name,
                speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value),
                use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
                latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value),
                longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value),
            };
        },
    },
    candlelight: {
        load(css) {
            (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
            (document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
            (document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
            (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
            (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
            (document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
            (document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
            (document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
            (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
            if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
        },
        reset() {
            (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
            (document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = '1.0';
            (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
            (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0';
            (document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
            (document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
            (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
            if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
        },
        getPayload(name) {
            return {
                name,
                color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
                intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value),
                num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
                speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
                wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
                candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
            };
        },
    },
    weather: {
        async load(css) {
            await weatherSourcesCache.fetch();
            _populateWeatherSourceDropdown();
            (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
            (document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0;
            (document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
            (document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
            (document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
        },
        async reset() {
            await weatherSourcesCache.fetch();
            _populateWeatherSourceDropdown();
            (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
            (document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
            (document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
            (document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
            (document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
        },
        getPayload(name) {
            const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
            if (!wsId) {
                cssEditorModal.showError(t('color_strip.weather.error.no_source'));
                return null;
            }
            return {
                name,
                weather_source_id: wsId,
                speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
                temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
            };
        },
    },
    processed: {
        async load(css) {
            await csptCache.fetch();
            await colorStripSourcesCache.fetch();
            _populateProcessedSelectors();
            (document.getElementById('css-editor-processed-input') as HTMLInputElement).value = css.input_source_id || '';
            (document.getElementById('css-editor-processed-template') as HTMLInputElement).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') as HTMLInputElement).value;
            const templateId = (document.getElementById('css-editor-processed-template') as HTMLInputElement).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') as HTMLInputElement).value = css.interpolation_mode || 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
            const smoothing = css.smoothing ?? 0.3;
            (document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
            (document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
        },
        reset() {
            (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
            (document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
            (document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
        },
        getPayload(name) {
            return {
                name,
                interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
                smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
                led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
            };
        },
    },
    picture: {
        load(css, sourceSelect) {
            sourceSelect.value = css.picture_source_id || '';
            (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
            const smoothing = css.smoothing ?? 0.3;
            (document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
            (document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
        },
        reset() {
            (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
            if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
            (document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
            (document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
        },
        getPayload(name) {
            return {
                name,
                picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
                interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
                smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
                led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
            };
        },
    },
};

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

export async function showCSSEditor(cssId: any = null, cloneData: any = null, presetType: any = 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: any[] = await streamsCache.fetch();

        // Fetch all color strip sources for composite layer dropdowns
        await valueSourcesCache.fetch().catch((): any[] => []);
        const allCssSources: any[] = await colorStripSourcesCache.fetch().catch((): any[] => []);
        compositeSetAvailableSources(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') as HTMLSelectElement;
        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: any) => {
            const sourceType = css.source_type || 'picture';
            (document.getElementById('css-editor-type') as HTMLInputElement).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') as HTMLInputElement).value = css.led_count ?? 0;
        };

        // Ensure gradient entities are loaded before opening the editor
        await gradientsCache.fetch();

        // 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') as HTMLElement).style.display = 'none';

        if (cssId) {
            const cssSources: any[] = 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') as HTMLInputElement).value = css.id;
            (document.getElementById('css-editor-name') as HTMLInputElement).value = css.name;

            // Exclude self from composite/mapped sources when editing
            if (css.source_type === 'composite') {
                compositeSetAvailableSources(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);
            const editIcon = getColorStripIcon(css.source_type);
            (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${editIcon} ${t('color_strip.edit')} ${t(`color_strip.type.${css.source_type}`)}`;
        } else if (cloneData) {
            (document.getElementById('css-editor-id') as HTMLInputElement).value = '';
            (document.getElementById('css-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
            await _populateFromCSS(cloneData);
            const cloneIcon = getColorStripIcon(cloneData.source_type);
            (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${cloneIcon} ${t('color_strip.add')} ${t(`color_strip.type.${cloneData.source_type}`)}`;
        } else {
            (document.getElementById('css-editor-id') as HTMLInputElement).value = '';
            (document.getElementById('css-editor-name') as HTMLInputElement).value = '';
            const effectiveType = presetType || 'picture';
            (document.getElementById('css-editor-type') as HTMLInputElement).value = effectiveType;
            onCSSTypeChange();
            (document.getElementById('css-editor-led-count') as HTMLInputElement).value = 0 as any;

            // 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') as HTMLElement).innerHTML = `${typeIcon} ${t('color_strip.add')} ${t(`color_strip.type.${effectiveType}`)}`;
            _autoGenerateCSSName();
        }

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

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

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

        cssEditorModal.snapshot();
        cssEditorModal.open();
        setTimeout(() => desktopFocus(document.getElementById('css-editor-name') as HTMLElement), 100);
    } catch (error: any) {
        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') as HTMLInputElement).value;
    const name = (document.getElementById('css-editor-name') as HTMLInputElement).value.trim();
    const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).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', 'weather'];
    if (clockTypes.includes(sourceType)) {
        const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
        payload.clock_id = clockVal || null;
    }

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

    try {
        let response: any;
        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: any) {
        if (error.isAuth) return;
        console.error('Error saving CSS:', error);
        cssEditorModal.showError(error.message);
    }
}

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

function _showApiInputEndpoints(cssId: any) {
    const el = document.getElementById('css-editor-api-input-endpoints') as HTMLElement | null;
    const group = document.getElementById('css-editor-api-input-endpoints-group') as HTMLElement | null;
    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 origin = getBaseOrigin();
    const base = `${origin}/api/v1`;
    const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:';
    const hostPart = origin.replace(/^https?:\/\//, '');
    const wsBase = `${wsProto}//${hostPart}/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">${ICON_CLONE}</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">${ICON_CLONE}</button></div>
    `;
}

export function copyEndpointUrl(btn: any) {
    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: string) {
    try {
        const sources: any[] = 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: any) {
        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: string) {
    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: any) {
        if (error.isAuth) return;
        showToast(t('color_strip.error.delete_failed'), 'error');
    }
}

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

export async function startCSSOverlay(cssId: string) {
    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: any) {
        if (error.isAuth) return;
        showToast(t('overlay.error.start'), 'error');
    }
}

export async function toggleCSSOverlay(cssId: string) {
    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: any) {
        if (err.isAuth) return;
        console.error('Failed to toggle CSS overlay:', err);
    }
}

export async function stopCSSOverlay(cssId: string) {
    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: any) {
        if (error.isAuth) return;
        showToast(t('overlay.error.stop'), 'error');
    }
}

// ── Gradient entity management ────────────────────────────────────────

class GradientEditorModal extends Modal {
    constructor() { super('gradient-editor-modal'); }

    snapshotValues() {
        return {
            name: (document.getElementById('gradient-editor-name') as HTMLInputElement).value,
            description: (document.getElementById('gradient-editor-description') as HTMLInputElement).value,
            stops: JSON.stringify(getGradientStops()),
            tags: JSON.stringify(_gradientTagsInput ? _gradientTagsInput.getValue() : []),
        };
    }

    onForceClose() {
        if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; }
        gradientSetIdPrefix(''); // Reset to default for CSS editor
    }
}

const gradientEditorModal = new GradientEditorModal();
let _gradientTagsInput: any = null;

/** Open the gradient editor modal for create, edit, or clone. */
export async function showGradientModal(editId: string | null = null, cloneData: any = null) {
    const titleEl = document.getElementById('gradient-editor-title')!;
    const idInput = document.getElementById('gradient-editor-id') as HTMLInputElement;
    const nameInput = document.getElementById('gradient-editor-name') as HTMLInputElement;
    const descInput = document.getElementById('gradient-editor-description') as HTMLInputElement;
    const errorEl = document.getElementById('gradient-editor-error') as HTMLElement;

    // Reset
    idInput.value = '';
    nameInput.value = '';
    descInput.value = '';
    errorEl.style.display = 'none';

    // Tags
    if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; }
    const tagsContainer = document.getElementById('gradient-editor-tags-container')!;
    _gradientTagsInput = new TagInput(tagsContainer, { placeholder: t('tags.placeholder') });

    let stops = [{ position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }];

    if (editId) {
        // Edit mode
        const g = _findGradient(editId);
        if (!g) return;
        idInput.value = g.id;
        nameInput.value = g.name;
        descInput.value = g.description || '';
        _gradientTagsInput.setValue(g.tags || []);
        stops = g.stops;
        titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.edit')}`;
    } else if (cloneData) {
        // Clone mode
        nameInput.value = (cloneData.name || '') + ' (Copy)';
        descInput.value = cloneData.description || '';
        _gradientTagsInput.setValue(cloneData.tags || []);
        stops = cloneData.stops || stops;
        titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`;
    } else {
        // Create mode
        titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`;
    }

    gradientEditorModal.open();
    // Init gradient editor with ge- prefix (standalone modal, not CSS editor)
    requestAnimationFrame(() => {
        gradientSetIdPrefix('ge-');
        gradientInit(stops);
        gradientEditorModal.snapshot();
    });
}

/** Close the gradient editor modal (with dirty check). */
export async function closeGradientEditor() {
    await gradientEditorModal.close();
}

/** Save gradient entity from the editor modal. */
export async function saveGradientEntity() {
    const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value;
    const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim();
    const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null;
    const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : [];
    const errorEl = document.getElementById('gradient-editor-error') as HTMLElement;

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

    const stops = getGradientStops().map(s => ({
        position: s.position,
        color: [...s.color],
    }));

    if (stops.length < 2) {
        errorEl.textContent = t('gradient.error.min_stops');
        errorEl.style.display = '';
        return;
    }

    const payload: any = { name, stops, description, tags };

    try {
        const url = id ? `/gradients/${id}` : '/gradients';
        const method = id ? 'PUT' : 'POST';
        const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
        if (!res!.ok) {
            const err = await res!.json();
            throw new Error(err.detail || 'Failed to save gradient');
        }

        showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
        gradientsCache.invalidate();
        gradientEditorModal.forceClose();
        if (window.loadPictureSources) await window.loadPictureSources();
    } catch (e: any) {
        if (e.isAuth) return;
        errorEl.textContent = e.message;
        errorEl.style.display = '';
    }
}

/** Clone a gradient entity — opens editor with cloned data. */
export async function cloneGradient(gradientId: string) {
    const g = _findGradient(gradientId);
    if (!g) return;
    await showGradientModal(null, g);
}

/** Edit a gradient entity. */
export async function editGradient(gradientId: string) {
    await showGradientModal(gradientId);
}

/** Delete a gradient entity (with confirmation). */
export async function deleteGradient(gradientId: string) {
    const g = _findGradient(gradientId);
    if (!g) return;
    const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
    if (!ok) return;
    try {
        await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
        gradientsCache.invalidate();
        showToast(t('gradient.deleted'), 'success');
        if (window.loadPictureSources) await window.loadPictureSources();
    } catch (e: any) {
        if (e.isAuth) return;
        showToast(e.message || t('gradient.error.delete_failed'), 'error');
    }
}

// ── Test / Preview modal (extracted to color-strips-test.ts) ──
export {
    previewCSSFromEditor,
    testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings,
    fireCssTestNotification, fireCssTestNotificationLayer,
} from './color-strips-test.ts';
