"""Parser for Airthings BLE devices"""

from __future__ import annotations

import asyncio
import dataclasses
import re
from collections import namedtuple
from functools import partial
from logging import Logger

from async_interrupt import interrupt
from bleak import BleakClient, BleakError
from bleak.backends.device import BLEDevice
from bleak.backends.service import BleakGATTService
from bleak_retry_connector import BleakClientWithServiceCache, establish_connection

from airthings_ble.airthings_firmware import AirthingsFirmwareVersion
from airthings_ble.atom.request_path import AtomRequestPath
from airthings_ble.command_decode import COMMAND_DECODERS, AtomCommandDecode
from airthings_ble.radon_level import get_radon_level
from airthings_ble.sensor_decoders import SENSOR_DECODERS

from .const import (
    ATOM_BAT,
    ATOM_CO2,
    ATOM_HUMIDITY,
    ATOM_LUX,
    ATOM_NOISE,
    ATOM_PRESSURE,
    ATOM_RADON_1DAY_AVG,
    ATOM_RADON_MONTH_AVG,
    ATOM_RADON_WEEK_AVG,
    ATOM_RADON_YEAR_AVG,
    ATOM_TEMPERATURE,
    ATOM_VOC,
    BATTERY,
    BQ_TO_PCI_MULTIPLIER,
    CHAR_UUID_DATETIME,
    CHAR_UUID_DEVICE_NAME,
    CHAR_UUID_FIRMWARE_REV,
    CHAR_UUID_HARDWARE_REV,
    CHAR_UUID_HUMIDITY,
    CHAR_UUID_ILLUMINANCE_ACCELEROMETER,
    CHAR_UUID_MANUFACTURER_NAME,
    CHAR_UUID_MODEL_NUMBER_STRING,
    CHAR_UUID_RADON_1DAYAVG,
    CHAR_UUID_RADON_LONG_TERM_AVG,
    CHAR_UUID_SERIAL_NUMBER_STRING,
    CHAR_UUID_TEMPERATURE,
    CHAR_UUID_WAVE_2_DATA,
    CHAR_UUID_WAVE_PLUS_DATA,
    CHAR_UUID_WAVEMINI_DATA,
    CO2,
    COMMAND_UUID_ATOM,
    COMMAND_UUID_ATOM_NOTIFY,
    COMMAND_UUID_WAVE_2,
    COMMAND_UUID_WAVE_MINI,
    COMMAND_UUID_WAVE_PLUS,
    DEFAULT_MAX_UPDATE_ATTEMPTS,
    HUMIDITY,
    ILLUMINANCE,
    LUX,
    NOISE,
    PRESSURE,
    RADON_1DAY_AVG,
    RADON_1DAY_LEVEL,
    RADON_LONGTERM_AVG,
    RADON_LONGTERM_LEVEL,
    RADON_MONTH_AVG,
    RADON_MONTH_LEVEL,
    RADON_WEEK_AVG,
    RADON_WEEK_LEVEL,
    RADON_YEAR_AVG,
    RADON_YEAR_LEVEL,
    TEMPERATURE,
    UPDATE_TIMEOUT,
    VOC,
)
from .device_type import AirthingsDeviceType

Characteristic = namedtuple("Characteristic", ["uuid", "name", "format"])

wave_gen_1_device_info_characteristics = [
    Characteristic(CHAR_UUID_MANUFACTURER_NAME, "manufacturer", "utf-8"),
    Characteristic(CHAR_UUID_SERIAL_NUMBER_STRING, "serial_nr", "utf-8"),
    Characteristic(CHAR_UUID_DEVICE_NAME, "device_name", "utf-8"),
    Characteristic(CHAR_UUID_FIRMWARE_REV, "firmware_rev", "utf-8"),
]
device_info_characteristics = wave_gen_1_device_info_characteristics + [
    Characteristic(CHAR_UUID_HARDWARE_REV, "hardware_rev", "utf-8"),
]
_CHARS_BY_MODELS = {
    "2900": wave_gen_1_device_info_characteristics,
}

