"""Open Pixel Control (OPC) LED client.

OPC is a tiny TCP-based protocol used by Fadecandy boards, OpenRGB-OPC
bridges, art-installation controllers, and a variety of hobbyist
LED-driver software. Each packet is a 4-byte header followed by a body:

    [channel:1][command:1][length_hi:1][length_lo:1][body…]

For pixel data we use ``command=0`` (set 8-bit pixel colors) with an
RGB body. ``channel=0`` broadcasts to every channel on the server;
channels 1-255 address a specific output. The connection is
persistent — open once and stream frames forever.

URL scheme: ``opc://<host>[:port]`` or bare ``<host>[:port]``.
Default port 7890.

Reference: https://github.com/zestyping/openpixelcontrol
"""

from __future__ import annotations

import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse

import numpy as np

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

logger = get_logger(__name__)

OPC_PORT = 7890
OPC_CMD_SET_PIXELS = 0


def parse_opc_url(url: str) -> Tuple[str, int]:
    """Pull ``(host, port)`` from ``opc://host[:port]`` or bare ``host[:port]``."""
    if not url:
        raise ValueError("OPC URL is empty")
    raw = url.strip()
    if "://" in raw:
        parsed = urlparse(raw)
        host = parsed.hostname or ""
        port = parsed.port or OPC_PORT
    else:
        parsed = urlparse(f"opc://{raw}")
        host = parsed.hostname or ""
        port = parsed.port or OPC_PORT
    if not host:
        raise ValueError(f"OPC URL has no host: {url!r}")
    return host, port


def _build_set_pixels_header(channel: int, body_len: int) -> bytes:
    """Pack the 4-byte OPC header for a SET_PIXELS frame."""
    return bytes(
        [
            channel & 0xFF,
            OPC_CMD_SET_PIXELS,
            (body_len >> 8) & 0xFF,
            body_len & 0xFF,
        ]
    )


class OPCClient(LEDClient):
    """LEDClient for an Open Pixel Control receiver."""

    def __init__(
        self,
        url: str,
        led_count: int = 0,
        *,
        channel: int = 0,
        connect_timeout_s: float = 3.0,
    ):
        host, port = parse_opc_url(url)
        self._host = host
        self._port = port
        self._led_count = led_count
        self._channel = channel & 0xFF
        self._connect_timeout_s = connect_timeout_s
        self._writer: Optional[asyncio.StreamWriter] = None
        self._reader: Optional[asyncio.StreamReader] = None
        self._connected = False

    @property
    def host(self) -> str:
        return self._host

    @property
    def port(self) -> int:
        return self._port

    @property
    def channel(self) -> int:
        return self._channel

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

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

    async def connect(self) -> bool:
        if self._connected and self._writer is not None:
            return True
        try:
            self._reader, self._writer = await asyncio.wait_for(
                asyncio.open_connection(self._host, self._port),
                timeout=self._connect_timeout_s,
            )
        except (OSError, asyncio.TimeoutError) as exc:
            raise RuntimeError(f"Failed to connect to OPC at {self._host}: {exc}") from exc
        self._connected = True
        logger.info(
            "OPCClient connected to %s:%d (channel=%d)",
            self._host,
            self._port,
            self._channel,
        )
        return True

    async def close(self) -> None:
        if self._writer is not None:
            try:
                self._writer.close()
                await self._writer.wait_closed()
            except (OSError, asyncio.CancelledError):
                pass
        self._writer = None
        self._reader = None
        self._connected = False

    @staticmethod
    def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
        if brightness >= 255:
            return pixels
        if brightness <= 0:
            return np.zeros_like(pixels)
        return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)

    def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
        if isinstance(pixels, np.ndarray):
            arr = pixels
        else:
            arr = np.asarray(pixels, dtype=np.uint8)
        if arr.dtype != np.uint8:
            arr = arr.astype(np.uint8)
        if arr.ndim == 1 and arr.shape[0] % 3 == 0:
            arr = arr.reshape(-1, 3)
        if not arr.flags["C_CONTIGUOUS"]:
            arr = np.ascontiguousarray(arr)
        return arr

    async def send_pixels(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> bool:
        if not self.is_connected:
            raise RuntimeError("OPCClient not connected")
        arr = self._apply_brightness(self._as_numpy(pixels), brightness)
        body = arr.tobytes()
        header = _build_set_pixels_header(self._channel, len(body))
        assert self._writer is not None
        self._writer.write(header)
        self._writer.write(body)
        await self._writer.drain()
        return True

    def send_pixels_fast(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> None:
        """Synchronous hot-path write. Drain runs implicitly when the OS buffer
        flushes — for the ambilight loop, dropping the await is the point."""
        if not self.is_connected or self._writer is None:
            raise RuntimeError("OPCClient not connected")
        arr = self._apply_brightness(self._as_numpy(pixels), brightness)
        body = arr.tobytes()
        header = _build_set_pixels_header(self._channel, len(body))
        self._writer.write(header)
        self._writer.write(body)

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

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: Optional[DeviceHealth] = None,
    ) -> DeviceHealth:
        """Open a TCP connection and close it. OPC has no protocol-level
        ping; reachable TCP is the strongest signal we get."""
        now = datetime.now(timezone.utc)
        try:
            host, port = parse_opc_url(url)
        except ValueError as exc:
            return DeviceHealth(online=False, last_checked=now, error=str(exc))
        loop = asyncio.get_running_loop()
        start = loop.time()
        try:
            reader, writer = await asyncio.wait_for(
                asyncio.open_connection(host, port),
                timeout=2.0,
            )
        except (OSError, asyncio.TimeoutError) as exc:
            return DeviceHealth(
                online=False,
                last_checked=now,
                error=f"OPC unreachable at {host}:{port}: {exc}",
            )
        latency_ms = (loop.time() - start) * 1000.0
        writer.close()
        try:
            await writer.wait_closed()
        except OSError:
            pass
        del reader
        return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
