"""Value stream — runtime scalar signal generators.

A ValueStream wraps a ValueSource config and computes a float (0.0–1.0)
on demand via ``get_value()``.  Six concrete types:

  StaticValueStream      — returns a constant
  AnimatedValueStream    — evaluates a periodic waveform (sine/triangle/square/sawtooth)
  AudioValueStream       — polls audio analysis for RMS/peak/beat, applies
                           sensitivity and temporal smoothing
  TimeOfDayValueStream   — interpolates brightness along a 24h schedule (adaptive_time)
  SceneValueStream       — derives brightness from a picture source's frame luminance (adaptive_scene)
  DaylightValueStream    — brightness derived from daylight cycle LUT (real-time or simulated)

ValueStreams are cheap (trivial math or single poll), so they compute inline
in the caller's processing loop — no background threads required.

ValueStreamManager owns all running ValueStreams, keyed by
``{vs_id}:{consumer_id}``.  Processors call acquire/release.
"""

from __future__ import annotations

import asyncio
import math
import time
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

import numpy as np

from wled_controller.utils import get_logger

if TYPE_CHECKING:
    from wled_controller.core.audio.audio_capture import AudioCaptureManager
    from wled_controller.core.game_integration.event_bus import GameEventBus
    from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
    from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
    from wled_controller.core.processing.live_stream_manager import LiveStreamManager
    from wled_controller.storage.audio_source_store import AudioSourceStore
    from wled_controller.storage.value_source import ValueSource
    from wled_controller.storage.value_source_store import ValueSourceStore

logger = get_logger(__name__)


# ---------------------------------------------------------------------------
# Base class
# ---------------------------------------------------------------------------


class ValueStream(ABC):
    """Abstract base for runtime value streams."""

    @abstractmethod
    def get_value(self) -> float:
        """Return current scalar value (0.0–1.0)."""
        ...

    def get_color(self) -> tuple:
        """Return current RGB color as (R, G, B). Override in color streams."""
        raise NotImplementedError("This stream does not produce colors")

    def start(self) -> None:
        """Acquire resources (if any)."""

    def stop(self) -> None:
        """Release resources (if any)."""

    def update_source(self, source: "ValueSource") -> None:
        """Hot-update parameters from a modified ValueSource config."""


# ---------------------------------------------------------------------------
# Static
# ---------------------------------------------------------------------------


class StaticValueStream(ValueStream):
    """Returns a constant float."""

    def __init__(self, value: float = 1.0):
        self._value = max(0.0, min(1.0, value))

    def get_value(self) -> float:
        return self._value

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import StaticValueSource

        if isinstance(source, StaticValueSource):
            self._value = max(0.0, min(1.0, source.value))


# ---------------------------------------------------------------------------
# Animated
# ---------------------------------------------------------------------------

_TWO_PI = 2.0 * math.pi


class AnimatedValueStream(ValueStream):
    """Evaluates a periodic waveform from wall-clock time.

    Waveforms: sine, triangle, square, sawtooth.
    Speed is in cycles per minute (cpm).  Output is mapped to
    [min_value, max_value].
    """

    def __init__(
        self,
        waveform: str = "sine",
        speed: float = 10.0,
        min_value: float = 0.0,
        max_value: float = 1.0,
    ):
        self._waveform = waveform
        self._speed = speed
        self._min = min_value
        self._max = max_value
        self._start_time = time.perf_counter()

    def get_value(self) -> float:
        elapsed = time.perf_counter() - self._start_time
        # phase in [0, 1)
        cycles_per_sec = self._speed / 60.0
        phase = (elapsed * cycles_per_sec) % 1.0

        # Raw waveform value in [0, 1]
        wf = self._waveform
        if wf == "sine":
            raw = 0.5 + 0.5 * math.sin(_TWO_PI * phase)
        elif wf == "triangle":
            raw = 1.0 - abs(2.0 * phase - 1.0)
        elif wf == "square":
            raw = 1.0 if phase < 0.5 else 0.0
        elif wf == "sawtooth":
            raw = phase
        else:
            raw = 0.5 + 0.5 * math.sin(_TWO_PI * phase)

        # Map to [min, max]
        return self._min + raw * (self._max - self._min)

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import AnimatedValueSource

        if isinstance(source, AnimatedValueSource):
            self._waveform = source.waveform
            self._speed = source.speed
            self._min = source.min_value
            self._max = source.max_value


# ---------------------------------------------------------------------------
# Audio
# ---------------------------------------------------------------------------


