"""Color strip source routes: CRUD, calibration test, preview, and API input push."""

import asyncio
import json as _json
import time as _time
import uuid as _uuid
from typing import Annotated

import numpy as np
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect

from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
    fire_entity_event,
    get_color_strip_store,
    get_device_store,
    get_picture_source_store,
    get_output_target_store,
    get_pp_template_store,
    get_processor_manager,
    get_template_store,
)
from wled_controller.api.schemas.color_strip_sources import (
    ApiInputCSSResponse,
    AudioCSSResponse,
    CandlelightCSSResponse,
    ColorCycleCSSResponse,
    ColorPushRequest,
    ColorStop as ColorStopSchema,
    ColorStripSourceCreate,
    ColorStripSourceListResponse,
    ColorStripSourceResponse,
    ColorStripSourceUpdate,
    CompositeCSSResponse,
    CSSCalibrationTestRequest,
    DaylightCSSResponse,
    EffectCSSResponse,
    GradientCSSResponse,
    KeyColorsCSSResponse,
    MappedCSSResponse,
    NotificationCSSResponse,
    NotifyRequest,
    PictureAdvancedCSSResponse,
    PictureCSSResponse,
    ProcessedCSSResponse,
    StaticCSSResponse,
    WeatherCSSResponse,
)
from wled_controller.api.schemas.devices import (
    Calibration as CalibrationSchema,
    CalibrationTestModeResponse,
)
from wled_controller.core.capture.calibration import (
    calibration_from_dict,
    calibration_to_dict,
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import (
    AdvancedPictureColorStripSource,
    ApiInputColorStripSource,
    AudioColorStripSource,
    CandlelightColorStripSource,
    ColorCycleColorStripSource,
    CompositeColorStripSource,
    DaylightColorStripSource,
    EffectColorStripSource,
    GradientColorStripSource,
    KeyColorsColorStripSource,
    MappedColorStripSource,
    NotificationColorStripSource,
    PictureColorStripSource,
    ProcessedColorStripSource,
    StaticColorStripSource,
    WeatherColorStripSource,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source import (
    ProcessedPictureSource,
    ScreenCapturePictureSource,
)
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError

logger = get_logger(__name__)

router = APIRouter()


def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
    """Shared response fields from any ColorStripSource."""
    return dict(
        id=source.id,
        name=source.name,
        description=source.description,
        led_count=getattr(source, "led_count", 0),
        overlay_active=overlay_active,
        clock_id=source.clock_id,
        tags=source.tags,
        created_at=source.created_at,
        updated_at=source.updated_at,
    )


def _calibration_schema(source) -> CalibrationSchema | None:
    """Convert a source's calibration to a schema object, or None."""
    cal = getattr(source, "calibration", None)
    if cal is None:
        return None
    try:
        return CalibrationSchema(**calibration_to_dict(cal))
    except Exception:
        return None


def _stops_schema(source) -> list[ColorStopSchema] | None:
    """Convert a source's stops list to schema objects, or None."""
    raw = getattr(source, "stops", None)
    if raw is None:
        return None
    try:
        return [ColorStopSchema(**dict(s)) for s in raw]
    except Exception:
        return None


# Maps storage class → response builder lambda.
_RESPONSE_MAP: dict = {
    PictureColorStripSource: lambda s, kw: PictureCSSResponse(
        **kw,
        picture_source_id=s.picture_source_id,
        smoothing=s.smoothing.to_dict(),
        interpolation_mode=s.interpolation_mode,
        calibration=_calibration_schema(s),
    ),
    AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
        **kw,
        smoothing=s.smoothing.to_dict(),
        interpolation_mode=s.interpolation_mode,
        calibration=_calibration_schema(s),
    ),
    StaticColorStripSource: lambda s, kw: StaticCSSResponse(
        **kw,
        color=s.color.to_dict(),
        animation=s.animation,
    ),
    GradientColorStripSource: lambda s, kw: GradientCSSResponse(
        **kw,
        stops=_stops_schema(s),
        animation=s.animation,
        easing=s.easing,
        gradient_id=s.gradient_id,
    ),
    ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
        **kw,
        colors=[list(c) for c in s.colors],
    ),
    EffectColorStripSource: lambda s, kw: EffectCSSResponse(
        **kw,
        effect_type=s.effect_type,
        palette=s.palette,
        gradient_id=s.gradient_id,
        color=s.color.to_dict(),
        intensity=s.intensity.to_dict(),
        scale=s.scale.to_dict(),
        mirror=s.mirror,
        custom_palette=s.custom_palette,
    ),
    CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
        **kw,
        layers=[dict(layer) for layer in s.layers],
    ),
    MappedColorStripSource: lambda s, kw: MappedCSSResponse(
        **kw,
        zones=[dict(z) for z in s.zones],
    ),
    AudioColorStripSource: lambda s, kw: AudioCSSResponse(
        **kw,
        visualization_mode=s.visualization_mode,
        audio_source_id=s.audio_source_id,
        sensitivity=s.sensitivity.to_dict(),
        smoothing=s.smoothing.to_dict(),
        palette=s.palette,
        gradient_id=s.gradient_id,
        color=s.color.to_dict(),
        color_peak=s.color_peak.to_dict(),
        mirror=s.mirror,
    ),
    ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
        **kw,
        fallback_color=s.fallback_color.to_dict(),
        timeout=s.timeout.to_dict(),
        interpolation=s.interpolation,
    ),
    NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
        **kw,
        notification_effect=s.notification_effect,
        duration_ms=s.duration_ms.to_dict(),
        default_color=s.default_color.to_dict(),
        app_colors=dict(s.app_colors),
        app_filter_mode=s.app_filter_mode,
        app_filter_list=list(s.app_filter_list),
        os_listener=s.os_listener,
        sound_asset_id=s.sound_asset_id,
        sound_volume=s.sound_volume.to_dict(),
        app_sounds=dict(s.app_sounds),
    ),
    DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
        **kw,
        speed=s.speed.to_dict(),
        use_real_time=s.use_real_time,
        latitude=s.latitude,
        longitude=s.longitude,
    ),
    CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
        **kw,
        color=s.color.to_dict(),
        intensity=s.intensity.to_dict(),
        num_candles=s.num_candles,
        speed=s.speed.to_dict(),
        wind_strength=s.wind_strength.to_dict(),
        candle_type=s.candle_type,
    ),
    ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
        **kw,
        input_source_id=s.input_source_id,
        processing_template_id=s.processing_template_id,
    ),
    WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
        **kw,
        weather_source_id=s.weather_source_id,
        speed=s.speed.to_dict(),
        temperature_influence=s.temperature_influence.to_dict(),
    ),
    KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
        **kw,
        picture_source_id=s.picture_source_id,
        rectangles=[r.to_dict() for r in s.rectangles],
        interpolation_mode=s.interpolation_mode,
        smoothing=s.smoothing.to_dict(),
        brightness=s.brightness.to_dict(),
    ),
}


