"""Color strip stream — produces LED color arrays from a source.

A ColorStripStream is the runtime counterpart of ColorStripSource.
It continuously computes and caches a fresh np.ndarray (led_count, 3) uint8
by processing frames from a LiveStream.

Multiple WledTargetProcessors may read from the same ColorStripStream instance
(shared via ColorStripStreamManager reference counting), meaning the CPU-bound
processing — border extraction, pixel mapping, smoothing — runs only once
even when multiple devices share the same source configuration.
"""

import colorsys
import math
import threading
import time
from abc import ABC, abstractmethod
from typing import Optional

import numpy as np

from wled_controller.core.capture.calibration import (
    CalibrationConfig,
    AdvancedPixelMapper,
    create_pixel_mapper,
)
from wled_controller.core.capture.screen_capture import extract_border_pixels
from wled_controller.storage.bindable import bcolor, bfloat
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer


class _SimpleNoise1D:
    """Minimal 1-D value noise for gradient perturbation (avoids circular import)."""

    def __init__(self, seed: int = 99):
        rng = np.random.RandomState(seed)
        self._table = rng.random(512).astype(np.float32)

    def noise(self, x: np.ndarray) -> np.ndarray:
        size = len(self._table)
        xi = np.floor(x).astype(np.int64)
        frac = x - np.floor(x)
        t = frac * frac * (3.0 - 2.0 * frac)
        a = self._table[xi % size]
        b = self._table[(xi + 1) % size]
        return a + t * (b - a)


# Module-level noise for gradient perturbation
_gradient_noise = _SimpleNoise1D(seed=99)

logger = get_logger(__name__)


class ColorStripStream(ABC):
    """Abstract base: a runtime source of LED color arrays.

    Produces a continuous stream of np.ndarray (led_count, 3) uint8 values.
    Consumers call get_latest_colors() (non-blocking) to read the most recent
    computed frame.
    """

    @property
    @abstractmethod
    def target_fps(self) -> int:
        """Target processing rate."""

    @property
    @abstractmethod
    def led_count(self) -> int:
        """Number of LEDs this stream produces colors for."""

    @property
    def is_animated(self) -> bool:
        """Whether this stream is actively producing new frames.

        Used by the processor to adjust polling rate: animated streams are
        polled at 5 ms (SKIP_REPOLL) to stay in sync with the animation
        thread, while static streams are polled at frame_time rate.
        """
        return True

    @property
    def display_index(self) -> Optional[int]:
        """Display index of the underlying capture, or None."""
        return None

    @property
    def calibration(self) -> Optional[CalibrationConfig]:
        """Calibration config, or None if not applicable."""
        return None

    @abstractmethod
    def start(self) -> None:
        """Start producing colors."""

    @abstractmethod
    def stop(self) -> None:
        """Stop producing colors and release resources."""

    @abstractmethod
    def get_latest_colors(self) -> Optional[np.ndarray]:
        """Get the most recent LED color array (led_count, 3) uint8, or None."""

    def get_last_timing(self) -> dict:
        """Return per-stage timing from the last processed frame (ms)."""
        return {}

    def update_source(self, source) -> None:
        """Hot-update processing parameters. No-op by default."""

    # ── BindableFloat value stream resolution ──

    _value_streams: dict = None  # property_name → ValueStream

    def set_value_stream(self, prop: str, stream) -> None:
        """Inject a ValueStream for a bindable property."""
        if self._value_streams is None:
            self._value_streams = {}
        self._value_streams[prop] = stream

    def remove_value_stream(self, prop: str) -> None:
        """Remove a ValueStream for a bindable property."""
        if self._value_streams:
            self._value_streams.pop(prop, None)

    def resolve(self, prop: str, static: float) -> float:
        """Resolve a bindable property: ValueStream value if bound, else static."""
        if self._value_streams:
            vs = self._value_streams.get(prop)
            if vs is not None:
                try:
                    return vs.get_value()
                except Exception:
                    pass
        return static

    def resolve_color(self, prop: str, static: list) -> list:
        """Resolve a bindable color: ValueStream color if bound, else static [R,G,B]."""
        if self._value_streams:
            vs = self._value_streams.get(prop)
            if vs is not None:
                try:
                    c = vs.get_color()
                    return [c[0], c[1], c[2]]
                except Exception:
                    pass
        return static


