"""Color strip source data model with inheritance-based source types.

A ColorStripSource produces a stream of LED color arrays (np.ndarray shape (N, 3))
from some input, encapsulating everything needed to drive a physical LED strip:
calibration, color correction, smoothing, and FPS.

Current types:
  PictureColorStripSource          —  derives LED colors from a single PictureSource (simple 4-edge calibration)
  AdvancedPictureColorStripSource  —  line-based calibration across multiple PictureSources
  StaticColorStripSource    —  constant solid color fills all LEDs
  GradientColorStripSource  —  linear gradient across all LEDs from user-defined color stops
  ColorCycleColorStripSource —  smoothly cycles through a user-defined list of colors
  AudioColorStripSource      —  audio-reactive visualization (spectrum, beat pulse, VU meter)
  ApiInputColorStripSource   —  receives raw LED colors from external clients via REST/WebSocket
  NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
  DaylightColorStripSource   —  simulates natural daylight color temperature over a 24-hour cycle
  CandlelightColorStripSource —  realistic per-LED candle flickering with warm glow
"""

from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional, Type

from wled_controller.core.capture.calibration import (
    CalibrationConfig,
    calibration_from_dict,
    calibration_to_dict,
)
from wled_controller.storage.utils import resolve_ref


def _validate_rgb(value, default: list) -> list:
    """Return value if it's a 3-element list, otherwise return default."""
    return value if isinstance(value, list) and len(value) == 3 else list(default)


@dataclass
class ColorStripSource:
    """Base class for color strip source configurations."""

    id: str
    name: str
    source_type: str  # "picture" | future types
    created_at: datetime
    updated_at: datetime
    description: Optional[str] = None
    clock_id: Optional[str] = None  # optional SyncClock reference
    tags: List[str] = field(default_factory=list)

    @property
    def sharable(self) -> bool:
        """Whether multiple consumers can share a single stream instance.

        Count-dependent sources (static, gradient, cycle, effect) return False
        because each consumer may configure a different LED count.
        """
        return False

    def to_dict(self) -> dict:
        """Convert source to dictionary. Subclasses extend this with their own fields."""
        return {
            "id": self.id,
            "name": self.name,
            "source_type": self.source_type,
            "created_at": self.created_at.isoformat(),
            "updated_at": self.updated_at.isoformat(),
            "description": self.description,
            "clock_id": self.clock_id,
            "tags": self.tags,
        }

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description: Optional[str] = None,
                           clock_id: Optional[str] = None,
                           tags: Optional[List[str]] = None,
                           **kwargs) -> "ColorStripSource":
        """Create an instance from keyword arguments.

        Base implementation — subclasses override to extract type-specific fields.
        This should not be called directly; use create_instance() instead.
        """
        raise NotImplementedError(f"create_from_kwargs not implemented on {cls.__name__}")

    def apply_update(self, **kwargs) -> None:
        """Apply type-specific field updates. Subclasses override this.

        Only fields present in kwargs (and not None) are updated.
        Base class handles common fields (name, description, clock_id, tags)
        in the store; this method handles subclass-specific fields only.
        """
        pass

    @staticmethod
    def create_instance(source_type: str, **kwargs) -> "ColorStripSource":
        """Factory: create the correct subclass based on source_type."""
        cls = _SOURCE_TYPE_MAP.get(source_type)
        if cls is None:
            # Default to PictureColorStripSource for unknown types
            cls = PictureColorStripSource
        return cls.create_from_kwargs(source_type=source_type, **kwargs)

    @staticmethod
    def from_dict(data: dict) -> "ColorStripSource":
        """Factory: dispatch to the correct subclass based on source_type."""
        source_type = data.get("source_type", "picture") or "picture"
        subcls = _SOURCE_TYPE_MAP.get(source_type, PictureColorStripSource)
        return subcls.from_dict(data)


def _parse_css_common(data: dict) -> dict:
    """Extract fields common to all ColorStripSource types."""
    raw_created = data.get("created_at")
    created_at = (
        datetime.fromisoformat(raw_created)
        if isinstance(raw_created, str)
        else raw_created if isinstance(raw_created, datetime)
        else datetime.now(timezone.utc)
    )
    raw_updated = data.get("updated_at")
    updated_at = (
        datetime.fromisoformat(raw_updated)
        if isinstance(raw_updated, str)
        else raw_updated if isinstance(raw_updated, datetime)
        else datetime.now(timezone.utc)
    )
    return dict(
        id=data["id"],
        name=data["name"],
        description=data.get("description"),
        clock_id=data.get("clock_id"),
        tags=data.get("tags", []),
        created_at=created_at,
        updated_at=updated_at,
    )


def _parse_picture_fields(data: dict) -> dict:
    """Extract fields shared by picture-type color strip sources."""
    calibration_data = data.get("calibration")
    calibration = (
        calibration_from_dict(calibration_data)
        if calibration_data
        else CalibrationConfig(layout="clockwise", start_position="bottom_left")
    )
    return dict(
        fps=data.get("fps") or 30,
        smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3,
        interpolation_mode=data.get("interpolation_mode") or "average",
        calibration=calibration,
        led_count=data.get("led_count") or 0,
    )


