"""Thin async wrapper around ``bleak`` for LED-controller use.

Exists to:
  * Isolate the ``import bleak`` site so the rest of the codebase doesn't
    crash on platforms where bleak is unavailable (Chaquopy / Android).
  * Normalise addresses so UUID-on-macOS and MAC-on-Windows/Linux both
    work with the same API shape.
  * Coalesce rapid ``write()`` calls — BLE writes are O(tens of ms) each
    and LedGrab's hot loop runs at 60+ FPS, so we drop any pending write
    that has been superseded before it is sent.

Import-order note: ``bleak`` is imported lazily inside methods so the
module itself imports cleanly on Android, where the whole BLE feature
is effectively disabled.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from typing import List, Optional

from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android

logger = get_logger(__name__)


async def _bounded_await(coro, timeout: float, what: str):
    """Like ``asyncio.wait_for`` but safe when the inner task can't be cancelled.

    bleak's WinRT backend wraps WinRT ``IAsyncOperation`` s that ignore
    asyncio cancellation. ``asyncio.wait_for`` responds to a timeout by
    cancelling the inner task and then **awaiting its cancellation** — and
    since the WinRT task never finishes cancelling, ``wait_for`` itself
    hangs forever.

    ``asyncio.wait`` with a timeout returns as soon as the timeout fires
    regardless of whether pending tasks can actually be cancelled, so the
    caller always regains control. The orphaned task is best-effort
    cancelled and then abandoned.
    """
    task = asyncio.create_task(coro)
    done, _pending = await asyncio.wait({task}, timeout=timeout)
    if task not in done:
        task.cancel()
        raise asyncio.TimeoutError(f"{what} exceeded {timeout:.1f}s")
    exc = task.exception()
    if exc is not None:
        raise exc
    return task.result()


def _bleak_available() -> bool:
    try:
        import bleak  # noqa: F401
    except ImportError:
        return False
    return True


@dataclass(frozen=True)
class DiscoveredBLEDevice:
    """One BLE peripheral found during scanning."""

    address: str
    name: str
    rssi: Optional[int]
    service_uuids: tuple = ()


async def scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
    """Scan for nearby BLE peripherals.

    On Android dispatches to the Kotlin BleBridge scanner.
    On desktop uses bleak (requires the [ble] extra).

    Returns devices sorted by RSSI descending (strongest first).

    Raises:
        RuntimeError: If neither backend is available.
    """
    if is_android():
        from ledgrab.core.devices.android_ble_transport import android_ble_scan

        return await android_ble_scan(timeout=timeout)

    if not _bleak_available():
        raise RuntimeError(
            "bleak is not installed — BLE support requires the [ble] extra. "
            "Install with: pip install 'ledgrab[ble]'"
        )
    from bleak import BleakScanner

    raw = await BleakScanner.discover(timeout=timeout, return_adv=True)
    devices: List[DiscoveredBLEDevice] = []
    for address, (device, adv) in raw.items():
        # Some platforms don't surface names for non-advertising peripherals —
        # fall back to the address so the UI can still show something.
        name = adv.local_name or device.name or address
        rssi = getattr(adv, "rssi", None)
        service_uuids = tuple(getattr(adv, "service_uuids", None) or [])
        devices.append(
            DiscoveredBLEDevice(address=address, name=name, rssi=rssi, service_uuids=service_uuids)
        )
    devices.sort(key=lambda d: (d.rssi is None, -(d.rssi or 0)))
    return devices


class BLETransport:
    """Async wrapper around a ``BleakClient`` with write coalescing.

    Lifecycle:
        transport = BLETransport(address, write_char_uuid)
        await transport.connect()
        await transport.write(b"...")
        await transport.close()
    """

    def __init__(
        self,
        address: str,
        write_char_uuid: str,
        write_with_response: bool = False,
        connect_timeout: float = 10.0,
    ):
        self._address = address
        self._write_char_uuid = write_char_uuid
        self._write_with_response = write_with_response
        self._connect_timeout = connect_timeout
        self._client = None  # BleakClient | None
        self._lock = asyncio.Lock()

    @property
    def address(self) -> str:
        return self._address

    @property
    def is_connected(self) -> bool:
        return self._client is not None and bool(getattr(self._client, "is_connected", False))

    async def connect(self) -> None:
        """Connect to the peripheral.

        Raises:
            RuntimeError: If bleak is unavailable or connection fails.
        """
        if not _bleak_available():
            raise RuntimeError("bleak is not installed — BLE support requires the [ble] extra.")
        from bleak import BleakClient

        if self.is_connected:
            return

        # Pre-scan for a fresh advertisement so bleak gets a BLEDevice (with
        # address type + service UUIDs baked in) rather than just a raw MAC
        # string. On Windows/WinRT, connecting by string fails/times out when
        # the cached address type is wrong (common for cheap BLE LED chips
        # that advertise as Random Static). Passing the BLEDevice avoids that.
        from bleak import BleakScanner

        scan_timeout = max(3.0, self._connect_timeout / 2)
        logger.info("BLE pre-scan for %s (timeout=%.1fs)", self._address, scan_timeout)
        try:
            device = await _bounded_await(
                BleakScanner.find_device_by_address(self._address, timeout=scan_timeout),
                timeout=scan_timeout + 2.0,
                what=f"BLE pre-scan for {self._address}",
            )
        except asyncio.TimeoutError as exc:
            raise RuntimeError(str(exc)) from exc
        except Exception as exc:
            raise RuntimeError(
                f"BLE pre-scan for {self._address} failed: {type(exc).__name__}: {exc}"
            ) from exc
        if device is None:
            raise RuntimeError(
                f"BLE device {self._address} not found in scan "
                f"(timeout={scan_timeout}s) — is it powered on and in range?"
            )
        # Log everything bleak knows about this peripheral from the fresh
        # advertisement. If connects keep timing out, look here for the
        # address type (public vs random static) and advertised services —
        # Windows WinRT is strict about matching those on connect.
        details_summary = ""
        try:
            det = getattr(device, "details", None)
            if det is not None:
                details_summary = f" details={det!r}"
        except Exception:
            pass
        logger.info(
            "BLE pre-scan found %s name=%r rssi=%s%s — opening GATT",
            self._address,
            getattr(device, "name", None),
            getattr(device, "rssi", None),
            details_summary,
        )

        self._client = BleakClient(device, timeout=self._connect_timeout)
        try:
            # bleak's WinRT backend does not always respect the constructor
            # timeout — connect() can block 30s+ when the peripheral is gone.
            # _bounded_await enforces the timeout on the Python side even when
            # the inner WinRT future refuses to be cancelled (wait_for hangs
            # forever in that case — see _bounded_await docstring).
            await _bounded_await(
                self._client.connect(),
                timeout=self._connect_timeout,
                what=f"BLE connect to {self._address}",
            )
        except Exception as exc:
            self._client = None
            # Many bleak/WinRT failures surface with an empty ``str(exc)`` —
            # include the exception type so the log actually tells us which
            # branch of "connect failed" we're in (timeout vs WinRT HRESULT
            # vs BleakDeviceNotFoundError vs ...).
            detail = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
            raise RuntimeError(
                f"Failed to connect to BLE device {self._address}: {detail}"
            ) from exc

        logger.info("BLE connected to %s", self._address)

    async def close(self) -> None:
        """Disconnect (best effort — never raises)."""
        client = self._client
        self._client = None
        if client is None:
            return
        try:
            if getattr(client, "is_connected", False):
                await client.disconnect()
        except Exception as exc:
            logger.warning("BLE disconnect of %s raised: %s", self._address, exc)

    async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
        """Send bytes to a GATT write characteristic.

        Serialised through an internal lock — BLE stacks do not like
        overlapping writes on the same GATT characteristic.

        Bounded by a 2-second timeout: Windows/bleak occasionally hangs for
        its default 30s on the second write to certain cheap BLE LED chips.
        Timing out keeps the target's processing loop responsive.

        Args:
            data: payload bytes.
            char_uuid: target characteristic. Defaults to the transport's
                configured ``write_char_uuid`` (used for color/power frames);
                pass an alternate UUID for vendor-specific init writes that
                target a different characteristic (e.g. SP110E's ``ffe2``).

        Raises:
            RuntimeError: If not connected.
            TimeoutError: If the write does not complete within 2 seconds.
        """
        if not self.is_connected or self._client is None:
            raise RuntimeError(f"BLE transport {self._address} not connected")
        target = char_uuid or self._write_char_uuid
        async with self._lock:
            await _bounded_await(
                self._client.write_gatt_char(target, data, response=self._write_with_response),
                timeout=2.0,
                what=f"BLE write to {self._address}",
            )


def make_transport(
    address: str,
    write_char_uuid: str,
    write_with_response: bool = False,
    connect_timeout: float = 10.0,
) -> "BLETransport":
    """Return the appropriate BLE transport for the current platform.

    On Android returns an :class:`~ledgrab.core.devices.android_ble_transport.AndroidBLETransport`
    backed by the Kotlin ``BleBridge`` singleton.  On desktop returns a
    :class:`BLETransport` backed by bleak.

    The returned object has the same interface regardless of backend
    (``connect``, ``close``, ``write``, ``is_connected``, ``address``).
    """
    if is_android():
        from ledgrab.core.devices.android_ble_transport import AndroidBLETransport

        return AndroidBLETransport(  # type: ignore[return-value]
            address=address,
            write_char_uuid=write_char_uuid,
            write_with_response=write_with_response,
            connect_timeout=connect_timeout,
        )
    return BLETransport(
        address=address,
        write_char_uuid=write_char_uuid,
        write_with_response=write_with_response,
        connect_timeout=connect_timeout,
    )