class PictureColorStripStream(ColorStripStream):
    """Color strip stream backed by a LiveStream (picture source).

    Runs a background thread that:
      1. Reads the latest frame from the LiveStream
      2. Extracts border pixels using the calibration's border_width
      3. Maps border pixels to LED colors via PixelMapper
      4. Applies temporal smoothing
      5. Caches the result for lock-free consumer reads

    Processing parameters can be hot-updated via update_source() without
    restarting the thread (except when the underlying LiveStream changes).
    """

    def __init__(self, live_stream, source):
        """
        Args:
            live_stream: Acquired LiveStream or Dict[str, LiveStream] for advanced mode.
                         Lifecycle managed by ColorStripStreamManager.
            source: PictureColorStripSource config
        """

        # Support both single LiveStream and dict of streams (advanced mode)
        if isinstance(live_stream, dict):
            self._live_streams = live_stream
            self._live_stream = next(iter(live_stream.values()))
        else:
            self._live_streams = {}
            self._live_stream = live_stream

        self._fps: int = 30  # internal capture rate (send FPS is on the target)
        self._frame_time: float = 1.0 / 30
        self._smoothing: float = bfloat(source.smoothing, 0.3)
        self._interpolation_mode: str = source.interpolation_mode
        self._calibration: CalibrationConfig = source.calibration
        self._pixel_mapper = create_pixel_mapper(
            self._calibration, interpolation_mode=self._interpolation_mode
        )
        cal_leds = self._calibration.get_total_leds()
        self._led_count: int = source.led_count if source.led_count > 0 else cal_leds

        # Thread-safe color cache
        self._latest_colors: Optional[np.ndarray] = None
        self._colors_lock = threading.Lock()
        self._previous_colors: Optional[np.ndarray] = None

        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._last_timing: dict = {}

    @property
    def live_stream(self):
        """Public accessor for the underlying LiveStream (used by preview WebSocket)."""
        return self._live_stream

    @property
    def target_fps(self) -> int:
        return self._fps

    @property
    def led_count(self) -> int:
        return self._led_count

    @property
    def display_index(self) -> Optional[int]:
        if self._live_streams:
            return None  # multi-source, ambiguous
        return self._live_stream.display_index

    @property
    def calibration(self) -> Optional[CalibrationConfig]:
        return self._calibration

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._processing_loop,
            name="css-picture-stream",
            daemon=True,
        )
        self._thread.start()
        logger.info(f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})")

    def stop(self) -> None:
        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning("PictureColorStripStream thread did not terminate within 5s")
            self._thread = None
        self._latest_colors = None
        self._previous_colors = None
        logger.info("PictureColorStripStream stopped")

    def get_latest_colors(self) -> Optional[np.ndarray]:
        with self._colors_lock:
            return self._latest_colors

    def get_last_timing(self) -> dict:
        return dict(self._last_timing)

    def set_capture_fps(self, fps: int) -> None:
        """Update the internal capture rate. Thread-safe (read atomically by the loop)."""
        fps = max(1, min(90, fps))
        if fps != self._fps:
            self._fps = fps
            self._frame_time = 1.0 / fps
            logger.info(f"PictureColorStripStream capture FPS set to {fps}")

    def update_source(self, source) -> None:
        """Hot-update processing parameters. Thread-safe for scalar params.

        PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
        """
        from wled_controller.storage.color_strip_source import (
            PictureColorStripSource,
            AdvancedPictureColorStripSource,
        )

        if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
            return

        self._smoothing = bfloat(source.smoothing, 0.3)

        if (
            source.interpolation_mode != self._interpolation_mode
            or source.calibration != self._calibration
            or source.led_count != self._led_count
        ):
            self._interpolation_mode = source.interpolation_mode
            self._calibration = source.calibration
            cal_leds = source.calibration.get_total_leds()
            self._led_count = source.led_count if source.led_count > 0 else cal_leds
            self._pixel_mapper = create_pixel_mapper(
                source.calibration, interpolation_mode=source.interpolation_mode
            )
            self._previous_colors = None  # Reset smoothing history on calibration change

        logger.info("PictureColorStripStream params updated in-place")

    def _processing_loop(self) -> None:
        """Background thread: poll source, process, cache colors."""
        cached_frame = None

        # Scratch buffer pool (pre-allocated, resized when LED count changes)
        _pool_n = 0
        _frame_a = _frame_b = None  # double-buffered uint8 output
        _use_a = True
        _u16_a = _u16_b = None  # uint16 scratch for smoothing blending

        def _blend_u16(a, b, alpha_b, out):
            """Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.

            Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b).
            """
            nonlocal _u16_a, _u16_b
            np.copyto(_u16_a, a, casting="unsafe")
            np.copyto(_u16_b, b, casting="unsafe")
            _u16_a *= 256 - alpha_b
            _u16_b *= alpha_b
            _u16_a += _u16_b
            _u16_a >>= 8
            np.copyto(out, _u16_a, casting="unsafe")

        try:
            with high_resolution_timer():
                while self._running:
                    loop_start = time.perf_counter()
                    frame_time = self._frame_time

                    try:
                        frame = self._live_stream.get_latest_frame()

                        if frame is None or frame is cached_frame:
                            elapsed = time.perf_counter() - loop_start
                            time.sleep(max(frame_time - elapsed, 0.001))
                            continue

                        cached_frame = frame

                        t0 = time.perf_counter()

                        calibration = self._calibration
                        mapper = self._pixel_mapper

                        if isinstance(mapper, AdvancedPixelMapper):
                            # Advanced mode: gather frames from all live streams
                            frames_dict = {}
                            for ps_id, ls in self._live_streams.items():
                                f = ls.get_latest_frame()
                                if f is not None:
                                    frames_dict[ps_id] = f
                            t1 = time.perf_counter()
                            led_colors = mapper.map_lines_to_leds(frames_dict)
                        else:
                            border_pixels = extract_border_pixels(frame, calibration.border_width)
                            t1 = time.perf_counter()
                            led_colors = mapper.map_border_to_leds(border_pixels)
                        t2 = time.perf_counter()

                        # Ensure scratch pool is sized for this frame
                        target_count = self._led_count
                        _n = target_count if target_count > 0 else len(led_colors)
                        if _n > 0 and _n != _pool_n:
                            _pool_n = _n
                            _frame_a = np.empty((_n, 3), dtype=np.uint8)
                            _frame_b = np.empty((_n, 3), dtype=np.uint8)
                            _u16_a = np.empty((_n, 3), dtype=np.uint16)
                            _u16_b = np.empty((_n, 3), dtype=np.uint16)
                            self._previous_colors = None

                        # Copy/pad into double-buffered frame (avoids per-frame allocations)
                        frame_buf = _frame_a if _use_a else _frame_b
                        _use_a = not _use_a
                        n_leds = len(led_colors)
                        if _pool_n > 0:
                            if n_leds < _pool_n:
                                frame_buf[:n_leds] = led_colors
                                frame_buf[n_leds:] = 0
                            elif n_leds > _pool_n:
                                frame_buf[:] = led_colors[:_pool_n]
                            else:
                                frame_buf[:] = led_colors
                            led_colors = frame_buf

                        # Temporal smoothing (pre-allocated uint16 scratch)
                        smoothing = self.resolve("smoothing", self._smoothing)
                        if (
                            self._previous_colors is not None
                            and smoothing > 0
                            and len(self._previous_colors) == len(led_colors)
                            and _u16_a is not None
                        ):
                            _blend_u16(
                                led_colors, self._previous_colors, int(smoothing * 256), led_colors
                            )
                        t3 = time.perf_counter()

                        self._previous_colors = led_colors

                        with self._colors_lock:
                            self._latest_colors = led_colors

                        self._last_timing = {
                            "extract_ms": (t1 - t0) * 1000,
                            "map_leds_ms": (t2 - t1) * 1000,
                            "smooth_ms": (t3 - t2) * 1000,
                            "total_ms": (t3 - t0) * 1000,
                        }

                    except Exception as e:
                        logger.error(
                            f"PictureColorStripStream processing error: {e}", exc_info=True
                        )

                    elapsed = time.perf_counter() - loop_start
                    remaining = frame_time - elapsed
                    if remaining > 0:
                        time.sleep(remaining)
        except Exception as e:
            logger.error(f"Fatal PictureColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False


def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear") -> np.ndarray:
    """Compute an (led_count, 3) uint8 array from gradient color stops.

    Each stop: {"position": float 0–1, "color": [R,G,B], "color_right": [R,G,B] | absent}

    Interpolation:
      Sort stops by position.  For each LED at relative position p = i/(N-1):
        p ≤ first stop  → first stop primary color
        p ≥ last stop   → last stop right color (if bidirectional) else primary
        else find surrounding stops A (≤p) and B (>p):
          left_color  = A["color_right"] if present, else A["color"]
          right_color = B["color"]
          t = (p - A.pos) / (B.pos - A.pos)
          color = lerp(left_color, right_color, eased(t))
    """
    if led_count <= 0:
        led_count = 1

    if not stops:
        return np.zeros((led_count, 3), dtype=np.uint8)

    sorted_stops = sorted(stops, key=lambda s: float(s.get("position", 0)))

    def _color(stop: dict, side: str = "left") -> np.ndarray:
        if side == "right":
            cr = stop.get("color_right")
            if cr and isinstance(cr, list) and len(cr) == 3:
                return np.array(cr, dtype=np.float32)
        c = stop.get("color", [255, 255, 255])
        return np.array(
            c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32
        )

    # Vectorized: compute all LED positions at once
    positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0])
    result = np.zeros((led_count, 3), dtype=np.float32)

    # Extract stop positions and colors into arrays
    n_stops = len(sorted_stops)
    stop_positions = np.array([float(s.get("position", 0)) for s in sorted_stops], dtype=np.float32)

    # Pre-compute left/right colors for each stop
    left_colors = np.array([_color(s, "left") for s in sorted_stops], dtype=np.float32)
    right_colors = np.array([_color(s, "right") for s in sorted_stops], dtype=np.float32)

    # LEDs before first stop
    mask_before = positions <= stop_positions[0]
    result[mask_before] = left_colors[0]

    # LEDs after last stop
    mask_after = positions >= stop_positions[-1]
    result[mask_after] = right_colors[-1]

    # LEDs between stops — vectorized per segment
    mask_between = ~mask_before & ~mask_after
    if np.any(mask_between):
        between_pos = positions[mask_between]
        # np.searchsorted finds the right stop index for each LED
        idx = np.searchsorted(stop_positions, between_pos, side="right") - 1
        idx = np.clip(idx, 0, n_stops - 2)

        a_pos = stop_positions[idx]
        b_pos = stop_positions[idx + 1]
        span = b_pos - a_pos
        t = np.where(span > 0, (between_pos - a_pos) / span, 0.0)

        # Apply easing to interpolation parameter
        if easing == "ease_in_out":
            t = t * t * (3.0 - 2.0 * t)
        elif easing == "cubic":
            t = np.where(t < 0.5, 4.0 * t * t * t, 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0)
        elif easing == "step":
            steps = float(max(2, n_stops))
            t = np.round(t * steps) / steps

        a_colors = right_colors[idx]  # A's right color
        b_colors = left_colors[idx + 1]  # B's left color
        result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)

    return np.clip(result, 0, 255).astype(np.uint8)


