"""BLE primitives for BiVital integrations."""

from __future__ import annotations

import asyncio
import platform
import re
from typing import Any, Callable, Optional

from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.exc import BleakError
from bleak.exc import BleakDBusError

from .ble_characteristics import (
    CHARACTERISTICS_BY_KEY,
    CHARACTERISTICS_BY_UUID,
    CharacteristicDef,
    canonical_uuid,
    encode_write_value,
)


class BiVitalBLEClient:
    """Minimal BLE client wrapper for BiVital."""

    def __init__(
        self,
        address: str,
    ) -> None:
        self.device = address.strip()
        self.address: Optional[str] = None
        self._client: Optional[BleakClient] = None

    @staticmethod
    async def scan(timeout: float = 5.0, retries: int = 3, retry_delay: float = 1.0):
        """Return nearby BLE devices discovered during timeout."""
        last_error: Optional[Exception] = None
        for attempt in range(1, retries + 1):
            try:
                return await BleakScanner.discover(timeout=timeout)
            except BleakError as exc:
                text = str(exc).lower()
                if "failed to start scanner" in text:
                    last_error = exc
                    if attempt < retries:
                        await asyncio.sleep(retry_delay)
                        continue
                    raise RuntimeError(_windows_scanner_start_message()) from exc
                raise
            except BleakDBusError as exc:
                text = str(exc)
                if _is_bluez_service_error(exc):
                    raise RuntimeError(_bluez_missing_message()) from exc
                if "org.bluez.Error.InProgress" in text:
                    last_error = exc
                    if attempt < retries:
                        await asyncio.sleep(retry_delay)
                        continue
                    raise RuntimeError(
                        "Bluetooth scan is already in progress in BlueZ. "
                        "Close other BLE scanners and retry. "
                        "If it persists, restart bluetooth service "
                        "(`sudo service bluetooth restart`) in WSL/Linux."
                    ) from exc
                raise
        if last_error is not None:
            raise RuntimeError(f"BLE scan failed after retries: {last_error}")
        return []

    async def connect(self) -> None:
        """Connect once using default retry policy."""
        await self.connect_with_retry()

    async def __aenter__(self) -> "BiVitalBLEClient":
        """Context-manager entry: connect and return client."""
        await self.connect_with_retry()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        """Context-manager exit: always disconnect."""
        await self.disconnect()

    async def connect_with_retry(
        self,
        timeout: float = 15.0,
        retries: int = 3,
        retry_delay: float = 1.0,
        settle_delay: float = 0.3,
        scan_timeout: float = 5.0,
        scan_retries: int = 3,
    ) -> None:
        """Connect with retry logic and backend-independent connected checks."""
        self.address = await self.resolve_address(
            self.device, timeout=scan_timeout, retries=scan_retries
        )
        last_error: Optional[Exception] = None
        for attempt in range(1, retries + 1):
            self._client = BleakClient(self.address, timeout=timeout)
            try:
                await self._client.connect()
                if settle_delay > 0:
                    await asyncio.sleep(settle_delay)
                if self._client.is_connected:
                    return
                last_error = RuntimeError(
                    f"Connection attempt {attempt} did not reach connected state"
                )
            except BleakDBusError as exc:
                if _is_bluez_service_error(exc):
                    raise RuntimeError(_bluez_missing_message()) from exc
                last_error = exc
            except Exception as exc:  # noqa: BLE001
                last_error = exc

            try:
                if self._client and self._client.is_connected:
                    await self._client.disconnect()
            except Exception:  # noqa: BLE001
                pass

            if attempt < retries:
                await asyncio.sleep(retry_delay)

        details = f": {last_error}" if last_error else ""
        raise RuntimeError(
            f"Could not connect to {self.address} after {retries} attempts{details}"
        )

    @classmethod
    async def resolve_address(
        cls,
        device: str,
        timeout: float = 5.0,
        retries: int = 3,
        retry_delay: float = 1.0,
    ) -> str:
        """Resolve a device identifier (MAC, short MAC suffix, or name token) to MAC."""
        identifier = device.strip()
        if _is_mac_address(identifier):
            return _normalize_mac(identifier)

        token = _normalize_token(identifier)
        devices = await cls.scan(timeout=timeout, retries=retries, retry_delay=retry_delay)
        matches = []
        for dev in devices:
            addr = (dev.address or "").strip()
            name = (dev.name or "").strip()
            if not addr:
                continue
            if _matches_device_token(addr, name, token):
                matches.append(dev)

        if not matches:
            raise RuntimeError(
                f"Could not resolve device identifier '{device}'. "
                "Use --scan to list BI-Vital devices."
            )

        if len(matches) > 1:
            choices = ", ".join(
                f"{m.address} ({m.name or 'Unknown'})" for m in matches[:6]
            )
            raise RuntimeError(
                f"Identifier '{device}' is ambiguous. Matching devices: {choices}"
            )
        return matches[0].address

    async def disconnect(self) -> None:
        """Disconnect active BLE session if connected."""
        if self._client and self._client.is_connected:
            await self._client.disconnect()

    async def list_gatt(self) -> list[dict[str, Any]]:
        """Return the discovered GATT services/characteristics for the active device."""
        if not self._client or not self._client.is_connected:
            raise RuntimeError("Client is not connected")
        services = self._client.services
        if services is None:
            services = await self._client.get_services()
        output: list[dict[str, Any]] = []
        for service in services:
            output.append(
                {
                    "service_uuid": str(service.uuid).lower(),
                    "service_description": str(service.description),
                    "characteristics": [
                        {
                            "uuid": str(char.uuid).lower(),
                            "description": str(char.description),
                            "properties": list(char.properties),
                        }
                        for char in service.characteristics
                    ],
                }
            )
        return output

    async def read_characteristic(self, key_or_uuid: str) -> bytes:
        """Read raw bytes from one characteristic by key or UUID."""
        if not self._client or not self._client.is_connected:
            raise RuntimeError("Client is not connected")
        base_key_or_uuid, _ = split_target_selector(key_or_uuid)
        known = self._get_known_def(base_key_or_uuid)
        if known and not known.readable:
            raise RuntimeError(
                f"Read not supported for {known.key} ({known.uuid}); use stream/notify."
            )
        target = self._resolve_char_specifier(base_key_or_uuid)
        target_uuid = _target_uuid_from_specifier(target)
        try:
            return bytes(await self._client.read_gatt_char(target))
        except BleakDBusError as exc:
            if "org.bluez.Error.NotPermitted" in str(exc):
                raise RuntimeError(
                    f"Read not permitted for {key_or_uuid} ({target_uuid})"
                ) from exc
            raise
        except BleakError as exc:
            if "Multiple Characteristics with this UUID" in str(exc):
                raise RuntimeError(
                    f"Ambiguous UUID for {key_or_uuid} ({target_uuid}). "
                    "Use a known key so service+characteristic can be resolved."
                ) from exc
            raise

    async def read_value(self, key_or_uuid: str) -> Any:
        """Read and decode one characteristic value, including optional field selector."""
        base, selector = split_target_selector(key_or_uuid)
        raw = await self.read_characteristic(base)
        decoded = self.decode_payload(resolve_uuid(base), raw)
        return select_decoded_value(decoded, selector)

    async def write_characteristic(
        self, key_or_uuid: str, payload: bytes | str, response: bool = True
    ) -> None:
        """Write payload to one characteristic by key or UUID."""
        if not self._client or not self._client.is_connected:
            raise RuntimeError("Client is not connected")
        base_key_or_uuid, _ = split_target_selector(key_or_uuid)
        known = self._get_known_def(base_key_or_uuid)
        if known and not known.writable:
            raise RuntimeError(
                f"Write not supported for {known.key} ({known.uuid})."
            )
        target = self._resolve_char_specifier(base_key_or_uuid)
        target_uuid = _target_uuid_from_specifier(target)
        encoded_payload = (
            encode_write_value(base_key_or_uuid, payload)
            if isinstance(payload, str)
            else payload
        )
        if not isinstance(encoded_payload, (bytes, bytearray)):
            raise ValueError("Payload must encode to bytes")
        try:
            await self._client.write_gatt_char(
                target, bytes(encoded_payload), response=response
            )
        except BleakDBusError as exc:
            if "org.bluez.Error.NotPermitted" in str(exc):
                raise RuntimeError(
                    f"Write not permitted for {key_or_uuid} ({target_uuid})"
                ) from exc
            raise
        except BleakError as exc:
            if "Multiple Characteristics with this UUID" in str(exc):
                raise RuntimeError(
                    f"Ambiguous UUID for {key_or_uuid} ({target_uuid}). "
                    "Use a known key so service+characteristic can be resolved."
                ) from exc
            raise

    async def write_value(
        self, key_or_uuid: str, value: bytes | str, response: bool = True
    ) -> None:
        """Write one characteristic value with optional encoded text payload."""
        await self.write_characteristic(key_or_uuid, value, response=response)

    async def stream_characteristic(
        self,
        key_or_uuid: str,
        on_data: Callable[[Any], None],
        duration_seconds: Optional[float] = None,
    ) -> None:
        """Subscribe to notifications and dispatch decoded samples to callback."""
        if not self._client or not self._client.is_connected:
            raise RuntimeError("Client is not connected")

        base_key_or_uuid, selector = split_target_selector(key_or_uuid)
        known = self._get_known_def(base_key_or_uuid)
        if known and not known.stream:
            raise RuntimeError(
                f"Stream/notify not supported for {known.key} ({known.uuid}); use read."
            )
        target = self._resolve_char_specifier(base_key_or_uuid)
        target_uuid = _target_uuid_from_specifier(target)
        stop_event = asyncio.Event()

        def _notification_handler(_: int, data: bytearray) -> None:
            payload = bytes(data)
            try:
                decoded = self.decode_payload(target_uuid, payload)
                on_data(select_decoded_value(decoded, selector))
            except Exception as exc:  # noqa: BLE001
                on_data(
                    {
                        "decode_error": str(exc),
                        "uuid": target_uuid,
                        "raw_hex": payload.hex(),
                        "len": len(payload),
                    }
                )

        try:
            await self._client.start_notify(target, _notification_handler)
        except BleakDBusError as exc:
            if "org.bluez.Error.NotSupported" in str(exc):
                raise RuntimeError(
                    f"Stream/notify not supported for {base_key_or_uuid} ({target_uuid})"
                ) from exc
            raise
        try:
            if duration_seconds is None:
                await stop_event.wait()
            else:
                await asyncio.sleep(duration_seconds)
        finally:
            await self._client.stop_notify(target)

    async def stream(
        self,
        key_or_uuid: str,
        on_data: Callable[[Any], None],
        duration_seconds: Optional[float] = None,
    ) -> None:
        """Alias for `stream_characteristic` for concise notebook/script usage."""
        await self.stream_characteristic(
            key_or_uuid=key_or_uuid,
            on_data=on_data,
            duration_seconds=duration_seconds,
        )

    def decode_payload(self, char_uuid: str, payload: bytes) -> Any:
        """Decode raw payload using known characteristic decoders."""
        target_uuid = canonical_uuid(char_uuid)
        known = CHARACTERISTICS_BY_UUID.get(target_uuid)
        if known and known.decoder is not None:
            return known.decoder(payload)
        return payload.hex()

    def _resolve_char_specifier(
        self, key_or_uuid: str
    ) -> str | int | BleakGATTCharacteristic:
        """Resolve known keys to exact characteristic objects to avoid UUID ambiguity."""
        if not self._client or not self._client.is_connected:
            raise RuntimeError("Client is not connected")

        key = normalize_key_or_alias(key_or_uuid)
        known = CHARACTERISTICS_BY_KEY.get(key)
        if known:
            services = self._client.services
            if services is None:
                return known.uuid
            service = services.get_service(known.service_uuid)
            if service is None:
                return known.uuid
            matches = [c for c in service.characteristics if c.uuid.lower() == known.uuid]
            if len(matches) == 1:
                return matches[0]
            if len(matches) > 1:
                raise RuntimeError(
                    f"Multiple matches for key '{key}' in service {known.service_uuid}. "
                    "Use characteristic handle."
                )
            return known.uuid
        return resolve_uuid(key_or_uuid)

    @staticmethod
    def _get_known_def(key_or_uuid: str) -> Optional[CharacteristicDef]:
        """Resolve normalized key into characteristic definition if known."""
        key = normalize_key_or_alias(key_or_uuid)
        return CHARACTERISTICS_BY_KEY.get(key)