def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
    """Convert a ColorStripSource to the matching per-type response schema."""
    kw = _common_response_kwargs(source, overlay_active)
    builder = _RESPONSE_MAP.get(type(source))
    if builder is None:
        # Fallback: use to_dict() and build a PictureCSSResponse
        logger.warning("No response builder for %s, falling back", type(source).__name__)
        return PictureCSSResponse(
            **kw,
            picture_source_id="",
            smoothing=0.3,
            interpolation_mode="average",
            calibration=None,
        )
    return builder(source, kw)


def _resolve_display_index(
    picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0
) -> int:
    """Resolve display index from a picture source, following processed source chains."""
    if not picture_source_id or depth > 5:
        return 0
    try:
        ps = picture_source_store.get_stream(picture_source_id)
    except Exception as e:
        logger.debug(
            "Failed to resolve display index for picture source %s: %s", picture_source_id, e
        )
        return 0
    if isinstance(ps, ScreenCapturePictureSource):
        return ps.display_index
    if isinstance(ps, ProcessedPictureSource):
        return _resolve_display_index(ps.source_stream_id, picture_source_store, depth + 1)
    return 0


# ===== CRUD ENDPOINTS =====


@router.get(
    "/api/v1/color-strip-sources",
    response_model=ColorStripSourceListResponse,
    tags=["Color Strip Sources"],
)
async def list_color_strip_sources(
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """List all color strip sources."""
    sources = store.get_all_sources()
    responses = [_css_to_response(s, manager.is_css_overlay_active(s.id)) for s in sources]
    return ColorStripSourceListResponse(sources=responses, count=len(responses))


def _extract_css_kwargs(data) -> dict:
    """Extract store-compatible kwargs from a Pydantic CSS create/update schema.

    Converts nested Pydantic models (calibration, stops, layers, zones,
    animation) to plain dicts/lists that the store expects.
    """
    # Exclude nested models that need special conversion
    exclude_fields = {"source_type"}
    for nested in ("calibration", "stops", "layers", "zones", "animation"):
        if hasattr(data, nested):
            exclude_fields.add(nested)
    kwargs = data.model_dump(exclude_unset=False, exclude=exclude_fields)

    # Convert nested Pydantic models → plain dicts for the store
    if hasattr(data, "calibration"):
        cal = getattr(data, "calibration", None)
        if cal is not None:
            kwargs["calibration"] = calibration_from_dict(cal.model_dump())
        else:
            kwargs["calibration"] = None

    if hasattr(data, "stops"):
        stops = getattr(data, "stops", None)
        kwargs["stops"] = [s.model_dump() for s in stops] if stops is not None else None

    if hasattr(data, "layers"):
        layers = getattr(data, "layers", None)
        kwargs["layers"] = [layer.model_dump() for layer in layers] if layers is not None else None

    if hasattr(data, "zones"):
        zones = getattr(data, "zones", None)
        kwargs["zones"] = [z.model_dump() for z in zones] if zones is not None else None

    if hasattr(data, "animation"):
        anim = getattr(data, "animation", None)
        kwargs["animation"] = anim.model_dump() if anim else None

    return kwargs


@router.post(
    "/api/v1/color-strip-sources",
    response_model=ColorStripSourceResponse,
    tags=["Color Strip Sources"],
    status_code=201,
)
async def create_color_strip_source(
    data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")],
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
):
    """Create a new color strip source."""
    try:
        kwargs = _extract_css_kwargs(data)
        source = store.create_source(source_type=data.source_type, **kwargs)
        fire_entity_event("color_strip_source", "created", source.id)
        return _css_to_response(source)

    except EntityNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))

    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        logger.error("Failed to create color strip source: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")