class StaticColorStripStream(ColorStripStream):
    """Color strip stream that returns a constant single-color array.

    When animation is enabled a 30 fps background thread updates _colors with
    the animated result.  Parameters can be hot-updated via update_source().
    """

    def __init__(self, source):
        """
        Args:
            source: StaticColorStripSource config
        """
        self._colors_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._fps = 30
        self._frame_time = 1.0 / 30
        self._clock = None  # optional SyncClockRuntime
        self._update_from_source(source)

    def _update_from_source(self, source) -> None:
        self._source_color = bcolor(source.color, [255, 255, 255])
        _lc = getattr(source, "led_count", 0)
        self._auto_size = not _lc
        led_count = _lc if _lc and _lc > 0 else 1
        self._led_count = led_count
        self._animation = source.animation  # dict or None; read atomically by _animate_loop
        self._rebuild_colors()

    def _rebuild_colors(self) -> None:
        colors = np.tile(
            np.array(self._source_color, dtype=np.uint8),
            (self._led_count, 1),
        )
        with self._colors_lock:
            self._colors = colors

    def configure(self, device_led_count: int) -> None:
        """Set LED count from the target device (called by WledTargetProcessor on start).

        Only takes effect when led_count was 0 (auto-size).  Silently ignored
        when an explicit led_count was configured on the source.
        """
        if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
            self._led_count = device_led_count
            self._rebuild_colors()
            logger.debug(f"StaticColorStripStream auto-sized to {device_led_count} LEDs")

    @property
    def target_fps(self) -> int:
        return self._fps

    @property
    def is_animated(self) -> bool:
        anim = self._animation
        return bool(anim and anim.get("enabled"))

    @property
    def led_count(self) -> int:
        return self._led_count

    def set_capture_fps(self, fps: int) -> None:
        """Update animation loop rate. Thread-safe (read atomically by the loop)."""
        fps = max(1, min(90, fps))
        self._fps = fps
        self._frame_time = 1.0 / fps

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._animate_loop,
            name="css-static-animate",
            daemon=True,
        )
        self._thread.start()
        logger.info(f"StaticColorStripStream started (leds={self._led_count})")

    def stop(self) -> None:
        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning("StaticColorStripStream animate thread did not terminate within 5s")
            self._thread = None
        logger.info("StaticColorStripStream stopped")

    def get_latest_colors(self) -> Optional[np.ndarray]:
        with self._colors_lock:
            return self._colors

    def update_source(self, source) -> None:
        from wled_controller.storage.color_strip_source import StaticColorStripSource

        if isinstance(source, StaticColorStripSource):
            prev_led_count = self._led_count if self._auto_size else None
            self._update_from_source(source)
            # If we were auto-sized, preserve the runtime LED count across updates
            if prev_led_count and self._auto_size:
                self._led_count = prev_led_count
                self._rebuild_colors()
            logger.info("StaticColorStripStream params updated in-place")

    def set_clock(self, clock) -> None:
        """Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
        self._clock = clock

    def _animate_loop(self) -> None:
        """Background thread: compute animated colors at target fps when animation is active.

        Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame
        numpy allocations while preserving the identity check used by the
        processing loop (``colors is prev_colors``).
        """
        # Double-buffer pool — re-allocated only when LED count changes
        _pool_n = 0
        _buf_a = _buf_b = None
        _use_a = True

        try:
            with high_resolution_timer():
                while self._running:
                    wall_start = time.perf_counter()
                    frame_time = self._frame_time
                    try:
                        anim = self._animation
                        if anim and anim.get("enabled"):
                            clock = self._clock
                            if clock:
                                if not clock.is_running:
                                    time.sleep(0.1)
                                    continue
                                speed = clock.speed
                                t = clock.get_time()
                            else:
                                speed = 1.0
                                t = wall_start
                            atype = anim.get("type", "breathing")
                            n = self._led_count

                            if n != _pool_n:
                                _pool_n = n
                                _buf_a = np.empty((n, 3), dtype=np.uint8)
                                _buf_b = np.empty((n, 3), dtype=np.uint8)

                            buf = _buf_a if _use_a else _buf_b
                            _use_a = not _use_a
                            colors = None

                            if atype == "breathing":
                                factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
                                r, g, b = self.resolve_color("color", self._source_color)
                                buf[:] = (
                                    min(255, int(r * factor)),
                                    min(255, int(g * factor)),
                                    min(255, int(b * factor)),
                                )
                                colors = buf

                            elif atype == "strobe":
                                # Square wave: on for half the period, off for the other half.
                                # speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s)
                                if math.sin(2 * math.pi * speed * t * 2.0) >= 0:
                                    buf[:] = self.resolve_color("color", self._source_color)
                                else:
                                    buf[:] = 0
                                colors = buf

                            elif atype == "sparkle":
                                # Random LEDs flash white while the rest stay the base color
                                buf[:] = self.resolve_color("color", self._source_color)
                                density = min(0.5, 0.1 * speed)
                                mask = np.random.random(n) < density
                                buf[mask] = (255, 255, 255)
                                colors = buf

                            elif atype == "pulse":
                                # Sharp attack, slow exponential decay — heartbeat-like
                                # speed=1.0 → ~1 pulse per second
                                phase = (speed * t * 1.0) % 1.0
                                if phase < 0.1:
                                    factor = phase / 0.1
                                else:
                                    factor = math.exp(-5.0 * (phase - 0.1))
                                r, g, b = self.resolve_color("color", self._source_color)
                                buf[:] = (
                                    min(255, int(r * factor)),
                                    min(255, int(g * factor)),
                                    min(255, int(b * factor)),
                                )
                                colors = buf

                            elif atype == "candle":
                                # Random brightness fluctuations simulating a candle flame
                                base_factor = 0.75
                                flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7)
                                flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3)
                                flicker += 0.10 * (np.random.random() - 0.5)
                                factor = max(0.2, min(1.0, base_factor + flicker))
                                r, g, b = self.resolve_color("color", self._source_color)
                                buf[:] = (
                                    min(255, int(r * factor)),
                                    min(255, int(g * factor)),
                                    min(255, int(b * factor)),
                                )
                                colors = buf

                            elif atype == "rainbow_fade":
                                # Shift hue continuously from the base color
                                r, g, b = self.resolve_color("color", self._source_color)
                                h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
                                # speed=1.0 → one full hue rotation every ~10s
                                h_shift = (speed * t * 0.1) % 1.0
                                new_h = (h + h_shift) % 1.0
                                nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
                                buf[:] = (int(nr * 255), int(ng * 255), int(nb * 255))
                                colors = buf

                            if colors is not None:
                                with self._colors_lock:
                                    self._colors = colors
                    except Exception as e:
                        logger.error(f"StaticColorStripStream animation error: {e}")

                    elapsed = time.perf_counter() - wall_start
                    sleep_target = frame_time if anim and anim.get("enabled") else 0.25
                    time.sleep(max(sleep_target - elapsed, 0.001))
        except Exception as e:
            logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False


class ColorCycleColorStripStream(ColorStripStream):
    """Color strip stream that smoothly cycles through a user-defined color list.

    All LEDs receive the same solid color at any moment, continuously interpolating
    between the configured colors in a loop.

    LED count auto-sizes from the connected device when led_count == 0 in
    the source config; configure(device_led_count) is called by
    WledTargetProcessor on start.
    """

    def __init__(self, source):
        self._colors_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._fps = 30
        self._frame_time = 1.0 / 30
        self._clock = None  # optional SyncClockRuntime
        self._update_from_source(source)

    def _update_from_source(self, source) -> None:
        raw = source.colors if isinstance(source.colors, list) else []
        default = [
            [255, 0, 0],
            [255, 255, 0],
            [0, 255, 0],
            [0, 255, 255],
            [0, 0, 255],
            [255, 0, 255],
        ]
        self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
        _lc = getattr(source, "led_count", 0)
        self._auto_size = not _lc
        self._led_count = _lc if _lc > 0 else 1
        self._rebuild_colors()

    def _rebuild_colors(self) -> None:
        pixel = np.array(self._color_list[0], dtype=np.uint8)
        colors = np.tile(pixel, (self._led_count, 1))
        with self._colors_lock:
            self._colors = colors

    def configure(self, device_led_count: int) -> None:
        """Size to device LED count when led_count was 0 (auto-size)."""
        if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
            self._led_count = device_led_count
            self._rebuild_colors()
            logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs")

    @property
    def target_fps(self) -> int:
        return self._fps

    @property
    def led_count(self) -> int:
        return self._led_count

    def set_capture_fps(self, fps: int) -> None:
        """Update animation loop rate. Thread-safe (read atomically by the loop)."""
        fps = max(1, min(90, fps))
        self._fps = fps
        self._frame_time = 1.0 / fps

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._animate_loop,
            name="css-color-cycle",
            daemon=True,
        )
        self._thread.start()
        logger.info(
            f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
        )

    def stop(self) -> None:
        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning(
                    "ColorCycleColorStripStream animate thread did not terminate within 5s"
                )
            self._thread = None
        logger.info("ColorCycleColorStripStream stopped")

    def get_latest_colors(self) -> Optional[np.ndarray]:
        with self._colors_lock:
            return self._colors

    def update_source(self, source) -> None:
        from wled_controller.storage.color_strip_source import ColorCycleColorStripSource

        if isinstance(source, ColorCycleColorStripSource):
            prev_led_count = self._led_count if self._auto_size else None
            self._update_from_source(source)
            if prev_led_count and self._auto_size:
                self._led_count = prev_led_count
                self._rebuild_colors()
            logger.info("ColorCycleColorStripStream params updated in-place")

    def set_clock(self, clock) -> None:
        """Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
        self._clock = clock

    def _animate_loop(self) -> None:
        """Background thread: interpolate between colors at target fps.

        Uses double-buffered output arrays to avoid per-frame allocations.
        """
        _pool_n = 0
        _buf_a = _buf_b = None
        _use_a = True

        try:
            with high_resolution_timer():
                while self._running:
                    wall_start = time.perf_counter()
                    frame_time = self._frame_time
                    try:
                        color_list = self._color_list
                        clock = self._clock
                        if clock:
                            if not clock.is_running:
                                time.sleep(0.1)
                                continue
                            speed = clock.speed
                            t = clock.get_time()
                        else:
                            speed = 1.0
                            t = wall_start
                        n = self._led_count
                        num = len(color_list)
                        if num >= 2:
                            if n != _pool_n:
                                _pool_n = n
                                _buf_a = np.empty((n, 3), dtype=np.uint8)
                                _buf_b = np.empty((n, 3), dtype=np.uint8)

                            buf = _buf_a if _use_a else _buf_b
                            _use_a = not _use_a

                            # 0.05 factor → one full cycle every 20s at speed=1.0
                            cycle_pos = (speed * t * 0.05) % 1.0
                            seg = cycle_pos * num
                            idx = int(seg) % num
                            t_i = seg - int(seg)
                            c1 = color_list[idx]
                            c2 = color_list[(idx + 1) % num]
                            buf[:] = (
                                min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
                                min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
                                min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
                            )
                            with self._colors_lock:
                                self._colors = buf
                    except Exception as e:
                        logger.error(f"ColorCycleColorStripStream animation error: {e}")
                    elapsed = time.perf_counter() - wall_start
                    time.sleep(max(frame_time - elapsed, 0.001))
        except Exception as e:
            logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False