def resolve_uuid(key_or_uuid: str) -> str:
    """Resolve a key/alias/UUID string into canonical 128-bit UUID."""
    key = normalize_key_or_alias(key_or_uuid)
    known = CHARACTERISTICS_BY_KEY.get(key)
    if known:
        return known.uuid
    return canonical_uuid(key_or_uuid)


def normalize_key_or_alias(key_or_uuid: str) -> str:
    """Normalize user-friendly aliases to canonical library characteristic keys."""
    key = key_or_uuid.strip().lower()
    aliases = {
        "hr": "heart_rate",
        "motion": "motion_raw",
        "env": "temperature",
        "environmental": "temperature",
        "custom_led": "led_color",
        "custom_power_mode": "power_mode",
        "custom_flags": "record_mode",
        "ecg_analog": "ecg",
    }
    return aliases.get(key, key)


def split_target_selector(key_or_uuid: str) -> tuple[str, list[str]]:
    """Split selector syntax like `heart_rate.bpm` into base key and path parts."""
    raw = key_or_uuid.strip()
    if "." not in raw:
        return normalize_key_or_alias(raw), []
    base, *selector = raw.split(".")
    normalized_base = normalize_key_or_alias(base)
    if normalized_base in CHARACTERISTICS_BY_KEY and selector:
        return normalized_base, selector
    return normalize_key_or_alias(raw), []


