"""OpenRGB LED client — controls RGB peripherals via the OpenRGB SDK server."""

import asyncio
import socket
import struct
import threading
from datetime import datetime, timezone
from typing import Any, List, Optional, Tuple, Union

import numpy as np

from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger

logger = get_logger(__name__)


def parse_openrgb_url(url: str) -> Tuple[str, int, int, List[str]]:
    """Parse an openrgb:// URL into (host, port, device_index, zone_names).

    Format: openrgb://host:port/device_index[/zone1+zone2+...]
    Defaults: host=localhost, port=6742, device_index=0, zone_names=[]

    When *zone_names* is non-empty, only LEDs in those zones are addressed.
    Multiple zones are separated by ``+``.
    """
    zones_str: Optional[str] = None

    if url.startswith("openrgb://"):
        url = url[len("openrgb://"):]
    else:
        return ("localhost", 6742, 0, [])

    # Split path from host:port
    if "/" in url:
        host_port, path = url.split("/", 1)
        # path may be "0" or "0/JRAINBOW2" or "0/JRAINBOW1+JRAINBOW2"
        if "/" in path:
            index_str, zones_str = path.split("/", 1)
            zones_str = zones_str.strip() or None
        else:
            index_str = path
        try:
            device_index = int(index_str)
        except ValueError:
            device_index = 0
    else:
        host_port = url
        device_index = 0

    # Split host and port
    if ":" in host_port:
        host, port_str = host_port.rsplit(":", 1)
        try:
            port = int(port_str)
        except ValueError:
            port = 6742
    else:
        host = host_port if host_port else "localhost"
        port = 6742

    zone_names: List[str] = []
    if zones_str:
        zone_names = [z.strip() for z in zones_str.split("+") if z.strip()]

    return (host, port, device_index, zone_names)


