"""Yeelight (Xiaomi) LAN LED client.

Yeelight bulbs and lightstrips accept JSON-RPC commands over a plain TCP
socket on port 55443. This client speaks the simplest useful subset —
``set_rgb``, ``set_bright``, ``set_power``, ``get_prop`` — and averages the
incoming pixel strip down to one RGB color (Yeelight bulbs are single-pixel
devices, like Hue or generic BLE bulbs).

Rate limit: each Yeelight bulb caps inbound commands at roughly one per
second by default. We enforce a configurable client-side gate to stay under
that limit; faster updates would need Yeelight "music mode" (bulb dials
back to our TCP server) which is a follow-up.

URL scheme: ``yeelight://<host>`` or bare ``<host>``. Port is fixed at 55443
on the protocol side; we don't parse it from the URL.
"""

from __future__ import annotations

import asyncio
import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Tuple
from urllib.parse import urlparse

import numpy as np

from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger

logger = get_logger(__name__)

YEELIGHT_PORT = 55443
DEFAULT_MIN_INTERVAL_S = 0.5  # half a second between TX → ~2 Hz, well under the cap


def parse_yeelight_url(url: str) -> str:
    """Pull the host out of ``yeelight://host`` or accept a bare ``host``.

    The TCP port is fixed on the protocol side (55443), so we ignore any port
    specifier rather than silently accept one the bulb won't answer on.
    """
    if not url:
        raise ValueError("Yeelight URL is empty")
    raw = url.strip()
    if "://" in raw:
        parsed = urlparse(raw)
        host = parsed.hostname or ""
    else:
        parsed = urlparse(f"yeelight://{raw}")
        host = parsed.hostname or ""
    if not host:
        raise ValueError(f"Yeelight URL has no host: {url!r}")
    return host


def _pack_rgb(r: int, g: int, b: int) -> int:
    """Pack an (R, G, B) triple into the 24-bit integer Yeelight expects."""
    return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)