def _picture_base_to_dict(source, d: dict) -> dict:
    """Populate dict with fields common to both picture source types."""
    d["fps"] = source.fps
    d["smoothing"] = source.smoothing
    d["interpolation_mode"] = source.interpolation_mode
    d["calibration"] = calibration_to_dict(source.calibration)
    d["led_count"] = source.led_count
    return d


def _apply_picture_update(source, **kwargs) -> None:
    """Apply update fields common to both picture source types."""
    if kwargs.get("fps") is not None:
        source.fps = kwargs["fps"]
    if kwargs.get("smoothing") is not None:
        source.smoothing = kwargs["smoothing"]
    if kwargs.get("interpolation_mode") is not None:
        source.interpolation_mode = kwargs["interpolation_mode"]
    if kwargs.get("calibration") is not None:
        source.calibration = kwargs["calibration"]
    if kwargs.get("led_count") is not None:
        source.led_count = kwargs["led_count"]


@dataclass
class PictureColorStripSource(ColorStripSource):
    """Color strip source driven by a single PictureSource (simple 4-edge calibration).

    Contains everything required to produce LED color arrays from a picture stream:
    calibration (LED positions), color correction, smoothing, FPS target.
    """

    @property
    def sharable(self) -> bool:
        """Picture streams are expensive (screen capture) and safe to share."""
        return True

    picture_source_id: str = ""
    fps: int = 30
    smoothing: float = 0.3           # temporal smoothing (0.0 = none, 1.0 = full)
    interpolation_mode: str = "average"   # "average" | "median" | "dominant"
    calibration: CalibrationConfig = field(
        default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
    )
    led_count: int = 0               # explicit LED count; 0 = auto (derived from calibration)

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["picture_source_id"] = self.picture_source_id
        return _picture_base_to_dict(self, d)

    @classmethod
    def from_dict(cls, data: dict) -> "PictureColorStripSource":
        common = _parse_css_common(data)
        pic = _parse_picture_fields(data)
        return cls(
            **common,
            source_type=data.get("source_type", "picture") or "picture",
            picture_source_id=data.get("picture_source_id") or "",
            **pic,
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           picture_source_id="", fps=30,
                           smoothing=0.3,
                           interpolation_mode="average", calibration=None,
                           led_count=0, **_kwargs):
        if calibration is None:
            calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
        return cls(
            id=id, name=name, source_type=source_type,
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            picture_source_id=picture_source_id, fps=fps,
            smoothing=smoothing, interpolation_mode=interpolation_mode,
            calibration=calibration, led_count=led_count,
        )

    def apply_update(self, **kwargs) -> None:
        picture_source_id = kwargs.get("picture_source_id")
        if picture_source_id is not None:
            self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id)
        _apply_picture_update(self, **kwargs)


@dataclass
class AdvancedPictureColorStripSource(ColorStripSource):
    """Color strip source with line-based calibration across multiple picture sources.

    Each calibration line references its own picture source and edge, enabling
    LED strips that span multiple monitors. No single picture_source_id -- the
    picture sources are defined per-line in the calibration config.
    """

    @property
    def sharable(self) -> bool:
        """Picture streams are expensive (screen capture) and safe to share."""
        return True

    fps: int = 30
    smoothing: float = 0.3
    interpolation_mode: str = "average"
    calibration: CalibrationConfig = field(
        default_factory=lambda: CalibrationConfig(mode="advanced")
    )
    led_count: int = 0

    def to_dict(self) -> dict:
        d = super().to_dict()
        return _picture_base_to_dict(self, d)

    @classmethod
    def from_dict(cls, data: dict) -> "AdvancedPictureColorStripSource":
        common = _parse_css_common(data)
        pic = _parse_picture_fields(data)
        return cls(**common, source_type="picture_advanced", **pic)

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           fps=30,
                           smoothing=0.3, interpolation_mode="average",
                           calibration=None, led_count=0, **_kwargs):
        if calibration is None:
            calibration = CalibrationConfig(mode="advanced")
        return cls(
            id=id, name=name, source_type="picture_advanced",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            fps=fps,
            smoothing=smoothing, interpolation_mode=interpolation_mode,
            calibration=calibration, led_count=led_count,
        )

    def apply_update(self, **kwargs) -> None:
        _apply_picture_update(self, **kwargs)


