"""Gradient color strip stream — pre-computed gradient with optional animation."""

import math
import threading
import time
from typing import Optional

import numpy as np

from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer

from .base import ColorStripStream, _gradient_noise
from .helpers import _compute_gradient_colors

logger = get_logger(__name__)


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 ledgrab.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

        limiter = FrameLimiter(self._fps)

        try:
            with high_resolution_timer():
                while self._running:
                    limiter.begin()
                    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}")

                    sleep_target = frame_time if anim and anim.get("enabled") else 0.25
                    limiter.wait(sleep_target)
        except Exception as e:
            logger.error(f"Fatal GradientColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False