@router.get(
    "/api/v1/color-strip-sources/{source_id}",
    response_model=ColorStripSourceResponse,
    tags=["Color Strip Sources"],
)
async def get_color_strip_source(
    source_id: str,
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Get a color strip source by ID."""
    try:
        source = store.get_source(source_id)
        return _css_to_response(source, manager.is_css_overlay_active(source_id))
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))


@router.put(
    "/api/v1/color-strip-sources/{source_id}",
    response_model=ColorStripSourceResponse,
    tags=["Color Strip Sources"],
)
async def update_color_strip_source(
    source_id: str,
    data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")],
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Update a color strip source and hot-reload any running streams."""
    try:
        kwargs = _extract_css_kwargs(data)
        source = store.update_source(source_id=source_id, **kwargs)

        # Hot-reload running stream (no restart needed for in-place param changes)
        try:
            manager.color_strip_stream_manager.update_source(source_id, source)
        except Exception as e:
            logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")

        fire_entity_event("color_strip_source", "updated", source_id)
        return _css_to_response(source)

    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        logger.error("Failed to update color strip source: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")


@router.delete(
    "/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"]
)
async def delete_color_strip_source(
    source_id: str,
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    target_store: OutputTargetStore = Depends(get_output_target_store),
):
    """Delete a color strip source. Returns 409 if referenced by any LED target."""
    try:
        target_names = target_store.get_targets_referencing_css(source_id)
        if target_names:
            names = ", ".join(target_names)
            raise HTTPException(
                status_code=409,
                detail=f"Color strip source is referenced by target(s): {names}. "
                "Delete or reassign the target(s) first.",
            )
        composite_names = store.get_composites_referencing(source_id)
        if composite_names:
            names = ", ".join(composite_names)
            raise HTTPException(
                status_code=409,
                detail=f"Color strip source is used as a layer in composite source(s): {names}. "
                "Remove it from the composite(s) first.",
            )
        mapped_names = store.get_mapped_referencing(source_id)
        if mapped_names:
            names = ", ".join(mapped_names)
            raise HTTPException(
                status_code=409,
                detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
                "Remove it from the mapped source(s) first.",
            )
        processed_names = store.get_processed_referencing(source_id)
        if processed_names:
            names = ", ".join(processed_names)
            raise HTTPException(
                status_code=409,
                detail=f"Color strip source is used as input in processed source(s): {names}. "
                "Delete or reassign the processed source(s) first.",
            )
        store.delete_source(source_id)
        fire_entity_event("color_strip_source", "deleted", source_id)
    except HTTPException:
        raise
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        logger.error("Failed to delete color strip source: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")


# ===== KEY COLORS TEST =====


@router.post(
    "/api/v1/color-strip-sources/{source_id}/key-colors/test",
    tags=["Color Strip Sources"],
)
async def test_key_colors_source(
    source_id: str,
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    source_store: PictureSourceStore = Depends(get_picture_source_store),
    template_store: TemplateStore = Depends(get_template_store),
    processor_manager: ProcessorManager = Depends(get_processor_manager),
    device_store: DeviceStore = Depends(get_device_store),
    pp_template_store=Depends(get_pp_template_store),
):
    """Test a key_colors source: capture a frame, extract colors from each rectangle."""
    from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
    from wled_controller.core.capture.screen_capture import (
        calculate_average_color,
        calculate_dominant_color,
        calculate_median_color,
    )
    from wled_controller.core.capture_engines import EngineRegistry
    from wled_controller.core.filters import FilterRegistry, ImagePool
    from wled_controller.storage.picture_source import (
        ScreenCapturePictureSource,
        StaticImagePictureSource,
    )
    from wled_controller.utils.image_codec import encode_jpeg_data_uri

    stream = None
    try:
        source = store.get_source(source_id)
        if not isinstance(source, KeyColorsColorStripSource):
            raise HTTPException(status_code=400, detail="Source is not a key_colors type")

        if not source.rectangles:
            raise HTTPException(status_code=400, detail="No screen regions configured")
        if not source.picture_source_id:
            raise HTTPException(status_code=400, detail="No picture source configured")

        # Resolve picture source and capture a frame
        chain = source_store.resolve_stream_chain(source.picture_source_id)
        raw_stream = chain["raw_stream"]

        from wled_controller.utils.image_codec import load_image_file

        if isinstance(raw_stream, StaticImagePictureSource):
            from wled_controller.api.dependencies import get_asset_store as _get_asset_store

            asset_store = _get_asset_store()
            image_path = (
                asset_store.get_file_path(raw_stream.image_asset_id)
                if raw_stream.image_asset_id
                else None
            )
            if not image_path:
                raise HTTPException(status_code=400, detail="Image asset not found")
            image = load_image_file(image_path)
        elif isinstance(raw_stream, ScreenCapturePictureSource):
            capture_template = template_store.get_template(raw_stream.capture_template_id)
            display_index = raw_stream.display_index

            if capture_template.engine_type not in EngineRegistry.get_available_engines():
                raise HTTPException(
                    status_code=400, detail=f"Engine '{capture_template.engine_type}' not available"
                )

            locked = processor_manager.get_display_lock_info(display_index)
            if locked:
                try:
                    device_name = device_store.get_device(locked).name
                except Exception:
                    device_name = locked
                raise HTTPException(
                    status_code=409,
                    detail=f"Display {display_index} is captured by '{device_name}'. Stop it first.",
                )

            stream = EngineRegistry.create_stream(
                capture_template.engine_type, display_index, capture_template.engine_config
            )
            stream.initialize()
            sc = stream.capture_frame()
            if sc is None:
                raise RuntimeError("No frame captured")
            image = sc.image
        else:
            raise HTTPException(status_code=400, detail="Unsupported picture source type")

        # Apply postprocessing filters
        pp_ids = chain.get("postprocessing_template_ids", [])
        if pp_ids and pp_template_store:
            pool = ImagePool()
            for pp_id in pp_ids:
                try:
                    pp = pp_template_store.get_template(pp_id)
                    for fi in pp_template_store.resolve_filter_instances(pp.filters):
                        f = FilterRegistry.create_instance(fi.filter_id, fi.options)
                        result = f.process_image(image, pool)
                        if result is not None:
                            image = result
                except Exception:
                    pass

        # Extract colors from each rectangle
        h, w = image.shape[:2]
        calc_fns = {
            "average": calculate_average_color,
            "median": calculate_median_color,
            "dominant": calculate_dominant_color,
        }
        calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color)

        result_rects = []
        for rect in source.rectangles:
            px_x = max(0, int(rect.x * w))
            px_y = max(0, int(rect.y * h))
            px_w = max(1, int(rect.width * w))
            px_h = max(1, int(rect.height * h))
            px_x, px_y = min(px_x, w - 1), min(px_y, h - 1)
            px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y)
            sub_img = image[px_y : px_y + px_h, px_x : px_x + px_w]
            r, g, b = calc_fn(sub_img)
            result_rects.append(
                {
                    "name": rect.name,
                    "x": rect.x,
                    "y": rect.y,
                    "width": rect.width,
                    "height": rect.height,
                    "color": {
                        "r": int(r),
                        "g": int(g),
                        "b": int(b),
                        "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}",
                    },
                }
            )

        image_data_uri = encode_jpeg_data_uri(image, quality=90)

        return {
            "image": image_data_uri,
            "rectangles": result_rects,
            "interpolation_mode": source.interpolation_mode,
        }
    except HTTPException:
        raise
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        logger.error("Key colors test failed: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")
    finally:
        if stream:
            try:
                stream.stop()
            except Exception:
                pass