@dataclass
class StaticColorStripSource(ColorStripSource):
    """Color strip source that fills all LEDs with a single static color.

    No capture or processing -- the entire LED strip is set to one constant
    RGB color.  Useful for solid-color accents or as a placeholder while
    a PictureColorStripSource is being configured.
    """

    color: list = field(default_factory=lambda: [255, 255, 255])  # [R, G, B]
    animation: Optional[dict] = None  # {"enabled": bool, "type": str, "speed": float} or None

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["color"] = list(self.color)
        d["animation"] = self.animation
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "StaticColorStripSource":
        common = _parse_css_common(data)
        color = _validate_rgb(data.get("color"), [255, 255, 255])
        return cls(
            **common, source_type="static",
            color=color, animation=data.get("animation"),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           color=None, animation=None, **_kwargs):
        rgb = _validate_rgb(color, [255, 255, 255])
        return cls(
            id=id, name=name, source_type="static",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            color=rgb, animation=animation,
        )

    def apply_update(self, **kwargs) -> None:
        color = kwargs.get("color")
        if color is not None:
            if isinstance(color, list) and len(color) == 3:
                self.color = color
        if kwargs.get("animation") is not None:
            self.animation = kwargs["animation"]


@dataclass
class GradientColorStripSource(ColorStripSource):
    """Color strip source that produces a linear gradient across all LEDs.

    The gradient is defined by color stops at relative positions (0.0-1.0).
    Each stop has a primary color; optionally a second "right" color to create
    a hard discontinuity (bidirectional stop) at that position.

    LED count auto-sizes from the connected device.
    """

    # Each stop: {"position": float, "color": [R,G,B], "color_right": [R,G,B] | null}
    stops: list = field(default_factory=lambda: [
        {"position": 0.0, "color": [255, 0, 0]},
        {"position": 1.0, "color": [0, 0, 255]},
    ])
    animation: Optional[dict] = None  # {"enabled": bool, "type": str, "speed": float} or None
    easing: str = "linear"  # linear | ease_in_out | step | cubic
    gradient_id: Optional[str] = None  # references a Gradient entity; overrides inline stops

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["stops"] = [dict(s) for s in self.stops]
        d["animation"] = self.animation
        d["easing"] = self.easing
        d["gradient_id"] = self.gradient_id
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "GradientColorStripSource":
        common = _parse_css_common(data)
        raw_stops = data.get("stops")
        return cls(
            **common, source_type="gradient",
            stops=raw_stops if isinstance(raw_stops, list) else [],
            animation=data.get("animation"),
            easing=data.get("easing") or "linear",
            gradient_id=data.get("gradient_id"),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           stops=None, animation=None, easing=None,
                           gradient_id=None, **_kwargs):
        return cls(
            id=id, name=name, source_type="gradient",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            stops=stops if isinstance(stops, list) else [
                {"position": 0.0, "color": [255, 0, 0]},
                {"position": 1.0, "color": [0, 0, 255]},
            ],
            animation=animation,
            easing=easing if easing in ("linear", "ease_in_out", "step", "cubic") else "linear",
            gradient_id=gradient_id,
        )

    def apply_update(self, **kwargs) -> None:
        stops = kwargs.get("stops")
        if stops is not None and isinstance(stops, list):
            self.stops = stops
        if kwargs.get("animation") is not None:
            self.animation = kwargs["animation"]
        if kwargs.get("easing") is not None:
            self.easing = kwargs["easing"]
        if "gradient_id" in kwargs:
            self.gradient_id = kwargs["gradient_id"]


@dataclass
class ColorCycleColorStripSource(ColorStripSource):
    """Color strip source that smoothly cycles through a user-defined list of colors.

    All LEDs receive the same solid color at any point in time, smoothly
    interpolating between the configured color stops in a continuous loop.
    LED count auto-sizes from the connected device.
    """

    colors: list = field(default_factory=lambda: [
        [255, 0, 0], [255, 255, 0], [0, 255, 0],
        [0, 255, 255], [0, 0, 255], [255, 0, 255],
    ])

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["colors"] = [list(c) for c in self.colors]
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "ColorCycleColorStripSource":
        common = _parse_css_common(data)
        raw_colors = data.get("colors")
        return cls(
            **common, source_type="color_cycle",
            colors=raw_colors if isinstance(raw_colors, list) else [],
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           colors=None, **_kwargs):
        default_colors = [
            [255, 0, 0], [255, 255, 0], [0, 255, 0],
            [0, 255, 255], [0, 0, 255], [255, 0, 255],
        ]
        return cls(
            id=id, name=name, source_type="color_cycle",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
        )

    def apply_update(self, **kwargs) -> None:
        colors = kwargs.get("colors")
        if colors is not None and isinstance(colors, list) and len(colors) >= 2:
            self.colors = colors


