"""Notification color strip stream — fires one-shot visual alerts.

Renders brief animated LED effects (flash, pulse, sweep) triggered via the
fire() method. When idle, outputs black. Thread-safe: fire() can be called
from any thread (REST handler) while get_latest_colors() is called from the
target processor thread.

Uses a background render loop at 30 FPS with double-buffered output.
Optionally plays a notification sound via the asset store and sound player.
"""

import collections
import math
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.storage.bindable import bfloat
from wled_controller.utils import get_logger

logger = get_logger(__name__)


def _hex_to_rgb(hex_str: str) -> tuple:
    """Convert a '#RRGGBB' hex string to an (R, G, B) tuple.

    Returns (255, 255, 255) for invalid input.
    """
    try:
        h = hex_str.lstrip("#")
        if len(h) != 6:
            return (255, 255, 255)
        return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
    except (ValueError, TypeError):
        return (255, 255, 255)


class NotificationColorStripStream(ColorStripStream):
    """Color strip stream that fires one-shot visual alerts.

    Supports three notification effects:
    - flash: linear fade from full brightness to zero
    - pulse: smooth bell curve (sin)
    - sweep: fill LEDs left-to-right, then fade out

    Uses collections.deque for thread-safe event passing and threading.Lock
    for the output color buffer.
    """

    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

        # Event queue: deque of (color_rgb_tuple, start_time)
        self._event_queue: collections.deque = collections.deque(maxlen=16)

        # Active effect state
        self._active_effect: Optional[dict] = None  # {"color": (r,g,b), "start": float}

        # Asset store for resolving sound file paths (injected via set_asset_store)
        self._asset_store = None

        self._update_from_source(source)

    def set_asset_store(self, asset_store) -> None:
        """Inject asset store for resolving notification sound file paths."""
        self._asset_store = asset_store

    def _update_from_source(self, source) -> None:
        """Parse config from source dataclass."""
        self._notification_effect = getattr(source, "notification_effect", "flash")
        self._duration_ms = max(100, int(bfloat(getattr(source, "duration_ms", 1500), 1500)))
        from wled_controller.storage.bindable import bcolor

        self._default_color = bcolor(getattr(source, "default_color", None), [255, 255, 255])
        self._app_colors = {
            k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()
        }
        self._app_filter_mode = getattr(source, "app_filter_mode", "off")
        self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
        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
        )
        # Sound config
        self._sound_asset_id = getattr(source, "sound_asset_id", None)
        self._sound_volume = bfloat(getattr(source, "sound_volume", 1.0), 1.0)
        raw_app_sounds = dict(getattr(source, "app_sounds", {}))
        self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
        with self._colors_lock:
            self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8)

    def fire(self, app_name: str = None, color_override: str = None) -> bool:
        """Trigger a notification effect. Thread-safe.

        Args:
            app_name: Optional app name for color lookup and filtering.
            color_override: Optional hex color override (#RRGGBB).

        Returns:
            True if the notification was accepted, False if filtered out.
        """
        # Normalize app name for case-insensitive matching
        app_lower = app_name.lower() if app_name else None

        # Check app filter
        if app_lower and self._app_filter_mode != "off":
            in_list = app_lower in self._app_filter_list
            if self._app_filter_mode == "whitelist" and not in_list:
                return False
            if self._app_filter_mode == "blacklist" and in_list:
                return False

        # Resolve color: override > app_colors[app_name] > default_color
        if color_override:
            color = _hex_to_rgb(color_override)
        elif app_lower and app_lower in self._app_colors:
            color = _hex_to_rgb(self._app_colors[app_lower])
        else:
            color = self.resolve_color("default_color", self._default_color)

        # Push event to queue (thread-safe deque.append)
        # Priority: 0 = normal, 1 = high (high interrupts current effect)
        priority = 1 if color_override else 0
        self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})

        # Play notification sound
        self._play_notification_sound(app_lower)

        return True

    def _play_notification_sound(self, app_lower: Optional[str]) -> None:
        """Resolve and play the notification sound for the given app."""
        if self._asset_store is None:
            return

        # Resolve sound: per-app override > global sound_asset_id
        sound_asset_id = None
        volume = self.resolve("sound_volume", self._sound_volume)

        if app_lower and app_lower in self._app_sounds:
            override = self._app_sounds[app_lower]
            if isinstance(override, dict):
                # sound_asset_id=None in override means mute this app
                if "sound_asset_id" not in override:
                    # No override entry, fall through to global
                    sound_asset_id = self._sound_asset_id
                else:
                    sound_asset_id = override.get("sound_asset_id")
                    if sound_asset_id is None:
                        return  # Muted for this app
                override_volume = override.get("volume")
                if override_volume is not None:
                    volume = float(override_volume)
        else:
            sound_asset_id = self._sound_asset_id

        if not sound_asset_id:
            return

        file_path = self._asset_store.get_file_path(sound_asset_id)
        if file_path is None:
            logger.debug(f"Sound asset not found: {sound_asset_id}")
            return

        try:
            from wled_controller.utils.sound_player import play_sound_async

            play_sound_async(file_path, volume=volume)
        except Exception as e:
            logger.error(f"Failed to play notification sound: {e}")

    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"NotificationColorStripStream 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._thread = threading.Thread(
            target=self._render_loop,
            name="css-notification",
            daemon=True,
        )
        self._thread.start()
        logger.info(f"NotificationColorStripStream 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(
                    "NotificationColorStripStream render thread did not terminate within 5s"
                )
            self._thread = None
        logger.info("NotificationColorStripStream 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 NotificationColorStripSource

        if isinstance(source, NotificationColorStripSource):
            prev_led_count = self._led_count if self._auto_size else None
            self._update_from_source(source)
            if prev_led_count and self._auto_size:
                self._led_count = prev_led_count
                with self._colors_lock:
                    self._colors = np.zeros((self._led_count, 3), dtype=np.uint8)
            logger.info("NotificationColorStripStream params updated in-place")

    def set_clock(self, clock) -> None:
        """Set or clear the sync clock runtime (not used, but required for interface)."""
        pass  # Notification stream does not use sync clocks

    # ── 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 — high priority interrupts 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.resolve("duration_ms", self._duration_ms)
                        progress = min(elapsed_ms / duration_ms, 1.0)

                        if progress >= 1.0:
                            # Effect complete, output black
                            self._active_effect = None
                            buf[:] = 0
                        else:
                            self._render_effect(buf, n, color, progress)
                    else:
                        # Idle: output black
                        buf[:] = 0

                    with self._colors_lock:
                        self._colors = buf

                except Exception as e:
                    logger.error(f"NotificationColorStripStream 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 NotificationColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False

    def _render_effect(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
        """Dispatch to the appropriate effect renderer."""
        effect = self._notification_effect
        if effect == "pulse":
            self._render_pulse(buf, n, color, progress)
        elif effect == "sweep":
            self._render_sweep(buf, n, color, progress)
        elif effect == "chase":
            self._render_chase(buf, n, color, progress)
        elif effect == "gradient_flash":
            self._render_gradient_flash(buf, n, color, progress)
        else:
            # Default: flash
            self._render_flash(buf, n, color, progress)

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

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

    def _render_sweep(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
        """Sweep effect: fill LEDs left-to-right, then fade all.

        First half: progressively fill LEDs from left to right.
        Second half: fade all filled LEDs from full to zero.
        """
        if progress < 0.5:
            # Fill phase: progress 0→0.5 maps to fill_pos 0→n
            fill_progress = progress * 2.0
            fill_pos = int(fill_progress * n)
            buf[:] = 0
            if fill_pos > 0:
                buf[:fill_pos, 0] = color[0]
                buf[:fill_pos, 1] = color[1]
                buf[:fill_pos, 2] = color[2]
        else:
            # Fade phase: progress 0.5→1.0 maps to brightness 1→0
            fade_progress = (progress - 0.5) * 2.0
            brightness = max(0.0, 1.0 - fade_progress)
            r = int(color[0] * brightness)
            g = int(color[1] * brightness)
            b = int(color[2] * brightness)
            buf[:, 0] = r
            buf[:, 1] = g
            buf[:, 2] = b

    def _render_chase(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
        """Chase effect: light travels across strip and bounces back.

        First half: light moves left-to-right with a glowing tail.
        Second half: light moves right-to-left back to start.
        Overall brightness fades as progress approaches 1.0.
        """
        buf[:] = 0
        if n <= 0:
            return

        # Position: bounce (0→n→0)
        if progress < 0.5:
            pos = progress * 2.0 * (n - 1)
        else:
            pos = (1.0 - (progress - 0.5) * 2.0) * (n - 1)

        # Overall fade
        fade = max(0.0, 1.0 - progress * 0.6)

        # Glow radius: ~5% of strip, minimum 2 LEDs
        radius = max(2.0, n * 0.05)

        for i in range(n):
            dist = abs(i - pos)
            if dist < radius * 3:
                glow = math.exp(-0.5 * (dist / radius) ** 2) * fade
                buf[i, 0] = min(255, int(color[0] * glow))
                buf[i, 1] = min(255, int(color[1] * glow))
                buf[i, 2] = min(255, int(color[2] * glow))

    def _render_gradient_flash(
        self, buf: np.ndarray, n: int, color: tuple, progress: float
    ) -> None:
        """Gradient flash: bright center fades to edges, then all fades out.

        Creates a gradient from the notification color at center to darker
        edges, with overall brightness fading over the duration.
        """
        if n <= 0:
            return

        # Overall brightness envelope: quick attack, exponential decay
        if progress < 0.1:
            brightness = progress / 0.1
        else:
            brightness = math.exp(-3.0 * (progress - 0.1))

        # Center-to-edge gradient
        center = n / 2.0
        max_dist = center if center > 0 else 1.0

        for i in range(n):
            dist = abs(i - center) / max_dist
            # Smooth falloff from center
            edge_factor = 1.0 - dist * 0.6
            b_final = brightness * edge_factor
            buf[i, 0] = min(255, int(color[0] * b_final))
            buf[i, 1] = min(255, int(color[1] * b_final))
            buf[i, 2] = min(255, int(color[2] * b_final))