@router.websocket("/api/v1/color-strip-sources/{source_id}/key-colors/test/ws")
async def test_key_colors_ws(
    websocket: WebSocket,
    source_id: str,
    token: str = Query(""),
    fps: int = Query(3),
    preview_width: int = Query(480),
):
    """WebSocket for real-time key_colors test preview with frame + rectangle overlay."""
    import json as ws_json
    import time as ws_time
    from wled_controller.api.auth import verify_ws_token
    from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
    from wled_controller.core.capture.screen_capture import (
        calculate_average_color,
        calculate_dominant_color,
        calculate_median_color,
    )
    from wled_controller.storage.picture_source import ScreenCapturePictureSource
    from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down

    if not verify_ws_token(token):
        await websocket.close(code=4001, reason="Unauthorized")
        return

    store = get_color_strip_store()
    source_store = get_picture_source_store()
    manager = get_processor_manager()
    device_store = get_device_store()

    try:
        source = store.get_source(source_id)
    except ValueError as e:
        await websocket.close(code=4004, reason=str(e))
        return

    if not isinstance(source, KeyColorsColorStripSource):
        await websocket.close(code=4003, reason="Not a key_colors source")
        return
    if not source.rectangles:
        await websocket.close(code=4003, reason="No regions configured")
        return
    if not source.picture_source_id:
        await websocket.close(code=4003, reason="No picture source configured")
        return

    try:
        chain = source_store.resolve_stream_chain(source.picture_source_id)
    except ValueError as e:
        await websocket.close(code=4003, reason=str(e))
        return

    raw_stream = chain["raw_stream"]
    if isinstance(raw_stream, ScreenCapturePictureSource):
        locked = manager.get_display_lock_info(raw_stream.display_index)
        if locked:
            try:
                name = device_store.get_device(locked).name
            except Exception:
                name = locked
            await websocket.close(code=4003, reason=f"Display captured by '{name}'")
            return

    fps = max(1, min(30, fps))
    preview_width = max(120, min(1920, preview_width))
    frame_interval = 1.0 / fps

    calc_fns = {
        "average": calculate_average_color,
        "median": calculate_median_color,
        "dominant": calculate_dominant_color,
    }
    calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color)

    await websocket.accept()
    logger.info(f"KC CSS test WS connected for {source_id} (fps={fps})")

    # Use LiveStreamManager — reuses the already-running capture engine when a
    # target is active.  BetterCam (DXGI) only supports one session per display,
    # so creating a second engine would produce no frames.
    live_stream_mgr = manager._live_stream_manager
    try:
        live_stream = await asyncio.to_thread(live_stream_mgr.acquire, source.picture_source_id)
    except Exception as e:
        logger.error(f"KC test: LiveStream acquire failed: {e}")
        await websocket.send_text(ws_json.dumps({"type": "error", "detail": str(e)}))
        await websocket.close(code=4003, reason=str(e))
        return

    try:
        prev_frame_ref = None

        while True:
            loop_start = ws_time.monotonic()
            try:
                capture = await asyncio.to_thread(live_stream.get_latest_frame)
                if capture is None or capture.image is None:
                    await asyncio.sleep(frame_interval)
                    continue
                if capture is prev_frame_ref:
                    await asyncio.sleep(frame_interval * 0.5)
                    continue
                prev_frame_ref = capture
                cur_image = capture.image

                if not isinstance(cur_image, np.ndarray):
                    await asyncio.sleep(frame_interval)
                    continue

                # NOTE: postprocessing is already applied by the ProcessedLiveStream
                # when using LiveStreamManager.acquire() — do NOT re-apply here.

                # Re-read source for hot-update support
                try:
                    source = store.get_source(source_id)
                    calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color)
                except Exception:
                    pass

                h, w = cur_image.shape[:2]
                result_rects = []
                for rect in source.rectangles:
                    px_x = max(0, int(rect.x * w))
                    px_y = max(0, int(rect.y * h))
                    px_w = max(1, int(rect.width * w))
                    px_h = max(1, int(rect.height * h))
                    px_x, px_y = min(px_x, w - 1), min(px_y, h - 1)
                    px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y)
                    sub = cur_image[px_y : px_y + px_h, px_x : px_x + px_w]
                    r, g, b = calc_fn(sub)
                    result_rects.append(
                        {
                            "name": rect.name,
                            "x": rect.x,
                            "y": rect.y,
                            "width": rect.width,
                            "height": rect.height,
                            "color": {
                                "r": int(r),
                                "g": int(g),
                                "b": int(b),
                                "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}",
                            },
                        }
                    )

                frame_to_encode = resize_down(cur_image, preview_width)
                frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85)

                await websocket.send_text(
                    ws_json.dumps(
                        {
                            "type": "frame",
                            "image": frame_uri,
                            "rectangles": result_rects,
                            "interpolation_mode": source.interpolation_mode,
                        }
                    )
                )

            except (WebSocketDisconnect, Exception) as inner_e:
                if isinstance(inner_e, WebSocketDisconnect):
                    raise
                logger.warning(f"KC CSS test WS frame error: {inner_e}")

            elapsed = ws_time.monotonic() - loop_start
            if frame_interval - elapsed > 0:
                await asyncio.sleep(frame_interval - elapsed)

    except WebSocketDisconnect:
        logger.info(f"KC CSS test WS disconnected for {source_id}")
    except Exception as e:
        logger.error(f"KC CSS test WS error: {e}", exc_info=True)
    finally:
        try:
            await asyncio.to_thread(live_stream_mgr.release, source.picture_source_id)
        except Exception:
            pass