@dataclass
class EffectColorStripSource(ColorStripSource):
    """Color strip source that runs a procedural LED effect.

    The effect_type field selects which algorithm to use:
    fire, meteor, plasma, noise, aurora.
    LED count auto-sizes from the connected device.
    """

    effect_type: str = "fire"       # fire | meteor | plasma | noise | aurora + new types
    palette: str = "fire"           # legacy palette name (kept for migration)
    gradient_id: Optional[str] = None  # references a Gradient entity (preferred over palette)
    color: list = field(default_factory=lambda: [255, 80, 0])  # [R,G,B] for meteor/comet/bouncing_ball head
    intensity: float = 1.0          # effect-specific intensity (0.1-2.0)
    scale: float = 1.0              # spatial scale / zoom (0.5-5.0)
    mirror: bool = False            # bounce mode (meteor/comet)
    custom_palette: Optional[list] = None  # legacy [[pos, R, G, B], ...] custom palette stops

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["effect_type"] = self.effect_type
        d["palette"] = self.palette
        d["gradient_id"] = self.gradient_id
        d["color"] = list(self.color)
        d["intensity"] = self.intensity
        d["scale"] = self.scale
        d["mirror"] = self.mirror
        d["custom_palette"] = self.custom_palette
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "EffectColorStripSource":
        common = _parse_css_common(data)
        color = _validate_rgb(data.get("color"), [255, 80, 0])
        return cls(
            **common, source_type="effect",
            effect_type=data.get("effect_type") or "fire",
            palette=data.get("palette") or "fire",
            gradient_id=data.get("gradient_id"),
            color=color,
            intensity=float(data.get("intensity") or 1.0),
            scale=float(data.get("scale") or 1.0),
            mirror=bool(data.get("mirror", False)),
            custom_palette=data.get("custom_palette"),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           effect_type="fire", palette="fire", gradient_id=None,
                           color=None, intensity=1.0, scale=1.0, mirror=False,
                           custom_palette=None, **_kwargs):
        rgb = _validate_rgb(color, [255, 80, 0])
        return cls(
            id=id, name=name, source_type="effect",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            effect_type=effect_type or "fire", palette=palette or "fire",
            gradient_id=gradient_id,
            color=rgb,
            intensity=float(intensity) if intensity else 1.0,
            scale=float(scale) if scale else 1.0,
            mirror=bool(mirror),
            custom_palette=custom_palette if isinstance(custom_palette, list) else None,
        )

    def apply_update(self, **kwargs) -> None:
        if kwargs.get("effect_type") is not None:
            self.effect_type = kwargs["effect_type"]
        if kwargs.get("palette") is not None:
            self.palette = kwargs["palette"]
        if "gradient_id" in kwargs:
            self.gradient_id = kwargs["gradient_id"]
        color = kwargs.get("color")
        if color is not None and isinstance(color, list) and len(color) == 3:
            self.color = color
        if kwargs.get("intensity") is not None:
            self.intensity = float(kwargs["intensity"])
        if kwargs.get("scale") is not None:
            self.scale = float(kwargs["scale"])
        if kwargs.get("mirror") is not None:
            self.mirror = bool(kwargs["mirror"])
        if "custom_palette" in kwargs:
            cp = kwargs["custom_palette"]
            self.custom_palette = cp if isinstance(cp, list) else None


@dataclass
class AudioColorStripSource(ColorStripSource):
    """Color strip source driven by audio input (microphone or system audio).

    visualization_mode selects the rendering algorithm:
    spectrum, beat_pulse, vu_meter.
    LED count auto-sizes from the connected device when led_count == 0.
    """

    visualization_mode: str = "spectrum"    # spectrum | beat_pulse | vu_meter
    audio_source_id: str = ""              # references a MonoAudioSource
    sensitivity: float = 1.0                # gain multiplier (0.1-5.0)
    smoothing: float = 0.3                  # temporal smoothing (0.0-1.0)
    palette: str = "rainbow"                # legacy palette name (kept for migration)
    gradient_id: Optional[str] = None       # references a Gradient entity (preferred)
    color: list = field(default_factory=lambda: [0, 255, 0])   # base RGB for VU meter
    color_peak: list = field(default_factory=lambda: [255, 0, 0])  # peak RGB for VU meter
    led_count: int = 0                      # 0 = use device LED count
    mirror: bool = False                    # mirror spectrum from center outward

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["visualization_mode"] = self.visualization_mode
        d["audio_source_id"] = self.audio_source_id
        d["sensitivity"] = self.sensitivity
        d["smoothing"] = self.smoothing
        d["palette"] = self.palette
        d["gradient_id"] = self.gradient_id
        d["color"] = list(self.color)
        d["color_peak"] = list(self.color_peak)
        d["led_count"] = self.led_count
        d["mirror"] = self.mirror
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "AudioColorStripSource":
        common = _parse_css_common(data)
        color = _validate_rgb(data.get("color"), [0, 255, 0])
        color_peak = _validate_rgb(data.get("color_peak"), [255, 0, 0])
        return cls(
            **common, source_type="audio",
            visualization_mode=data.get("visualization_mode") or "spectrum",
            audio_source_id=data.get("audio_source_id") or "",
            sensitivity=float(data.get("sensitivity") or 1.0),
            smoothing=float(data.get("smoothing") or 0.3),
            palette=data.get("palette") or "rainbow",
            gradient_id=data.get("gradient_id"),
            color=color, color_peak=color_peak,
            led_count=data.get("led_count") or 0,
            mirror=bool(data.get("mirror", False)),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           visualization_mode="spectrum", audio_source_id="",
                           sensitivity=1.0, smoothing=0.3, palette="rainbow",
                           gradient_id=None, color=None, color_peak=None,
                           led_count=0, mirror=False, **_kwargs):
        rgb = _validate_rgb(color, [0, 255, 0])
        peak = _validate_rgb(color_peak, [255, 0, 0])
        return cls(
            id=id, name=name, source_type="audio",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            visualization_mode=visualization_mode or "spectrum",
            audio_source_id=audio_source_id or "",
            sensitivity=float(sensitivity) if sensitivity else 1.0,
            smoothing=float(smoothing) if smoothing else 0.3,
            palette=palette or "rainbow",
            gradient_id=gradient_id,
            color=rgb, color_peak=peak, led_count=led_count,
            mirror=bool(mirror),
        )

    def apply_update(self, **kwargs) -> None:
        if kwargs.get("visualization_mode") is not None:
            self.visualization_mode = kwargs["visualization_mode"]
        audio_source_id = kwargs.get("audio_source_id")
        if audio_source_id is not None:
            self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id)
        if kwargs.get("sensitivity") is not None:
            self.sensitivity = float(kwargs["sensitivity"])
        if kwargs.get("smoothing") is not None:
            self.smoothing = float(kwargs["smoothing"])
        if kwargs.get("palette") is not None:
            self.palette = kwargs["palette"]
        if "gradient_id" in kwargs:
            self.gradient_id = kwargs["gradient_id"]
        color = kwargs.get("color")
        if color is not None and isinstance(color, list) and len(color) == 3:
            self.color = color
        color_peak = kwargs.get("color_peak")
        if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3:
            self.color_peak = color_peak
        if kwargs.get("led_count") is not None:
            self.led_count = kwargs["led_count"]
        if kwargs.get("mirror") is not None:
            self.mirror = bool(kwargs["mirror"])