class YeelightClient(LEDClient):
    """LEDClient for a single Yeelight bulb / lightstrip on the LAN."""

    def __init__(
        self,
        url: str,
        led_count: int = 1,
        *,
        min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
        connect_timeout_s: float = 3.0,
    ):
        self._host = parse_yeelight_url(url)
        self._led_count = led_count
        self._min_interval_s = max(0.0, min_interval_s)
        self._connect_timeout_s = connect_timeout_s
        self._reader: asyncio.StreamReader | None = None
        self._writer: asyncio.StreamWriter | None = None
        self._connected = False
        self._next_tx_at: float = 0.0
        self._req_id: int = 0
        self._send_lock = asyncio.Lock()

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

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

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

    async def connect(self) -> bool:
        if self._connected:
            return True
        try:
            self._reader, self._writer = await asyncio.wait_for(
                asyncio.open_connection(self._host, YEELIGHT_PORT),
                timeout=self._connect_timeout_s,
            )
        except (OSError, asyncio.TimeoutError) as exc:
            raise RuntimeError(f"Failed to connect to Yeelight at {self._host}: {exc}") from exc
        self._connected = True
        logger.info("YeelightClient connected to %s:%d", self._host, YEELIGHT_PORT)
        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

    def _next_id(self) -> int:
        self._req_id = (self._req_id + 1) % 1_000_000
        return self._req_id

    async def _send(self, method: str, params: list) -> None:
        """Fire a JSON-RPC command; replies are read-then-dropped opportunistically.

        Yeelight's bulb sends a JSON reply per command, but for streaming
        ambient lighting we don't need to wait for it — the data is
        write-only.
        """
        if self._writer is None:
            raise RuntimeError("YeelightClient not connected")
        payload = json.dumps({"id": self._next_id(), "method": method, "params": params}) + "\r\n"
        async with self._send_lock:
            self._writer.write(payload.encode("utf-8"))
            await self._writer.drain()

    async def send_pixels(
        self,
        pixels: List[Tuple[int, int, int]] | np.ndarray,
        brightness: int = 255,
    ) -> bool:
        """Average the pixel strip to one color and ``set_rgb``.

        Brightness is folded in by scaling the averaged RGB rather than
        sending a separate ``set_bright`` (avoids burning a command and
        keeps animation in sync). When the configured min interval hasn't
        elapsed the call returns ``True`` without TX — the next frame will
        carry whichever color was current at the time it eventually fires.
        """
        if not self.is_connected:
            raise RuntimeError("YeelightClient not connected")
        now = time.monotonic()
        if now < self._next_tx_at:
            return True
        r, g, b = _average_color(pixels)
        if brightness < 255:
            scale = max(0, min(255, brightness)) / 255.0
            r = int(r * scale)
            g = int(g * scale)
            b = int(b * scale)
        packed = _pack_rgb(r, g, b)
        # ``set_rgb`` params: [color_int, effect, duration_ms].
        # "sudden" + 0ms keeps latency minimal for ambilight.
        await self._send("set_rgb", [packed, "sudden", 0])
        self._next_tx_at = now + self._min_interval_s
        return True

    async def set_color(self, r: int, g: int, b: int) -> None:
        await self._send("set_rgb", [_pack_rgb(r, g, b), "sudden", 0])

    async def set_brightness(self, brightness_0_100: int) -> None:
        clamped = max(1, min(100, brightness_0_100))
        await self._send("set_bright", [clamped, "sudden", 0])

    async def set_power(self, on: bool) -> None:
        await self._send("set_power", ["on" if on else "off", "sudden", 0])

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: DeviceHealth | None = None,
    ) -> DeviceHealth:
        """Health check: open the TCP socket to the bulb and close it."""
        now = datetime.now(timezone.utc)
        try:
            host = parse_yeelight_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, YEELIGHT_PORT),
                timeout=2.0,
            )
        except (OSError, asyncio.TimeoutError) as exc:
            return DeviceHealth(
                online=False,
                last_checked=now,
                error=f"Yeelight unreachable at {host}:{YEELIGHT_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)


# ============================================================================
# SSDP-style discovery
# ============================================================================

_DISCOVER_GROUP = ("239.255.255.250", 1982)
_DISCOVER_REQUEST = (
    "M-SEARCH * HTTP/1.1\r\n"
    "HOST: 239.255.255.250:1982\r\n"
    'MAN: "ssdp:discover"\r\n'
    "ST: wifi_bulb\r\n"
    "\r\n"
).encode("ascii")


def _parse_ssdp_response(raw: bytes) -> dict | None:
    """Parse a Yeelight discovery response into a ``{header: value}`` dict.

    Returns ``None`` when the payload doesn't look like a Yeelight reply
    (e.g. a stray HTTP response from another SSDP service on the LAN).
    """
    try:
        text = raw.decode("utf-8", errors="replace")
    except UnicodeDecodeError:
        return None
    if "yeelight://" not in text.lower():
        return None
    headers: dict = {}
    for line in text.splitlines():
        if ":" in line:
            key, _, value = line.partition(":")
            headers[key.strip().lower()] = value.strip()
    return headers


async def discover_yeelight_bulbs(timeout: float = 2.0) -> List[dict]:
    """Scan the LAN for Yeelight bulbs via the bulb-specific SSDP variant.

    Returns a list of header-dicts (one per bulb that replied) so the caller
    can decide which fields to surface. Each dict has ``location``,
    ``id``, ``model``, ``support``, ``rgb``, ``bright`` etc.
    """
    loop = asyncio.get_running_loop()
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
    sock.setblocking(False)
    try:
        sock.bind(("", 0))
        await loop.sock_sendto(sock, _DISCOVER_REQUEST, _DISCOVER_GROUP)
        results: list[dict] = []
        seen_ids: set[str] = set()
        deadline = loop.time() + timeout
        while True:
            remaining = deadline - loop.time()
            if remaining <= 0:
                break
            try:
                raw, _addr = await asyncio.wait_for(
                    loop.sock_recvfrom(sock, 2048),
                    timeout=remaining,
                )
            except asyncio.TimeoutError:
                break
            headers = _parse_ssdp_response(raw)
            if not headers:
                continue
            bulb_id = headers.get("id", "")
            if bulb_id and bulb_id in seen_ids:
                continue
            if bulb_id:
                seen_ids.add(bulb_id)
            results.append(headers)
        return results
    finally:
        sock.close()
