"""Standalone DDP (Distributed Display Protocol) LEDClient.

Wraps the low-level ``DDPClient`` transport from ``ddp_client.py`` to expose
DDP as a first-class device type. Any receiver that speaks DDP — Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic DDP firmware — can be driven
through this client without WLED in the path.

URL scheme: ``ddp://host[:port]`` or a bare ``host[:port]``. Default port 4048.
"""

from __future__ import annotations

import asyncio
import socket
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.ddp_client import DDPClient
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.utils import get_logger

logger = get_logger(__name__)

DEFAULT_DDP_PORT = 4048
DEFAULT_DESTINATION_ID = 0x01  # 1 = display
DEFAULT_COLOR_ORDER = 1  # 1 = RGB (no reorder)


def parse_ddp_url(url: str) -> Tuple[str, int]:
    """Parse a DDP URL into ``(host, port)``.

    Accepted forms:
        ``ddp://192.168.1.50``           → ("192.168.1.50", 4048)
        ``ddp://192.168.1.50:4048``      → ("192.168.1.50", 4048)
        ``192.168.1.50``                  → ("192.168.1.50", 4048)
        ``192.168.1.50:4048``             → ("192.168.1.50", 4048)
        ``ddp://[fe80::1]:4048``         → ("fe80::1", 4048)
    """
    if not url:
        raise ValueError("DDP URL is empty")
    raw = url.strip()
    if "://" in raw:
        parsed = urlparse(raw)
        host = parsed.hostname or ""
        port = parsed.port or DEFAULT_DDP_PORT
    else:
        # Bare ``host`` or ``host:port`` — wrap into a URL so urlparse handles IPv6.
        parsed = urlparse(f"ddp://{raw}")
        host = parsed.hostname or ""
        port = parsed.port or DEFAULT_DDP_PORT
    if not host:
        raise ValueError(f"DDP URL has no host: {url!r}")
    return host, port


class DDPLEDClient(LEDClient):
    """LEDClient for generic DDP receivers.

    Designed for the streaming hot loop: ``send_pixels_fast`` is a synchronous
    fire-and-forget UDP push that delegates to the pre-allocated DDP transport.
    """

    def __init__(
        self,
        url: str,
        led_count: int = 0,
        *,
        rgbw: bool = False,
        port: Optional[int] = None,
        destination_id: int = DEFAULT_DESTINATION_ID,
        color_order: int = DEFAULT_COLOR_ORDER,
    ):
        parsed_host, parsed_port = parse_ddp_url(url)
        self._host = parsed_host
        self._port = port or parsed_port
        self._led_count = led_count
        self._rgbw = rgbw
        self._destination_id = destination_id & 0xFF
        self._color_order = color_order
        self._ddp: Optional[DDPClient] = None
        self._connected = False

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

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

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

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

    async def connect(self) -> bool:
        if self._connected and self._ddp is not None:
            return True
        ddp = DDPClient(self._host, port=self._port, rgbw=self._rgbw)
        await ddp.connect()
        # Use the BusConfig hook to encode color order across the full strip
        # if the user picked something other than RGB.
        if self._color_order != DEFAULT_COLOR_ORDER and self._led_count > 0:
            from ledgrab.core.devices.ddp_client import BusConfig

            ddp.set_buses(
                [
                    BusConfig(
                        start=0,
                        length=self._led_count,
                        color_order=self._color_order,
                    )
                ]
            )
        self._ddp = ddp
        self._connected = True
        logger.info(
            "DDPLEDClient connected to %s:%d (led_count=%d, rgbw=%s, order=%d)",
            self._host,
            self._port,
            self._led_count,
            self._rgbw,
            self._color_order,
        )
        return True

    async def close(self) -> None:
        if self._ddp is not None:
            try:
                await self._ddp.close()
            finally:
                self._ddp = None
        self._connected = False

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

    @staticmethod
    def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
        if brightness >= 255:
            return pixels
        if brightness <= 0:
            return np.zeros_like(pixels)
        # uint16 scratch avoids overflow; integer divide keeps everything in uint8.
        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)
        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("DDPLEDClient not connected")
        arr = self._apply_brightness(self._as_numpy(pixels), brightness)
        assert self._ddp is not None
        self._ddp.send_pixels_numpy(arr)
        return True

    def send_pixels_fast(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> None:
        if not self.is_connected or self._ddp is None:
            raise RuntimeError("DDPLEDClient not connected")
        arr = self._apply_brightness(self._as_numpy(pixels), brightness)
        self._ddp.send_pixels_numpy(arr)

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: Optional[DeviceHealth] = None,
    ) -> DeviceHealth:
        """DDP is connectionless UDP — health = host resolves + port reachable.

        We don't get an ACK back from DDP receivers, so this is a best-effort
        probe: resolve the host (cheap, async) and report online if it succeeds.
        Anything more would require sending a frame and waiting for side effects
        we can't observe.
        """
        now = datetime.now(timezone.utc)
        try:
            host, _port = parse_ddp_url(url)
        except ValueError as exc:
            return DeviceHealth(online=False, last_checked=now, error=str(exc))
        loop = asyncio.get_running_loop()
        try:
            start = loop.time()
            await loop.getaddrinfo(host, None, type=socket.SOCK_DGRAM)
            latency_ms = (loop.time() - start) * 1000.0
        except (socket.gaierror, OSError) as exc:
            return DeviceHealth(
                online=False,
                last_checked=now,
                error=f"DNS resolution failed for {host}: {exc}",
            )
        return DeviceHealth(
            online=True,
            latency_ms=latency_ms,
            last_checked=now,
        )