@dataclass
class CompositeColorStripSource(ColorStripSource):
    """Color strip source that composites (stacks) multiple other sources as layers.

    Each layer references a non-composite ColorStripSource with blend mode and opacity.
    Layers are blended bottom-to-top. LED count auto-sizes from the connected device
    when led_count == 0.
    """

    # Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool,
    #              "brightness_source_id": str|None, "processing_template_id": str|None,
    #              "start": int (0=full), "end": int (0=full), "reverse": bool}
    layers: list = field(default_factory=list)
    led_count: int = 0  # 0 = use device LED count

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["layers"] = [dict(layer) for layer in self.layers]
        d["led_count"] = self.led_count
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "CompositeColorStripSource":
        common = _parse_css_common(data)
        return cls(
            **common, source_type="composite",
            layers=data.get("layers") or [],
            led_count=data.get("led_count") or 0,
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           layers=None, led_count=0, **_kwargs):
        return cls(
            id=id, name=name, source_type="composite",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            layers=layers if isinstance(layers, list) else [],
            led_count=led_count,
        )

    def apply_update(self, **kwargs) -> None:
        layers = kwargs.get("layers")
        if layers is not None and isinstance(layers, list):
            self.layers = layers
        if kwargs.get("led_count") is not None:
            self.led_count = kwargs["led_count"]


@dataclass
class MappedColorStripSource(ColorStripSource):
    """Color strip source that maps different sources to different LED ranges.

    Each zone assigns a sub-range of LEDs to a different color strip source.
    Zones are placed side-by-side (spatial multiplexing) rather than blended.
    Gaps between zones stay black.  LED count auto-sizes from the connected
    device when led_count == 0.
    """

    # Each zone: {"source_id": str, "start": int, "end": int, "reverse": bool}
    zones: list = field(default_factory=list)
    led_count: int = 0  # 0 = use device LED count

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["zones"] = [dict(z) for z in self.zones]
        d["led_count"] = self.led_count
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "MappedColorStripSource":
        common = _parse_css_common(data)
        return cls(
            **common, source_type="mapped",
            zones=data.get("zones") or [],
            led_count=data.get("led_count") or 0,
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           zones=None, led_count=0, **_kwargs):
        return cls(
            id=id, name=name, source_type="mapped",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            zones=zones if isinstance(zones, list) else [],
            led_count=led_count,
        )

    def apply_update(self, **kwargs) -> None:
        zones = kwargs.get("zones")
        if zones is not None and isinstance(zones, list):
            self.zones = zones
        if kwargs.get("led_count") is not None:
            self.led_count = kwargs["led_count"]


