"""WLED client for controlling LED devices via HTTP or DDP."""

import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse

import httpx
import numpy as np

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

logger = get_logger(__name__)

# WLED LED bus type codes from const.h → human-readable names
WLED_LED_TYPES: Dict[int, str] = {
    18: "WS2812 1ch", 19: "WS2812 1ch x3", 20: "WS2812 CCT", 21: "WS2812 WWA",
    22: "WS2812B", 23: "GS8608", 24: "WS2811 400kHz", 25: "TM1829",
    26: "UCS8903", 27: "APA106", 28: "FW1906", 29: "UCS8904",
    30: "SK6812 RGBW", 31: "TM1814", 32: "WS2805", 33: "TM1914", 34: "SM16825",
    40: "On/Off", 41: "PWM 1ch", 42: "PWM 2ch", 43: "PWM 3ch",
    44: "PWM 4ch", 45: "PWM 5ch", 46: "PWM 6ch",
    50: "WS2801", 51: "APA102", 52: "LPD8806", 53: "P9813", 54: "LPD6803",
    65: "HUB75 HS", 66: "HUB75 QS",
    80: "DDP RGB", 81: "E1.31", 82: "Art-Net", 88: "DDP RGBW", 89: "Art-Net RGBW",
}


@dataclass
class WLEDInfo:
    """WLED device information."""

    name: str
    version: str
    led_count: int
    brand: str
    product: str
    mac: str
    ip: str
    rgbw: bool = False  # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
    color_order: int = 1  # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
    buses: List[BusConfig] = field(default_factory=list)  # Per-bus/GPIO config