sensors_characteristics_uuid = [
    CHAR_UUID_DATETIME,
    CHAR_UUID_TEMPERATURE,
    CHAR_UUID_HUMIDITY,
    CHAR_UUID_RADON_1DAYAVG,
    CHAR_UUID_RADON_LONG_TERM_AVG,
    CHAR_UUID_ILLUMINANCE_ACCELEROMETER,
    CHAR_UUID_WAVE_PLUS_DATA,
    CHAR_UUID_WAVE_2_DATA,
    CHAR_UUID_WAVEMINI_DATA,
    COMMAND_UUID_WAVE_2,
    COMMAND_UUID_WAVE_PLUS,
    COMMAND_UUID_WAVE_MINI,
]
sensors_characteristics_uuid_str = [str(x) for x in sensors_characteristics_uuid]


class DisconnectedError(Exception):
    """Disconnected from device."""


class UnsupportedDeviceError(Exception):
    """Unsupported device."""


def short_address(address: str) -> str:
    """Convert a Bluetooth address to a short address."""
    return address.replace("-", "").replace(":", "")[-6:].upper()


# pylint: disable=too-many-instance-attributes


@dataclasses.dataclass
class AirthingsDeviceInfo:
    """Response data with information about the Airthings device without sensors."""

    manufacturer: str = ""
    hw_version: str = ""
    sw_version: str = ""
    model: AirthingsDeviceType = AirthingsDeviceType.UNKNOWN
    name: str = ""
    identifier: str = ""
    address: str = ""
    did_first_sync: bool = False

    def friendly_name(self) -> str:
        """Generate a name for the device."""

        return f"Airthings {self.model.product_name}"


@dataclasses.dataclass
class AirthingsDevice(AirthingsDeviceInfo):
    """Response data with information about the Airthings device"""

    firmware = AirthingsFirmwareVersion()

    sensors: dict[str, str | float | None] = dataclasses.field(
        default_factory=lambda: {}
    )

    def friendly_name(self) -> str:
        """Generate a name for the device."""

        return f"Airthings {self.model.product_name}"


# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-few-public-methods
class AirthingsBluetoothDeviceData:
    """Data for Airthings BLE sensors."""

    def __init__(
        self,
        logger: Logger,
        is_metric: bool = True,
        max_attempts: int = DEFAULT_MAX_UPDATE_ATTEMPTS,
    ) -> None:
        """Initialize the Airthings BLE sensor data object."""
        self.logger = logger
        self.is_metric = is_metric
        self.device_info = AirthingsDeviceInfo()
        self.max_attempts = max_attempts

    def set_max_attempts(self, max_attempts: int) -> None:
        """Set the number of attempts."""
        self.max_attempts = max_attempts

    async def _get_device_characteristics(
        self, client: BleakClient, device: AirthingsDevice
    ) -> None:
        device_info = self.device_info
        device_info.address = client.address
        did_first_sync = device_info.did_first_sync

        device.firmware.update_current_version(device_info.sw_version)

        # We need to fetch model to determ what to fetch.
        if not did_first_sync:
            try:
                data = await client.read_gatt_char(CHAR_UUID_MODEL_NUMBER_STRING)
            except BleakError as err:
                self.logger.debug("Get device characteristics exception: %s", err)
                return

            device_info.model = AirthingsDeviceType.from_raw_value(data.decode("utf-8"))
            if device_info.model == AirthingsDeviceType.UNKNOWN:
                raise UnsupportedDeviceError(
                    f"Model {data.decode('utf-8')} is not supported"
                )

        characteristics = _CHARS_BY_MODELS.get(
            device_info.model.raw_value, device_info_characteristics
        )

        self.logger.debug("Fetching device info characteristics: %s", characteristics)

        for characteristic in characteristics:
            if did_first_sync and characteristic.name != "firmware_rev":
                # Only the sw_version can change once set, so we can skip the rest.
                continue

            try:
                data = await client.read_gatt_char(characteristic.uuid)
            except BleakError as err:
                self.logger.debug("Get device characteristics exception: %s", err)
                continue
            if characteristic.name == "manufacturer":
                device_info.manufacturer = data.decode(characteristic.format)
            elif characteristic.name == "hardware_rev":
                device_info.hw_version = data.decode(characteristic.format)
            elif characteristic.name == "firmware_rev":
                device_info.sw_version = data.decode(characteristic.format)
            elif characteristic.name == "device_name":
                device_info.name = data.decode(characteristic.format)
            elif characteristic.name == "serial_nr":
                identifier = data.decode(characteristic.format)
                # Some devices return `Serial Number` on Mac instead of
                # the actual serial number.
                if identifier != "Serial Number":
                    device_info.identifier = identifier
            else:
                self.logger.debug(
                    "Characteristics not handled: %s", characteristic.uuid
                )

        if (
            device_info.model == AirthingsDeviceType.WAVE_GEN_1
            and device_info.name
            and not device_info.identifier
        ):
            # For the Wave gen. 1 we need to fetch the identifier in the device name.
            # Example: From `AT#123456-2900Radon` we need to fetch `123456`.
            wave1_identifier = re.search(r"(?<=\#)[0-9]{1,6}", device_info.name)
            if wave1_identifier is not None and len(wave1_identifier.group()) == 6:
                device_info.identifier = wave1_identifier.group()

        # In some cases the device name will be empty, for example when using a Mac.
        if not device_info.name:
            device_info.name = device_info.friendly_name()

        if device_info.model:
            device_info.did_first_sync = True

        # Copy the cached device_info to device
        for field in dataclasses.fields(device_info):
            name = field.name
            setattr(device, name, getattr(device_info, name))

    async def _get_service_characteristics(
        self, client: BleakClient, device: AirthingsDevice
    ) -> None:
        svcs = client.services
        sensors = device.sensors
        for service in svcs:
            if (
                (
                    str(COMMAND_UUID_ATOM)
                    in (str(x.uuid) for x in service.characteristics)
                )
                and (
                    str(COMMAND_UUID_ATOM_NOTIFY)
                    in (str(x.uuid) for x in service.characteristics)
                )
                and device.model in AirthingsDeviceType.atom_devices()
            ):
                await self._atom_sensor_data(client, device, sensors, service)
            else:
                await self._wave_sensor_data(client, device, sensors, service)

    async def _wave_sensor_data(
        self,
        client: BleakClient,
        device: AirthingsDevice,
        sensors: dict[str, str | float | None],
        service: BleakGATTService,
    ) -> None:
        for characteristic in service.characteristics:
            uuid = characteristic.uuid
            uuid_str = str(uuid)
            if uuid in sensors_characteristics_uuid_str and uuid_str in SENSOR_DECODERS:
                try:
                    data = await client.read_gatt_char(characteristic)
                except BleakError as err:
                    self.logger.debug("Get service characteristics exception: %s", err)
                    continue

                sensor_data = SENSOR_DECODERS[uuid_str](data)

                # Skipping for now
                if "date_time" in sensor_data:
                    sensor_data.pop("date_time")

                sensors.update(sensor_data)

                # Manage radon values
                if (d := sensor_data.get(RADON_1DAY_AVG)) is not None:
                    sensors[RADON_1DAY_LEVEL] = get_radon_level(float(d))
                    if not self.is_metric:
                        sensors[RADON_1DAY_AVG] = float(d) * BQ_TO_PCI_MULTIPLIER
                if (d := sensor_data.get(RADON_LONGTERM_AVG)) is not None:
                    sensors[RADON_LONGTERM_LEVEL] = get_radon_level(float(d))
                    if not self.is_metric:
                        sensors[RADON_LONGTERM_AVG] = float(d) * BQ_TO_PCI_MULTIPLIER

            if uuid_str in COMMAND_DECODERS:
                decoder = COMMAND_DECODERS[uuid_str]
                command_data_receiver = decoder.make_data_receiver()

                # Set up the notification handlers
                await client.start_notify(characteristic, command_data_receiver)
                # send command to this 'indicate' characteristic
                await client.write_gatt_char(characteristic, bytearray(decoder.cmd))
                # Wait for up to one second to see if a callback comes in.
                try:
                    await command_data_receiver.wait_for_message(5)
                except asyncio.TimeoutError:
                    self.logger.warning("Timeout getting command data.")

                command_sensor_data = decoder.decode_data(
                    logger=self.logger, raw_data=command_data_receiver.message
                )
                if command_sensor_data is not None:
                    new_values: dict[str, float | str | None] = {}

                    if (bat_data := command_sensor_data.get(BATTERY)) is not None:
                        new_values[BATTERY] = device.model.battery_percentage(
                            float(bat_data)
                        )

                    if illuminance := command_sensor_data.get(ILLUMINANCE):
                        new_values[ILLUMINANCE] = illuminance

                    sensors.update(new_values)

                # Stop notification handler
                await client.stop_notify(characteristic)

    async def _atom_sensor_data(
        self,
        client: BleakClient,
        device: AirthingsDevice,
        sensors: dict[str, str | float | None],
        service: BleakGATTService,
    ) -> None:
        """Get sensor data from the device."""
        device.firmware = device.model.need_firmware_upgrade(
            self.device_info.sw_version
        )

        if device.firmware.need_firmware_upgrade:
            self.logger.warning(
                "The firmware for this device (%s) is not up to date, "
                "please update to %s or newer using the Airthings app.",
                self.device_info.address,
                device.firmware.required_version or "N/A",
            )

        connectivity_data = await self._create_decoder_and_fetch(
            client=client,
            service=service,
            url=AtomRequestPath.CONNECTIVITY_MODE,
        )
        if connectivity_data is not None:
            sensors.update(connectivity_data)

        sensor_data = await self._create_decoder_and_fetch(
            client=client,
            service=service,
            url=AtomRequestPath.LATEST_VALUES,
        )
        if sensor_data is not None:
            self._parse_sensor_data(
                device=device,
                sensors=sensors,
                sensor_data=sensor_data,
            )

    async def _create_decoder_and_fetch(
        self,
        client: BleakClient,
        service: BleakGATTService,
        url: AtomRequestPath,
    ) -> dict[str, float | str | None] | None:
        """Create decoder and fetch data."""
        decoder = AtomCommandDecode(url=url)
        command_data_receiver = decoder.make_data_receiver()

        atom_write = service.get_characteristic(COMMAND_UUID_ATOM)
        atom_notify = service.get_characteristic(COMMAND_UUID_ATOM_NOTIFY)

        if atom_write is None or atom_notify is None:
            raise ValueError("Missing characteristics for device")

        # Set up the notification handlers
        await client.start_notify(
            char_specifier=atom_notify, callback=command_data_receiver
        )

        # send command to this 'indicate' characteristic
        await client.write_gatt_char(atom_write, bytearray(decoder.cmd))
        # Wait for up to five seconds to see if a callback comes in.
        try:
            await command_data_receiver.wait_for_message(5)
        except asyncio.TimeoutError:
            self.logger.warning("Timeout getting command data.")

        data = decoder.decode_data(
            logger=self.logger,
            raw_data=command_data_receiver.message,
        )

        await client.stop_notify(atom_notify)

        return data

    def _parse_sensor_data(
        self,
        device: AirthingsDevice,
        sensors: dict[str, str | float | None],
        sensor_data: dict[str, float | str | None],
    ) -> None:
        """Parse sensor data from the device."""
        if sensor_data is not None:
            new_values: dict[str, float | str | None] = {}

            if (bat_data := sensor_data.get(ATOM_BAT)) is not None:
                new_values[BATTERY] = device.model.battery_percentage(
                    float(bat_data) / 1000.0
                )

            if (lux := sensor_data.get(ATOM_LUX)) is not None:
                new_values[LUX] = lux

            if (co2 := sensor_data.get(ATOM_CO2)) is not None:
                new_values[CO2] = co2

            if (voc := sensor_data.get(ATOM_VOC)) is not None:
                new_values[VOC] = voc

            if (hum := sensor_data.get(ATOM_HUMIDITY)) is not None:
                new_values[HUMIDITY] = float(hum) / 100.0

            if (temperature := sensor_data.get(ATOM_TEMPERATURE)) is not None:
                # Temperature reported as kelvin
                new_values[TEMPERATURE] = round(float(temperature) / 100.0 - 273.15, 2)

            if (noise := sensor_data.get(ATOM_NOISE)) is not None:
                new_values[NOISE] = noise

            if (pressure := sensor_data.get(ATOM_PRESSURE)) is not None:
                new_values[PRESSURE] = float(pressure) / (64 * 100)

            if (radon_1day_avg := sensor_data.get(ATOM_RADON_1DAY_AVG)) is not None:
                new_values[RADON_1DAY_AVG] = (
                    float(radon_1day_avg)
                    if self.is_metric
                    else float(radon_1day_avg) * BQ_TO_PCI_MULTIPLIER
                )
                new_values[RADON_1DAY_LEVEL] = get_radon_level(float(radon_1day_avg))

            if (radon_week_avg := sensor_data.get(ATOM_RADON_WEEK_AVG)) is not None:
                new_values[RADON_WEEK_AVG] = (
                    float(radon_week_avg)
                    if self.is_metric
                    else float(radon_week_avg) * BQ_TO_PCI_MULTIPLIER
                )
                new_values[RADON_WEEK_LEVEL] = get_radon_level(float(radon_week_avg))

            if (radon_month_avg := sensor_data.get(ATOM_RADON_MONTH_AVG)) is not None:
                new_values[RADON_MONTH_AVG] = (
                    float(radon_month_avg)
                    if self.is_metric
                    else float(radon_month_avg) * BQ_TO_PCI_MULTIPLIER
                )
                new_values[RADON_MONTH_LEVEL] = get_radon_level(float(radon_month_avg))

            if (radon_year_avg := sensor_data.get(ATOM_RADON_YEAR_AVG)) is not None:
                new_values[RADON_YEAR_AVG] = (
                    float(radon_year_avg)
                    if self.is_metric
                    else float(radon_year_avg) * BQ_TO_PCI_MULTIPLIER
                )
                new_values[RADON_YEAR_LEVEL] = get_radon_level(float(radon_year_avg))

            self.logger.debug("Sensor values: %s", new_values)

            sensors.update(new_values)

    def _handle_disconnect(
        self, disconnect_future: asyncio.Future[bool], client: BleakClient
    ) -> None:
        """Handle disconnect from device."""
        self.logger.debug("Disconnected from %s", client.address)
        if not disconnect_future.done():
            disconnect_future.set_result(True)

    async def update_device(self, ble_device: BLEDevice) -> AirthingsDevice:
        """Connects to the device through BLE and retrieves relevant data"""
        # Try to abort early if the device name indicates it is not supported.
        # In some cases we only get the mac address, so we need to connect to
        # the device to get the name.
        if name := ble_device.name:
            if "Renew" in name or "View" in name:
                raise UnsupportedDeviceError(f"Model {name} is not supported")
        for attempt in range(self.max_attempts):
            is_final_attempt = attempt == self.max_attempts - 1
            try:
                return await self._update_device(ble_device)
            except DisconnectedError:
                if is_final_attempt:
                    raise
                self.logger.debug(
                    "Unexpectedly disconnected from %s", ble_device.address
                )
            except BleakError as err:
                if is_final_attempt:
                    raise
                self.logger.debug("Bleak error: %s", err)
        raise RuntimeError("Should not reach this point")

    async def _update_device(self, ble_device: BLEDevice) -> AirthingsDevice:
        """Connects to the device through BLE and retrieves relevant data"""
        device = AirthingsDevice()
        loop = asyncio.get_running_loop()
        disconnect_future = loop.create_future()
        client: BleakClientWithServiceCache = (
            await establish_connection(  # pylint: disable=line-too-long
                BleakClientWithServiceCache,
                ble_device,
                ble_device.address,
                disconnected_callback=partial(
                    self._handle_disconnect, disconnect_future
                ),
            )
        )
        try:
            async with (
                interrupt(
                    disconnect_future,
                    DisconnectedError,
                    f"Disconnected from {client.address}",
                ),
                asyncio.timeout(UPDATE_TIMEOUT),
            ):
                await self._get_device_characteristics(client, device)
                await self._get_service_characteristics(client, device)
        except BleakError as err:
            if "not found" in str(err):  # In future bleak this is a named exception
                # Clear the char cache since a char is likely
                # missing from the cache
                await client.clear_cache()
            raise
        except UnsupportedDeviceError:
            await client.disconnect()
            raise
        finally:
            await client.disconnect()

        return device