# ===== CALIBRATION TEST =====


@router.put(
    "/api/v1/color-strip-sources/{source_id}/calibration/test",
    response_model=CalibrationTestModeResponse,
    tags=["Color Strip Sources"],
)
async def test_css_calibration(
    source_id: str,
    body: CSSCalibrationTestRequest,
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Temporarily light up LED edges to verify calibration.

    Pass a device_id and an edges dict with RGB colors.
    Send an empty edges dict to exit test mode.
    """
    try:
        # Validate device exists in manager
        if not manager.has_device(body.device_id):
            raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found")

        # Validate edge names and colors
        valid_edges = {"top", "right", "bottom", "left"}
        for edge_name, color in body.edges.items():
            if edge_name not in valid_edges:
                raise HTTPException(
                    status_code=400,
                    detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}",
                )
            if len(color) != 3 or not all(0 <= c <= 255 for c in color):
                raise HTTPException(
                    status_code=400,
                    detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.",
                )

        # Get CSS calibration to send the right pixel pattern
        calibration = None
        if body.edges:
            try:
                source = store.get_source(source_id)
                if not isinstance(
                    source, (PictureColorStripSource, AdvancedPictureColorStripSource)
                ):
                    raise HTTPException(
                        status_code=400,
                        detail="Calibration test is only available for picture color strip sources",
                    )
                if source.calibration:
                    calibration = source.calibration
            except ValueError as e:
                raise HTTPException(status_code=404, detail=str(e))

        await manager.set_test_mode(body.device_id, body.edges, calibration)

        active_edges = list(body.edges.keys())
        logger.info(
            f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} "
            f"for device {body.device_id} via CSS {source_id}: {active_edges}"
        )

        return CalibrationTestModeResponse(
            test_mode=len(active_edges) > 0,
            active_edges=active_edges,
            device_id=body.device_id,
        )

    except HTTPException:
        raise
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")


# ===== OVERLAY VISUALIZATION =====


@router.post("/api/v1/color-strip-sources/{source_id}/overlay/start", tags=["Color Strip Sources"])
async def start_css_overlay(
    source_id: str,
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Start screen overlay visualization for a color strip source."""
    try:
        source = store.get_source(source_id)
        if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
            raise HTTPException(
                status_code=400, detail="Overlay is only supported for picture color strip sources"
            )
        if not source.calibration:
            raise HTTPException(
                status_code=400, detail="Color strip source has no calibration configured"
            )

        ps_id = getattr(source, "picture_source_id", "") or ""
        display_index = _resolve_display_index(ps_id, picture_source_store)
        displays = get_available_displays()
        if not displays:
            raise HTTPException(status_code=409, detail="No displays available")
        display_index = min(display_index, len(displays) - 1)
        display_info = displays[display_index]

        await manager.start_css_overlay(source_id, display_info, source.calibration, source.name)
        return {"status": "started", "source_id": source_id}

    except HTTPException:
        raise
    except RuntimeError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        logger.error("Failed to start CSS overlay: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")


@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"])
async def stop_css_overlay(
    source_id: str,
    _auth: AuthRequired,
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Stop screen overlay visualization for a color strip source."""
    try:
        await manager.stop_css_overlay(source_id)
        return {"status": "stopped", "source_id": source_id}
    except Exception as e:
        logger.error("Failed to stop CSS overlay: %s", e, exc_info=True)
        raise HTTPException(status_code=500, detail="Internal server error")


@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"])
async def get_css_overlay_status(
    source_id: str,
    _auth: AuthRequired,
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Check if overlay is active for a color strip source."""
    return {"source_id": source_id, "active": manager.is_css_overlay_active(source_id)}


# ===== API INPUT: COLOR PUSH =====


@router.post("/api/v1/color-strip-sources/{source_id}/colors", tags=["Color Strip Sources"])
async def push_colors(
    source_id: str,
    body: ColorPushRequest,
    _auth: AuthRequired,
    store: ColorStripStore = Depends(get_color_strip_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Push raw LED colors to an api_input color strip source.

    Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
    The payload is forwarded to all running stream instances for this source.
    """
    try:
        source = store.get_source(source_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    if not isinstance(source, ApiInputColorStripSource):
        raise HTTPException(status_code=400, detail="Source is not an api_input type")

    streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)

    if body.segments is not None:
        # Segment-based path
        seg_dicts = [s.model_dump() for s in body.segments]
        for stream in streams:
            if hasattr(stream, "push_segments"):
                stream.push_segments(seg_dicts)
        return {
            "status": "ok",
            "streams_updated": len(streams),
            "segments_applied": len(body.segments),
        }
    else:
        # Legacy flat colors path
        colors_array = np.array(body.colors, dtype=np.uint8)
        if colors_array.ndim != 2 or colors_array.shape[1] != 3:
            raise HTTPException(
                status_code=400, detail="Colors must be an array of [R,G,B] triplets"
            )
        for stream in streams:
            if hasattr(stream, "push_colors"):
                stream.push_colors(colors_array)
        return {
            "status": "ok",
            "streams_updated": len(streams),
            "leds_received": len(body.colors),
        }


@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
async def notify_source(
    source_id: str,
    _auth: AuthRequired,
    body: NotifyRequest = None,
    store: ColorStripStore = Depends(get_color_strip_store),
    manager: ProcessorManager = Depends(get_processor_manager),
):
    """Trigger a notification on a notification color strip source.

    Fires a one-shot visual effect (flash, pulse, sweep) on all running
    stream instances for this source. Optionally specify an app name for
    color lookup or a hex color override.
    """
    try:
        source = store.get_source(source_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    if not isinstance(source, NotificationColorStripSource):
        raise HTTPException(status_code=400, detail="Source is not a notification type")

    app_name = body.app if body else None
    color_override = body.color if body else None

    streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
    accepted = 0
    for stream in streams:
        if hasattr(stream, "fire"):
            if stream.fire(app_name=app_name, color_override=color_override):
                accepted += 1

    return {
        "status": "ok",
        "streams_notified": accepted,
        "filtered": len(streams) - accepted,
    }


@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
async def os_notification_history(_auth: AuthRequired):
    """Return recent OS notification capture history (newest first)."""
    from wled_controller.core.processing.os_notification_listener import (
        get_os_notification_listener,
    )

    listener = get_os_notification_listener()
    if listener is None:
        return {"available": False, "history": []}
    return {
        "available": listener.available,
        "history": listener.recent_history,
    }


# ── Transient Preview WebSocket ────────────────────────────────────────

_PREVIEW_ALLOWED_TYPES = {
    "static",
    "gradient",
    "color_cycle",
    "effect",
    "daylight",
    "candlelight",
    "notification",
}


@router.websocket("/api/v1/color-strip-sources/preview/ws")
async def preview_color_strip_ws(
    websocket: WebSocket,
    token: str = Query(""),
    led_count: int = Query(100),
    fps: int = Query(20),
):
    """Transient preview WebSocket — stream frames for an ad-hoc source config.

    Auth via ``?token=<api_key>&led_count=100&fps=20``.

    After accepting, waits for a text message containing the full source config
    JSON (must include ``source_type``).  Responds with a JSON metadata message,
    then streams binary RGB frames at the requested FPS.

    Subsequent text messages are treated as config updates: if the source_type
    changed the old stream is replaced; otherwise ``update_source()`` is used.
    """
    from wled_controller.api.auth import verify_ws_token

    if not verify_ws_token(token):
        await websocket.close(code=4001, reason="Unauthorized")
        return

    await websocket.accept()

    led_count = max(1, min(1000, led_count))
    fps = max(1, min(60, fps))
    frame_interval = 1.0 / fps

    stream = None
    clock_id = None
    current_source_type = None

    # Helpers ────────────────────────────────────────────────────────────

    def _get_sync_clock_manager():
        """Return the SyncClockManager if available."""
        try:
            mgr = get_processor_manager()
            return getattr(mgr, "_sync_clock_manager", None)
        except Exception as e:
            logger.debug("SyncClockManager not available: %s", e)
            return None

    def _build_source(config: dict):
        """Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
        from wled_controller.storage.color_strip_source import ColorStripSource

        config.setdefault("id", "__preview__")
        config.setdefault("name", "__preview__")
        return ColorStripSource.from_dict(config)

    def _create_stream(source):
        """Instantiate and start the appropriate stream class for *source*."""
        from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP

        stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
        if not stream_cls:
            raise ValueError(f"Unsupported preview source_type: {source.source_type}")
        s = stream_cls(source)
        # Inject gradient store for palette resolution
        if hasattr(s, "set_gradient_store"):
            try:
                from wled_controller.api.dependencies import get_gradient_store

                s.set_gradient_store(get_gradient_store())
            except Exception:
                pass
        if hasattr(s, "configure"):
            s.configure(led_count)
        # Inject sync clock if requested
        cid = getattr(source, "clock_id", None)
        if cid and hasattr(s, "set_clock"):
            scm = _get_sync_clock_manager()
            if scm:
                try:
                    clock_rt = scm.acquire(cid)
                    s.set_clock(clock_rt)
                except Exception as e:
                    logger.warning(f"Preview: could not acquire clock {cid}: {e}")
                    cid = None
            else:
                cid = None
        else:
            cid = None
        s.start()
        return s, cid

    def _stop_stream(s, cid):
        """Stop a stream and release its clock."""
        try:
            s.stop()
        except Exception:
            pass
        if cid:
            scm = _get_sync_clock_manager()
            if scm:
                try:
                    scm.release(cid)
                except Exception:
                    pass

    async def _send_meta(source_type: str):
        meta = {"type": "meta", "led_count": led_count, "source_type": source_type}
        await websocket.send_text(_json.dumps(meta))

    # Wait for initial config ────────────────────────────────────────────

    try:
        initial_text = await websocket.receive_text()
    except WebSocketDisconnect:
        return
    except Exception:
        return

    try:
        config = _json.loads(initial_text)
        source_type = config.get("source_type")
        if source_type not in _PREVIEW_ALLOWED_TYPES:
            await websocket.send_text(
                _json.dumps(
                    {
                        "type": "error",
                        "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}",
                    }
                )
            )
            await websocket.close(code=4003, reason="Invalid source_type")
            return
        source = _build_source(config)
        stream, clock_id = _create_stream(source)
        current_source_type = source_type
    except Exception as e:
        logger.error(f"Preview WS: bad initial config: {e}")
        await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
        await websocket.close(code=4003, reason=str(e))
        return

    await _send_meta(current_source_type)
    logger.info(
        f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}"
    )

    # Frame loop ─────────────────────────────────────────────────────────

    try:
        while True:
            # Non-blocking check for incoming config updates
            try:
                msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval)
            except asyncio.TimeoutError:
                msg = None
            except WebSocketDisconnect:
                break

            if msg is not None:
                try:
                    new_config = _json.loads(msg)

                    # Handle "fire" command for notification streams
                    if new_config.get("action") == "fire":
                        from wled_controller.core.processing.notification_stream import (
                            NotificationColorStripStream,
                        )

                        if isinstance(stream, NotificationColorStripStream):
                            stream.fire(
                                app_name=new_config.get("app", ""),
                                color_override=new_config.get("color"),
                            )
                        continue

                    new_type = new_config.get("source_type")
                    if new_type not in _PREVIEW_ALLOWED_TYPES:
                        await websocket.send_text(
                            _json.dumps(
                                {
                                    "type": "error",
                                    "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}",
                                }
                            )
                        )
                        continue
                    new_source = _build_source(new_config)
                    if new_type != current_source_type:
                        # Source type changed — recreate stream
                        _stop_stream(stream, clock_id)
                        stream, clock_id = _create_stream(new_source)
                        current_source_type = new_type
                    else:
                        stream.update_source(new_source)
                        if hasattr(stream, "configure"):
                            stream.configure(led_count)
                    await _send_meta(current_source_type)
                except Exception as e:
                    logger.warning(f"Preview WS: bad config update: {e}")
                    await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))

            # Send frame
            colors = stream.get_latest_colors()
            if colors is not None:
                await websocket.send_bytes(colors.tobytes())
            else:
                # Stream hasn't produced a frame yet — send black
                await websocket.send_bytes(b"\x00" * led_count * 3)

    except WebSocketDisconnect:
        pass
    except Exception as e:
        logger.error(f"Preview WS error: {e}")
    finally:
        if stream is not None:
            _stop_stream(stream, clock_id)
        logger.info("Preview WS disconnected")


