"""Parser for OralB BLE advertisements.

This file is shamelessly copied from the following repository:
https://github.com/Ernst79/bleparser/blob/c42ae922e1abed2720c7fac993777e1bd59c0c93/package/bleparser/oral_b.py

MIT License applies.
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass
from enum import Enum, auto

from bleak import BleakError, BLEDevice
from bleak_retry_connector import (
    BleakClientWithServiceCache,
    establish_connection,
    retry_bluetooth_connection_error,
)
from bluetooth_data_tools import short_address
from bluetooth_sensor_state_data import BluetoothData
from home_assistant_bluetooth import BluetoothServiceInfo
from sensor_state_data import SensorDeviceClass, SensorUpdate, Units
from sensor_state_data.enum import StrEnum

from .const import (
    BRUSHING_UPDATE_INTERVAL_SECONDS,
    CHARACTERISTIC_BATTERY,
    CHARACTERISTIC_PRESSURE,
    NOT_BRUSHING_UPDATE_INTERVAL_SECONDS,
    TIMEOUT_RECENTLY_BRUSHING,
)

_LOGGER = logging.getLogger(__name__)


class OralBSensor(StrEnum):

    TIME = "time"
    SECTOR = "sector"
    NUMBER_OF_SECTORS = "number_of_sectors"
    SECTOR_TIMER = "sector_timer"
    TOOTHBRUSH_STATE = "toothbrush_state"
    PRESSURE = "pressure"
    MODE = "mode"
    SIGNAL_STRENGTH = "signal_strength"
    BATTERY_PERCENT = "battery_percent"


class OralBBinarySensor(StrEnum):
    BRUSHING = "brushing"


class Models(Enum):

    D36 = auto()
    D21 = auto()
    D601 = auto()
    D700 = auto()
    D701 = auto()
    D706 = auto()
    IOSeries4 = auto()
    IOSeries5 = auto()
    IOSeries = auto()
    Unknown = auto()


@dataclass
class ModelDescription:

    device_type: str
    modes: dict[int, str]


SMART_SERIES_MODES = {
    0: "off",
    1: "daily clean",
    2: "sensitive",
    3: "massage",
    4: "whitening",
    5: "deep clean",
    6: "tongue cleaning",
    7: "turbo",
    255: "unknown",
}


IO_SERIES_MODES = {
    0: "daily clean",
    1: "sensitive",
    2: "gum care",
    3: "whiten",
    4: "intense",
    5: "super sensitive",
    6: "tongue cleaning",
    8: "settings",
    9: "off",
}


DEVICE_TYPES = {
    Models.D36: ModelDescription("Triumph D36", SMART_SERIES_MODES),
    Models.D21: ModelDescription("Smart Series D21", SMART_SERIES_MODES),
    Models.D601: ModelDescription("Pro Series D601", SMART_SERIES_MODES),
    Models.D700: ModelDescription("Smart Series D700", SMART_SERIES_MODES),
    Models.D701: ModelDescription("Genius Series D701", SMART_SERIES_MODES),
    Models.D706: ModelDescription("Genius X D706", SMART_SERIES_MODES),
    Models.IOSeries4: ModelDescription("IO Series 4", IO_SERIES_MODES),
    Models.IOSeries5: ModelDescription("IO Series 5", IO_SERIES_MODES),
    Models.IOSeries: ModelDescription("IO Series", IO_SERIES_MODES),
    Models.Unknown: ModelDescription("Unknown", SMART_SERIES_MODES),
}

STATES = {
    0: "unknown",
    1: "initializing",
    2: "idle",
    3: "running",
    4: "charging",
    5: "setup",
    6: "flight menu",
    8: "selection menu",
    9: "off",
    113: "final test",
    114: "pcb test",
    115: "sleeping",
    116: "transport",
}

PRESSURE = {
    0: "normal",
    16: "normal",
    32: "normal",
    48: "normal",
    50: "normal",
    54: "button pressed",
    56: "power button pressed",
    58: "power button pressed",
    80: "normal",
    82: "normal",
    86: "button pressed",
    90: "power button pressed",
    114: "normal",
    118: "button pressed",
    122: "power button pressed",
    144: "high",
    146: "high",
    150: "button pressed",
    154: "power button pressed",
    178: "high",
    182: "button pressed",
    186: "power button pressed",
    192: "high",
    240: "high",
    242: "high",
}

ACTIVE_CONNECTION_PRESSURE = {0: "low", 1: "normal", 2: "high"}

ORALB_MANUFACTURER = 0x00DC


# Model type is determined by byte 1 of the manufacturer data.
# Byte 0 is the protocol version, which determines how to parse the
# advertisement, but is NOT the model identifier.
# Reference: https://github.com/MatrixEditor/oralb-io/blob/master/oralb/blesdk/brush.py
MODEL_ID_TO_MODEL: dict[int, Models] = {
    # D36 line (Triumph / Professional Care)
    0: Models.D36,  # D36 X_MODE
    1: Models.D36,  # D36 6_MODE
    2: Models.D36,  # D36 5_MODE
    # D701 line (Genius)
    32: Models.D701,  # D701 X_MODE
    33: Models.D701,  # D701 6_MODE
    34: Models.D701,  # D701 5_MODE
    # D700 line (Smart/Pro Series)
    39: Models.D700,  # D700 5_MODE
    40: Models.D700,  # D700 4_MODE
    41: Models.D700,  # D700 6_MODE
    # SONOS line (IO Series)
    48: Models.IOSeries,  # SONOS X_MODE
    49: Models.IOSeries,  # SONOS IO
    50: Models.IOSeries,  # SONOS IO BIG_TI
    52: Models.IOSeries4,  # SONOS GALAXY IO4
    53: Models.IOSeries5,  # SONOS GALAXY IO5
    54: Models.IOSeries,  # SONOS EPLATFORM
    # D21 line (Smart Series - older generation)
    64: Models.D21,  # D21 X_MODE
    65: Models.D21,  # D21 4_MODE
    66: Models.D21,  # D21 3_MODE
    67: Models.D21,  # D21 2A_MODE
    68: Models.D21,  # D21 2B_MODE
    69: Models.D21,  # D21 3_MODE_WHITENING
    70: Models.D21,  # D21 1_MODE
    # D601 line (Pro)
    80: Models.D601,  # D601 X_MODE
    81: Models.D601,  # D601 5_MODE
    82: Models.D601,  # D601 4_MODE
    83: Models.D601,  # D601 3A_MODE
    84: Models.D601,  # D601 2A_MODE
    85: Models.D601,  # D601 2B_MODE
    86: Models.D601,  # D601 3B_MODE
    87: Models.D601,  # D601 1_MODE
    # D706 line (Genius X)
    112: Models.D706,  # D706 X_MODE
    113: Models.D706,  # D706 6_MODE
    114: Models.D706,  # D706 5_MODE
    117: Models.D706,  # D706 X_MODE_CHINA
    118: Models.D706,  # D706 6_MODE_CHINA
    119: Models.D706,  # D706 5_MODE_CHINA
}

SECTOR_MAP = {
    1: "sector 1",
    9: "sector 1",
    2: "sector 2",
    10: "sector 2",
    3: "sector 3",
    11: "sector 3",
    19: "sector 3",
    27: "sector 3",
    4: "sector 4",
    7: "sector 4",
    15: "sector 4",
    31: "sector 4",
    39: "sector 4",
    41: "success",
    42: "success",
    43: "success",
    47: "success",
    55: "success",
}


class OralBBluetoothDeviceData(BluetoothData):
    """Data for OralB BLE sensors."""

    def __init__(self) -> None:
        super().__init__()
        # If this is True, we are currently brushing or were brushing as of the last advertisement data
        self._brushing = False
        self._last_brush = 0.0

    def _start_update(self, service_info: BluetoothServiceInfo) -> None:
        """Update from BLE advertisement data."""
        _LOGGER.debug("Parsing OralB BLE advertisement data: %s", service_info)
        manufacturer_data = service_info.manufacturer_data
        address = service_info.address
        if ORALB_MANUFACTURER not in manufacturer_data:
            return None
        data = manufacturer_data[ORALB_MANUFACTURER]
        self.set_device_manufacturer("Oral-B")
        _LOGGER.debug("Parsing Oral-B sensor: %s", data)
        msg_length = len(data)
        if msg_length not in (9, 11):
            return

        model_type = data[1]
        state = data[3]
        pressure = data[4]
        brush_time = data[5] * 60 + data[6]
        mode = data[7]
        sector = data[8]
        sector_timer = None
        no_of_sectors = None
        if msg_length >= 11:
            sector_timer = data[9]
            no_of_sectors = data[10]

        model = MODEL_ID_TO_MODEL.get(model_type, Models.Unknown)
        model_info = DEVICE_TYPES[model]
        self.brush_modes = model_info.modes
        self.set_device_type(model_info.device_type)
        name = f"{model_info.device_type} {short_address(address)}"
        self.set_device_name(name)
        self.set_title(name)
        tb_state = STATES.get(state, f"unknown state {state}")
        tb_mode = self.brush_modes.get(mode, f"unknown mode {mode}")
        tb_pressure = PRESSURE.get(pressure, f"unknown pressure {pressure}")
        tb_sector = SECTOR_MAP.get(sector, f"unknown sector code {sector}")

        self.update_sensor(str(OralBSensor.TIME), None, brush_time, None, "Time")
        if brush_time == 0 and tb_state != "running":
            # When starting up, sector is not accurate.
            self.update_sensor(
                str(OralBSensor.SECTOR), None, "no sector", None, "Sector"
            )
        else:
            self.update_sensor(str(OralBSensor.SECTOR), None, tb_sector, None, "Sector")
        if no_of_sectors is not None:
            self.update_sensor(
                str(OralBSensor.NUMBER_OF_SECTORS),
                None,
                no_of_sectors,
                None,
                "Number of sectors",
            )
        if sector_timer is not None:
            self.update_sensor(
                str(OralBSensor.SECTOR_TIMER), None, sector_timer, None, "Sector Timer"
            )
        self.update_sensor(
            str(OralBSensor.TOOTHBRUSH_STATE), None, tb_state, None, "Toothbrush State"
        )
        self.update_sensor(
            str(OralBSensor.PRESSURE), None, tb_pressure, None, "Pressure"
        )
        self.update_sensor(str(OralBSensor.MODE), None, tb_mode, None, "Mode")
        self.update_binary_sensor(
            str(OralBBinarySensor.BRUSHING), bool(state == 3), None, "Brushing"
        )
        if state == 3:
            self._brushing = True
            self._last_brush = time.monotonic()
        else:
            self._brushing = False

    def poll_needed(
        self, service_info: BluetoothServiceInfo, last_poll: float | None
    ) -> bool:
        """
        This is called every time we get a service_info for a device. It means the
        device is working and online.
        """
        if last_poll is None:
            return True
        update_interval = NOT_BRUSHING_UPDATE_INTERVAL_SECONDS
        if (
            self._brushing
            or time.monotonic() - self._last_brush <= TIMEOUT_RECENTLY_BRUSHING
        ):
            update_interval = BRUSHING_UPDATE_INTERVAL_SECONDS
        return last_poll > update_interval

    @retry_bluetooth_connection_error()
    async def _get_payload(self, client: BleakClientWithServiceCache) -> None:
        """Get the payload from the brush using its gatt_characteristics."""
        battery_char = client.services.get_characteristic(CHARACTERISTIC_BATTERY)
        battery_payload = await client.read_gatt_char(battery_char)
        pressure_char = client.services.get_characteristic(CHARACTERISTIC_PRESSURE)
        pressure_payload = await client.read_gatt_char(pressure_char)
        tb_pressure = ACTIVE_CONNECTION_PRESSURE.get(
            pressure_payload[0], f"unknown pressure {pressure_payload[0]}"
        )
        self.update_sensor(
            str(OralBSensor.PRESSURE), None, tb_pressure, None, "Pressure"
        )
        self.update_sensor(
            str(OralBSensor.BATTERY_PERCENT),
            Units.PERCENTAGE,
            battery_payload[0],
            SensorDeviceClass.BATTERY,
            "Battery",
        )
        _LOGGER.debug("Successfully read active gatt characters")

    async def async_poll(self, ble_device: BLEDevice) -> SensorUpdate:
        """
        Poll the device to retrieve any values we can't get from passive listening.
        """
        _LOGGER.debug("Polling Oral-B device: %s", ble_device.address)
        client = await establish_connection(
            BleakClientWithServiceCache, ble_device, ble_device.address
        )
        try:
            await self._get_payload(client)
        except BleakError as err:
            _LOGGER.warning(f"Reading gatt characters failed with err: {err}")
        finally:
            await client.disconnect()
            _LOGGER.debug("Disconnected from active bluetooth client")
        return self._finish_update()