class AudioValueStream(ValueStream):
    """Polls audio analysis for a scalar value.

    Modes:
      rms   — root-mean-square level (overall volume)
      peak  — peak amplitude
      beat  — 1.0 on beat, decays toward 0.0 between beats
    """

    def __init__(
        self,
        audio_source_id: str,
        mode: str = "rms",
        sensitivity: float = 1.0,
        smoothing: float = 0.3,
        min_value: float = 0.0,
        max_value: float = 1.0,
        auto_gain: bool = False,
        audio_capture_manager: Optional["AudioCaptureManager"] = None,
        audio_source_store: Optional["AudioSourceStore"] = None,
        audio_template_store=None,
        audio_processing_template_store=None,
    ):
        self._audio_source_id = audio_source_id
        self._mode = mode
        self._sensitivity = sensitivity
        self._smoothing = smoothing
        self._min = min_value
        self._max = max_value
        self._auto_gain = auto_gain
        self._rolling_peak = 0.0  # tracks observed max raw audio value
        self._rolling_decay = 0.995  # slow decay (~5-10s adaptation)
        self._audio_capture_manager = audio_capture_manager
        self._audio_source_store = audio_source_store
        self._audio_template_store = audio_template_store
        self._audio_processing_template_store = audio_processing_template_store

        # Resolved audio device params
        self._audio_device_index = -1
        self._audio_loopback = True
        self._audio_engine_type = None
        self._audio_engine_config = None
        self._audio_processing_template_ids: list = []

        self._audio_stream = None
        self._filter_pipeline = None  # AudioFilterPipeline
        self._prev_value = 0.0
        self._beat_brightness = 0.0

        self._resolve_audio_source()

    def _resolve_audio_source(self) -> None:
        """Resolve audio source to device index / engine info / processing template IDs.

        Builds the audio filter pipeline from the processing template chain.
        """
        if self._audio_source_id and self._audio_source_store:
            try:
                resolved = self._audio_source_store.resolve_audio_source(self._audio_source_id)
                self._audio_device_index = resolved.device_index
                self._audio_loopback = resolved.is_loopback
                self._audio_processing_template_ids = list(resolved.audio_processing_template_ids)
                if resolved.audio_template_id and self._audio_template_store:
                    try:
                        tpl = self._audio_template_store.get_template(resolved.audio_template_id)
                        self._audio_engine_type = tpl.engine_type
                        self._audio_engine_config = tpl.engine_config
                    except ValueError as e:
                        logger.warning(
                            "Audio template %s not found for value stream, using default engine: %s",
                            resolved.audio_template_id,
                            e,
                        )
                        pass
            except ValueError as e:
                logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
        self._rebuild_filter_pipeline()

    def _rebuild_filter_pipeline(self) -> None:
        """Build (or rebuild) the audio filter pipeline from processing template IDs."""
        from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids

        old_pipeline = self._filter_pipeline
        if self._audio_processing_template_ids and self._audio_processing_template_store:
            self._filter_pipeline = build_pipeline_from_template_ids(
                self._audio_processing_template_ids,
                self._audio_processing_template_store,
            )
        else:
            self._filter_pipeline = None
        if old_pipeline is not None:
            old_pipeline.close()

    def start(self) -> None:
        if self._audio_capture_manager is None:
            return
        self._audio_stream = self._audio_capture_manager.acquire(
            self._audio_device_index,
            self._audio_loopback,
            engine_type=self._audio_engine_type,
            engine_config=self._audio_engine_config,
        )
        logger.info(
            f"AudioValueStream started (mode={self._mode}, engine={self._audio_engine_type}, "
            f"device={self._audio_device_index}, loopback={self._audio_loopback})"
        )

    def stop(self) -> None:
        if self._audio_stream is not None and self._audio_capture_manager is not None:
            self._audio_capture_manager.release(
                self._audio_device_index,
                self._audio_loopback,
                engine_type=self._audio_engine_type,
            )
            self._audio_stream = None
        if self._filter_pipeline is not None:
            self._filter_pipeline.close()
            self._filter_pipeline = None
        self._prev_value = 0.0
        self._beat_brightness = 0.0

    def get_value(self) -> float:
        if self._audio_stream is None:
            return self._min

        analysis = self._audio_stream.get_latest_analysis()
        if analysis is None:
            return self._prev_value

        raw = self._extract_raw(analysis)

        # Auto-gain: normalize raw against rolling observed peak
        if self._auto_gain:
            self._rolling_peak = max(raw, self._rolling_peak * self._rolling_decay)
            if self._rolling_peak > 0.001:
                raw = raw / self._rolling_peak

        raw = min(1.0, raw * self._sensitivity)

        # Temporal smoothing
        smoothed = self._smoothing * self._prev_value + (1.0 - self._smoothing) * raw
        self._prev_value = smoothed

        # Map to [min, max]
        mapped = self._min + smoothed * (self._max - self._min)
        return max(0.0, min(1.0, mapped))

    def _extract_raw(self, analysis) -> float:
        """Extract raw scalar from audio analysis based on mode.

        Applies the audio filter pipeline (if any) before extracting the scalar.
        Channel extraction, band filtering, gain, etc. are handled by filters.
        """
        if self._filter_pipeline is not None:
            analysis = self._filter_pipeline.process(analysis)
        if self._mode == "peak":
            return self._pick_peak(analysis)
        if self._mode == "beat":
            return self._compute_beat(analysis)
        # Default: rms
        return self._pick_rms(analysis)

    def _pick_rms(self, analysis) -> float:
        return getattr(analysis, "rms", 0.0)

    def _pick_peak(self, analysis) -> float:
        return getattr(analysis, "peak", 0.0)

    def _compute_beat(self, analysis) -> float:
        if getattr(analysis, "beat", False):
            self._beat_brightness = 1.0
        else:
            decay = 0.05 + 0.15 * (1.0 / max(self._sensitivity, 0.1))
            self._beat_brightness = max(0.0, self._beat_brightness - decay)
        return self._beat_brightness

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import AudioValueSource

        if not isinstance(source, AudioValueSource):
            return

        old_source_id = self._audio_source_id
        old_auto_gain = self._auto_gain
        self._audio_source_id = source.audio_source_id
        self._mode = source.mode
        self._sensitivity = source.sensitivity
        self._smoothing = source.smoothing
        self._min = source.min_value
        self._max = source.max_value
        self._auto_gain = source.auto_gain

        # Reset rolling peak when auto-gain is toggled on
        if self._auto_gain and not old_auto_gain:
            self._rolling_peak = 0.0

        # If audio source changed, re-resolve and swap capture stream
        if source.audio_source_id != old_source_id:
            old_device = self._audio_device_index
            old_loopback = self._audio_loopback
            old_engine_type = self._audio_engine_type
            self._resolve_audio_source()
            if self._audio_stream is not None and self._audio_capture_manager is not None:
                self._audio_capture_manager.release(
                    old_device,
                    old_loopback,
                    engine_type=old_engine_type,
                )
                self._audio_stream = self._audio_capture_manager.acquire(
                    self._audio_device_index,
                    self._audio_loopback,
                    engine_type=self._audio_engine_type,
                    engine_config=self._audio_engine_config,
                )
                logger.info(
                    f"AudioValueStream swapped audio device: "
                    f"{old_engine_type}:{old_device}:{old_loopback} → "
                    f"{self._audio_engine_type}:{self._audio_device_index}:{self._audio_loopback}"
                )


# ---------------------------------------------------------------------------
# Time of Day
# ---------------------------------------------------------------------------

_MINUTES_PER_DAY = 1440  # 24 * 60


