"""ESP-NOW LED client — sends pixel data via serial to an ESP32 gateway which forwards over ESP-NOW."""

import asyncio
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union

import numpy as np

from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.serial_transport import (
    open_transport,
    parse_serial_url,
    port_exists,
)
from ledgrab.utils import get_logger

logger = get_logger(__name__)

# Gateway serial protocol constants
FRAME_START = 0xEE
FRAME_END = 0xEF
DEFAULT_ESPNOW_BAUD = 921600


def _mac_str_to_bytes(mac: str) -> bytes:
    """Convert 'AA:BB:CC:DD:EE:FF' to 6 raw bytes."""
    parts = mac.replace("-", ":").split(":")
    if len(parts) != 6:
        raise ValueError(f"Invalid MAC address: {mac}")
    return bytes(int(p, 16) for p in parts)


def _build_frame(peer_mac: bytes, pixels: bytes, brightness: int) -> bytes:
    """Build a serial frame for the ESP-NOW gateway.

    Wire format:
        [0xEE][MAC 6B][LED_COUNT 2B LE][BRIGHTNESS 1B][RGB...][CHECKSUM 1B][0xEF]

    Checksum is XOR of all bytes between START and END (exclusive).
    """
    led_count = len(pixels) // 3
    header = struct.pack("<B6sHB", FRAME_START, peer_mac, led_count, brightness)
    payload = header[1:] + pixels  # everything after START for checksum
    checksum = 0
    for b in payload:
        checksum ^= b
    return header + pixels + bytes([checksum, FRAME_END])


class ESPNowClient(LEDClient):
    """LED client that sends pixel data to an ESP32 ESP-NOW gateway over serial.

    The gateway ESP32 receives serial frames and forwards LED data via
    ESP-NOW to a peer ESP32 which drives the LED strip.
    """

    def __init__(
        self,
        url: str = "",
        led_count: int = 0,
        baud_rate: Optional[int] = None,
        espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
        espnow_channel: int = 1,
        **kwargs,
    ):
        self._port, url_baud = parse_serial_url(url)
        self._baud_rate = baud_rate or url_baud or DEFAULT_ESPNOW_BAUD
        self._led_count = led_count
        self._peer_mac = _mac_str_to_bytes(espnow_peer_mac)
        self._channel = espnow_channel
        self._serial = None
        self._connected = False
        self._lock = asyncio.Lock()

    async def connect(self) -> bool:
        try:
            self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1)
            await asyncio.to_thread(self._serial.open)
        except Exception as e:
            logger.error("Failed to open ESP-NOW gateway %s: %s", self._port, e)
            raise RuntimeError(f"Failed to open ESP-NOW gateway {self._port}: {e}")

        self._connected = True
        logger.info(
            "ESP-NOW client connected: port=%s baud=%d peer=%s channel=%d leds=%d",
            self._port,
            self._baud_rate,
            ":".join(f"{b:02X}" for b in self._peer_mac),
            self._channel,
            self._led_count,
        )
        return True

    async def close(self) -> None:
        if self._serial is not None and self._serial.is_open:
            await asyncio.to_thread(self._serial.close)
        self._serial = None
        self._connected = False
        logger.info("ESP-NOW client closed: port=%s", self._port)

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

    @property
    def supports_fast_send(self) -> bool:
        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:
            return
        if isinstance(pixels, np.ndarray):
            pixel_bytes = pixels.astype(np.uint8).tobytes()
        else:
            pixel_bytes = bytes(c for rgb in pixels for c in rgb)

        # Note: brightness already applied by processor loop; pass 255 to firmware
        frame = _build_frame(self._peer_mac, pixel_bytes, 255)
        try:
            self._serial.write(frame)
        except Exception as e:
            logger.warning("ESP-NOW send failed: %s", e)
            self._connected = False

    async def send_pixels(
        self,
        pixels: Union[List[Tuple[int, int, int]], np.ndarray],
        brightness: int = 255,
    ) -> bool:
        if not self.is_connected:
            return False
        try:
            await asyncio.to_thread(self.send_pixels_fast, pixels, brightness)
            return True
        except Exception as e:
            logger.error("ESP-NOW async send failed: %s", e)
            return False

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,
        prev_health: Optional[DeviceHealth] = None,
    ) -> DeviceHealth:
        """Check if the serial port is available without opening it."""
        port, _baud = parse_serial_url(url)
        try:
            if port_exists(port):
                return DeviceHealth(
                    online=True,
                    latency_ms=0.0,
                    last_checked=datetime.now(timezone.utc),
                    device_name=prev_health.device_name if prev_health else None,
                    device_led_count=prev_health.device_led_count if prev_health else None,
                )
            return DeviceHealth(
                online=False,
                last_checked=datetime.now(timezone.utc),
                error=f"Serial port {port} not found",
            )
        except Exception as e:
            return DeviceHealth(
                online=False,
                error=str(e),
                last_checked=datetime.now(timezone.utc),
            )