@dataclass
class ApiInputColorStripSource(ColorStripSource):
    """Color strip source that receives raw LED color arrays from external clients.

    External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
    buffers the latest frame and serves it to targets. When no data has been
    received within `timeout` seconds, LEDs revert to `fallback_color`.
    LED count auto-sizes from the connected device via configure().
    """

    fallback_color: list = field(default_factory=lambda: [0, 0, 0])  # [R, G, B]
    timeout: float = 5.0               # seconds before reverting to fallback
    interpolation: str = "linear"      # none | linear | nearest

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["fallback_color"] = list(self.fallback_color)
        d["timeout"] = self.timeout
        d["interpolation"] = self.interpolation
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "ApiInputColorStripSource":
        common = _parse_css_common(data)
        fallback_color = _validate_rgb(data.get("fallback_color"), [0, 0, 0])
        interpolation = data.get("interpolation", "linear")
        if interpolation not in ("none", "linear", "nearest"):
            interpolation = "linear"
        return cls(
            **common, source_type="api_input",
            fallback_color=fallback_color,
            timeout=float(data.get("timeout") or 5.0),
            interpolation=interpolation,
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           fallback_color=None, timeout=None,
                           interpolation=None,
                           **_kwargs):
        fb = _validate_rgb(fallback_color, [0, 0, 0])
        interp = interpolation if interpolation in ("none", "linear", "nearest") else "linear"
        return cls(
            id=id, name=name, source_type="api_input",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            fallback_color=fb,
            timeout=float(timeout) if timeout is not None else 5.0,
            interpolation=interp,
        )

    def apply_update(self, **kwargs) -> None:
        fallback_color = kwargs.get("fallback_color")
        if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3:
            self.fallback_color = fallback_color
        if kwargs.get("timeout") is not None:
            self.timeout = float(kwargs["timeout"])
        interpolation = kwargs.get("interpolation")
        if interpolation in ("none", "linear", "nearest"):
            self.interpolation = interpolation


@dataclass
class NotificationColorStripSource(ColorStripSource):
    """Color strip source that fires one-shot visual alerts (flash, pulse, sweep).

    External clients trigger notifications via a REST webhook. The stream
    renders a brief animated effect and then returns to black. Each notification
    can carry an optional app name (for per-app color lookup) or an explicit
    color override.
    LED count auto-sizes from the connected device when led_count == 0.
    """

    notification_effect: str = "flash"       # flash | pulse | sweep
    duration_ms: int = 1500                  # effect duration in milliseconds
    default_color: str = "#FFFFFF"           # hex color for notifications without app match
    app_colors: dict = field(default_factory=dict)       # app name -> hex color
    app_filter_mode: str = "off"             # off | whitelist | blacklist
    app_filter_list: list = field(default_factory=list)  # app names for filter
    os_listener: bool = False                # whether to listen for OS notifications
    sound_asset_id: Optional[str] = None     # global notification sound (asset ID)
    sound_volume: float = 1.0                # global volume 0.0-1.0
    app_sounds: dict = field(default_factory=dict)  # app name -> {"sound_asset_id": str|None, "volume": float|None}

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["notification_effect"] = self.notification_effect
        d["duration_ms"] = self.duration_ms
        d["default_color"] = self.default_color
        d["app_colors"] = dict(self.app_colors)
        d["app_filter_mode"] = self.app_filter_mode
        d["app_filter_list"] = list(self.app_filter_list)
        d["os_listener"] = self.os_listener
        d["sound_asset_id"] = self.sound_asset_id
        d["sound_volume"] = self.sound_volume
        d["app_sounds"] = dict(self.app_sounds)
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "NotificationColorStripSource":
        common = _parse_css_common(data)
        raw_app_colors = data.get("app_colors")
        raw_app_filter_list = data.get("app_filter_list")
        raw_app_sounds = data.get("app_sounds")
        return cls(
            **common, source_type="notification",
            notification_effect=data.get("notification_effect") or "flash",
            duration_ms=int(data.get("duration_ms") or 1500),
            default_color=data.get("default_color") or "#FFFFFF",
            app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
            app_filter_mode=data.get("app_filter_mode") or "off",
            app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
            os_listener=bool(data.get("os_listener", False)),
            sound_asset_id=data.get("sound_asset_id"),
            sound_volume=float(data.get("sound_volume", 1.0)),
            app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           notification_effect=None, duration_ms=None,
                           default_color=None, app_colors=None,
                           app_filter_mode=None, app_filter_list=None,
                           os_listener=None, sound_asset_id=None,
                           sound_volume=None, app_sounds=None,
                           **_kwargs):
        return cls(
            id=id, name=name, source_type="notification",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            notification_effect=notification_effect or "flash",
            duration_ms=int(duration_ms) if duration_ms is not None else 1500,
            default_color=default_color or "#FFFFFF",
            app_colors=app_colors if isinstance(app_colors, dict) else {},
            app_filter_mode=app_filter_mode or "off",
            app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
            os_listener=bool(os_listener) if os_listener is not None else False,
            sound_asset_id=sound_asset_id,
            sound_volume=float(sound_volume) if sound_volume is not None else 1.0,
            app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
        )

    def apply_update(self, **kwargs) -> None:
        if kwargs.get("notification_effect") is not None:
            self.notification_effect = kwargs["notification_effect"]
        if kwargs.get("duration_ms") is not None:
            self.duration_ms = int(kwargs["duration_ms"])
        if kwargs.get("default_color") is not None:
            self.default_color = kwargs["default_color"]
        app_colors = kwargs.get("app_colors")
        if app_colors is not None and isinstance(app_colors, dict):
            self.app_colors = app_colors
        if kwargs.get("app_filter_mode") is not None:
            self.app_filter_mode = kwargs["app_filter_mode"]
        app_filter_list = kwargs.get("app_filter_list")
        if app_filter_list is not None and isinstance(app_filter_list, list):
            self.app_filter_list = app_filter_list
        if kwargs.get("os_listener") is not None:
            self.os_listener = bool(kwargs["os_listener"])
        if "sound_asset_id" in kwargs:
            self.sound_asset_id = kwargs["sound_asset_id"]
        if kwargs.get("sound_volume") is not None:
            self.sound_volume = float(kwargs["sound_volume"])
        app_sounds = kwargs.get("app_sounds")
        if app_sounds is not None and isinstance(app_sounds, dict):
            self.app_sounds = app_sounds


