"""Device communication library."""
from __future__ import annotations

import asyncio
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass, replace
import logging
from typing import Any, AsyncIterator
from uuid import UUID

from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import BleakError

from bleak_retry_connector import establish_connection

COMMAND_FORMAT_FAN_SPEED_FORMAT = "-Luft-{:01d}-"
COMMAND_FORMAT_DIM = "-Dim{:03d}-"
COMMAND_FORMAT_PERIODIC_VENTING = "Period{:02d}"
COMMAND_FORMAT_AFTERCOOKINGSTRENGTHMANUAL = "Nachla-{:01d}"

COMMAND_STOP_FAN = "Luft-Aus"
COMMAND_LIGHT_ON_OFF = "Kochfeld"
COMMAND_RESETGREASEFILTER = "ResFett-"
COMMAND_RESETCHARCOALFILTER = "ResKohle"
COMMAND_AFTERCOOKINGTIMERMANUAL = "Nachlauf"
COMMAND_AFTERCOOKINGTIMERAUTO = "NachlAut"
COMMAND_AFTERCOOKINGTIMEROFF = "NachlAus"
COMMAND_ACTIVATECARBONFILTER = "coal-ava"

_LOGGER = logging.getLogger(__name__)

UUID_SERVICE = UUID("{77a2bd49-1e5a-4961-bba1-21f34fa4bc7b}")
UUID_RX = UUID("{23123e0a-1ad6-43a6-96ac-06f57995330d}")
UUID_TX = UUID("{68ecc82c-928d-4af0-aa60-0d578ffb35f7}")
UUID_CONFIG = UUID("{3e06fdc2-f432-404f-b321-dfa909f5c12c}")

DEVICE_NAME = "COOKERHOOD_FJAR"
ANNOUNCE_PREFIX = b"HOODFJAR"
ANNOUNCE_MANUFACTURER = int.from_bytes(ANNOUNCE_PREFIX[0:2], "little")

class FjaraskupanError(Exception):
    pass

class FjaraskupanBleakError(FjaraskupanError):
    pass

class FjaraskupanConnectionError(FjaraskupanBleakError):
    pass

class FjaraskupanWriteError(FjaraskupanBleakError):
    pass

class FjaraskupanReadError(FjaraskupanBleakError):
    pass

class FjaraskupanTimeout(FjaraskupanError, TimeoutError):
    pass


@dataclass(frozen=True)
class State:
    """Data received from characteristics."""

    light_on: bool = False
    after_cooking_fan_speed: int = 0
    after_cooking_on: bool = False
    carbon_filter_available: bool = False
    fan_speed: int = 0
    grease_filter_full: bool = False
    carbon_filter_full: bool = False
    dim_level: int = 0
    periodic_venting: int = 0
    periodic_venting_on: bool = False
    rssi: int = 0

    def replace_from_tx_char(self, databytes: bytes, **changes: Any):
        """Update state based on tx characteristics."""
        data = databytes.decode("ASCII")
        return replace(
            self,
            fan_speed=int(data[4]),
            light_on=data[5] == "L",
            after_cooking_on=data[6] == "N",
            carbon_filter_available=data[7] == "C",
            grease_filter_full=data[8] == "F",
            carbon_filter_full=data[9] == "K",
            dim_level=_range_check_dim(int(data[10:13]), self.dim_level),
            periodic_venting=_range_check_period(
                int(data[13:15]), self.periodic_venting
            ),
            **changes
        )

    def replace_from_manufacture_data(self, data: bytes, **changes: Any):
        """Update state based on broadcasted data."""
        light_on = _bittest(data[10], 0)
        dim_level = _range_check_dim(data[13], self.dim_level)
        if light_on and not self.light_on and dim_level < self.dim_level:
            light_on = False

        return replace(
            self,
            fan_speed=int(data[8]),
            after_cooking_fan_speed=int(data[9]),
            light_on=light_on,
            after_cooking_on=_bittest(data[10], 1),
            periodic_venting_on=_bittest(data[10], 2),
            grease_filter_full=_bittest(data[11], 0),
            carbon_filter_full=_bittest(data[11], 1),
            carbon_filter_available=_bittest(data[11], 2),
            dim_level=dim_level,
            periodic_venting=_range_check_period(data[14], self.periodic_venting),
            **changes
        )


