/**
 * Color Strip Sources — Test / Preview modal (WebSocket strip renderer).
 * Extracted from color-strips.ts to reduce file size.
 */

import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import {
    getColorStripIcon,
    ICON_WARNING, ICON_SUN_DIM,
} from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';

/* ── Preview config builder ───────────────────────────────────── */

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

function _collectPreviewConfig() {
    const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
    if (!_PREVIEW_TYPES.has(sourceType)) return null;
    let config: any;
    if (sourceType === 'static') {
        config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
    } else if (sourceType === 'gradient') {
        const stops = getGradientStops();
        if (stops.length < 2) return null;
        config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
    } else if (sourceType === 'color_cycle') {
        const colors = _colorCycleGetColors();
        if (colors.length < 2) return null;
        config = { source_type: 'color_cycle', colors };
    } else if (sourceType === 'effect') {
        config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') 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 };
        if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
    } else if (sourceType === 'daylight') {
        config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') 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) };
    } else if (sourceType === 'candlelight') {
        config = { source_type: 'candlelight', 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 };
    } else if (sourceType === 'notification') {
        const filterList = (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value.split('\n').map(s => s.trim()).filter(Boolean);
        config = {
            source_type: 'notification',
            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(),
        };
    }
    const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
    if (clockEl && clockEl.value) config.clock_id = clockEl.value;
    return config;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    // Reset views and clear stale canvas content
    (document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
    (document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
    (document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
    // Clear all test canvases to prevent stale frames from previous sessions
    modal.querySelectorAll('canvas').forEach(c => {
        const ctx = c.getContext('2d');
        if (ctx) ctx.clearRect(0, 0, c.width, c.height);
    });
    (document.getElementById('css-test-led-group') as HTMLElement).style.display = '';
    // Input source selector: shown for both CSS test and CSPT test, hidden for api_input
    const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
    if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
    const layersContainer = document.getElementById('css-test-layers') as HTMLElement | null;
    if (layersContainer) layersContainer.innerHTML = '';
    (document.getElementById('css-test-status') as HTMLElement).style.display = '';
    (document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.connecting');

    // Reset FPS tracking
    _cssTestFpsActualHistory = [];

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

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

        _cssTestConnect(sourceId, ledCount, fpsVal);
    }
}

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

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

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

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

    if (isTransient) {
        _cssTestWs.onopen = () => {
            if (gen !== _cssTestGeneration) return;
            _cssTestWs!.send(JSON.stringify(_cssTestTransientConfig));
            // Auto-fire notification after stream starts so user sees the effect immediately
            if (_cssTestTransientConfig.source_type === 'notification') {
                setTimeout(() => {
                    if (gen === _cssTestGeneration && _cssTestWs && _cssTestWs.readyState === WebSocket.OPEN) {
                        _cssTestWs.send(JSON.stringify({ action: 'fire', color: _cssTestTransientConfig.default_color }));
                    }
                }, 300);
            }
        };
    }

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

        if (typeof event.data === 'string') {
            let msg: any;
            try {
                msg = JSON.parse(event.data);
            } catch (e) {
                console.error('CSS test WS parse error:', e);
                return;
            }

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

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

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

            // Reset FPS timestamps so the initial bootstrap frame
            // (sent right after metadata for api_input) isn't counted
            if (_cssTestIsApiInput) _cssTestFpsTimestamps = [];

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

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

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

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

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

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

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

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

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

    _cssTestWs.onclose = (ev) => {
        if (gen !== _cssTestGeneration) return;
        _cssTestWs = null;
        // Show server-provided close reason (e.g. "No LEDs configured")
        if (ev.reason) {
            const statusEl = document.getElementById('css-test-status') as HTMLElement;
            statusEl.textContent = ev.reason;
            statusEl.style.display = '';
        }
    };

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

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

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

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

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

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

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

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

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

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

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

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

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

function _cssTestRenderStrip(rgbBytes: Uint8Array) {
    const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
    if (!canvas) return;
    const ledCount = rgbBytes.length / 3;
    canvas.width = ledCount;
    canvas.height = 1;
    const ctx = canvas.getContext('2d')!;
    const imageData = ctx.createImageData(ledCount, 1);
    const data = imageData.data;
    for (let i = 0; i < ledCount; i++) {
        const si = i * 3;
        const di = i * 4;
        data[di] = rgbBytes[si];
        data[di + 1] = rgbBytes[si + 1];
        data[di + 2] = rgbBytes[si + 2];
        data[di + 3] = 255;
    }
    ctx.putImageData(imageData, 0, 0);
}

function _cssTestRenderLayers(data: any) {
    const container = document.getElementById('css-test-layers') as HTMLElement | null;
    if (!container) return;

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

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

function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
    const ledCount = rgbBytes.length / 3;
    if (ledCount <= 0) return;
    canvas.width = ledCount;
    canvas.height = 1;
    const ctx = canvas.getContext('2d')!;
    const imageData = ctx.createImageData(ledCount, 1);
    const data = imageData.data;
    for (let i = 0; i < ledCount; i++) {
        const si = i * 3;
        const di = i * 4;
        data[di] = rgbBytes[si];
        data[di + 1] = rgbBytes[si + 1];
        data[di + 2] = rgbBytes[si + 2];
        data[di + 3] = 255;
    }
    ctx.putImageData(imageData, 0, 0);
}

function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
    // edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...]
    // indices are pre-computed on server: reverse + offset already applied
    const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
    for (const e of edges) {
        if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
    }

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

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

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

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

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

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

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

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

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

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

function _cssTestRenderTicks(edges: any[]) {
    const canvas = document.getElementById('css-test-rect-ticks') as HTMLCanvasElement | null;
    const rectEl = document.getElementById('css-test-rect') as HTMLElement | null;
    if (!canvas || !rectEl) return;

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

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

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

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

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

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

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

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

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

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

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

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

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

function _cssTestRenderStripAxis(canvasId: string, ledCount: number) {
    const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
    if (!canvas || ledCount <= 0) return;

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

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

    const tickLen = 5;

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

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

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

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

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

export function fireCssTestNotification() {
    // Transient preview: fire via WebSocket command
    if (_cssTestTransientConfig && _cssTestWs && _cssTestWs.readyState === WebSocket.OPEN) {
        _cssTestWs.send(JSON.stringify({ action: 'fire', color: _cssTestTransientConfig.default_color }));
        return;
    }
    // Saved source: fire via REST endpoint
    for (const id of _cssTestNotificationIds) {
        testNotification(id);
    }
}

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

let _cssTestFpsSampleInterval: ReturnType<typeof setInterval> | null = null;

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

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

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

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

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

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

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