@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
async def css_api_input_ws(
    websocket: WebSocket,
    source_id: str,
    token: str = Query(""),
):
    """WebSocket for pushing raw LED colors to an api_input source.

    Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
    or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
    """
    from wled_controller.api.auth import verify_ws_token

    if not verify_ws_token(token):
        await websocket.close(code=4001, reason="Unauthorized")
        return

    # Validate source exists and is api_input type
    manager = get_processor_manager()
    try:
        store = get_color_strip_store()
        source = store.get_source(source_id)
    except (ValueError, RuntimeError):
        await websocket.close(code=4004, reason="Source not found")
        return

    if not isinstance(source, ApiInputColorStripSource):
        await websocket.close(code=4003, reason="Source is not api_input type")
        return

    await websocket.accept()
    logger.info(f"API input WebSocket connected for source {source_id}")

    try:
        while True:
            message = await websocket.receive()

            if message.get("type") == "websocket.disconnect":
                break

            if "text" in message:
                # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
                import json

                try:
                    data = json.loads(message["text"])
                except (json.JSONDecodeError, ValueError) as e:
                    await websocket.send_json({"error": str(e)})
                    continue

                if "segments" in data:
                    # Segment-based path — validate and push
                    try:
                        from wled_controller.api.schemas.color_strip_sources import SegmentPayload

                        seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
                    except Exception as e:
                        await websocket.send_json({"error": f"Invalid segment: {e}"})
                        continue
                    streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
                    for stream in streams:
                        if hasattr(stream, "push_segments"):
                            stream.push_segments(seg_dicts)
                    continue

                elif "colors" in data:
                    try:
                        raw_colors = data["colors"]
                        colors_array = np.array(raw_colors, dtype=np.uint8)
                        if colors_array.ndim != 2 or colors_array.shape[1] != 3:
                            await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
                            continue
                    except (ValueError, TypeError) as e:
                        await websocket.send_json({"error": str(e)})
                        continue
                else:
                    await websocket.send_json(
                        {"error": "JSON frame must contain 'colors' or 'segments'"}
                    )
                    continue

            elif "bytes" in message:
                # Binary frame: raw RGBRGB... bytes (3 bytes per LED)
                raw_bytes = message["bytes"]
                if len(raw_bytes) % 3 != 0:
                    await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
                    continue
                colors_array = np.frombuffer(raw_bytes, dtype=np.uint8).reshape(-1, 3)

            else:
                continue

            # Push to all running streams (colors_array path only reaches here)
            streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
            for stream in streams:
                if hasattr(stream, "push_colors"):
                    stream.push_colors(colors_array)

    except WebSocketDisconnect:
        pass
    except Exception as e:
        logger.error(f"API input WebSocket error for source {source_id}: {e}")
    finally:
        logger.info(f"API input WebSocket disconnected for source {source_id}")