@dataclass
class DaylightColorStripSource(ColorStripSource):
    """Color strip source that simulates natural daylight over a 24-hour cycle.

    All LEDs receive the same color at any point in time, smoothly
    transitioning through dawn (warm orange), daylight (cool white),
    sunset (warm red/orange), and night (dim blue).
    LED count auto-sizes from the connected device.

    When use_real_time is True, the current wall-clock hour determines
    the color; speed is ignored.  When False, speed controls how fast
    a full 24-hour cycle plays (1.0 = 4 minutes per full cycle).
    """

    speed: float = 1.0                  # cycle speed (ignored when use_real_time)
    use_real_time: bool = False          # use actual time of day
    latitude: float = 50.0              # latitude for sunrise/sunset timing (-90..90)
    longitude: float = 0.0              # longitude for solar position (-180..180)

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["speed"] = self.speed
        d["use_real_time"] = self.use_real_time
        d["latitude"] = self.latitude
        d["longitude"] = self.longitude
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "DaylightColorStripSource":
        common = _parse_css_common(data)
        return cls(
            **common, source_type="daylight",
            speed=float(data.get("speed") or 1.0),
            use_real_time=bool(data.get("use_real_time", False)),
            latitude=float(data.get("latitude") or 50.0),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           speed=None, use_real_time=None, latitude=None,
                           longitude=None, **_kwargs):
        return cls(
            id=id, name=name, source_type="daylight",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            speed=float(speed) if speed is not None else 1.0,
            use_real_time=bool(use_real_time) if use_real_time is not None else False,
            latitude=float(latitude) if latitude is not None else 50.0,
            longitude=float(longitude) if longitude is not None else 0.0,
        )

    def apply_update(self, **kwargs) -> None:
        if kwargs.get("speed") is not None:
            self.speed = float(kwargs["speed"])
        if kwargs.get("use_real_time") is not None:
            self.use_real_time = bool(kwargs["use_real_time"])
        if kwargs.get("latitude") is not None:
            self.latitude = float(kwargs["latitude"])
        if kwargs.get("longitude") is not None:
            self.longitude = float(kwargs["longitude"])


@dataclass
class CandlelightColorStripSource(ColorStripSource):
    """Color strip source that simulates realistic candle flickering.

    Each LED or group of LEDs flickers independently with warm tones.
    Uses layered noise for organic, non-repeating flicker patterns.
    LED count auto-sizes from the connected device.
    """

    color: list = field(default_factory=lambda: [255, 147, 41])  # warm candle base [R,G,B]
    intensity: float = 1.0           # flicker intensity (0.1-2.0)
    num_candles: int = 3             # number of independent candle sources
    speed: float = 1.0               # flicker speed multiplier
    wind_strength: float = 0.0       # wind effect (0.0-2.0)
    candle_type: str = "default"     # default | taper | votive | bonfire

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["color"] = list(self.color)
        d["intensity"] = self.intensity
        d["num_candles"] = self.num_candles
        d["speed"] = self.speed
        d["wind_strength"] = self.wind_strength
        d["candle_type"] = self.candle_type
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "CandlelightColorStripSource":
        common = _parse_css_common(data)
        color = _validate_rgb(data.get("color"), [255, 147, 41])
        return cls(
            **common, source_type="candlelight",
            color=color,
            intensity=float(data.get("intensity") or 1.0),
            num_candles=int(data.get("num_candles") or 3),
            speed=float(data.get("speed") or 1.0),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           color=None, intensity=1.0, num_candles=None,
                           speed=None, wind_strength=None, candle_type=None,
                           **_kwargs):
        rgb = _validate_rgb(color, [255, 147, 41])
        return cls(
            id=id, name=name, source_type="candlelight",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            color=rgb,
            intensity=float(intensity) if intensity else 1.0,
            num_candles=int(num_candles) if num_candles is not None else 3,
            speed=float(speed) if speed is not None else 1.0,
            wind_strength=float(wind_strength) if wind_strength is not None else 0.0,
            candle_type=candle_type if candle_type in {"default", "taper", "votive", "bonfire"} else "default",
        )

    def apply_update(self, **kwargs) -> None:
        color = kwargs.get("color")
        if color is not None and isinstance(color, list) and len(color) == 3:
            self.color = color
        if kwargs.get("intensity") is not None:
            self.intensity = float(kwargs["intensity"])
        if kwargs.get("num_candles") is not None:
            self.num_candles = int(kwargs["num_candles"])
        if kwargs.get("speed") is not None:
            self.speed = float(kwargs["speed"])
        if kwargs.get("wind_strength") is not None:
            self.wind_strength = float(kwargs["wind_strength"])
        ct = kwargs.get("candle_type")
        if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
            self.candle_type = ct