class TimeOfDayValueStream(ValueStream):
    """Interpolates brightness along a 24-hour schedule.

    Schedule is a list of {"time": "HH:MM", "value": 0.0–1.0} dicts.
    At runtime, finds the two surrounding points and linearly interpolates.
    The schedule wraps around midnight.
    """

    def __init__(
        self,
        schedule: List[dict],
        min_value: float = 0.0,
        max_value: float = 1.0,
    ):
        self._points: List[Tuple[float, float]] = []  # (minutes, value)
        self._min = min_value
        self._max = max_value
        self._parse_schedule(schedule)

    def _parse_schedule(self, schedule: List[dict]) -> None:
        """Parse schedule into sorted (minutes, value) tuples."""
        points = []
        for entry in schedule:
            t_str = entry.get("time", "00:00")
            parts = t_str.split(":")
            h = int(parts[0])
            m = int(parts[1]) if len(parts) > 1 else 0
            minutes = h * 60 + m
            val = max(0.0, min(1.0, float(entry.get("value", 1.0))))
            points.append((minutes, val))
        points.sort(key=lambda p: p[0])
        self._points = points

    def get_value(self) -> float:
        if len(self._points) < 2:
            return self._max  # fallback: full brightness

        now = datetime.now()
        current = now.hour * 60 + now.minute + now.second / 60.0

        points = self._points
        n = len(points)

        # Find the first point whose time is > current
        right_idx = 0
        for i, (t, _) in enumerate(points):
            if t > current:
                right_idx = i
                break
        else:
            right_idx = 0  # all points <= current: wrap to first

        left_idx = (right_idx - 1) % n
        t_left, v_left = points[left_idx]
        t_right, v_right = points[right_idx]

        # Compute interval length (handling midnight wrap)
        if t_right > t_left:
            interval = t_right - t_left
            elapsed = current - t_left
        else:
            interval = (_MINUTES_PER_DAY - t_left) + t_right
            elapsed = (
                (current - t_left) if current >= t_left else (_MINUTES_PER_DAY - t_left) + current
            )

        frac = elapsed / interval if interval > 0 else 0.0
        raw = v_left + frac * (v_right - v_left)

        # Map to output range
        return self._min + max(0.0, min(1.0, raw)) * (self._max - self._min)

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import AdaptiveValueSource

        if isinstance(source, AdaptiveValueSource) and source.source_type == "adaptive_time":
            self._parse_schedule(source.schedule)
            self._min = source.min_value
            self._max = source.max_value


# ---------------------------------------------------------------------------
# Scene
# ---------------------------------------------------------------------------