def select_decoded_value(decoded: Any, selector: list[str]) -> Any:
    """Select nested fields/indexes from a decoded sample value."""
    if not selector:
        return decoded

    current: Any = decoded
    for part in selector:
        if isinstance(current, dict):
            if part not in current:
                raise KeyError(f"field '{part}' not found")
            current = current[part]
            continue
        if isinstance(current, (list, tuple)):
            try:
                index = int(part)
            except ValueError as exc:
                raise KeyError(f"index required, got '{part}'") from exc
            if index < 0 or index >= len(current):
                raise IndexError(f"index {index} out of range")
            current = current[index]
            continue
        raise KeyError(f"cannot select '{part}' from scalar value")
    return current


def _target_uuid_from_specifier(target: str | int | BleakGATTCharacteristic) -> str:
    """Extract canonical UUID text from Bleak target specifier."""
    if isinstance(target, BleakGATTCharacteristic):
        return target.uuid.lower()
    if isinstance(target, str):
        return canonical_uuid(target)
    return str(target)


def _bluez_missing_message() -> str:
    """Build a user-facing troubleshooting message for missing BlueZ service."""
    base = (
        "Bluetooth stack not available: BlueZ DBus service 'org.bluez' is missing.\n"
        "On Linux, install/start Bluetooth services, e.g.:\n"
        "  sudo apt install bluez\n"
        "  sudo service bluetooth start  (or: sudo systemctl start bluetooth)"
    )
    if "microsoft" in platform.uname().release.lower():
        return (
            base
            + "\nWSL note: BLE scanning from Linux userspace is often unavailable unless "
            "Bluetooth is explicitly forwarded into WSL (and BlueZ is running inside WSL)."
        )
    return base