class OpenRGBLEDClient(LEDClient):
    """Controls an OpenRGB device via the openrgb-python library.

    The OpenRGB SDK server (TCP port 6742) exposes PC peripherals such as
    keyboards, mice, RAM, GPU, and fans for live RGB control.

    Uses asyncio.to_thread() for blocking library calls in connect/close,
    and direct synchronous calls for send_pixels_fast (runs in processing thread).
    """

    def __init__(self, url: str, **kwargs):
        self._url = url
        host, port, device_index, zone_names = parse_openrgb_url(url)
        self._host = host
        self._port = port
        self._device_index = device_index
        self._zone_filters: List[str] = zone_names  # e.g. ["JRAINBOW2"]
        self._zone_mode: str = kwargs.pop("zone_mode", "combined")
        self._client: Any = None  # openrgb.OpenRGBClient
        self._device: Any = None  # openrgb.Device
        self._connected = False
        self._device_name: Optional[str] = None
        self._device_led_count: Optional[int] = None

        # Background sender thread — decouples processing loop from blocking TCP writes
        self._send_lock = threading.Lock()
        self._send_event = threading.Event()
        self._send_pending: Optional[Tuple[np.ndarray, int]] = None  # (pixels, brightness)
        self._send_thread: Optional[threading.Thread] = None
        self._send_stop = threading.Event()
        self._last_sent_pixels: Optional[np.ndarray] = None

    async def connect(self) -> bool:
        """Connect to OpenRGB server and access the target device."""
        try:
            self._client, self._device = await asyncio.to_thread(self._connect_sync)
            self._connected = True
            self._device_name = self._device.name

            # Resolve zone filter → target zones list
            all_zone_info = ", ".join(
                f"{z.name}({len(z.leds)})" for z in self._device.zones
            )
            if self._zone_filters:
                # Case-insensitive match by name(s)
                filt_set = {n.lower() for n in self._zone_filters}
                self._target_zones = [
                    z for z in self._device.zones
                    if z.name.lower() in filt_set
                ]
                if not self._target_zones:
                    avail = [z.name for z in self._device.zones]
                    raise ValueError(
                        f"Zone(s) {self._zone_filters} not found on device "
                        f"'{self._device.name}'. Available zones: {avail}"
                    )
            else:
                self._target_zones = list(self._device.zones)

            self._zone_sizes: List[int] = [len(z.leds) for z in self._target_zones]
            self._device_led_count = sum(self._zone_sizes)

            target_info = ", ".join(
                f"{z.name}({len(z.leds)})" for z in self._target_zones
            )
            # Optimize socket: disable Nagle, reduce timeout
            try:
                sock = self._client.comms.sock
                sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
                sock.settimeout(2.0)
            except Exception as e:
                logger.debug(f"Could not optimize OpenRGB socket: {e}")

            # Cache zone IDs for direct packet construction
            self._zone_ids: List[int] = [z.id for z in self._target_zones]

            logger.info(
                f"Connected to OpenRGB device '{self._device_name}' "
                f"(all zones: {all_zone_info}) "
                f"targeting {target_info} = {self._device_led_count} LEDs "
                f"at {self._host}:{self._port}/{self._device_index}"
            )

            # Start background sender thread
            self._send_stop.clear()
            self._send_thread = threading.Thread(
                target=self._sender_loop, daemon=True,
                name=f"orgb-send-{self._host}:{self._port}/{self._device_index}",
            )
            self._send_thread.start()

            return True
        except Exception as e:
            self._connected = False
            raise ConnectionError(f"Failed to connect to OpenRGB: {e}") from e

    def _connect_sync(self) -> Tuple[Any, Any]:
        """Synchronous connect — runs in thread pool."""
        from openrgb import OpenRGBClient

        client = OpenRGBClient(self._host, self._port, name="WLED Controller")
        devices = client.devices
        if self._device_index >= len(devices):
            client.disconnect()
            raise ValueError(
                f"Device index {self._device_index} out of range "
                f"(server has {len(devices)} device(s))"
            )
        device = devices[self._device_index]
        # Set direct mode for live control (try direct first, fall back)
        try:
            device.set_mode("direct")
        except Exception:
            try:
                # Some devices use different mode names
                for mode in device.modes:
                    if mode.name.lower() in ("direct", "static", "custom"):
                        device.set_mode(mode.id)
                        break
            except Exception as e:
                logger.warning(f"Could not set direct mode on '{device.name}': {e}")
        return client, device

    async def close(self) -> None:
        """Disconnect from the OpenRGB server."""
        # Stop sender thread first
        self._send_stop.set()
        self._send_event.set()  # wake it so it can exit
        if self._send_thread and self._send_thread.is_alive():
            self._send_thread.join(timeout=2.0)
        self._send_thread = None

        if self._client is not None:
            try:
                await asyncio.to_thread(self._client.disconnect)
            except Exception as e:
                logger.debug(f"Error disconnecting OpenRGB: {e}")
            finally:
                self._client = None
                self._device = None
                self._connected = False

    @property
    def is_connected(self) -> bool:
        return self._connected and self._client is not None

    @property
    def device_led_count(self) -> Optional[int]:
        return self._device_led_count

    async def send_pixels(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> bool:
        """Send pixel colors to the OpenRGB device (async wrapper)."""
        if not self.is_connected:
            return False
        try:
            await asyncio.to_thread(self.send_pixels_fast, pixels, brightness)
            return True
        except Exception as e:
            logger.error(f"OpenRGB send_pixels failed: {e}")
            self._connected = False
            return False

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

    def send_pixels_fast(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> None:
        """Non-blocking fire-and-forget send for the processing hot loop.

        Enqueues the latest frame into a single-slot buffer and returns
        immediately.  A background thread picks it up and does the blocking
        TCP write to the OpenRGB server, so the asyncio event loop is never
        stalled by slow socket operations.
        """
        if not self.is_connected or self._device is None:
            return

        if isinstance(pixels, np.ndarray):
            pixel_array = pixels.copy()
        else:
            pixel_array = np.array(pixels, dtype=np.uint8)

        with self._send_lock:
            self._send_pending = (pixel_array, brightness)
        self._send_event.set()

    def _sender_loop(self) -> None:
        """Background thread that drains the single-slot buffer."""
        while not self._send_stop.is_set():
            self._send_event.wait()
            if self._send_stop.is_set():
                break
            self._send_event.clear()

            with self._send_lock:
                pending = self._send_pending
                self._send_pending = None
            if pending is None:
                continue

            pixel_array, brightness = pending
            try:
                self._do_send(pixel_array, brightness)
            except Exception as e:
                logger.error(f"OpenRGB sender thread failed: {e}")
                self._connected = False

    def _do_send(self, pixel_array: np.ndarray, brightness: int) -> None:
        """Actual blocking send — runs in the sender thread.

        Builds raw OpenRGB UpdateZoneLeds packets directly with struct.pack,
        bypassing RGBColor object creation to avoid GC pressure.
        """
        # Truncate to match target LED count
        n_target = self._device_led_count
        if len(pixel_array) > n_target:
            pixel_array = pixel_array[:n_target]

        # Change-threshold dedup — compare RAW pixels before brightness scaling
        # so low brightness doesn't crush differences below the threshold.
        # Exact-match dedup — skip only if pixels are identical to last sent frame.
        # Threshold-based dedup caused stutter at low brightness.
        if self._last_sent_pixels is not None and self._last_sent_pixels.shape == pixel_array.shape:
            if np.array_equal(pixel_array, self._last_sent_pixels):
                return
        self._last_sent_pixels = pixel_array.copy()

        # Note: brightness already applied by processor loop (_cached_brightness)

        # Separate mode: resample full pixel array independently per zone
        if self._zone_mode == "separate" and len(self._target_zones) > 1:
            n_src = len(pixel_array)
            if n_src < 2:
                c = pixel_array[0] if n_src == 1 else np.array([0, 0, 0], dtype=np.uint8)
                for zone_id, zone_size in zip(self._zone_ids, self._zone_sizes):
                    self._send_zone_raw(zone_id, np.tile(c, (zone_size, 1)))
            else:
                src_indices = np.linspace(0, 1, n_src)
                for zone_id, zone_size in zip(self._zone_ids, self._zone_sizes):
                    dst_indices = np.linspace(0, 1, zone_size)
                    resampled = np.column_stack([
                        np.interp(dst_indices, src_indices, pixel_array[:, ch])
                        for ch in range(3)
                    ]).astype(np.uint8)
                    self._send_zone_raw(zone_id, resampled)
            return

        # Combined mode: distribute pixels sequentially across zones
        # Pad with black if fewer pixels than target LEDs
        if len(pixel_array) < n_target:
            pixel_array = np.vstack([
                pixel_array,
                np.zeros((n_target - len(pixel_array), 3), dtype=np.uint8),
            ])

        offset = 0
        for zone_id, zone_size in zip(self._zone_ids, self._zone_sizes):
            self._send_zone_raw(zone_id, pixel_array[offset:offset + zone_size])
            offset += zone_size

    def _send_zone_raw(self, zone_id: int, colors: np.ndarray) -> None:
        """Send UpdateZoneLeds packet directly via the library's socket.

        Constructs the raw binary packet instead of creating RGBColor objects,
        avoiding hundreds of Python object allocations per frame.
        """
        from openrgb.utils import PacketType

        n_leds = len(colors)
        # Build zone LED data: data_size(u32) + zone_id(i32) + num_colors(u16) + colors(r,g,b,pad)*n
        color_bytes = np.column_stack([
            colors,
            np.zeros(n_leds, dtype=np.uint8),  # padding byte per LED
        ]).tobytes()
        zone_data = struct.pack("iH", zone_id, n_leds) + color_bytes
        data_with_size = struct.pack("I", len(zone_data) + 4) + zone_data
        packet_size = len(data_with_size)

        # Use the library's comms lock + socket directly
        comms = self._client.comms
        if not comms.connected:
            self._connected = False
            return

        if not comms.lock.acquire(timeout=2):
            logger.warning("OpenRGB lock timeout — skipping frame")
            return
        try:
            header = struct.pack(
                'ccccIII', b'O', b'R', b'G', b'B',
                self._device_index,
                PacketType.RGBCONTROLLER_UPDATEZONELEDS,
                packet_size,
            )
            comms.sock.sendall(header + data_with_size)
        except Exception:
            comms.stop_connection()
            self._connected = False
            raise
        finally:
            comms.lock.release()

    async def snapshot_device_state(self) -> Optional[dict]:
        """Save the active mode index before streaming."""
        if self._device is None:
            return None
        try:
            return {"active_mode": self._device.active_mode}
        except Exception as e:
            logger.warning(f"Could not snapshot OpenRGB device state: {e}")
            return None

    async def restore_device_state(self, state: Optional[dict]) -> None:
        """Restore the original mode after streaming stops."""
        if not state or self._device is None:
            return
        try:
            mode_id = state.get("active_mode")
            if mode_id is not None:
                await asyncio.to_thread(self._device.set_mode, mode_id)
                logger.info(f"Restored OpenRGB device mode to {mode_id}")
        except Exception as e:
            logger.warning(f"Could not restore OpenRGB device state: {e}")

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: Optional[DeviceHealth] = None,
    ) -> DeviceHealth:
        """Check OpenRGB server reachability via raw TCP socket connect.

        Uses a lightweight socket probe instead of full library init to avoid
        re-downloading all device data every health check cycle (~30s).
        """
        host, port, device_index, _zones = parse_openrgb_url(url)
        start = asyncio.get_event_loop().time()
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(3.0)
            await asyncio.to_thread(sock.connect, (host, port))
            sock.close()
            latency = (asyncio.get_event_loop().time() - start) * 1000

            # Preserve cached device metadata from previous health check
            device_name = prev_health.device_name if prev_health else None
            device_led_count = prev_health.device_led_count if prev_health else None

            return DeviceHealth(
                online=True,
                latency_ms=latency,
                last_checked=datetime.now(timezone.utc),
                device_name=device_name,
                device_led_count=device_led_count,
            )
        except Exception as e:
            return DeviceHealth(
                online=False,
                last_checked=datetime.now(timezone.utc),
                error=str(e),
            )
