"""WiZ Connected (Philips' budget tier) LAN LED client.

WiZ bulbs accept JSON commands as UDP datagrams on port 38899. There's no
persistent connection — every frame is fire-and-forget — so the client is
simpler than Yeelight and tolerates higher update rates.

URL scheme: ``wiz://<host>[:port]`` or bare ``<host>``. Default port 38899.

Discovery: UDP broadcast of a ``registration`` envelope on
255.255.255.255:38899 — bulbs reply unicast with their MAC and state.
"""

from __future__ import annotations

import asyncio
import json
import socket
import time
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.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger

logger = get_logger(__name__)

WIZ_PORT = 38899
DEFAULT_MIN_INTERVAL_S = 0.05  # ~20 Hz cap; bulbs tolerate it, UDP costs nothing


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


class _WiZProtocol(asyncio.DatagramProtocol):
    """Minimal protocol: sends only, drops any inbound packets silently."""

    def connection_made(self, transport):
        self.transport = transport

    def datagram_received(self, data, addr):
        # WiZ bulbs reply to setPilot with a small ack. We don't need it for
        # ambilight streaming — just drop the bytes on the floor.
        pass

    def error_received(self, exc):
        # UDP errors (ICMP unreachable, route changes) surface here. Log
        # once and let the next frame retry; transient drops are normal.
        logger.debug("WiZ UDP error: %s", exc)


class WiZClient(LEDClient):
    """LEDClient for a single WiZ Connected bulb on the LAN."""

    def __init__(
        self,
        url: str,
        led_count: int = 1,
        *,
        min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
    ):
        host, port = parse_wiz_url(url)
        self._host = host
        self._port = port
        self._led_count = led_count
        self._min_interval_s = max(0.0, min_interval_s)
        self._transport: Optional[asyncio.DatagramTransport] = None
        self._protocol: Optional[_WiZProtocol] = None
        self._connected = False
        self._next_tx_at: float = 0.0

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

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

    @property
    def is_connected(self) -> bool:
        return self._connected and self._transport 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._transport is not None:
            return True
        loop = asyncio.get_running_loop()
        try:
            transport, protocol = await loop.create_datagram_endpoint(
                _WiZProtocol, remote_addr=(self._host, self._port)
            )
        except OSError as exc:
            raise RuntimeError(f"Failed to open UDP to WiZ at {self._host}: {exc}") from exc
        self._transport = transport
        self._protocol = protocol  # type: ignore[assignment]
        self._connected = True
        logger.info("WiZClient connected to %s:%d", self._host, self._port)
        return True

    async def close(self) -> None:
        if self._transport is not None:
            try:
                self._transport.close()
            except OSError:
                pass
        self._transport = None
        self._protocol = None
        self._connected = False

    def _send_json(self, payload: dict) -> None:
        """Fire one JSON UDP packet. Caller must hold an open transport."""
        assert self._transport is not None
        raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
        self._transport.sendto(raw)

    async def send_pixels(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> bool:
        """Average the pixel strip to one color and push ``setPilot``."""
        if not self.is_connected:
            raise RuntimeError("WiZClient not connected")
        now = time.monotonic()
        if now < self._next_tx_at:
            return True
        r, g, b = _average_color(pixels)
        # WiZ has a separate "dimming" param (1-100). For ambilight we keep
        # things linear and fold brightness into the RGB scalars — that's
        # what the bulb shows anyway with state=on.
        if brightness < 255:
            scale = max(0, min(255, brightness)) / 255.0
            r = int(r * scale)
            g = int(g * scale)
            b = int(b * scale)
        self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
        self._next_tx_at = now + self._min_interval_s
        return True

    def send_pixels_fast(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> None:
        """Synchronous variant for the hot path. Same shape as send_pixels."""
        if not self.is_connected or self._transport is None:
            raise RuntimeError("WiZClient not connected")
        now = time.monotonic()
        if now < self._next_tx_at:
            return
        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)
        self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
        self._next_tx_at = now + self._min_interval_s

    @property
    def supports_fast_send(self) -> bool:
        # WiZ is UDP fire-and-forget — perfect candidate for the sync hot path.
        return True

    async def set_color(self, r: int, g: int, b: int) -> None:
        if not self.is_connected:
            raise RuntimeError("WiZClient not connected")
        self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})

    async def set_brightness(self, brightness_0_100: int) -> None:
        if not self.is_connected:
            raise RuntimeError("WiZClient not connected")
        clamped = max(10, min(100, brightness_0_100))  # WiZ rejects <10
        self._send_json({"method": "setPilot", "params": {"dimming": clamped}})

    async def set_power(self, on: bool) -> None:
        if not self.is_connected:
            raise RuntimeError("WiZClient not connected")
        self._send_json({"method": "setPilot", "params": {"state": on}})

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: Optional[DeviceHealth] = None,
    ) -> DeviceHealth:
        """Send a getPilot and wait briefly for any reply on a one-shot socket."""
        now = datetime.now(timezone.utc)
        try:
            host, port = parse_wiz_url(url)
        except ValueError as exc:
            return DeviceHealth(online=False, last_checked=now, error=str(exc))
        loop = asyncio.get_running_loop()
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
        sock.setblocking(False)
        try:
            sock.bind(("", 0))
            start = loop.time()
            await loop.sock_sendto(sock, b'{"method":"getPilot","params":{}}', (host, port))
            try:
                await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
            except asyncio.TimeoutError:
                return DeviceHealth(
                    online=False,
                    last_checked=now,
                    error=f"No WiZ reply from {host}:{port} within 1.5s",
                )
            latency_ms = (loop.time() - start) * 1000.0
            return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
        except OSError as exc:
            return DeviceHealth(
                online=False,
                last_checked=now,
                error=f"WiZ probe failed for {host}: {exc}",
            )
        finally:
            sock.close()


# ============================================================================
# Broadcast discovery
# ============================================================================


_DISCOVERY_REQUEST = (
    b'{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,'
    b'"phoneIp":"0.0.0.0","id":"1"}}'
)


def _extract_mac(payload: dict) -> str:
    """Pull a bulb MAC out of the standard ``result`` envelope, if present."""
    result = payload.get("result")
    if isinstance(result, dict):
        return str(result.get("mac", "")).lower()
    return ""


async def discover_wiz_bulbs(timeout: float = 2.0) -> List[dict]:
    """Broadcast a registration probe and collect bulb replies.

    Returns a list of ``{"ip": ..., "mac": ..., "raw": <parsed_json>}`` dicts.
    Multicast / broadcast failures (no network, firewall) raise OSError;
    callers handle that by returning an empty discovery list.
    """
    loop = asyncio.get_running_loop()
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.setblocking(False)
    try:
        sock.bind(("", 0))
        await loop.sock_sendto(sock, _DISCOVERY_REQUEST, ("255.255.255.255", WIZ_PORT))
        results: list[dict] = []
        seen_ips: 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
            ip = addr[0]
            if ip in seen_ips:
                continue
            try:
                payload = json.loads(raw.decode("utf-8", errors="replace"))
            except (json.JSONDecodeError, UnicodeDecodeError):
                continue
            if not isinstance(payload, dict):
                continue
            seen_ips.add(ip)
            results.append({"ip": ip, "mac": _extract_mac(payload), "raw": payload})
        return results
    finally:
        sock.close()