def _windows_scanner_start_message() -> str:
    """Build a user-facing troubleshooting message for Windows scanner start errors."""
    return (
        "Windows BLE scanner could not start.\n"
        "Check these points:\n"
        "  1) Bluetooth adapter is enabled in Windows Settings.\n"
        "  2) Windows Location Services are enabled (required for BLE scan APIs).\n"
        "  3) No other app is blocking the adapter (close vendor Bluetooth tools).\n"
        "  4) Bluetooth Support Service is running (services.msc).\n"
        "  5) If needed, disable/enable the adapter in Device Manager and retry."
    )


def _is_bluez_service_error(exc: BleakDBusError) -> bool:
    """Check whether a DBus error indicates unavailable BlueZ service."""
    text = str(exc)
    return (
        "org.freedesktop.DBus.Error.ServiceUnknown" in text
        or "org.freedesktop.DBus.Error.TimedOut" in text
    ) and "org.bluez" in text


def _is_mac_address(value: str) -> bool:
    """Return True if value looks like a standard BLE MAC address."""
    return bool(re.fullmatch(r"(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}", value.strip()))


def _normalize_mac(value: str) -> str:
    """Normalize MAC address to uppercase AA:BB:CC:DD:EE:FF."""
    return value.strip().upper()


def _normalize_token(value: str) -> str:
    """Normalize a short identifier token for matching against MAC/name."""
    return re.sub(r"[^0-9A-Za-z]", "", value.strip()).upper()


def _matches_device_token(address: str, name: str, token: str) -> bool:
    """Match token against full/suffix MAC and BI-Vital device name."""
    addr_compact = re.sub(r"[^0-9A-Fa-f]", "", address).upper()
    name_upper = name.upper()
    if not token:
        return False
    if addr_compact == token or addr_compact.endswith(token):
        return True
    if token in name_upper:
        return True
    return False