def _range_check_dim(value: int, fallback: int):
    if value >= 0 and value <= 100:
        return value
    else:
        return fallback


def _range_check_period(value: int, fallback: int):
    if value >= 0 and value < 60:
        return value
    else:
        return fallback


def _bittest(data: int, bit: int):
    return (data & (1 << bit)) != 0


def device_filter(device: BLEDevice, advertisement_data: AdvertisementData) -> bool:
    uuids = advertisement_data.service_uuids
    if str(UUID_SERVICE) in uuids:
        return True

    if device.name == DEVICE_NAME:
        return True

    manufacturer_data = advertisement_data.manufacturer_data.get(ANNOUNCE_MANUFACTURER, b'')
    if manufacturer_data.startswith(ANNOUNCE_PREFIX[2:]):
        return True

    return False


class Device:
    """Communication handler."""


    def __init__(self, address: str, keycode=b"1234", disconnect_delay: float = 5.0) -> None:
        """Initialize handler."""
        self.address = address
        self._keycode = keycode
        self.state = State()
        self._lock = asyncio.Lock()
        self._client: BleakClient | None = None
        self._client_count = 0
        self._client_stack = AsyncExitStack()
        self._disconnect_delay = disconnect_delay
        self._disconnect_task: asyncio.Task | None = None

    async def _disconnect_callback(self):
        await asyncio.sleep(self._disconnect_delay)
        async with self._lock:
            await self._disconnect()

    async def _disconnect_later(self):
        self._disconnect_task = asyncio.create_task(self._disconnect_callback(), name="Fjaraskupen Disconnector")
        
    async def _disconnect(self):
        assert self._client
        self._client = None

        _LOGGER.debug("Disconnecting")
        try:
            await self._client_stack.pop_all().aclose()
        except TimeoutError as exc:
            _LOGGER.debug("Timeout on disconnect", exc_info=True)
            raise FjaraskupanTimeout("Timeout on disconnect") from exc
        except BleakError as exc:
            _LOGGER.debug("Error on disconnect", exc_info=True)
            raise FjaraskupanConnectionError("Error on disconnect") from exc
        _LOGGER.debug("Disconnected")

    async def _connect(self, address_or_ble_device: BLEDevice | str | None = None):
        if address_or_ble_device is None:
            address_or_ble_device = self.address

        assert self._client is None

        _LOGGER.debug("Connecting")
        try:
            if isinstance(address_or_ble_device, BLEDevice):
                ble_device = address_or_ble_device
            else:
                ble_device = await BleakScanner.find_device_by_address(address_or_ble_device)

            self._client = await establish_connection(BleakClient, ble_device, name="Fjaraskupan")
            self._client_stack.push_async_exit(self._client)
        except asyncio.TimeoutError as exc:
            _LOGGER.debug("Timeout on connect", exc_info=True)
            raise FjaraskupanTimeout("Timeout on connect") from exc
        except BleakError as exc:
            _LOGGER.debug("Error on connect", exc_info=True)
            raise FjaraskupanConnectionError("Error on connect") from exc
        _LOGGER.debug("Connected")
    
    @asynccontextmanager
    async def connect(self, address_or_ble_device: BLEDevice | None = None) -> AsyncIterator[Device]:
        async with self._lock:
            if self._disconnect_task:
                self._disconnect_task.cancel()
                self._disconnect_task = None

            if self._client is None:
                await self._connect(address_or_ble_device)
            else:
                _LOGGER.debug("Connection reused")
            self._client_count += 1

        try:
            yield self
        finally:
            async with self._lock:
                self._client_count -= 1
                if self._client_count == 0:
                    if self._disconnect_delay:
                        await self._disconnect_later()
                    else:
                        await self._disconnect()


    def characteristic_callback(self, data: bytearray):
        """Handle callback on characteristic change."""
        _LOGGER.debug("Characteristic callback: %s", data)

        if data[0:4] != self._keycode:
            _LOGGER.warning("Wrong keycode in data %s", data)
            return

        self.state = self.state.replace_from_tx_char(data)

        _LOGGER.debug("Characteristic callback result: %s", self.state)

    def detection_callback(self, device: BLEDevice, advertisement_data: AdvertisementData):
        """Handle scanner data."""
        data = advertisement_data.manufacturer_data.get(ANNOUNCE_MANUFACTURER)
        if data is None:
            return
        # Recover full manufacturer data. It's breaking standard by
        # not providing a manufacturer prefix here.
        data = ANNOUNCE_PREFIX[0:2] + data
        self.detection_callback_raw(data, advertisement_data.rssi)

    def detection_callback_raw(self, data: bytes, rssi: int):

        if data[0:8] != ANNOUNCE_PREFIX:
            _LOGGER.debug("Missing key in manufacturer data %s", data)
            return

        self.state = self.state.replace_from_manufacture_data(data, rssi=rssi)

        _LOGGER.debug("Detection callback result: %s", self.state)

    async def update(self):
        async with self._lock:
            await self._update()

    async def _update(self):
        """Update internal state."""
        assert self._client, "Device must be connected"
        try:
            databytes = await self._client.read_gatt_char(UUID_RX)
        except asyncio.TimeoutError as exc:
            _LOGGER.debug("Timeout on update", exc_info=True)
            raise FjaraskupanTimeout from exc
        except BleakError as exc:
            _LOGGER.debug("Failed to update", exc_info=True)
            raise FjaraskupanReadError("Failed to update device") from exc

        self.characteristic_callback(databytes)

    async def send_command(self, cmd: str):
        """Send given command."""
        async with self._lock:
            await self._send_command(cmd)

    async def _send_command(self, cmd: str):
        """Send given command."""
        assert len(cmd) == 8
        assert self._client, "Device must be connected"
        data = self._keycode + cmd.encode("ASCII")
        try:
            await self._client.write_gatt_char(UUID_RX, data, True)
        except asyncio.TimeoutError as exc:
            _LOGGER.debug("Timeout on write", exc_info=True)
            raise FjaraskupanTimeout from exc
        except BleakError as exc:
            _LOGGER.debug("Failed to write", exc_info=True)
            raise FjaraskupanWriteError("Failed to write") from exc

        if cmd == COMMAND_STOP_FAN:
            self.state = replace(self.state, fan_speed=0)
        elif cmd == COMMAND_LIGHT_ON_OFF:
            self.state = replace(self.state, light_on=not self.state.light_on)
        elif cmd == COMMAND_AFTERCOOKINGTIMERMANUAL:
            self.state = replace(self.state, after_cooking_on=True)
        elif cmd == COMMAND_AFTERCOOKINGTIMERAUTO:
            self.state = replace(
                self.state, after_cooking_on=True, after_cooking_fan_speed=0
            )

    async def send_fan_speed(self, speed: int):
        """Set numbered fan speed."""
        async with self._lock:
            await self._send_command(COMMAND_FORMAT_FAN_SPEED_FORMAT.format(speed))
            self.state = replace(self.state, fan_speed=speed)

    async def send_after_cooking(self, speed: int):
        """Set numbered fan speed."""
        async with self._lock:
            await self._send_command(COMMAND_FORMAT_AFTERCOOKINGSTRENGTHMANUAL.format(speed))
            self.state = replace(self.state, after_cooking_fan_speed=speed)

    async def send_periodic_venting(self, minutes: int):
        """Set periodic venting."""
        async with self._lock:
            await self._send_command(COMMAND_FORMAT_PERIODIC_VENTING.format(minutes))
            self.state = replace(self.state, periodic_venting=minutes)

    async def send_dim(self, level: int):
        """Ask to dim to a certain level."""
        async with self._lock:
            if self.state.light_on ^ (level > 0):
                await self._send_command(COMMAND_LIGHT_ON_OFF)
            await self._send_command(COMMAND_FORMAT_DIM.format(level))
            self.state = replace(self.state, dim_level=level, light_on=level > 0)
