"""API Input color strip stream — receives raw LED colors from external clients.

External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`.

Thread-safe: push_colors() / push_segments() can be called from any thread
(REST handler, WebSocket handler) while get_latest_colors() is called from
the target processor thread.
"""

import threading
import time
from typing import Optional

import numpy as np

from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger

logger = get_logger(__name__)

_DEFAULT_LED_COUNT = 150


class ApiInputColorStripStream(ColorStripStream):
    """Color strip stream backed by externally-pushed LED color data.

    Holds a thread-safe np.ndarray buffer. External clients push colors via
    push_colors() or push_segments(). A background thread checks for timeout
    and reverts to fallback_color when no data arrives within the configured
    timeout window.
    """

    def __init__(self, source):
        """
        Args:
            source: ApiInputColorStripSource config
        """
        self._lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._fps = 30

        # Parse config
        fallback = source.fallback_color
        self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
        self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
        self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
        self._led_count = _DEFAULT_LED_COUNT

        # Build initial fallback buffer
        self._fallback_array = self._build_fallback(self._led_count)
        self._colors = self._fallback_array.copy()
        self._last_push_time: float = 0.0
        self._timed_out = True  # Start in timed-out state
        self._push_generation: int = 0  # Incremented on each push; used by test WS

    def _build_fallback(self, led_count: int) -> np.ndarray:
        """Build a (led_count, 3) uint8 array filled with fallback_color."""
        return np.tile(
            np.array(self._fallback_color, dtype=np.uint8),
            (led_count, 1),
        )

    def _ensure_capacity(self, required: int) -> None:
        """Grow the buffer to at least `required` LEDs (must be called under lock)."""
        if required > self._led_count:
            self._led_count = required
            self._fallback_array = self._build_fallback(self._led_count)
            # Preserve existing data if not timed out
            if not self._timed_out:
                new_buf = self._fallback_array.copy()
                old_len = min(len(self._colors), required)
                new_buf[:old_len] = self._colors[:old_len]
                self._colors = new_buf
            else:
                self._colors = self._fallback_array.copy()
            logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")

    def _resize(self, colors: np.ndarray, target_count: int) -> np.ndarray:
        """Resize colors array to target_count using the configured interpolation.

        Args:
            colors: np.ndarray shape (N, 3) uint8
            target_count: desired LED count

        Returns:
            np.ndarray shape (target_count, 3) uint8
        """
        n = len(colors)
        if n == target_count:
            return colors

        if self._interpolation == "none":
            # Truncate or zero-pad (legacy behavior)
            result = np.zeros((target_count, 3), dtype=np.uint8)
            copy_len = min(n, target_count)
            result[:copy_len] = colors[:copy_len]
            return result

        if self._interpolation == "nearest":
            indices = np.round(np.linspace(0, n - 1, target_count)).astype(int)
            return colors[indices].copy()

        # linear (default)
        src_positions = np.linspace(0, 1, n)
        dst_positions = np.linspace(0, 1, target_count)
        result = np.empty((target_count, 3), dtype=np.uint8)
        for ch in range(3):
            result[:, ch] = np.interp(dst_positions, src_positions, colors[:, ch].astype(np.float32)).astype(np.uint8)
        return result

    def push_colors(self, colors: np.ndarray) -> None:
        """Push a new frame of LED colors.

        Thread-safe. When the incoming LED count differs from the device
        LED count, the data is resized according to the configured
        interpolation mode (none/linear/nearest).

        Args:
            colors: np.ndarray shape (N, 3) uint8
        """
        with self._lock:
            n = len(colors)
            if n == self._led_count:
                if self._colors.shape == colors.shape:
                    np.copyto(self._colors, colors, casting='unsafe')
                else:
                    self._colors = np.empty((n, 3), dtype=np.uint8)
                    np.copyto(self._colors, colors, casting='unsafe')
            else:
                self._colors = self._resize(colors, self._led_count)
            self._last_push_time = time.monotonic()
            self._push_generation += 1
            self._timed_out = False

    def push_segments(self, segments: list) -> None:
        """Apply segment-based color updates to the buffer.

        Each segment defines a range and fill mode. Segments are applied in
        order (last wins on overlap). The buffer is auto-grown if needed.

        Args:
            segments: list of dicts with keys:
                start  (int)  – starting LED index
                length (int)  – number of LEDs in segment
                mode   (str)  – "solid" | "per_pixel" | "gradient"
                color  (list) – [R,G,B] for solid mode
                colors (list) – [[R,G,B], ...] for per_pixel/gradient
        """
        # Compute required buffer size from all segments
        max_index = max(seg["start"] + seg["length"] for seg in segments)

        with self._lock:
            # Auto-grow buffer if needed
            if max_index > self._led_count:
                self._ensure_capacity(max_index)

            # Start from current buffer (or fallback if timed out)
            if self._timed_out:
                buf = self._fallback_array.copy()
            else:
                buf = self._colors.copy()

            for seg in segments:
                start = seg["start"]
                length = seg["length"]
                mode = seg["mode"]
                end = start + length

                if mode == "solid":
                    color = np.array(seg["color"], dtype=np.uint8)
                    buf[start:end] = color

                elif mode == "per_pixel":
                    colors = np.array(seg["colors"], dtype=np.uint8)
                    available = len(colors)
                    if available >= length:
                        buf[start:end] = colors[:length]
                    else:
                        # Pad with zeros if fewer colors than length
                        buf[start:start + available] = colors
                        buf[start + available:end] = 0

                elif mode == "gradient":
                    stops = np.array(seg["colors"], dtype=np.float32)
                    num_stops = len(stops)
                    # Positions of stops evenly spaced 0..length-1
                    stop_positions = np.linspace(0, length - 1, num_stops)
                    pixel_positions = np.arange(length, dtype=np.float32)
                    for ch in range(3):
                        buf[start:end, ch] = np.interp(
                            pixel_positions,
                            stop_positions,
                            stops[:, ch],
                        ).astype(np.uint8)

            self._colors = buf
            self._last_push_time = time.monotonic()
            self._push_generation += 1
            self._timed_out = False

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

        Always resizes the buffer to the device LED count.
        """
        if device_led_count > 0 and device_led_count != self._led_count:
            with self._lock:
                self._led_count = device_led_count
                self._fallback_array = self._build_fallback(device_led_count)
                self._colors = self._fallback_array.copy()
                self._timed_out = True
            logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")

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

    @property
    def is_animated(self) -> bool:
        return True  # Always poll — external data can arrive at any time

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

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._timeout_loop,
            name="css-api-input-timeout",
            daemon=True,
        )
        self._thread.start()
        logger.info(f"ApiInputColorStripStream 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("ApiInputColorStripStream timeout thread did not terminate within 5s")
            self._thread = None
        logger.info("ApiInputColorStripStream stopped")

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

    @property
    def push_generation(self) -> int:
        """Monotonically increasing counter, bumped on each push_colors/push_segments."""
        return self._push_generation

    def update_source(self, source) -> None:
        """Hot-update fallback_color, timeout, and interpolation from updated source config."""
        from wled_controller.storage.color_strip_source import ApiInputColorStripSource
        if isinstance(source, ApiInputColorStripSource):
            fallback = source.fallback_color
            self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
            self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
            self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
            with self._lock:
                self._fallback_array = self._build_fallback(self._led_count)
                if self._timed_out:
                    self._colors = self._fallback_array.copy()
            logger.info("ApiInputColorStripStream params updated in-place")

    def _timeout_loop(self) -> None:
        """Background thread that reverts to fallback on timeout."""
        while self._running:
            time.sleep(0.5)
            if self._timeout <= 0:
                continue
            if self._timed_out:
                continue
            elapsed = time.monotonic() - self._last_push_time
            if elapsed >= self._timeout:
                with self._lock:
                    self._colors = self._fallback_array.copy()
                    self._timed_out = True
                logger.debug("ApiInputColorStripStream timed out, reverted to fallback")
