"""Unified BLE LED client — whole-strip ambient color for BLE controllers.

Supports four families via the :mod:`ble_protocols` registry: SP110E,
Triones/HappyLighting, Zengge/iLightsIn, Govee. None of these protocols
stream per-pixel frames — so ``send_pixels`` averages the incoming strip
and writes one solid color per frame.

URL format: ``ble://<address>`` where ``<address>`` is a MAC on
Windows/Linux and a CoreBluetooth UUID on macOS. The protocol family is
a separate ``ble_family`` field on the device record (not in the URL)
because the same address advertises different services depending on
firmware variant.
"""

from __future__ import annotations

import asyncio
import time
from datetime import datetime, timezone
from typing import List, Tuple

import numpy as np

from ledgrab.core.devices.ble_protocols import BLEProtocol, get_protocol
from ledgrab.core.devices.ble_transport import make_transport
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__)

# Minimum interval between BLE writes. BLE connection intervals start at
# ~7.5 ms on most controllers; 30 ms leaves headroom for GATT ACKs on the
# with-response families without saturating the air time.
_MIN_WRITE_INTERVAL_SEC = 0.03


def _encrypt_govee_frame(frame: bytes, key: bytes) -> bytes:
    """AES-128-ECB encrypt a 20-byte Govee frame using a 16-byte device key.

    Newer Govee firmware (2022+) drops unencrypted frames silently.
    Pads the 20-byte frame to 32 bytes (two AES blocks) before encrypting.
    Falls back to the plaintext frame if the ``cryptography`` package is
    unavailable (logs a warning so the user knows why the controller ignores it).
    """
    try:
        from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
    except ImportError:
        logger.warning(
            "cryptography package not available — sending unencrypted Govee frame; "
            "install it with: pip install cryptography"
        )
        return frame
    padded = frame + b"\x00" * (32 - len(frame))
    cipher = Cipher(algorithms.AES(key), modes.ECB())
    enc = cipher.encryptor()
    return enc.update(padded) + enc.finalize()


def _strip_ble_scheme(url: str) -> str:
    """Normalise a ``ble://<address>`` URL to just the address."""
    if url.startswith("ble://"):
        return url[len("ble://") :].strip("/")
    return url.strip("/")