# ── Test / Preview WebSocket ──────────────────────────────────────────


@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws")
async def test_color_strip_ws(
    websocket: WebSocket,
    source_id: str,
    token: str = Query(""),
    led_count: int = Query(100),
    fps: int = Query(20),
):
    """WebSocket for real-time CSS source preview. Auth via ``?token=<api_key>``.

    First message is JSON metadata (source_type, led_count, calibration segments).
    Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
    """
    from wled_controller.api.auth import verify_ws_token

    if not verify_ws_token(token):
        await websocket.close(code=4001, reason="Unauthorized")
        return

    # Validate source exists
    store: ColorStripStore = get_color_strip_store()
    try:
        source = store.get_source(source_id)
    except ValueError as e:
        await websocket.close(code=4004, reason=str(e))
        return

    # Acquire stream – unique consumer ID per WS to avoid release races
    manager: ProcessorManager = get_processor_manager()
    csm = manager.color_strip_stream_manager
    consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__"
    try:
        stream = csm.acquire(source_id, consumer_id)
    except Exception as e:
        logger.error(f"CSS test: failed to acquire stream for {source_id}: {e}")
        await websocket.close(code=4003, reason=str(e))
        return

    # Configure LED count for auto-sizing streams
    if hasattr(stream, "configure"):
        stream.configure(max(1, led_count))

    # Reject picture sources with 0 calibration LEDs (no edges configured)
    if stream.led_count <= 0:
        csm.release(source_id, consumer_id)
        await websocket.close(
            code=4005,
            reason="No LEDs configured. Open Calibration and set LED counts for each edge.",
        )
        return

    # Clamp FPS to sane range
    fps = max(1, min(60, fps))
    _frame_interval = 1.0 / fps

    await websocket.accept()
    logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")

    try:
        from wled_controller.core.processing.composite_stream import CompositeColorStripStream

        from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream

        is_api_input = isinstance(stream, ApiInputColorStripStream)
        _last_push_gen = 0  # track api_input push generation to skip unchanged frames

        # Send metadata as first message
        is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
        is_composite = isinstance(source, CompositeColorStripSource)
        meta: dict = {
            "type": "meta",
            "source_type": source.source_type,
            "source_name": source.name,
            "led_count": stream.led_count,
        }
        if is_picture and stream.calibration:
            cal = stream.calibration
            total = cal.get_total_leds()
            offset = cal.offset % total if total > 0 else 0
            edges = []
            for seg in cal.segments:
                # Compute output indices matching PixelMapper logic
                indices = list(range(seg.led_start, seg.led_start + seg.led_count))
                if seg.reverse:
                    indices = indices[::-1]
                if offset > 0:
                    indices = [(idx + offset) % total for idx in indices]
                edges.append({"edge": seg.edge, "indices": indices})
            meta["edges"] = edges
            meta["border_width"] = cal.border_width
        if is_composite and hasattr(source, "layers"):
            # Send layer info for composite preview
            enabled_layers = [layer for layer in source.layers if layer.get("enabled", True)]
            layer_infos = []  # [{name, id, is_notification, has_brightness, ...}, ...]
            for layer in enabled_layers:
                info = {
                    "id": layer["source_id"],
                    "name": layer.get("source_id", "?"),
                    "is_notification": False,
                    "has_brightness": bool(layer.get("brightness_source_id")),
                }
                try:
                    layer_src = store.get_source(layer["source_id"])
                    info["name"] = layer_src.name
                    info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
                    if isinstance(
                        layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)
                    ):
                        info["is_picture"] = True
                        if hasattr(layer_src, "calibration") and layer_src.calibration:
                            info["calibration_led_count"] = layer_src.calibration.get_total_leds()
                except (ValueError, KeyError):
                    pass
                layer_infos.append(info)
            meta["layers"] = [li["name"] for li in layer_infos]
            meta["layer_infos"] = layer_infos
        await websocket.send_text(_json.dumps(meta))

        # For api_input: send the current buffer immediately so the client
        # gets a frame right away (fallback color if inactive) rather than
        # leaving the canvas blank/stale until external data arrives.
        if is_api_input:
            initial_colors = stream.get_latest_colors()
            if initial_colors is not None:
                await websocket.send_bytes(initial_colors.tobytes())

        # For picture sources, grab the live stream for frame preview
        _frame_live = None
        if is_picture and hasattr(stream, "live_stream"):
            _frame_live = stream.live_stream
        _last_aux_time = 0.0
        _AUX_INTERVAL = 0.08  # send JPEG preview / brightness updates ~12 FPS
        _frame_dims_sent = False  # send frame dimensions once with first JPEG

        # Stream binary RGB frames at ~20 Hz
        while True:
            # For composite sources, send per-layer data like target preview does
            if is_composite and isinstance(stream, CompositeColorStripStream):
                layer_colors = stream.get_layer_colors()
                composite_colors = stream.get_latest_colors()
                if composite_colors is not None and layer_colors and len(layer_colors) > 1:
                    led_count = composite_colors.shape[0]
                    rgb_size = led_count * 3
                    # Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb]
                    header = bytes(
                        [0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]
                    )
                    parts = [header]
                    for lc in layer_colors:
                        if lc is not None and lc.shape[0] == led_count:
                            parts.append(lc.tobytes())
                        else:
                            parts.append(b"\x00" * rgb_size)
                    parts.append(composite_colors.tobytes())
                    await websocket.send_bytes(b"".join(parts))
                elif composite_colors is not None:
                    await websocket.send_bytes(composite_colors.tobytes())
            else:
                # For api_input: only send when new data was pushed
                if is_api_input:
                    gen = stream.push_generation
                    if gen != _last_push_gen:
                        _last_push_gen = gen
                        colors = stream.get_latest_colors()
                        if colors is not None:
                            await websocket.send_bytes(colors.tobytes())
                else:
                    colors = stream.get_latest_colors()
                    if colors is not None:
                        await websocket.send_bytes(colors.tobytes())

            # Periodically send auxiliary data (frame preview, brightness)
            now = _time.monotonic()
            if now - _last_aux_time >= _AUX_INTERVAL:
                _last_aux_time = now

                # Send brightness values for composite layers
                if is_composite and isinstance(stream, CompositeColorStripStream):
                    try:
                        bri_values = stream.get_layer_brightness()
                        if any(v is not None for v in bri_values):
                            bri_msg = {
                                "type": "brightness",
                                "values": [
                                    round(v * 100) if v is not None else None for v in bri_values
                                ],
                            }
                            await websocket.send_text(_json.dumps(bri_msg))
                    except Exception:
                        pass

                # Send JPEG frame preview for picture sources
                if _frame_live:
                    try:
                        frame = _frame_live.get_latest_frame()
                        if frame is not None and frame.image is not None:
                            from wled_controller.utils.image_codec import encode_jpeg
                            import cv2 as _cv2

                            img = frame.image
                            # Ensure 3-channel RGB (some engines may produce BGRA)
                            if img.ndim == 3 and img.shape[2] == 4:
                                img = img[:, :, :3]
                            h, w = img.shape[:2]
                            # Send frame dimensions once so client can compute border overlay
                            if not _frame_dims_sent:
                                _frame_dims_sent = True
                                await websocket.send_text(
                                    _json.dumps(
                                        {
                                            "type": "frame_dims",
                                            "width": w,
                                            "height": h,
                                        }
                                    )
                                )
                            # Downscale for bandwidth
                            scale = min(960 / w, 540 / h, 1.0)
                            if scale < 1.0:
                                new_w = max(1, int(w * scale))
                                new_h = max(1, int(h * scale))
                                img = _cv2.resize(
                                    img, (new_w, new_h), interpolation=_cv2.INTER_AREA
                                )
                            # Wire format: [0xFD] [jpeg_bytes]
                            await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70))
                    except Exception as e:
                        logger.warning(f"JPEG frame preview error: {e}")

            await asyncio.sleep(_frame_interval)
    except WebSocketDisconnect:
        pass
    except Exception as e:
        logger.error(f"CSS test WebSocket error for {source_id}: {e}")
    finally:
        csm.release(source_id, consumer_id)
        logger.info(f"CSS test WebSocket disconnected for {source_id}")