class SceneValueStream(ValueStream):
    """Derives brightness from a picture source's average frame luminance.

    Acquires a LiveStream from LiveStreamManager on start().  On each
    get_value() call, reads the latest frame, subsamples, computes mean
    luminance (BT.601), applies sensitivity/smoothing/mapping.

    Behaviors:
      complement — dark scene → high brightness (ambient backlight visibility)
      match      — bright scene → high brightness
    """

    def __init__(
        self,
        picture_source_id: str,
        scene_behavior: str = "complement",
        sensitivity: float = 1.0,
        smoothing: float = 0.3,
        min_value: float = 0.0,
        max_value: float = 1.0,
        live_stream_manager: Optional["LiveStreamManager"] = None,
    ):
        self._picture_source_id = picture_source_id
        self._behavior = scene_behavior
        self._sensitivity = sensitivity
        self._smoothing = smoothing
        self._min = min_value
        self._max = max_value
        self._live_stream_manager = live_stream_manager
        self._live_stream = None
        self._prev_value = None  # None = no frame seen yet; skip smoothing on first frame

    def start(self) -> None:
        if self._live_stream_manager and self._picture_source_id:
            try:
                self._live_stream = self._live_stream_manager.acquire(self._picture_source_id)
                logger.info(f"SceneValueStream acquired live stream for {self._picture_source_id}")
            except Exception as e:
                logger.warning(f"SceneValueStream failed to acquire live stream: {e}")
                self._live_stream = None

    def stop(self) -> None:
        if self._live_stream is not None and self._live_stream_manager:
            try:
                self._live_stream_manager.release(self._picture_source_id)
            except Exception as e:
                logger.warning(f"SceneValueStream failed to release live stream: {e}")
            self._live_stream = None
        self._prev_value = None

    def get_value(self) -> float:
        if self._live_stream is None:
            return self._prev_value if self._prev_value is not None else 0.0

        frame = self._live_stream.get_latest_frame()
        if frame is None:
            return self._prev_value if self._prev_value is not None else 0.0

        # Fast luminance: subsample to ~64x64 via numpy stride (zero-copy view)
        img = frame.image
        h, w = img.shape[:2]
        step_h = max(1, h // 64)
        step_w = max(1, w // 64)
        sampled = img[::step_h, ::step_w].astype(np.float32)

        # BT.601 weighted luminance, normalized to [0, 1]
        luminance = (
            float(
                (
                    0.299 * sampled[:, :, 0] + 0.587 * sampled[:, :, 1] + 0.114 * sampled[:, :, 2]
                ).mean()
            )
            / 255.0
        )

        # Apply sensitivity
        raw = min(1.0, luminance * self._sensitivity)

        # Apply behavior
        if self._behavior == "complement":
            raw = 1.0 - raw

        # Temporal smoothing (EMA) — skip on first frame (no history to blend with)
        if self._prev_value is None:
            smoothed = raw
        else:
            smoothed = self._smoothing * self._prev_value + (1.0 - self._smoothing) * raw
        self._prev_value = smoothed

        # Map to output range
        clamped = max(0.0, min(1.0, smoothed))
        return self._min + clamped * (self._max - self._min)

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import AdaptiveValueSource

        if not isinstance(source, AdaptiveValueSource) or source.source_type != "adaptive_scene":
            return

        self._behavior = source.scene_behavior
        self._sensitivity = source.sensitivity
        self._smoothing = source.smoothing
        self._min = source.min_value
        self._max = source.max_value

        # If picture source changed, swap live streams
        if source.picture_source_id != self._picture_source_id:
            old_id = self._picture_source_id
            self._picture_source_id = source.picture_source_id
            if self._live_stream is not None and self._live_stream_manager:
                self._live_stream_manager.release(old_id)
                try:
                    self._live_stream = self._live_stream_manager.acquire(self._picture_source_id)
                    logger.info(
                        f"SceneValueStream swapped live stream: {old_id} → "
                        f"{self._picture_source_id}"
                    )
                except Exception as e:
                    logger.warning(f"SceneValueStream failed to swap live stream: {e}")
                    self._live_stream = None


# ---------------------------------------------------------------------------
# Daylight
# ---------------------------------------------------------------------------


class DaylightValueStream(ValueStream):
    """Brightness derived from the daylight color cycle LUT.

    Computes BT.601 luminance from the RGB sky color at the current
    simulated (or real-time) minute of day, then maps to [min, max].
    Cheap inline computation — no background thread needed.
    """

    def __init__(
        self,
        speed: float = 1.0,
        use_real_time: bool = False,
        latitude: float = 50.0,
        min_value: float = 0.0,
        max_value: float = 1.0,
    ):
        from wled_controller.core.processing.daylight_stream import _get_daylight_lut

        self._lut = _get_daylight_lut()
        self._speed = speed
        self._use_real_time = use_real_time
        self._latitude = latitude
        self._min = min_value
        self._max = max_value
        self._start_time = time.perf_counter()

    def get_value(self) -> float:
        if self._use_real_time:
            now = datetime.now()
            minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
        else:
            t_elapsed = time.perf_counter() - self._start_time
            cycle_seconds = 240.0 / max(self._speed, 0.01)
            phase = (t_elapsed % cycle_seconds) / cycle_seconds
            minute_of_day = phase * 1440.0

        idx = int(minute_of_day) % 1440
        r, g, b = self._lut[idx]

        # BT.601 luminance → 0..1
        luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0

        return self._min + luminance * (self._max - self._min)

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import DaylightValueSource

        if isinstance(source, DaylightValueSource):
            self._speed = source.speed
            self._use_real_time = source.use_real_time
            self._latitude = source.latitude
            self._min = source.min_value
            self._max = source.max_value


# ---------------------------------------------------------------------------
# Color streams
# ---------------------------------------------------------------------------


class StaticColorValueStream(ValueStream):
    """Returns a constant RGB color."""

    def __init__(self, color):
        c = color if isinstance(color, (list, tuple)) and len(color) == 3 else [255, 255, 255]
        self._color = (int(c[0]), int(c[1]), int(c[2]))

    def get_value(self) -> float:
        r, g, b = self._color
        return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0

    def get_color(self) -> tuple:
        return self._color

    def update_source(self, source) -> None:
        c = getattr(source, "color", [255, 255, 255])
        self._color = (
            (int(c[0]), int(c[1]), int(c[2]))
            if isinstance(c, (list, tuple)) and len(c) == 3
            else (255, 255, 255)
        )


class AnimatedColorValueStream(ValueStream):
    """Cycles through a list of colors over time."""

    def __init__(self, colors, speed=10.0, easing="linear"):
        self._colors = [
            (int(c[0]), int(c[1]), int(c[2]))
            for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
            if isinstance(c, (list, tuple)) and len(c) == 3
        ] or [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
        self._speed = max(0.01, float(speed))
        self._easing = easing
        self._start_time = 0.0

    def start(self) -> None:
        self._start_time = time.monotonic()

    def get_value(self) -> float:
        r, g, b = self.get_color()
        return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0

    def get_color(self) -> tuple:
        elapsed = time.monotonic() - self._start_time
        cycle_time = 60.0 / self._speed
        n = len(self._colors)
        if self._easing == "step":
            idx = int((elapsed / cycle_time * n) % n)
            return self._colors[idx]
        phase = (elapsed / cycle_time * n) % n
        idx = int(phase)
        frac = phase - idx
        c1 = self._colors[idx % n]
        c2 = self._colors[(idx + 1) % n]
        return (
            int(c1[0] + (c2[0] - c1[0]) * frac),
            int(c1[1] + (c2[1] - c1[1]) * frac),
            int(c1[2] + (c2[2] - c1[2]) * frac),
        )

    def update_source(self, source) -> None:
        raw = getattr(source, "colors", None)
        if raw and isinstance(raw, list):
            self._colors = [
                (int(c[0]), int(c[1]), int(c[2]))
                for c in raw
                if isinstance(c, (list, tuple)) and len(c) == 3
            ] or self._colors
        self._speed = max(0.01, float(getattr(source, "speed", self._speed)))
        self._easing = getattr(source, "easing", self._easing)


class AdaptiveTimeColorValueStream(ValueStream):
    """Interpolates RGB colors along a 24-hour schedule."""

    def __init__(self, schedule):
        self._schedule = schedule or []
        self._parsed = self._parse(self._schedule)

    @staticmethod
    def _parse(schedule):
        result = []
        for entry in schedule:
            t_str = entry.get("time", "00:00")
            parts = t_str.split(":")
            minutes = int(parts[0]) * 60 + int(parts[1]) if len(parts) >= 2 else 0
            c = entry.get("color", [255, 255, 255])
            color = (
                (int(c[0]), int(c[1]), int(c[2]))
                if isinstance(c, (list, tuple)) and len(c) == 3
                else (255, 255, 255)
            )
            result.append((minutes, color))
        result.sort(key=lambda x: x[0])
        return result

    def get_value(self) -> float:
        r, g, b = self.get_color()
        return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0

    def get_color(self) -> tuple:
        from datetime import datetime

        now = datetime.now()
        cur = now.hour * 60 + now.minute + now.second / 60.0
        pts = self._parsed
        if not pts:
            return (255, 255, 255)
        if len(pts) == 1:
            return pts[0][1]
        before = pts[-1]
        after = pts[0]
        for i, (t, c) in enumerate(pts):
            if t <= cur:
                before = (t, c)
                after = pts[(i + 1) % len(pts)]
        b_t, b_c = before
        a_t, a_c = after
        span = a_t - b_t
        if span <= 0:
            span += 1440
        elapsed = cur - b_t
        if elapsed < 0:
            elapsed += 1440
        frac = max(0.0, min(1.0, elapsed / span if span > 0 else 0.0))
        return (
            int(b_c[0] + (a_c[0] - b_c[0]) * frac),
            int(b_c[1] + (a_c[1] - b_c[1]) * frac),
            int(b_c[2] + (a_c[2] - b_c[2]) * frac),
        )

    def update_source(self, source) -> None:
        self._schedule = getattr(source, "schedule", self._schedule)
        self._parsed = self._parse(self._schedule)


# ---------------------------------------------------------------------------
# HA Entity
# ---------------------------------------------------------------------------

# Common HA boolean states mapped to numeric values for value sources
_HA_BOOL_MAP: Dict[str, float] = {
    "on": 1.0,
    "off": 0.0,
    "home": 1.0,
    "away": 0.0,
    "open": 1.0,
    "closed": 0.0,
    "true": 1.0,
    "false": 0.0,
}


class HAEntityValueStream(ValueStream):
    """Reads a numeric value from a Home Assistant entity state or attribute.

    Normalizes the raw HA value to [0, 1] using the configured min/max range,
    applies EMA smoothing.
    """

    def __init__(
        self,
        ha_source_id: str,
        entity_id: str,
        attribute: str = "",
        min_ha_value: float = 0.0,
        max_ha_value: float = 100.0,
        smoothing: float = 0.0,
        ha_manager: Optional[Any] = None,
    ):
        self._ha_source_id = ha_source_id
        self._entity_id = entity_id
        self._attribute = attribute
        self._min_ha = min_ha_value
        self._max_ha = max_ha_value
        self._smoothing = smoothing
        self._ha_manager = ha_manager
        self._prev_value: Optional[float] = None
        self._raw_value: Optional[float] = None

    def start(self) -> None:
        if self._ha_manager and self._ha_source_id:
            try:
                loop = asyncio.get_event_loop()
                loop.create_task(self._async_start())
            except Exception as e:
                logger.warning("HAEntityValueStream failed to schedule start: %s", e)

    async def _async_start(self) -> None:
        try:
            await self._ha_manager.acquire(self._ha_source_id)
            logger.info(
                "HAEntityValueStream started (ha=%s, entity=%s, attr=%s)",
                self._ha_source_id,
                self._entity_id,
                self._attribute or "<state>",
            )
        except Exception as e:
            logger.warning("HAEntityValueStream failed to acquire HA runtime: %s", e)

    def stop(self) -> None:
        if self._ha_manager and self._ha_source_id:
            try:
                loop = asyncio.get_event_loop()
                loop.create_task(self._async_stop())
            except Exception as e:
                logger.warning("HAEntityValueStream failed to schedule stop: %s", e)
        self._prev_value = None

    async def _async_stop(self) -> None:
        try:
            await self._ha_manager.release(self._ha_source_id)
        except Exception as e:
            logger.warning("HAEntityValueStream failed to release HA runtime: %s", e)

    def get_value(self) -> float:
        if self._ha_manager is None:
            return self._prev_value if self._prev_value is not None else 0.0

        state = self._ha_manager.get_state(self._ha_source_id, self._entity_id)
        if state is None:
            return self._prev_value if self._prev_value is not None else 0.0

        # Extract raw value from state or attribute
        try:
            if self._attribute:
                attrs = getattr(state, "attributes", {})
                raw_str = attrs.get(self._attribute, getattr(state, "state", "0"))
            else:
                raw_str = getattr(state, "state", "0")
            raw_lower = raw_str.lower() if isinstance(raw_str, str) else raw_str
            if raw_lower in _HA_BOOL_MAP:
                raw = _HA_BOOL_MAP[raw_lower]
            else:
                raw = float(raw_str)
        except (ValueError, TypeError):
            return self._prev_value if self._prev_value is not None else 0.0

        self._raw_value = raw

        # Normalize to [0, 1]
        ha_range = self._max_ha - self._min_ha
        if abs(ha_range) < 1e-9:
            normalized = 0.5
        else:
            normalized = (raw - self._min_ha) / ha_range

        normalized = max(0.0, min(1.0, normalized))

        # EMA smoothing
        if self._smoothing > 0.0 and self._prev_value is not None:
            normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized

        self._prev_value = normalized
        return normalized

    def get_raw_value(self) -> Optional[float]:
        """Return the last raw HA entity value before normalization."""
        return self._raw_value

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import HAEntityValueSource

        if not isinstance(source, HAEntityValueSource):
            return

        old_ha_source = self._ha_source_id
        self._ha_source_id = source.ha_source_id
        self._entity_id = source.entity_id
        self._attribute = source.attribute
        self._min_ha = source.min_ha_value
        self._max_ha = source.max_ha_value
        self._smoothing = source.smoothing

        # If HA source changed, swap runtime
        if source.ha_source_id != old_ha_source and self._ha_manager:
            try:
                loop = asyncio.get_event_loop()
                loop.create_task(self._async_swap_runtime(old_ha_source))
            except Exception as e:
                logger.warning("HAEntityValueStream failed to schedule runtime swap: %s", e)

    async def _async_swap_runtime(self, old_ha_source: str) -> None:
        try:
            await self._ha_manager.release(old_ha_source)
            await self._ha_manager.acquire(self._ha_source_id)
        except Exception as e:
            logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e)


# ---------------------------------------------------------------------------
# Gradient Map
# ---------------------------------------------------------------------------


class GradientMapValueStream(ValueStream):
    """Maps a float value source through a color gradient.

    Acquires a float-returning ValueStream, reads its value (0..1), and
    interpolates the color at that position in the gradient stops resolved
    from a gradient entity.
    """

    def __init__(
        self,
        value_source_id: str,
        gradient_id: str = "",
        easing: str = "linear",
        value_stream_manager: Optional["ValueStreamManager"] = None,
        gradient_store: Optional[Any] = None,
    ):
        self._value_source_id = value_source_id
        self._gradient_id = gradient_id
        self._easing = easing
        self._vsm = value_stream_manager
        self._gradient_store = gradient_store
        self._inner_stream: Optional[ValueStream] = None
        self._stops: list = []
        self._input_value: float = 0.0
        self._resolve_gradient()

    def _resolve_gradient(self) -> None:
        """Resolve gradient_id to stops list."""
        if self._gradient_id and self._gradient_store:
            try:
                gradient = self._gradient_store.get(self._gradient_id)
                self._stops = sorted(
                    gradient.stops if hasattr(gradient, "stops") else [],
                    key=lambda s: (
                        s.get("position", 0) if isinstance(s, dict) else getattr(s, "position", 0)
                    ),
                )
            except Exception as e:
                logger.warning(
                    "GradientMapValueStream failed to resolve gradient %s: %s", self._gradient_id, e
                )
                self._stops = []
        else:
            self._stops = []

    def start(self) -> None:
        if self._vsm and self._value_source_id:
            try:
                self._inner_stream = self._vsm.acquire(self._value_source_id)
                logger.info(
                    "GradientMapValueStream acquired inner stream %s", self._value_source_id
                )
            except Exception as e:
                logger.warning("GradientMapValueStream failed to acquire inner stream: %s", e)

    def stop(self) -> None:
        if self._vsm and self._value_source_id:
            self._vsm.release(self._value_source_id)
            self._inner_stream = None

    def get_value(self) -> float:
        r, g, b = self.get_color()
        return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0

    def get_input_value(self) -> float:
        """Return the last input interpolation factor (0.0–1.0)."""
        return self._input_value

    def get_color(self) -> tuple:
        if self._inner_stream is None:
            return (128, 128, 128)

        t = max(0.0, min(1.0, self._inner_stream.get_value()))
        self._input_value = t
        stops = self._stops
        if not stops:
            return (128, 128, 128)
        if len(stops) == 1:
            c = stops[0].get("color", [128, 128, 128])
            return (int(c[0]), int(c[1]), int(c[2]))

        # Find surrounding stops
        left = stops[0]
        right = stops[-1]
        for i in range(len(stops) - 1):
            if stops[i].get("position", 0) <= t <= stops[i + 1].get("position", 1):
                left = stops[i]
                right = stops[i + 1]
                break

        lp = left.get("position", 0)
        rp = right.get("position", 1)
        lc = left.get("color", [0, 0, 0])
        rc = right.get("color", [255, 255, 255])

        span = rp - lp
        if span <= 0 or self._easing == "step":
            return (int(lc[0]), int(lc[1]), int(lc[2]))

        frac = (t - lp) / span
        return (
            int(lc[0] + (rc[0] - lc[0]) * frac),
            int(lc[1] + (rc[1] - lc[1]) * frac),
            int(lc[2] + (rc[2] - lc[2]) * frac),
        )

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import GradientMapValueSource

        if not isinstance(source, GradientMapValueSource):
            return

        self._gradient_id = source.gradient_id
        self._easing = source.easing
        self._resolve_gradient()

        # If input source changed, swap
        if source.value_source_id != self._value_source_id and self._vsm:
            old_id = self._value_source_id
            self._value_source_id = source.value_source_id
            if self._inner_stream is not None:
                self._vsm.release(old_id)
                try:
                    self._inner_stream = self._vsm.acquire(self._value_source_id)
                except Exception as e:
                    logger.warning("GradientMapValueStream failed to swap inner stream: %s", e)
                    self._inner_stream = None


# ---------------------------------------------------------------------------
# CSS Extract
# ---------------------------------------------------------------------------


class CSSExtractValueStream(ValueStream):
    """Extracts a single color by averaging a LED range from a color strip stream.

    Acquires a ColorStripStream, reads the LED colors, and averages the
    specified range to produce a single RGB color.
    """

    _DEFAULT_LED_COUNT = 60

    def __init__(
        self,
        color_strip_source_id: str,
        led_start: int = 0,
        led_end: int = -1,
        css_stream_manager: Optional["ColorStripStreamManager"] = None,
    ):
        self._css_source_id = color_strip_source_id
        self._led_start = led_start
        self._led_end = led_end
        self._cssm = css_stream_manager
        self._css_stream = None

    def start(self) -> None:
        if self._cssm and self._css_source_id:
            try:
                self._css_stream = self._cssm.acquire(
                    self._css_source_id,
                    self._DEFAULT_LED_COUNT,
                )
                logger.info("CSSExtractValueStream acquired CSS stream %s", self._css_source_id)
            except Exception as e:
                logger.warning("CSSExtractValueStream failed to acquire CSS stream: %s", e)

    def stop(self) -> None:
        if self._cssm and self._css_source_id:
            try:
                self._cssm.release(self._css_source_id, self._DEFAULT_LED_COUNT)
            except Exception as e:
                logger.warning("CSSExtractValueStream failed to release CSS stream: %s", e)
            self._css_stream = None

    def get_value(self) -> float:
        r, g, b = self.get_color()
        return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0

    def get_color(self) -> tuple:
        if self._css_stream is None:
            return (0, 0, 0)

        colors = self._css_stream.get_colors()
        if colors is None or len(colors) == 0:
            return (0, 0, 0)

        n = len(colors)
        start = max(0, self._led_start)
        end = self._led_end if self._led_end >= 0 else n
        end = min(end, n)

        if start >= end:
            return (0, 0, 0)

        segment = colors[start:end]
        if len(segment) == 0:
            return (0, 0, 0)

        # Average the segment — colors is numpy array (N, 3) uint8
        if isinstance(segment, np.ndarray):
            avg = segment.mean(axis=0)
            return (int(avg[0]), int(avg[1]), int(avg[2]))

        # Fallback for list
        r = sum(c[0] for c in segment) // len(segment)
        g = sum(c[1] for c in segment) // len(segment)
        b = sum(c[2] for c in segment) // len(segment)
        return (r, g, b)

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import CSSExtractValueSource

        if not isinstance(source, CSSExtractValueSource):
            return

        self._led_start = source.led_start
        self._led_end = source.led_end

        # If CSS source changed, swap
        if source.color_strip_source_id != self._css_source_id and self._cssm:
            old_id = self._css_source_id
            self._css_source_id = source.color_strip_source_id
            if self._css_stream is not None:
                try:
                    self._cssm.release(old_id, self._DEFAULT_LED_COUNT)
                except Exception:
                    pass
                try:
                    self._css_stream = self._cssm.acquire(
                        self._css_source_id,
                        self._DEFAULT_LED_COUNT,
                    )
                except Exception as e:
                    logger.warning("CSSExtractValueStream failed to swap CSS stream: %s", e)
                    self._css_stream = None


# ---------------------------------------------------------------------------
# System metrics stream
# ---------------------------------------------------------------------------


class SystemMetricsValueStream(ValueStream):
    """Reads system hardware metrics via psutil/pynvml.

    Normalizes readings to [0, 1], with optional EMA smoothing and
    configurable poll interval.
    """

    def __init__(
        self,
        metric: str = "cpu_load",
        min_value: float = 0.0,
        max_value: float = 100.0,
        max_rate: float = 125_000_000.0,
        disk_path: str = "",
        sensor_label: str = "",
        poll_interval: float = 1.0,
        smoothing: float = 0.0,
    ):
        self._metric = metric
        self._min_val = min_value
        self._max_val = max_value
        self._max_rate = max_rate
        self._disk_path = disk_path or ("/" if not disk_path else disk_path)
        self._sensor_label = sensor_label
        self._poll_interval = max(0.1, poll_interval)
        self._smoothing = smoothing
        self._prev_value: Optional[float] = None
        self._raw_value: Optional[float] = None
        self._last_poll: float = 0.0
        # Network delta tracking
        self._prev_net_bytes: Optional[int] = None
        self._prev_net_time: Optional[float] = None
        # GPU unavailable flag (avoid repeated warnings)
        self._gpu_unavailable = False

    def start(self) -> None:
        import psutil

        # Prime cpu_percent so the first real call returns meaningful data
        if self._metric == "cpu_load":
            psutil.cpu_percent(interval=None)
        # Prime network counters
        if self._metric in ("network_rx", "network_tx"):
            counters = psutil.net_io_counters()
            if counters:
                self._prev_net_bytes = (
                    counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
                )
                self._prev_net_time = time.monotonic()

    def stop(self) -> None:
        self._prev_value = None
        self._raw_value = None
        self._prev_net_bytes = None
        self._prev_net_time = None

    def get_value(self) -> float:
        now = time.monotonic()
        if self._prev_value is not None and (now - self._last_poll) < self._poll_interval:
            return self._prev_value

        self._last_poll = now
        raw = self._read_metric()
        self._raw_value = raw

        # Normalize
        normalized = self._normalize(raw)

        # EMA smoothing
        if self._smoothing > 0.0 and self._prev_value is not None:
            normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized

        self._prev_value = normalized
        return normalized

    def get_raw_value(self) -> Optional[float]:
        """Return the last raw metric value before normalization."""
        return self._raw_value

    def _normalize(self, raw: float) -> float:
        """Normalize raw value to [0, 1]."""
        if self._metric in ("cpu_load", "ram_usage", "gpu_load", "battery_level", "disk_usage"):
            return max(0.0, min(1.0, raw / 100.0))
        elif self._metric in ("cpu_temp", "gpu_temp", "fan_speed"):
            rng = self._max_val - self._min_val
            if abs(rng) < 1e-9:
                return 0.5
            return max(0.0, min(1.0, (raw - self._min_val) / rng))
        elif self._metric in ("network_rx", "network_tx"):
            if self._max_rate <= 0:
                return 0.5
            return max(0.0, min(1.0, raw / self._max_rate))
        return 0.0

    def _read_metric(self) -> float:
        """Read the raw metric value from the system."""
        import psutil

        try:
            if self._metric == "cpu_load":
                return psutil.cpu_percent(interval=None)
            elif self._metric == "ram_usage":
                return psutil.virtual_memory().percent
            elif self._metric == "disk_usage":
                return psutil.disk_usage(self._disk_path).percent
            elif self._metric == "battery_level":
                bat = psutil.sensors_battery()
                return bat.percent if bat else 0.0
            elif self._metric == "cpu_temp":
                return self._read_cpu_temp()
            elif self._metric == "fan_speed":
                return self._read_fan_speed()
            elif self._metric in ("gpu_load", "gpu_temp"):
                return self._read_gpu_metric()
            elif self._metric in ("network_rx", "network_tx"):
                return self._read_network_rate()
        except Exception as e:
            logger.debug("SystemMetricsValueStream read error (%s): %s", self._metric, e)
        return self._raw_value if self._raw_value is not None else 0.0

    def _read_cpu_temp(self) -> float:
        import psutil

        temps = psutil.sensors_temperatures()
        if not temps:
            return 0.0
        # If sensor_label specified, try to find it
        if self._sensor_label:
            for group_name, entries in temps.items():
                for entry in entries:
                    if entry.label == self._sensor_label or group_name == self._sensor_label:
                        return entry.current
        # Fallback: first available sensor
        for entries in temps.values():
            if entries:
                return entries[0].current
        return 0.0

    def _read_fan_speed(self) -> float:
        import psutil

        fans = psutil.sensors_fans()
        if not fans:
            return 0.0
        if self._sensor_label:
            for group_name, entries in fans.items():
                for entry in entries:
                    if entry.label == self._sensor_label or group_name == self._sensor_label:
                        return entry.current
        # Fallback: first available fan
        for entries in fans.values():
            if entries:
                return entries[0].current
        return 0.0

    def _read_gpu_metric(self) -> float:
        if self._gpu_unavailable:
            return 0.0
        try:
            from wled_controller.utils.gpu import nvml, nvml_available, nvml_handle

            if not nvml_available or nvml_handle is None:
                self._gpu_unavailable = True
                return 0.0
            if self._metric == "gpu_load":
                util = nvml.nvmlDeviceGetUtilizationRates(nvml_handle)
                return float(util.gpu)
            else:  # gpu_temp
                return float(nvml.nvmlDeviceGetTemperature(nvml_handle, 0))
        except Exception as e:
            logger.debug("GPU metric read error: %s", e)
            self._gpu_unavailable = True
            return 0.0

    def _read_network_rate(self) -> float:
        import psutil

        counters = psutil.net_io_counters()
        if not counters:
            return 0.0
        current_bytes = counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
        now = time.monotonic()
        if self._prev_net_bytes is None or self._prev_net_time is None:
            self._prev_net_bytes = current_bytes
            self._prev_net_time = now
            return 0.0
        dt = now - self._prev_net_time
        if dt <= 0:
            return 0.0
        # Cap delta time to avoid spikes after long gaps
        dt = min(dt, self._poll_interval * 2)
        rate = (current_bytes - self._prev_net_bytes) / dt
        self._prev_net_bytes = current_bytes
        self._prev_net_time = now
        return max(0.0, rate)

    def update_source(self, source: "ValueSource") -> None:
        from wled_controller.storage.value_source import SystemMetricsValueSource

        if not isinstance(source, SystemMetricsValueSource):
            return
        self._metric = source.metric
        self._min_val = source.min_value
        self._max_val = source.max_value
        self._max_rate = source.max_rate
        self._disk_path = source.disk_path or "/"
        self._sensor_label = source.sensor_label
        self._poll_interval = max(0.1, source.poll_interval)
        self._smoothing = source.smoothing


# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------


class ValueStreamManager:
    """Owns running ValueStream instances, shared and ref-counted by vs_id.

    Value streams produce scalars (not LED-count-dependent), so a single
    stream instance is shared across all consumers that use the same
    ValueSource.  Ref-counting ensures the stream is stopped only when
    the last consumer releases it.
    """

    def __init__(
        self,
        value_source_store: "ValueSourceStore",
        audio_capture_manager: Optional["AudioCaptureManager"] = None,
        audio_source_store: Optional["AudioSourceStore"] = None,
        live_stream_manager: Optional["LiveStreamManager"] = None,
        audio_template_store=None,
        ha_manager: Optional["HomeAssistantManager"] = None,
        css_stream_manager: Optional["ColorStripStreamManager"] = None,
        gradient_store: Optional[Any] = None,
        event_bus: Optional["GameEventBus"] = None,
        audio_processing_template_store=None,
    ):
        self._value_source_store = value_source_store
        self._audio_capture_manager = audio_capture_manager
        self._audio_source_store = audio_source_store
        self._live_stream_manager = live_stream_manager
        self._audio_template_store = audio_template_store
        self._ha_manager = ha_manager
        self._css_stream_manager = css_stream_manager
        self._gradient_store = gradient_store
        self._event_bus = event_bus
        self._audio_processing_template_store = audio_processing_template_store
        self._streams: Dict[str, ValueStream] = {}  # vs_id → stream
        self._ref_counts: Dict[str, int] = {}  # vs_id → ref count

    def acquire(self, vs_id: str) -> ValueStream:
        """Get or create a shared ValueStream for the given ValueSource.

        Increments the ref count.  The stream is stopped only when all
        consumers have called :meth:`release`.
        """
        if vs_id in self._streams:
            self._ref_counts[vs_id] += 1
            logger.info(f"Shared value stream {vs_id} (refs={self._ref_counts[vs_id]})")
            return self._streams[vs_id]

        source = self._value_source_store.get_source(vs_id)
        stream = self._create_stream(source)
        stream.start()
        self._streams[vs_id] = stream
        self._ref_counts[vs_id] = 1
        logger.info(f"Acquired value stream {vs_id} (type={source.source_type})")
        return stream

    def release(self, vs_id: str) -> None:
        """Decrement ref count; stop the stream when it reaches zero."""
        if vs_id not in self._ref_counts:
            return

        self._ref_counts[vs_id] -= 1
        refs = self._ref_counts[vs_id]

        if refs <= 0:
            stream = self._streams.pop(vs_id, None)
            if stream:
                stream.stop()
            del self._ref_counts[vs_id]
            logger.info(f"Released value stream {vs_id} (last ref)")
        else:
            logger.info(f"Released ref for value stream {vs_id} (refs={refs})")

    def update_source(self, vs_id: str) -> None:
        """Hot-update the shared stream for the given ValueSource."""
        try:
            source = self._value_source_store.get_source(vs_id)
        except ValueError as e:
            logger.debug("Value source %s not found for hot-update: %s", vs_id, e)
            return

        stream = self._streams.get(vs_id)
        if stream:
            stream.update_source(source)
            logger.debug(f"Updated value stream {vs_id}")

    def refresh_audio_filter_pipelines(self, template_id: str) -> None:
        """Rebuild audio filter pipelines for any running AudioValueStream
        that references the given audio processing template ID.

        Called when an audio processing template is updated or deleted.
        """
        count = 0
        for stream in self._streams.values():
            if isinstance(stream, AudioValueStream):
                if template_id in getattr(stream, "_audio_processing_template_ids", []):
                    stream._rebuild_filter_pipeline()
                    count += 1
        if count:
            logger.info(
                "Refreshed audio filter pipeline for %d value stream(s) after template %s update",
                count,
                template_id,
            )

    def release_all(self) -> None:
        """Stop and remove all managed streams. Called on shutdown."""
        for vs_id, stream in self._streams.items():
            try:
                stream.stop()
            except Exception as e:
                logger.error(f"Error stopping value stream {vs_id}: {e}")
        self._streams.clear()
        self._ref_counts.clear()
        logger.info("Released all value streams")

    def _create_stream(self, source: "ValueSource") -> ValueStream:
        """Factory: create the appropriate ValueStream for a ValueSource."""
        from wled_controller.storage.value_source import (
            AdaptiveValueSource,
            AnimatedValueSource,
            AudioValueSource,
            CSSExtractValueSource,
            DaylightValueSource,
            GameEventValueSource,
            GradientMapValueSource,
            HAEntityValueSource,
            StaticValueSource,
            StaticColorValueSource,
            AnimatedColorValueSource,
            AdaptiveTimeColorValueSource,
            SystemMetricsValueSource,
        )

        if isinstance(source, StaticValueSource):
            return StaticValueStream(value=source.value)

        if isinstance(source, AnimatedValueSource):
            return AnimatedValueStream(
                waveform=source.waveform,
                speed=source.speed,
                min_value=source.min_value,
                max_value=source.max_value,
            )

        if isinstance(source, AudioValueSource):
            return AudioValueStream(
                audio_source_id=source.audio_source_id,
                mode=source.mode,
                sensitivity=source.sensitivity,
                smoothing=source.smoothing,
                min_value=source.min_value,
                max_value=source.max_value,
                auto_gain=source.auto_gain,
                audio_capture_manager=self._audio_capture_manager,
                audio_source_store=self._audio_source_store,
                audio_template_store=self._audio_template_store,
                audio_processing_template_store=self._audio_processing_template_store,
            )

        if isinstance(source, DaylightValueSource):
            return DaylightValueStream(
                speed=source.speed,
                use_real_time=source.use_real_time,
                latitude=source.latitude,
                min_value=source.min_value,
                max_value=source.max_value,
            )

        if isinstance(source, AdaptiveValueSource):
            if source.source_type == "adaptive_scene":
                return SceneValueStream(
                    picture_source_id=source.picture_source_id,
                    scene_behavior=source.scene_behavior,
                    sensitivity=source.sensitivity,
                    smoothing=source.smoothing,
                    min_value=source.min_value,
                    max_value=source.max_value,
                    live_stream_manager=self._live_stream_manager,
                )
            return TimeOfDayValueStream(
                schedule=source.schedule,
                min_value=source.min_value,
                max_value=source.max_value,
            )

        # Color streams
        if isinstance(source, StaticColorValueSource):
            return StaticColorValueStream(color=source.color)

        if isinstance(source, AnimatedColorValueSource):
            return AnimatedColorValueStream(
                colors=source.colors,
                speed=source.speed,
                easing=source.easing,
            )

        if isinstance(source, AdaptiveTimeColorValueSource):
            return AdaptiveTimeColorValueStream(schedule=source.schedule)

        if isinstance(source, HAEntityValueSource):
            return HAEntityValueStream(
                ha_source_id=source.ha_source_id,
                entity_id=source.entity_id,
                attribute=source.attribute,
                min_ha_value=source.min_ha_value,
                max_ha_value=source.max_ha_value,
                smoothing=source.smoothing,
                ha_manager=self._ha_manager,
            )

        if isinstance(source, GradientMapValueSource):
            return GradientMapValueStream(
                value_source_id=source.value_source_id,
                gradient_id=source.gradient_id,
                easing=source.easing,
                value_stream_manager=self,
                gradient_store=self._gradient_store,
            )

        if isinstance(source, CSSExtractValueSource):
            return CSSExtractValueStream(
                color_strip_source_id=source.color_strip_source_id,
                led_start=source.led_start,
                led_end=source.led_end,
                css_stream_manager=self._css_stream_manager,
            )

        if isinstance(source, SystemMetricsValueSource):
            return SystemMetricsValueStream(
                metric=source.metric,
                min_value=source.min_value,
                max_value=source.max_value,
                max_rate=source.max_rate,
                disk_path=source.disk_path,
                sensor_label=source.sensor_label,
                poll_interval=source.poll_interval,
                smoothing=source.smoothing,
            )

        if isinstance(source, GameEventValueSource):
            from wled_controller.core.value_sources.game_event_value_source import (
                GameEventValueStream,
            )

            return GameEventValueStream(
                event_type=source.event_type,
                min_game_value=source.min_game_value,
                max_game_value=source.max_game_value,
                smoothing=source.smoothing,
                default_value=source.default_value,
                timeout=source.timeout,
                event_bus=self._event_bus,
            )

        # Fallback
        return StaticValueStream(value=1.0)