@dataclass
class ProcessedColorStripSource(ColorStripSource):
    """Color strip source that takes another CSS and applies a processing template.

    Wraps an existing color strip source and pipes its output through a
    ColorStripProcessingTemplate filter chain.  Useful for applying brightness,
    gamma, palette quantization, etc. to any source without modifying the
    original.
    """

    input_source_id: str = ""               # ID of the input color strip source
    processing_template_id: str = ""        # ID of the CSPT to apply

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["input_source_id"] = self.input_source_id
        d["processing_template_id"] = self.processing_template_id
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "ProcessedColorStripSource":
        common = _parse_css_common(data)
        return cls(
            **common, source_type="processed",
            input_source_id=data.get("input_source_id") or "",
            processing_template_id=data.get("processing_template_id") or "",
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           input_source_id="", processing_template_id="",
                           **_kwargs):
        return cls(
            id=id, name=name, source_type="processed",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            input_source_id=input_source_id,
            processing_template_id=processing_template_id,
        )

    def apply_update(self, **kwargs) -> None:
        input_source_id = kwargs.get("input_source_id")
        if input_source_id is not None:
            self.input_source_id = resolve_ref(input_source_id, self.input_source_id)
        processing_template_id = kwargs.get("processing_template_id")
        if processing_template_id is not None:
            self.processing_template_id = resolve_ref(processing_template_id, self.processing_template_id)


@dataclass
class WeatherColorStripSource(ColorStripSource):
    """Color strip source driven by real-time weather data.

    References a WeatherSource entity for API polling.  Maps weather
    conditions (rain, snow, clear, storm) to ambient color palettes
    with temperature-influenced hue shifting.
    """

    weather_source_id: str = ""        # reference to WeatherSource entity
    speed: float = 1.0                 # ambient drift animation speed
    temperature_influence: float = 0.5  # 0.0=none, 1.0=full temp hue shift

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["weather_source_id"] = self.weather_source_id
        d["speed"] = self.speed
        d["temperature_influence"] = self.temperature_influence
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "WeatherColorStripSource":
        common = _parse_css_common(data)
        return cls(
            **common, source_type="weather",
            weather_source_id=data.get("weather_source_id", ""),
            speed=float(data.get("speed") or 1.0),
            temperature_influence=float(data.get("temperature_influence") if data.get("temperature_influence") is not None else 0.5),
        )

    @classmethod
    def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
                           created_at: datetime, updated_at: datetime,
                           description=None, clock_id=None, tags=None,
                           weather_source_id=None, speed=None,
                           temperature_influence=None, **_kwargs):
        return cls(
            id=id, name=name, source_type="weather",
            created_at=created_at, updated_at=updated_at,
            description=description, clock_id=clock_id, tags=tags or [],
            weather_source_id=weather_source_id or "",
            speed=float(speed) if speed is not None else 1.0,
            temperature_influence=float(temperature_influence) if temperature_influence is not None else 0.5,
        )

    def apply_update(self, **kwargs) -> None:
        if kwargs.get("weather_source_id") is not None:
            self.weather_source_id = resolve_ref(kwargs["weather_source_id"], self.weather_source_id)
        if kwargs.get("speed") is not None:
            self.speed = float(kwargs["speed"])
        if kwargs.get("temperature_influence") is not None:
            self.temperature_influence = float(kwargs["temperature_influence"])


# -- Source type registry --
# Maps source_type string to its subclass for factory dispatch.
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
    "picture": PictureColorStripSource,
    "picture_advanced": AdvancedPictureColorStripSource,
    "static": StaticColorStripSource,
    "gradient": GradientColorStripSource,
    "color_cycle": ColorCycleColorStripSource,
    "effect": EffectColorStripSource,
    "audio": AudioColorStripSource,
    "composite": CompositeColorStripSource,
    "mapped": MappedColorStripSource,
    "api_input": ApiInputColorStripSource,
    "notification": NotificationColorStripSource,
    "daylight": DaylightColorStripSource,
    "candlelight": CandlelightColorStripSource,
    "processed": ProcessedColorStripSource,
    "weather": WeatherColorStripSource,
}