class BLEClient(LEDClient):
    """LED client for BLE controllers speaking one of the registered protocols.

    Args:
        url: ``ble://<address>`` URL.
        ble_family: Family identifier (``sp110e``, ``triones``, ``zengge``, ``govee``).
        led_count: Logical LED count — recorded for UI/reporting; on the wire
            every BLE protocol here is whole-strip.
    """

    def __init__(
        self,
        url: str,
        ble_family: str,
        led_count: int = 0,
        ble_govee_key: str = "",
        **_kwargs,
    ):
        self._url = url
        self._address = _strip_ble_scheme(url)
        self._led_count = led_count
        self._protocol: BLEProtocol = get_protocol(ble_family)
        self._transport = make_transport(
            address=self._address,
            write_char_uuid=self._protocol.write_char_uuid,
            write_with_response=self._protocol.write_with_response,
        )
        # AES key for Govee encrypted firmware — 16 raw bytes or None.
        self._aes_key: bytes | None = None
        if ble_govee_key and ble_family == "govee":
            try:
                import binascii

                key_bytes = binascii.unhexlify(ble_govee_key.strip())
                if len(key_bytes) != 16:
                    raise ValueError(f"Govee AES key must be 16 bytes, got {len(key_bytes)}")
                self._aes_key = key_bytes
            except Exception as exc:
                logger.warning("Invalid Govee AES key — ignoring: %s", exc)
        self._last_write_at: float = 0.0
        self._last_color: Tuple[int, int, int, int] | None = None
        self._connected = False
        # Throttle "not connected" warnings so the send loop doesn't spam logs
        # at frame rate when a BLE connection drops silently.
        self._last_not_connected_warn_at: float = 0.0
        # When a reconnect attempt fails, skip further write attempts for a
        # cooldown window. Each failed write on Windows can hang up to the
        # transport's write timeout + a full connect timeout, so letting
        # every frame retry turns a 60 FPS loop into a 0.03 FPS slideshow.
        self._reconnect_cooldown_until: float = 0.0

    async def connect(self) -> bool:
        await self._transport.connect()
        # Some controllers (notably SP110E) require a vendor handshake within
        # ~1 s of GATT connect or they silently tear the link down — which
        # later surfaces as writes hanging until the Windows BLE stack
        # times them out at 30 s. Run it here so send_pixels can assume the
        # link is actually live.
        if self._protocol.init_writes:
            logger.debug(
                "BLE init handshake: %d write(s) for %s",
                len(self._protocol.init_writes),
                self._protocol.family,
            )
            for char_uuid, payload in self._protocol.init_writes:
                try:
                    await self._transport.write(payload, char_uuid=char_uuid)
                except Exception as exc:
                    # Handshake failure is fatal — close and surface it so
                    # the caller doesn't stream into a dead link.
                    logger.warning(
                        "BLE init write to %s on %s failed: %s",
                        char_uuid,
                        self._address,
                        exc,
                    )
                    await self._transport.close()
                    raise
        self._connected = True
        logger.info(
            "BLE client connected: address=%s family=%s", self._address, self._protocol.family
        )
        return True

    async def close(self) -> None:
        # Leave the strip in whatever state it's in — streaming power commands
        # on every connect/close cycle causes Windows BLE stack quirks (back-to-back
        # writes after connect can hang for 30s on some firmwares). The user can
        # explicitly toggle power via the UI.
        await self._transport.close()
        self._connected = False

    @property
    def is_connected(self) -> bool:
        return self._connected and self._transport.is_connected

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

    async def send_pixels(
        self,
        pixels: List[Tuple[int, int, int]] | np.ndarray,
        brightness: int = 255,
    ) -> bool:
        """Average the strip to one color and write it — BLE protocols are whole-strip only."""
        now = time.monotonic()
        if now < self._reconnect_cooldown_until:
            return False
        if not self.is_connected:
            if (now - self._last_not_connected_warn_at) >= 5.0:
                logger.warning(
                    "BLE send_pixels skipped — not connected (address=%s family=%s)",
                    self._address,
                    self._protocol.family,
                )
                self._last_not_connected_warn_at = now
            return False

        r, g, b = _average_color(pixels)
        color = (r, g, b, brightness)

        # Skip exact duplicates within a short window — long idle periods on a
        # BLE connection can cause the peripheral to drop it, after which the
        # next write hangs for 30s on Windows. A 250ms window forces regular
        # traffic which keeps cheap BLE LED chips alive without flooding them.
        now = time.monotonic()
        if color == self._last_color and (now - self._last_write_at) < 0.25:
            return True
        delay = _MIN_WRITE_INTERVAL_SEC - (now - self._last_write_at)
        if delay > 0:
            await asyncio.sleep(delay)

        frame = self._protocol.encode_color(r, g, b, brightness)
        if self._aes_key is not None:
            frame = _encrypt_govee_frame(frame, self._aes_key)
        try:
            await self._transport.write(frame)
        except asyncio.TimeoutError:
            # BLE connection likely dropped silently — reconnect and retry once.
            logger.warning(
                "BLE write to %s (%s) timed out — reconnecting",
                self._address,
                self._protocol.family,
            )
            try:
                await self._transport.close()
                await self._transport.connect()
                await self._transport.write(frame)
            except Exception as exc:
                logger.warning(
                    "BLE reconnect+retry to %s failed — backing off 10s: %s",
                    self._address,
                    exc,
                )
                self._reconnect_cooldown_until = time.monotonic() + 10.0
                return False
        except Exception as exc:
            logger.warning(
                "BLE write to %s (%s) failed: %s", self._address, self._protocol.family, exc
            )
            return False

        self._last_color = color
        self._last_write_at = time.monotonic()
        return True

    async def set_power(self, on: bool) -> bool:
        if not self.is_connected:
            return False
        try:
            frame = self._protocol.encode_power(on)
            if self._aes_key is not None:
                frame = _encrypt_govee_frame(frame, self._aes_key)
            await self._transport.write(frame)
            return True
        except Exception as exc:
            logger.warning("BLE power command to %s failed: %s", self._address, exc)
            return False

    @classmethod
    async def check_health(
        cls,
        url: str,
        http_client,  # noqa: ARG003 — unused; kept for the LEDClient contract
        prev_health: DeviceHealth | None = None,
    ) -> DeviceHealth:
        """BLE health isn't a passive check — a full GATT connect is the only signal.

        Doing that on every poll would exhaust the controller's connection
        slots, so we report the previously observed state and refresh only
        the timestamp. Live errors surface via ``send_pixels`` and are
        persisted by the device health tracker.
        """
        address = _strip_ble_scheme(url)
        return DeviceHealth(
            online=prev_health.online if prev_health else False,
            latency_ms=prev_health.latency_ms if prev_health else None,
            last_checked=datetime.now(timezone.utc),
            device_name=prev_health.device_name if prev_health else address,
            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_led_type=prev_health.device_led_type if prev_health else None,
            error=prev_health.error if prev_health else None,
        )
