"""Game event color strip stream — renders LED effects on game events.

Subscribes to a GameEventBus and renders animated LED effects (flash, pulse,
sweep, color_shift, breathing) when matching game events arrive.  When idle,
outputs the configured idle_color.

Thread-safe: event callbacks arrive from the EventBus dispatch thread while
get_latest_colors() is called from the target processor thread.

Uses a background render loop at 30 FPS with double-buffered output.
"""

import collections
import math
import threading
import time
from typing import Optional

import numpy as np

from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bcolor
from wled_controller.storage.game_integration import EventMapping
from wled_controller.utils import get_logger

logger = get_logger(__name__)


class GameEventColorStripStream(ColorStripStream):
    """Color strip stream that renders effects in response to game events.

    Supports five effect types:
    - flash: linear fade from full brightness to zero
    - pulse: smooth bell curve (sin)
    - sweep: fill LEDs left-to-right, then fade out
    - color_shift: gradual hue rotation from the event color
    - breathing: slow sine-wave brightness oscillation

    Uses collections.deque for thread-safe event passing and threading.Lock
    for the output color buffer.  Priority-based layering: higher priority
    effects override lower ones (same as notification stream).
    """

    def __init__(self, source, event_bus: Optional[GameEventBus] = None) -> None:
        self._colors_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._fps = 30
        self._frame_time = 1.0 / 30

        # Event queue: deque of effect dicts
        self._event_queue: collections.deque = collections.deque(maxlen=32)

        # Active effect state
        self._active_effect: Optional[dict] = None

        # EventBus reference and subscription IDs
        self._event_bus = event_bus
        self._subscription_ids: list[str] = []

        self._update_from_source(source)

    def _update_from_source(self, source) -> None:
        """Parse config from source dataclass."""
        self._idle_color = bcolor(getattr(source, "idle_color", None), [0, 0, 0])
        self._game_integration_id = getattr(source, "game_integration_id", "")
        self._auto_size = not getattr(source, "led_count", 0)
        self._led_count = (
            getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
        )

        # Parse event_mappings into lookup dict: event_type -> EventMapping
        self._mapping_lookup: dict[str, EventMapping] = {}
        raw_mappings = getattr(source, "event_mappings", [])
        for m in raw_mappings:
            if isinstance(m, dict):
                try:
                    mapping = EventMapping.from_dict(m)
                    self._mapping_lookup[mapping.event_type] = mapping
                except (KeyError, TypeError):
                    logger.warning(f"Skipping invalid event mapping: {m}")
            elif isinstance(m, EventMapping):
                self._mapping_lookup[m.event_type] = m

        with self._colors_lock:
            idle = self.resolve_color("idle_color", self._idle_color)
            self._colors: Optional[np.ndarray] = np.zeros(
                (self._led_count, 3),
                dtype=np.uint8,
            )
            self._colors[:, 0] = idle[0]
            self._colors[:, 1] = idle[1]
            self._colors[:, 2] = idle[2]

    def set_event_bus(self, event_bus: GameEventBus) -> None:
        """Inject or replace the EventBus (called by stream manager)."""
        self._event_bus = event_bus

    def configure(self, device_led_count: int) -> None:
        """Set LED count from the target device (called on target start)."""
        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
                with self._colors_lock:
                    self._colors = np.zeros((new_count, 3), dtype=np.uint8)
                logger.debug(
                    f"GameEventColorStripStream auto-sized to {new_count} LEDs",
                )

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

    def set_capture_fps(self, fps: int) -> None:
        self._fps = max(1, min(90, fps))
        self._frame_time = 1.0 / self._fps

    @property
    def is_animated(self) -> bool:
        return True

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

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._subscribe_to_events()
        self._thread = threading.Thread(
            target=self._render_loop,
            name="css-game-event",
            daemon=True,
        )
        self._thread.start()
        logger.info(
            f"GameEventColorStripStream started "
            f"(leds={self._led_count}, mappings={len(self._mapping_lookup)})",
        )

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

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

    def update_source(self, source) -> None:
        """Hot-update config from updated source."""
        from wled_controller.storage.color_strip_source import GameEventColorStripSource

        if isinstance(source, GameEventColorStripSource):
            prev_led_count = self._led_count if self._auto_size else None
            was_running = self._running

            # Re-subscribe if integration changed
            old_integration = self._game_integration_id
            self._update_from_source(source)

            if prev_led_count and self._auto_size:
                self._led_count = prev_led_count
                with self._colors_lock:
                    self._colors = np.zeros((self._led_count, 3), dtype=np.uint8)

            # If mappings changed, re-subscribe
            if was_running and old_integration != self._game_integration_id:
                self._unsubscribe_from_events()
                self._subscribe_to_events()

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

    def set_clock(self, clock) -> None:
        """Set or clear the sync clock runtime (not used for game events)."""
        pass

    # ── EventBus subscription ────────────────────────────────────────

    def _subscribe_to_events(self) -> None:
        """Subscribe to EventBus for all mapped event types."""
        if not self._event_bus:
            logger.warning(
                "GameEventColorStripStream: no EventBus available, "
                "events will not trigger effects",
            )
            return

        if not self._mapping_lookup:
            return

        for event_type in self._mapping_lookup:
            sub_id = self._event_bus.subscribe(event_type, self._on_game_event)
            self._subscription_ids.append(sub_id)

        logger.debug(
            f"GameEventColorStripStream subscribed to {len(self._subscription_ids)} "
            f"event types",
        )

    def _unsubscribe_from_events(self) -> None:
        """Unsubscribe all active subscriptions."""
        if self._event_bus:
            for sub_id in self._subscription_ids:
                self._event_bus.unsubscribe(sub_id)
        self._subscription_ids.clear()

    def _on_game_event(self, event: GameEvent) -> None:
        """Callback from EventBus — enqueue an effect (thread-safe)."""
        mapping = self._mapping_lookup.get(event.event_type)
        if mapping is None:
            return

        color = tuple(mapping.color)
        self._event_queue.append(
            {
                "color": color,
                "start": time.monotonic(),
                "priority": mapping.priority,
                "effect": mapping.effect,
                "duration_ms": mapping.duration_ms,
                "intensity": mapping.intensity,
            }
        )

    # ── Render loop ──────────────────────────────────────────────────

    def _render_loop(self) -> None:
        """Background thread rendering at 30 FPS."""
        _pool_n = 0
        _buf_a = _buf_b = None
        _use_a = True

        try:
            while self._running:
                wall_start = time.perf_counter()
                frame_time = self._frame_time

                try:
                    # Check for new events — higher priority overrides current
                    while self._event_queue:
                        try:
                            event = self._event_queue.popleft()
                            if self._active_effect is None or event.get(
                                "priority", 0
                            ) >= self._active_effect.get("priority", 0):
                                self._active_effect = event
                        except IndexError:
                            break

                    n = self._led_count

                    # Reallocate buffers if LED count changed
                    if n != _pool_n:
                        _pool_n = n
                        _buf_a = np.zeros((n, 3), dtype=np.uint8)
                        _buf_b = np.zeros((n, 3), dtype=np.uint8)

                    buf = _buf_a if _use_a else _buf_b
                    _use_a = not _use_a

                    if self._active_effect is not None:
                        color = self._active_effect["color"]
                        start_time = self._active_effect["start"]
                        elapsed_ms = (time.monotonic() - start_time) * 1000.0
                        duration_ms = self._active_effect.get("duration_ms", 500)
                        progress = min(elapsed_ms / max(duration_ms, 1), 1.0)

                        if progress >= 1.0:
                            # Effect complete — return to idle
                            self._active_effect = None
                            idle = self.resolve_color("idle_color", self._idle_color)
                            buf[:, 0] = idle[0]
                            buf[:, 1] = idle[1]
                            buf[:, 2] = idle[2]
                        else:
                            intensity = self._active_effect.get("intensity", 1.0)
                            self._render_effect(
                                buf,
                                n,
                                color,
                                progress,
                                self._active_effect.get("effect", "flash"),
                                intensity,
                            )
                    else:
                        # Idle: output idle_color
                        idle = self.resolve_color("idle_color", self._idle_color)
                        buf[:, 0] = idle[0]
                        buf[:, 1] = idle[1]
                        buf[:, 2] = idle[2]

                    with self._colors_lock:
                        self._colors = buf

                except Exception as e:
                    logger.error(f"GameEventColorStripStream render error: {e}")

                elapsed = time.perf_counter() - wall_start
                time.sleep(max(frame_time - elapsed, 0.001))

        except Exception as e:
            logger.error(
                f"Fatal GameEventColorStripStream loop error: {e}",
                exc_info=True,
            )
        finally:
            self._running = False

    # ── Effect renderers ─────────────────────────────────────────────

    def _render_effect(
        self,
        buf: np.ndarray,
        n: int,
        color: tuple,
        progress: float,
        effect: str,
        intensity: float,
    ) -> None:
        """Dispatch to the appropriate effect renderer."""
        if effect == "pulse":
            self._render_pulse(buf, n, color, progress, intensity)
        elif effect == "sweep":
            self._render_sweep(buf, n, color, progress, intensity)
        elif effect == "color_shift":
            self._render_color_shift(buf, n, color, progress, intensity)
        elif effect == "breathing":
            self._render_breathing(buf, n, color, progress, intensity)
        else:
            # Default: flash
            self._render_flash(buf, n, color, progress, intensity)

    def _render_flash(
        self,
        buf: np.ndarray,
        n: int,
        color: tuple,
        progress: float,
        intensity: float,
    ) -> None:
        """Flash effect: linear fade from full brightness to zero."""
        brightness = max(0.0, (1.0 - progress) * intensity)
        buf[:, 0] = int(color[0] * brightness)
        buf[:, 1] = int(color[1] * brightness)
        buf[:, 2] = int(color[2] * brightness)

    def _render_pulse(
        self,
        buf: np.ndarray,
        n: int,
        color: tuple,
        progress: float,
        intensity: float,
    ) -> None:
        """Pulse effect: smooth bell curve (sin)."""
        brightness = math.sin(progress * math.pi) * intensity
        buf[:, 0] = int(color[0] * brightness)
        buf[:, 1] = int(color[1] * brightness)
        buf[:, 2] = int(color[2] * brightness)

    def _render_sweep(
        self,
        buf: np.ndarray,
        n: int,
        color: tuple,
        progress: float,
        intensity: float,
    ) -> None:
        """Sweep effect: fill LEDs left-to-right, then fade all."""
        if progress < 0.5:
            fill_progress = progress * 2.0
            fill_pos = int(fill_progress * n)
            buf[:] = 0
            if fill_pos > 0:
                buf[:fill_pos, 0] = int(color[0] * intensity)
                buf[:fill_pos, 1] = int(color[1] * intensity)
                buf[:fill_pos, 2] = int(color[2] * intensity)
        else:
            fade_progress = (progress - 0.5) * 2.0
            brightness = max(0.0, (1.0 - fade_progress) * intensity)
            buf[:, 0] = int(color[0] * brightness)
            buf[:, 1] = int(color[1] * brightness)
            buf[:, 2] = int(color[2] * brightness)

    def _render_color_shift(
        self,
        buf: np.ndarray,
        n: int,
        color: tuple,
        progress: float,
        intensity: float,
    ) -> None:
        """Color shift effect: gradual hue rotation from the event color.

        Rotates the hue by up to 180 degrees over the effect duration while
        fading brightness with intensity.
        """
        import colorsys

        r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
        h, s, v = colorsys.rgb_to_hsv(r, g, b)
        # Rotate hue by up to 0.5 (180 degrees)
        shifted_h = (h + progress * 0.5) % 1.0
        # Fade brightness over time
        shifted_v = v * intensity * max(0.0, 1.0 - progress * 0.5)
        sr, sg, sb = colorsys.hsv_to_rgb(shifted_h, s, shifted_v)
        buf[:, 0] = min(255, int(sr * 255))
        buf[:, 1] = min(255, int(sg * 255))
        buf[:, 2] = min(255, int(sb * 255))

    def _render_breathing(
        self,
        buf: np.ndarray,
        n: int,
        color: tuple,
        progress: float,
        intensity: float,
    ) -> None:
        """Breathing effect: slow sine-wave brightness oscillation.

        Performs two full breathing cycles over the effect duration.
        """
        # Two full sin cycles over the duration
        brightness = (math.sin(progress * 4 * math.pi - math.pi / 2) + 1.0) / 2.0
        brightness *= intensity
        buf[:, 0] = int(color[0] * brightness)
        buf[:, 1] = int(color[1] * brightness)
        buf[:, 2] = int(color[2] * brightness)