class WLEDClient(LEDClient):
    """Client for WLED devices supporting both HTTP and DDP protocols."""

    # HTTP JSON API has ~10KB limit, ~500 LEDs max
    HTTP_MAX_LEDS = 500

    def __init__(
        self,
        url: str,
        timeout: int = 5,
        retry_attempts: int = 3,
        retry_delay: int = 1,
        use_ddp: bool = False,
    ):
        """Initialize WLED client.

        Args:
            url: WLED device URL (e.g., http://192.168.1.100)
            timeout: Request timeout in seconds
            retry_attempts: Number of retry attempts on failure
            retry_delay: Delay between retries in seconds
            use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
        """
        self.url = url.rstrip("/")
        self.timeout = timeout
        self.retry_attempts = retry_attempts
        self.retry_delay = retry_delay
        self.use_ddp = use_ddp

        # Extract hostname/IP from URL for DDP
        parsed = urlparse(self.url)
        self.host = parsed.hostname or parsed.netloc.split(':')[0]

        self._client: Optional[httpx.AsyncClient] = None
        self._ddp_client: Optional[DDPClient] = None
        self._connected = False
        self._pre_connect_state: Optional[dict] = None

    async def connect(self) -> bool:
        """Establish connection to WLED device.

        Returns:
            True if connection successful

        Raises:
            RuntimeError: If connection fails
        """
        try:
            # Always create HTTP client for info/control
            self._client = httpx.AsyncClient(timeout=self.timeout)

            # Test connection by getting device info
            info = await self.get_info()

            # Auto-enable DDP for large LED counts
            if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
                logger.info(
                    f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
                    "auto-enabling DDP protocol"
                )
                self.use_ddp = True

            # Snapshot device state BEFORE any mutations (for auto-restore)
            self._pre_connect_state = await self.snapshot_device_state()

            # Create DDP client if needed
            if self.use_ddp:
                self._ddp_client = DDPClient(self.host, rgbw=False)
                # Pass per-bus config so DDP client can apply per-bus color reordering
                if info.buses:
                    self._ddp_client.set_buses(info.buses)
                await self._ddp_client.connect()

                # Configure device for DDP streaming:
                # - Turn on, set lor=0 (live data overrides effects),
                #   and disable Audio Reactive.
                # - Do NOT set live — it's read-only and causes issues on WLED 0.15.x.
                #   DDP packets automatically enter realtime mode.
                try:
                    await self._request("POST", "/json/state", json_data={
                        "on": True,
                        "lor": 0,
                        "AudioReactive": {"on": False}
                    })
                    logger.info("Configured device for DDP streaming")
                except Exception as e:
                    logger.warning(f"Could not configure device for DDP: {e}")

                logger.info("DDP protocol enabled for pixel data transmission (RGB mode)")

            self._connected = True

            protocol = "DDP" if self.use_ddp else "HTTP"
            logger.info(
                f"Connected to WLED device: {info.name} ({info.version}) "
                f"with {info.led_count} LEDs via {protocol}"
            )
            return True

        except Exception as e:
            logger.error(f"Failed to connect to WLED device at {self.url}: {e}")
            self._connected = False
            if self._client:
                await self._client.aclose()
                self._client = None
            if self._ddp_client:
                await self._ddp_client.close()
                self._ddp_client = None
            raise RuntimeError(f"Failed to connect to WLED device: {e}")

    async def close(self):
        """Close the connection to WLED device."""
        if self._client:
            await self._client.aclose()
            self._client = None
        if self._ddp_client:
            await self._ddp_client.close()
            self._ddp_client = None
        self._connected = False
        logger.debug(f"Closed connection to {self.url}")

    @property
    def is_connected(self) -> bool:
        """Check if connected to WLED device."""
        return self._connected and self._client is not None

    @property
    def supports_fast_send(self) -> bool:
        """True when DDP is active and ready for fire-and-forget sends."""
        return self.use_ddp and self._ddp_client is not None

    async def _request(
        self,
        method: str,
        endpoint: str,
        json_data: Optional[Dict[str, Any]] = None,
        retry: bool = True,
    ) -> Dict[str, Any]:
        """Make HTTP request to WLED device with retry logic.

        Args:
            method: HTTP method (GET, POST, etc.)
            endpoint: API endpoint
            json_data: JSON data for request body
            retry: Whether to retry on failure

        Returns:
            Response JSON data

        Raises:
            RuntimeError: If request fails after retries
        """
        if not self._client:
            raise RuntimeError("Client not connected. Call connect() first.")

        url = f"{self.url}{endpoint}"
        attempts = self.retry_attempts if retry else 1

        for attempt in range(attempts):
            try:
                if method == "GET":
                    response = await self._client.get(url)
                elif method == "POST":
                    response = await self._client.post(url, json=json_data)
                else:
                    raise ValueError(f"Unsupported HTTP method: {method}")

                response.raise_for_status()
                return response.json()

            except httpx.HTTPStatusError as e:
                logger.error(f"HTTP error {e.response.status_code} on attempt {attempt + 1}: {e}")
                if attempt < attempts - 1:
                    await asyncio.sleep(self.retry_delay)
                else:
                    raise RuntimeError(f"HTTP request failed: {e}")

            except httpx.RequestError as e:
                logger.error(f"Request error on attempt {attempt + 1}: {e}")
                if attempt < attempts - 1:
                    await asyncio.sleep(self.retry_delay)
                else:
                    self._connected = False
                    raise RuntimeError(f"Request to WLED device failed: {e}")

            except Exception as e:
                logger.error(f"Unexpected error on attempt {attempt + 1}: {e}")
                if attempt < attempts - 1:
                    await asyncio.sleep(self.retry_delay)
                else:
                    raise RuntimeError(f"WLED request failed: {e}")

        raise RuntimeError("Request failed after all retry attempts")

    async def get_info(self) -> WLEDInfo:
        """Get WLED device information.

        Returns:
            WLEDInfo object with device details

        Raises:
            RuntimeError: If request fails
        """
        try:
            # Get basic info
            data = await self._request("GET", "/json/info")
            leds_info = data.get("leds", {})

            # Get LED configuration for color order and per-bus info
            cfg_data = await self._request("GET", "/json/cfg")
            color_order = 1  # Default to RGB
            buses: List[BusConfig] = []
            if "hw" in cfg_data and "led" in cfg_data["hw"]:
                led_cfg = cfg_data["hw"]["led"]
                ins_list = led_cfg.get("ins", [])
                if ins_list:
                    # Use color order from first LED strip
                    color_order = ins_list[0].get("order", 1)

                    # Parse all buses for per-segment color reordering
                    order_names = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
                    for idx, bus_cfg in enumerate(ins_list):
                        bus = BusConfig(
                            start=bus_cfg.get("start", 0),
                            length=bus_cfg.get("len", 0),
                            color_order=bus_cfg.get("order", 1),
                        )
                        buses.append(bus)
                        logger.info(
                            f"WLED bus {idx}: LEDs {bus.start}-{bus.start + bus.length - 1}, "
                            f"pin={bus_cfg.get('pin', '?')}, "
                            f"order={order_names.get(bus.color_order, '?')} ({bus.color_order}), "
                            f"type={bus_cfg.get('type', '?')}"
                        )

            return WLEDInfo(
                name=data.get("name", "Unknown"),
                version=data.get("ver", "Unknown"),
                led_count=leds_info.get("count", 0),
                brand=data.get("brand", "WLED"),
                product=data.get("product", "FOSS"),
                mac=data.get("mac", ""),
                ip=data.get("ip", ""),
                rgbw=leds_info.get("rgbw", False),
                color_order=color_order,
                buses=buses,
            )

        except Exception as e:
            logger.error(f"Failed to get device info: {e}")
            raise

    async def get_state(self) -> Dict[str, Any]:
        """Get current WLED device state.

        Returns:
            State dictionary

        Raises:
            RuntimeError: If request fails
        """
        try:
            return await self._request("GET", "/json/state")

        except Exception as e:
            logger.error(f"Failed to get device state: {e}")
            raise

    async def send_pixels(
        self,
        pixels: List[Tuple[int, int, int]],
        brightness: int = 255,
        segment_id: int = 0,
    ) -> bool:
        """Send pixel colors to WLED device.

        Uses DDP for large LED counts (>500), HTTP JSON API for smaller counts.

        Args:
            pixels: List of (R, G, B) tuples for each LED
            brightness: Global brightness (0-255)
            segment_id: Segment ID to update (HTTP only)

        Returns:
            True if successful

        Raises:
            ValueError: If pixel values are invalid
            RuntimeError: If request fails
        """
        # Validate inputs
        if isinstance(pixels, np.ndarray):
            if pixels.size == 0:
                raise ValueError("Pixels array cannot be empty")
            pixel_arr = pixels
        else:
            if not pixels:
                raise ValueError("Pixels list cannot be empty")
            pixel_arr = np.array(pixels, dtype=np.int16)

        if not 0 <= brightness <= 255:
            raise ValueError(f"Brightness must be 0-255, got {brightness}")

        # Validate pixel values using vectorized bounds check
        if pixel_arr.dtype != np.uint8:
            if np.any((pixel_arr < 0) | (pixel_arr > 255)):
                bad_mask = np.any((pixel_arr < 0) | (pixel_arr > 255), axis=1)
                idx = int(np.argmax(bad_mask))
                raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
        validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr

        # Use DDP protocol if enabled
        if self.use_ddp and self._ddp_client:
            return await self._send_pixels_ddp(validated_pixels, brightness)
        else:
            return await self._send_pixels_http(validated_pixels, brightness, segment_id)

    async def _send_pixels_ddp(
        self,
        pixels: np.ndarray,
        brightness: int = 255,
    ) -> bool:
        """Send pixels via DDP protocol.

        Args:
            pixels: (N, 3) uint8 numpy array of RGB values
            brightness: Global brightness (0-255)

        Returns:
            True if successful
        """
        try:
            # Note: brightness already applied by processor loop (_cached_brightness)
            logger.debug(f"Sending {len(pixels)} LEDs via DDP")
            self._ddp_client.send_pixels_numpy(pixels)
            logger.debug("Successfully sent pixel colors via DDP")
            return True

        except Exception as e:
            logger.error(f"Failed to send pixels via DDP: {e}")
            raise

    async def _send_pixels_http(
        self,
        pixels: np.ndarray,
        brightness: int = 255,
        segment_id: int = 0,
    ) -> bool:
        """Send pixels via HTTP JSON API.

        Args:
            pixels: (N, 3) uint8 numpy array of RGB values
            brightness: Global brightness (0-255)
            segment_id: Segment ID to update

        Returns:
            True if successful
        """
        try:
            # Build indexed pixel array: [led_index, r, g, b, ...]
            n = len(pixels)
            flat = np.empty(n * 4, dtype=np.int32)
            flat[0::4] = np.arange(n, dtype=np.int32)
            flat[1::4] = pixels[:, 0]
            flat[2::4] = pixels[:, 1]
            flat[3::4] = pixels[:, 2]
            indexed_pixels = flat.tolist()

            # Build WLED JSON state
            payload = {
                "on": True,
                "bri": 255,  # brightness already applied by processor loop
                "seg": [
                    {
                        "id": segment_id,
                        "i": indexed_pixels,
                    }
                ],
            }

            logger.debug(f"Sending {len(pixels)} LEDs via HTTP ({len(indexed_pixels)} values)")
            logger.debug(f"Payload size: ~{len(str(payload))} bytes")

            await self._request("POST", "/json/state", json_data=payload)
            logger.debug("Successfully sent pixel colors via HTTP")
            return True

        except Exception as e:
            logger.error(f"Failed to send pixels via HTTP: {e}")
            raise

    def send_pixels_fast(
        self,
        pixels,
        brightness: int = 255,
    ) -> None:
        """Optimized send for the hot loop — fire-and-forget DDP.

        Accepts numpy array (N,3) uint8 directly to avoid conversion overhead.
        Synchronous (no await). Only works for DDP path.

        Args:
            pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
            brightness: Global brightness (0-255)
        """
        if not self.use_ddp or not self._ddp_client:
            raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")

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

        # Note: brightness already applied by processor loop (_cached_brightness)
        self._ddp_client.send_pixels_numpy(pixel_array)

    # ===== LEDClient abstraction methods =====

    async def snapshot_device_state(self) -> Optional[dict]:
        """Snapshot WLED state (on, lor, AudioReactive).

        If connect() already captured a pre-mutation snapshot, returns that
        instead of the current (potentially already-modified) state.
        """
        # Return pre-connect snapshot if available (captured before DDP setup)
        if hasattr(self, '_pre_connect_state') and self._pre_connect_state is not None:
            return self._pre_connect_state
        if not self._client:
            return None
        try:
            resp = await self._client.get(f"{self.url}/json/state")
            resp.raise_for_status()
            wled_state = resp.json()
            state = {
                "on": wled_state.get("on", True),
                "lor": wled_state.get("lor", 0),
            }
            if "AudioReactive" in wled_state:
                state["AudioReactive"] = wled_state["AudioReactive"]
            logger.info(f"Saved WLED state before streaming: {state}")
            return state
        except Exception as e:
            logger.warning(f"Could not snapshot WLED state: {e}")
            return None

    async def restore_device_state(self, state: Optional[dict]) -> None:
        """Restore WLED state after streaming."""
        if not state:
            return
        try:
            async with httpx.AsyncClient(timeout=5) as http:
                await http.post(f"{self.url}/json/state", json=state)
            logger.info(f"Restored WLED state: {state}")
        except Exception as e:
            logger.warning(f"Could not restore WLED state: {e}")

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: Optional[DeviceHealth] = None,
    ) -> DeviceHealth:
        """WLED health check via GET /json/info (+ /json/cfg for LED type)."""
        url = url.rstrip("/")
        start = time.time()
        try:
            response = await http_client.get(f"{url}/json/info")
            response.raise_for_status()
            data = response.json()
            latency = (time.time() - start) * 1000
            leds_info = data.get("leds", {})

            # Fetch LED type from /json/cfg once (it's static config)
            device_led_type = prev_health.device_led_type if prev_health else None
            if device_led_type is None:
                try:
                    cfg = await http_client.get(f"{url}/json/cfg")
                    cfg.raise_for_status()
                    cfg_data = cfg.json()
                    ins = cfg_data.get("hw", {}).get("led", {}).get("ins", [])
                    if ins:
                        type_code = ins[0].get("type", 0)
                        device_led_type = WLED_LED_TYPES.get(type_code, f"Type {type_code}")
                except Exception as cfg_err:
                    logger.debug(f"Could not fetch LED type: {cfg_err}")

            return DeviceHealth(
                online=True,
                latency_ms=round(latency, 1),
                last_checked=datetime.now(timezone.utc),
                device_name=data.get("name"),
                device_version=data.get("ver"),
                device_led_count=leds_info.get("count"),
                device_rgbw=leds_info.get("rgbw", False),
                device_led_type=device_led_type,
                device_fps=leds_info.get("fps"),
                error=None,
            )
        except Exception as e:
            return DeviceHealth(
                online=False,
                latency_ms=None,
                last_checked=datetime.now(timezone.utc),
                device_name=prev_health.device_name if prev_health else None,
                device_version=prev_health.device_version if prev_health else None,
                device_led_count=prev_health.device_led_count if prev_health else None,
                device_rgbw=prev_health.device_rgbw if prev_health else None,
                device_led_type=prev_health.device_led_type if prev_health else None,
                device_fps=prev_health.device_fps if prev_health else None,
                error=str(e),
            )

    # ===== WLED-specific methods =====

    async def set_power(self, on: bool) -> bool:
        """Turn WLED device on or off.

        Args:
            on: True to turn on, False to turn off

        Returns:
            True if successful

        Raises:
            RuntimeError: If request fails
        """
        payload = {"on": on}

        try:
            await self._request("POST", "/json/state", json_data=payload)
            logger.info(f"Set WLED power: {'ON' if on else 'OFF'}")
            return True

        except Exception as e:
            logger.error(f"Failed to set power: {e}")
            raise

    async def set_brightness(self, brightness: int) -> bool:
        """Set global brightness.

        Args:
            brightness: Brightness value (0-255)

        Returns:
            True if successful

        Raises:
            ValueError: If brightness is out of range
            RuntimeError: If request fails
        """
        if not 0 <= brightness <= 255:
            raise ValueError(f"Brightness must be 0-255, got {brightness}")

        payload = {"bri": brightness}

        try:
            await self._request("POST", "/json/state", json_data=payload)
            logger.debug(f"Set brightness to {brightness}")
            return True

        except Exception as e:
            logger.error(f"Failed to set brightness: {e}")
            raise

    async def test_connection(self) -> bool:
        """Test connection to WLED device.

        Returns:
            True if device is reachable

        Raises:
            RuntimeError: If connection test fails
        """
        try:
            await self.get_info()
            return True

        except Exception as e:
            logger.error(f"Connection test failed: {e}")
            raise

    async def send_test_pattern(self, led_count: int, duration: float = 2.0):
        """Send a test pattern to verify LED configuration.

        Cycles through red, green, blue on all LEDs.

        Args:
            led_count: Number of LEDs
            duration: Duration for each color in seconds

        Raises:
            RuntimeError: If test pattern fails
        """
        logger.info(f"Sending test pattern to {led_count} LEDs")

        try:
            # Red
            pixels = [(255, 0, 0)] * led_count
            await self.send_pixels(pixels)
            await asyncio.sleep(duration)

            # Green
            pixels = [(0, 255, 0)] * led_count
            await self.send_pixels(pixels)
            await asyncio.sleep(duration)

            # Blue
            pixels = [(0, 0, 255)] * led_count
            await self.send_pixels(pixels)
            await asyncio.sleep(duration)

            # Off
            pixels = [(0, 0, 0)] * led_count
            await self.send_pixels(pixels)

            logger.info("Test pattern complete")

        except Exception as e:
            logger.error(f"Test pattern failed: {e}")
            raise