class GradientColorStripStream(ColorStripStream):
    """Color strip stream that distributes a gradient across all LEDs.

    Produces a pre-computed (led_count, 3) uint8 array from user-defined
    color stops.  When animation is enabled a background thread applies
    dynamic effects (breathing, gradient_shift, wave).

    LED count auto-sizes from the connected device when led_count == 0 in
    the source config; configure(device_led_count) is called by
    WledTargetProcessor on start.
    """

    def __init__(self, source):
        self._colors_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._fps = 30
        self._frame_time = 1.0 / 30
        self._clock = None  # optional SyncClockRuntime
        self._gradient_store = None  # injected by stream manager
        self._update_from_source(source)

    def set_gradient_store(self, gradient_store) -> None:
        """Inject gradient store for resolving gradient_id to stops."""
        self._gradient_store = gradient_store
        # Re-resolve stops if gradient_id is set
        gradient_id = getattr(self, "_gradient_id", None)
        if gradient_id and self._gradient_store:
            stops = self._gradient_store.resolve_stops(gradient_id)
            if stops:
                self._stops = stops
                self._rebuild_colors()

    def _update_from_source(self, source) -> None:
        self._gradient_id = getattr(source, "gradient_id", None)
        self._stops = list(source.stops) if source.stops else []
        # Override inline stops with gradient entity if set
        if self._gradient_id and self._gradient_store:
            resolved = self._gradient_store.resolve_stops(self._gradient_id)
            if resolved:
                self._stops = resolved
        _lc = getattr(source, "led_count", 0)
        self._auto_size = not _lc
        led_count = _lc if _lc and _lc > 0 else 1
        self._led_count = led_count
        self._animation = source.animation  # dict or None; read atomically by _animate_loop
        self._easing = getattr(source, "easing", "linear") or "linear"
        self._rebuild_colors()

    def _rebuild_colors(self) -> None:
        colors = _compute_gradient_colors(self._stops, self._led_count, self._easing)
        with self._colors_lock:
            self._colors = colors

    def configure(self, device_led_count: int) -> None:
        """Size to device LED count when led_count was 0 (auto-size).

        When multiple targets share this stream, uses the maximum LED count
        so the gradient covers enough resolution for all consumers.  Targets
        with fewer LEDs get a properly resampled gradient via _fit_to_device.
        """
        if self._auto_size and device_led_count > 0:
            new_count = max(self._led_count, device_led_count)
            if new_count != self._led_count:
                self._led_count = new_count
                self._rebuild_colors()
                logger.debug(f"GradientColorStripStream auto-sized to {new_count} LEDs")

    @property
    def target_fps(self) -> int:
        return self._fps

    @property
    def is_animated(self) -> bool:
        anim = self._animation
        return bool(anim and anim.get("enabled"))

    @property
    def led_count(self) -> int:
        return self._led_count

    def set_capture_fps(self, fps: int) -> None:
        """Update animation loop rate. Thread-safe (read atomically by the loop)."""
        fps = max(1, min(90, fps))
        self._fps = fps
        self._frame_time = 1.0 / fps

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._animate_loop,
            name="css-gradient-animate",
            daemon=True,
        )
        self._thread.start()
        logger.info(
            f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})"
        )

    def stop(self) -> None:
        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning(
                    "GradientColorStripStream animate thread did not terminate within 5s"
                )
            self._thread = None
        logger.info("GradientColorStripStream stopped")

    def get_latest_colors(self) -> Optional[np.ndarray]:
        with self._colors_lock:
            return self._colors

    def update_source(self, source) -> None:
        from wled_controller.storage.color_strip_source import GradientColorStripSource

        if isinstance(source, GradientColorStripSource):
            prev_led_count = self._led_count if self._auto_size else None
            self._update_from_source(source)
            # Preserve runtime LED count across hot-updates when auto-sized
            if prev_led_count and self._auto_size:
                self._led_count = prev_led_count
                self._rebuild_colors()
            logger.info("GradientColorStripStream params updated in-place")

    def set_clock(self, clock) -> None:
        """Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
        self._clock = clock

    def _animate_loop(self) -> None:
        """Background thread: apply animation effects at target fps when animation is active.

        Uses double-buffered output arrays plus a uint16 scratch buffer for
        integer-math brightness scaling, avoiding per-frame numpy allocations.
        """
        _cached_base: Optional[np.ndarray] = None
        _cached_n: int = 0
        _cached_stops: Optional[list] = None
        _cached_easing: str = ""
        # Double-buffer pool + uint16 scratch for brightness math
        _pool_n = 0
        _buf_a = _buf_b = _scratch_u16 = None
        _use_a = True
        _wave_i = None  # cached np.arange for wave animation
        _wave_factors = None  # float32 scratch for wave sin result
        _wave_u16 = None  # uint16 scratch for wave int factors

        try:
            with high_resolution_timer():
                while self._running:
                    wall_start = time.perf_counter()
                    frame_time = self._frame_time
                    try:
                        anim = self._animation
                        if anim and anim.get("enabled"):
                            clock = self._clock
                            if clock:
                                if not clock.is_running:
                                    time.sleep(0.1)
                                    continue
                                speed = clock.speed
                                t = clock.get_time()
                            else:
                                speed = 1.0
                                t = wall_start
                            atype = anim.get("type", "breathing")
                            n = self._led_count
                            stops = self._stops
                            colors = None

                            # Recompute base gradient only when stops, led_count, or easing change
                            easing = self._easing
                            if (
                                _cached_base is None
                                or _cached_n != n
                                or _cached_stops is not stops
                                or _cached_easing != easing
                            ):
                                _cached_base = _compute_gradient_colors(stops, n, easing)
                                _cached_n = n
                                _cached_stops = stops
                                _cached_easing = easing
                            base = _cached_base

                            # Re-allocate pool only when LED count changes
                            if n != _pool_n:
                                _pool_n = n
                                _buf_a = np.empty((n, 3), dtype=np.uint8)
                                _buf_b = np.empty((n, 3), dtype=np.uint8)
                                _scratch_u16 = np.empty((n, 3), dtype=np.uint16)
                                _wave_i = np.arange(n, dtype=np.float32)
                                _wave_factors = np.empty(n, dtype=np.float32)
                                _wave_u16 = np.empty(n, dtype=np.uint16)

                            buf = _buf_a if _use_a else _buf_b
                            _use_a = not _use_a

                            if atype == "breathing":
                                int_f = max(
                                    0,
                                    min(
                                        256,
                                        int(
                                            0.5
                                            * (1 + math.sin(2 * math.pi * speed * t * 0.5))
                                            * 256
                                        ),
                                    ),
                                )
                                np.copyto(_scratch_u16, base)
                                _scratch_u16 *= int_f
                                _scratch_u16 >>= 8
                                np.copyto(buf, _scratch_u16, casting="unsafe")
                                colors = buf

                            elif atype == "gradient_shift":
                                shift = int(speed * t * 10) % max(n, 1)
                                if shift > 0:
                                    buf[: n - shift] = base[shift:]
                                    buf[n - shift :] = base[:shift]
                                else:
                                    np.copyto(buf, base)
                                colors = buf

                            elif atype == "wave":
                                if n > 1:
                                    np.sin(
                                        2 * math.pi * _wave_i / n - 2 * math.pi * speed * t * 0.25,
                                        out=_wave_factors,
                                    )
                                    _wave_factors *= 0.5
                                    _wave_factors += 0.5
                                    np.multiply(_wave_factors, 256, out=_wave_factors)
                                    np.clip(_wave_factors, 0, 256, out=_wave_factors)
                                    np.copyto(_wave_u16, _wave_factors, casting="unsafe")
                                    np.copyto(_scratch_u16, base)
                                    _scratch_u16 *= _wave_u16[:, None]
                                    _scratch_u16 >>= 8
                                    np.copyto(buf, _scratch_u16, casting="unsafe")
                                    colors = buf
                                else:
                                    np.copyto(buf, base)
                                    colors = buf

                            elif atype == "strobe":
                                if math.sin(2 * math.pi * speed * t * 2.0) >= 0:
                                    np.copyto(buf, base)
                                else:
                                    buf[:] = 0
                                colors = buf

                            elif atype == "sparkle":
                                np.copyto(buf, base)
                                density = min(0.5, 0.1 * speed)
                                mask = np.random.random(n) < density
                                buf[mask] = (255, 255, 255)
                                colors = buf

                            elif atype == "pulse":
                                phase = (speed * t * 1.0) % 1.0
                                if phase < 0.1:
                                    factor = phase / 0.1
                                else:
                                    factor = math.exp(-5.0 * (phase - 0.1))
                                int_f = max(0, min(256, int(factor * 256)))
                                np.copyto(_scratch_u16, base)
                                _scratch_u16 *= int_f
                                _scratch_u16 >>= 8
                                np.copyto(buf, _scratch_u16, casting="unsafe")
                                colors = buf

                            elif atype == "candle":
                                base_factor = 0.75
                                flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7)
                                flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3)
                                flicker += 0.10 * (np.random.random() - 0.5)
                                factor = max(0.2, min(1.0, base_factor + flicker))
                                int_f = int(factor * 256)
                                np.copyto(_scratch_u16, base)
                                _scratch_u16 *= int_f
                                _scratch_u16 >>= 8
                                np.copyto(buf, _scratch_u16, casting="unsafe")
                                colors = buf

                            elif atype == "rainbow_fade":
                                h_shift = (speed * t * 0.1) % 1.0
                                # Vectorized RGB->HSV shift->RGB (no per-LED colorsys)
                                rgb_f = base.astype(np.float32) * (1.0 / 255.0)
                                r_f = rgb_f[:, 0]
                                g_f = rgb_f[:, 1]
                                b_f = rgb_f[:, 2]
                                cmax = np.maximum(np.maximum(r_f, g_f), b_f)
                                cmin = np.minimum(np.minimum(r_f, g_f), b_f)
                                delta = cmax - cmin
                                # Hue
                                h_arr = np.zeros(n, dtype=np.float32)
                                mask_r = (delta > 0) & (cmax == r_f)
                                mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
                                mask_b = (delta > 0) & ~mask_r & ~mask_g
                                h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
                                h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
                                h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
                                h_arr *= 1.0 / 6.0
                                h_arr %= 1.0
                                # Saturation & Value with clamping
                                s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
                                np.maximum(s_arr, 0.5, out=s_arr)
                                v_arr = cmax.copy()
                                np.maximum(v_arr, 0.3, out=v_arr)
                                # Shift hue
                                h_arr += h_shift
                                h_arr %= 1.0
                                # Vectorized HSV->RGB
                                h6 = h_arr * 6.0
                                hi = h6.astype(np.int32) % 6
                                f_arr = h6 - np.floor(h6)
                                p = v_arr * (1.0 - s_arr)
                                q = v_arr * (1.0 - s_arr * f_arr)
                                tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
                                ro = np.empty(n, dtype=np.float32)
                                go = np.empty(n, dtype=np.float32)
                                bo = np.empty(n, dtype=np.float32)
                                for sxt, rv, gv, bv in (
                                    (0, v_arr, tt, p),
                                    (1, q, v_arr, p),
                                    (2, p, v_arr, tt),
                                    (3, p, q, v_arr),
                                    (4, tt, p, v_arr),
                                    (5, v_arr, p, q),
                                ):
                                    m = hi == sxt
                                    ro[m] = rv[m]
                                    go[m] = gv[m]
                                    bo[m] = bv[m]
                                buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
                                buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
                                buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
                                colors = buf

                            elif atype == "noise_perturb":
                                # Perturb gradient stop positions with value noise
                                perturbed = []
                                for si, s in enumerate(stops):
                                    noise_val = _gradient_noise.noise(
                                        np.array([si * 10.0 + t * speed], dtype=np.float32)
                                    )[0]
                                    new_pos = min(
                                        1.0,
                                        max(
                                            0.0,
                                            float(s.get("position", 0)) + (noise_val - 0.5) * 0.2,
                                        ),
                                    )
                                    perturbed.append(dict(s, position=new_pos))
                                buf[:] = _compute_gradient_colors(perturbed, n, easing)
                                colors = buf

                            elif atype == "hue_rotate":
                                # Rotate hue while preserving original S/V
                                h_shift = (speed * t * 0.1) % 1.0
                                rgb_f = base.astype(np.float32) * (1.0 / 255.0)
                                r_f = rgb_f[:, 0]
                                g_f = rgb_f[:, 1]
                                b_f = rgb_f[:, 2]
                                cmax = np.maximum(np.maximum(r_f, g_f), b_f)
                                cmin = np.minimum(np.minimum(r_f, g_f), b_f)
                                delta = cmax - cmin
                                # Hue
                                h_arr = np.zeros(n, dtype=np.float32)
                                mask_r = (delta > 0) & (cmax == r_f)
                                mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
                                mask_b = (delta > 0) & ~mask_r & ~mask_g
                                h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
                                h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
                                h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
                                h_arr *= 1.0 / 6.0
                                h_arr %= 1.0
                                # S and V — preserve original values (no clamping)
                                s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
                                v_arr = cmax
                                # Shift hue
                                h_arr += h_shift
                                h_arr %= 1.0
                                # HSV->RGB
                                h6 = h_arr * 6.0
                                hi = h6.astype(np.int32) % 6
                                f_arr = h6 - np.floor(h6)
                                p = v_arr * (1.0 - s_arr)
                                q = v_arr * (1.0 - s_arr * f_arr)
                                tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
                                ro = np.empty(n, dtype=np.float32)
                                go = np.empty(n, dtype=np.float32)
                                bo = np.empty(n, dtype=np.float32)
                                for sxt, rv, gv, bv in (
                                    (0, v_arr, tt, p),
                                    (1, q, v_arr, p),
                                    (2, p, v_arr, tt),
                                    (3, p, q, v_arr),
                                    (4, tt, p, v_arr),
                                    (5, v_arr, p, q),
                                ):
                                    m = hi == sxt
                                    ro[m] = rv[m]
                                    go[m] = gv[m]
                                    bo[m] = bv[m]
                                buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
                                buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
                                buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
                                colors = buf

                            if colors is not None:
                                with self._colors_lock:
                                    self._colors = colors
                    except Exception as e:
                        logger.error(f"GradientColorStripStream animation error: {e}")

                    elapsed = time.perf_counter() - wall_start
                    sleep_target = frame_time if anim and anim.get("enabled") else 0.25
                    time.sleep(max(sleep_target - elapsed, 0.001))
        except Exception as e:
            logger.error(f"Fatal GradientColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False
