"""
Creator : k0rventen
License : MIT
Source  : https://github.com/k0rventen/avea
"""

import asyncio
import logging
import math
import re
import threading
from contextlib import suppress
from typing import Awaitable, Callable, Optional, Sequence

from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
from bleak.backends.device import BLEDevice
from bleak_retry_connector import establish_connection

__all__ = [
    "Bulb",
    "discover_avea_bulbs",
    "compute_brightness",
    "compute_transition_table",
    "compute_color",
    "check_bounds",
]

FIRMWARE_REVISION_UUID = "00002a26-0000-1000-8000-00805f9b34fb"
SERIAL_NUMBER_UUID = "00002a25-0000-1000-8000-00805f9b34fb"
HARDWARE_REVISION_UUID = "00002a27-0000-1000-8000-00805f9b34fb"
MANUFACTURER_NAME_UUID = "00002a29-0000-1000-8000-00805f9b34fb"
AVEA_SERVICE_UUID = "f815e810-456c-6761-746f-4d756e696368"
CONTROL_CHARACTERISTIC_UUID = "f815e811-456c-6761-746f-4d756e696368"
MAX_TRANSITION_FPS = 5


_LOGGER = logging.getLogger(__name__)
_FW_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)\.(\d+)")


def _format_firmware_version(value: str) -> str:
    match = _FW_VERSION_RE.match(value)
    if not match:
        return value
    major, minor, patch, build = match.groups()
    return f"{major}.{minor}.{patch} ({build})"


class Bulb:
    """The class that represents an Avea bulb."""

    def __init__(self, address: str):
        self.addr = address
        self.name = "Unknown"
        self.fw_version = "Unknown"
        self.serial_number = "Unknown"
        self.hardware_revision = "Unknown"
        self.manufacturer_name = "Unknown"
        self.red = 0
        self.blue = 0
        self.green = 0
        self.brightness = 0
        self.white = 0

        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._loop_thread: Optional[threading.Thread] = None
        self._loop_ready: Optional[threading.Event] = None
        self._client: Optional[BleakClient] = None
        self._notification_event: Optional[asyncio.Event] = None
        self._expected_cmd: Optional[int] = None
        self._op_lock = threading.RLock()
        self._color_known = False
        self._device: Optional[BLEDevice] = None

    # ------------------------------------------------------------------
    # Event loop helpers
    # ------------------------------------------------------------------
    def _start_loop(self, loop: asyncio.AbstractEventLoop) -> None:
        asyncio.set_event_loop(loop)
        if self._loop_ready:
            self._loop_ready.set()
        loop.run_forever()

    def _ensure_loop(self) -> None:
        if self._loop is not None:
            return
        loop = asyncio.new_event_loop()
        self._loop = loop
        self._loop_ready = threading.Event()
        self._loop_thread = threading.Thread(
            target=self._start_loop, args=(loop,), daemon=True
        )
        self._loop_thread.start()
        self._loop_ready.wait()

    def _submit(self, coro: Awaitable):
        self._ensure_loop()
        future = asyncio.run_coroutine_threadsafe(coro, self._loop)
        return future.result()

    def _shutdown_loop(self) -> None:
        loop = self._loop
        thread = self._loop_thread
        if loop is None:
            return
        loop.call_soon_threadsafe(loop.stop)
        if thread and thread.is_alive():
            thread.join(timeout=1)
        loop.close()
        self._loop = None
        self._loop_thread = None
        self._loop_ready = None

    def close(self) -> None:
        with self._op_lock:
            if self._loop is None:
                return
            with suppress(Exception):
                if self._client:
                    self._submit(self._disconnect())
            self._shutdown_loop()

    def __del__(self):
        with suppress(Exception):
            self.close()

    # ------------------------------------------------------------------
    # Connection handling
    # ------------------------------------------------------------------
    def _notification_handler(self, _: int, data: bytearray) -> None:
        payload = bytes(data)
        self.process_notification(payload)
        event = self._notification_event
        expected = self._expected_cmd
        if event is not None and (expected is None or (payload and payload[0] == expected)):
            event.set()

    async def _connect(self) -> bool:
        if self._client and self._client.is_connected:
            return True

        client: Optional[BleakClient] = None
        try:
            device: Optional[BLEDevice]
            if isinstance(self.addr, BLEDevice):
                device = self.addr
            elif self._device:
                device = self._device
            else:
                device = await BleakScanner.find_device_by_address(
                    self.addr, timeout=5.0
                )
            if device is None:
                raise BleakError(f"Device {self.addr} not found")
            self._device = device
            display_name = (
                device.name
                or (self.name if self.name != "Unknown" else self.addr)
            )
            client = await establish_connection(
                BleakClient,
                device,
                display_name,
                timeout=15.0,
                use_services_cache=False,
            )
            await client.start_notify(
                CONTROL_CHARACTERISTIC_UUID,
                self._notification_handler,
            )
        except (BleakError, asyncio.TimeoutError, OSError):
            _LOGGER.warning(
                "Could not connect to the Bulb %s",
                self.addr,
                exc_info=True,
            )
            with suppress(Exception):
                if client:
                    await client.disconnect()
            return False

        self._client = client
        return True

    async def _disconnect(self) -> None:
        client = self._client
        self._client = None
        if not client:
            return
        with suppress(Exception):
            await client.stop_notify(CONTROL_CHARACTERISTIC_UUID)
        with suppress(Exception):
            await client.disconnect()

    def subscribe_to_notification(self):
        """Notifications are handled directly by bleak during connect."""
        return True

    def connect(self) -> bool:
        with self._op_lock:
            return self._submit(self._connect())

    def disconnect(self) -> None:
        with self._op_lock:
            if not self._client:
                return
            self._submit(self._disconnect())

    def _is_connected(self) -> bool:
        return self._client is not None and self._client.is_connected

    # ------------------------------------------------------------------
    # Command helpers
    # ------------------------------------------------------------------
    async def _write_command(self, payload: bytes, *, with_response: bool = False) -> None:
        if not self._client or not self._client.is_connected:
            raise BleakError("Client is not connected")
        await self._client.write_gatt_char(
            CONTROL_CHARACTERISTIC_UUID,
            payload,
            response=with_response,
        )

    async def _request_notification(
        self,
        command: bytes,
        expected_cmd: Optional[int],
        delay: float = 0.0,
        timeout: float = 1.0,
    ) -> None:
        if not self._client or not self._client.is_connected:
            raise BleakError("Client is not connected")
        if delay:
            await asyncio.sleep(delay)
        event = asyncio.Event()
        self._notification_event = event
        self._expected_cmd = expected_cmd
        try:
            await self._client.write_gatt_char(CONTROL_CHARACTERISTIC_UUID, command, response=False)
            await asyncio.wait_for(event.wait(), timeout)
        except asyncio.TimeoutError:
            pass
        finally:
            self._notification_event = None
            self._expected_cmd = None

    async def _read_firmware_version(self) -> str:
        if not self._client:
            return ""
        try:
            payload = await self._client.read_gatt_char(FIRMWARE_REVISION_UUID)
        except (BleakError, AttributeError, OSError):
            _LOGGER.warning(
                "Could not read firmware version from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""
        if isinstance(payload, bytearray):
            payload = bytes(payload)
        try:
            return payload.decode("utf-8").rstrip("\x00")
        except UnicodeDecodeError:
            _LOGGER.warning(
                "Could not decode firmware version from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""

    async def _read_serial_number(self) -> str:
        if not self._client:
            return ""
        try:
            payload = await self._client.read_gatt_char(SERIAL_NUMBER_UUID)
        except (BleakError, AttributeError, OSError):
            _LOGGER.warning(
                "Could not read serial number from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""
        if isinstance(payload, bytearray):
            payload = bytes(payload)
        try:
            return payload.decode("utf-8").split("\x00", 1)[0]
        except UnicodeDecodeError:
            _LOGGER.warning(
                "Could not decode serial number from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""

    async def _read_hardware_revision(self) -> str:
        if not self._client:
            return ""
        try:
            payload = await self._client.read_gatt_char(HARDWARE_REVISION_UUID)
        except (BleakError, AttributeError, OSError):
            _LOGGER.warning(
                "Could not read hardware revision from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""
        if isinstance(payload, bytearray):
            payload = bytes(payload)
        try:
            return payload.decode("utf-8").rstrip("\x00")
        except UnicodeDecodeError:
            _LOGGER.warning(
                "Could not decode hardware revision from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""

    async def _read_manufacturer_name(self) -> str:
        if not self._client:
            return ""
        try:
            payload = await self._client.read_gatt_char(MANUFACTURER_NAME_UUID)
        except (BleakError, AttributeError, OSError):
            _LOGGER.warning(
                "Could not read manufacturer name from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""
        if isinstance(payload, bytearray):
            payload = bytes(payload)
        try:
            return payload.decode("utf-8").rstrip("\x00")
        except UnicodeDecodeError:
            _LOGGER.warning(
                "Could not decode manufacturer name from bulb %s",
                self.addr,
                exc_info=True,
            )
            return ""

    async def _smooth_transition(
        self,
        red_table: Sequence[int],
        green_table: Sequence[int],
        blue_table: Sequence[int],
        interval: float,
    ) -> None:
        if not self._client or not self._client.is_connected:
            raise BleakError("Client is not connected")
        last_payload = None
        for index, (r, g, b) in enumerate(zip(red_table, green_table, blue_table), start=1):
            payload = compute_color(
                check_bounds(0),
                check_bounds(r),
                check_bounds(g),
                check_bounds(b),
            )
            try:
                await self._write_command(payload, with_response=False)
            except (BleakError, asyncio.TimeoutError, OSError):
                _LOGGER.warning(
                    "Lost connection during smooth transition for bulb %s; reconnecting",
                    self.addr,
                    exc_info=True,
                )
                await self._disconnect()
                if not await self._connect():
                    break
                await self._write_command(payload, with_response=False)
            if interval:
                await asyncio.sleep(interval)
            last_payload = payload
        if last_payload is not None:
            await self._write_command(last_payload, with_response=True)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------
    def get_fw_version(self) -> str:
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                self.fw_version = ""
                return ""
            value = self._submit(self._read_firmware_version())
            if not already_connected:
                self.disconnect()
        result = _format_firmware_version(value) if isinstance(value, str) else ""
        self.fw_version = result
        return result

    def get_serial_number(self) -> str:
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                self.serial_number = ""
                return ""
            try:
                value = self._submit(self._read_serial_number())
            finally:
                if not already_connected:
                    self.disconnect()
        result = value if isinstance(value, str) else ""
        self.serial_number = result
        return result

    def get_hardware_revision(self) -> str:
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                self.hardware_revision = ""
                return ""
            value = self._submit(self._read_hardware_revision())
            if not already_connected:
                self.disconnect()
        result = value if isinstance(value, str) else ""
        self.hardware_revision = result
        return result

    def get_manufacturer_name(self) -> str:
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                self.manufacturer_name = ""
                return ""
            value = self._submit(self._read_manufacturer_name())
            if not already_connected:
                self.disconnect()
        result = value if isinstance(value, str) else ""
        self.manufacturer_name = result
        return result

    def set_brightness(self, brightness):
        with self._op_lock:
            payload = compute_brightness(check_bounds(brightness))
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return
            self._submit(self._write_command(payload))
            if not already_connected:
                self.disconnect()
        self.brightness = check_bounds(brightness)

    def get_brightness(self):
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return self.brightness
            self._submit(
                self._request_notification(b"\x57", expected_cmd=0x57, delay=0.5)
            )
            if not already_connected:
                self.disconnect()
            return self.brightness

    def set_color(self, white, red, green, blue):
        with self._op_lock:
            payload = compute_color(
                check_bounds(white),
                check_bounds(red),
                check_bounds(green),
                check_bounds(blue),
            )
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return
            self._submit(self._write_command(payload))
            if not already_connected:
                self.disconnect()
        self.white = check_bounds(white)
        self.red = check_bounds(red)
        self.green = check_bounds(green)
        self.blue = check_bounds(blue)
        self._color_known = True

    def set_rgb(self, red, green, blue):
        with self._op_lock:
            self.set_color(0, red * 16, green * 16, blue * 16)

    def set_smooth_transition(self, target_red, target_green, target_blue, duration=2, fps=60):
        if self._color_known:
            init_r = self.red
            init_g = self.green
            init_b = self.blue
        else:
            try:
                init_w, init_r, init_g, init_b = self.get_color()
            except (BleakError, asyncio.TimeoutError, OSError):
                _LOGGER.warning(
                    "Could not read current color before smooth transition for bulb %s",
                    self.addr,
                    exc_info=True,
                )
                return

        with self._op_lock:
            clamped_fps = max(1, min(int(fps), MAX_TRANSITION_FPS))
            iterations = max(1, int(duration * clamped_fps))
            interval = 0 if clamped_fps <= 0 else 1 / clamped_fps
            target_red_12 = check_bounds(target_red * 16)
            target_green_12 = check_bounds(target_green * 16)
            target_blue_12 = check_bounds(target_blue * 16)
            red_table = compute_transition_table(init_r, target_red_12, iterations)
            green_table = compute_transition_table(init_g, target_green_12, iterations)
            blue_table = compute_transition_table(init_b, target_blue_12, iterations)
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return
            self._submit(
                self._smooth_transition(red_table, green_table, blue_table, interval)
            )
            self.red = target_red_12
            self.green = target_green_12
            self.blue = target_blue_12
            self._color_known = True
            if not already_connected:
                self.disconnect()

    def get_color(self):
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return self.white, self.red, self.green, self.blue
            self._submit(
                self._request_notification(b"\x35", expected_cmd=0x35, delay=0.5)
            )
            if not already_connected:
                self.disconnect()
            self._color_known = True
            return self.white, self.red, self.green, self.blue

    def get_rgb(self):
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return int(self.red / 16), int(self.green / 16), int(self.blue / 16)
            self._submit(
                self._request_notification(b"\x35", expected_cmd=0x35, delay=0.5)
            )
            if not already_connected:
                self.disconnect()
            self._color_known = True
            return int(self.red / 16), int(self.green / 16), int(self.blue / 16)

    def get_name(self):
        with self._op_lock:
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return self.name
            self._submit(
                self._request_notification(b"\x58", expected_cmd=0x58, delay=0.5)
            )
            if not already_connected:
                self.disconnect()
            return self.name

    def set_name(self, name: str):
        with self._op_lock:
            byte_name = name.encode("utf-8")
            command = b"\x58" + byte_name
            already_connected = self._is_connected()
            if not already_connected and not self.connect():
                return
            self._submit(self._write_command(command))
            if not already_connected:
                self.disconnect()

    def process_notification(self, data: bytes):
        if not data:
            return
        cmd = data[0]
        values = data[1:]

        if cmd == 0x57:
            self.brightness = int.from_bytes(values, "little")

        elif cmd == 0x35:
            hex_val = values.hex()
            self.red = int.from_bytes(bytes.fromhex(hex_val[-4:]), "little") ^ int(0x3000)
            self.green = int.from_bytes(bytes.fromhex(hex_val[-8:-4]), "little") ^ int(0x2000)
            self.blue = int.from_bytes(bytes.fromhex(hex_val[-12:-8]), "little") ^ int(0x1000)
            self.white = int.from_bytes(bytes.fromhex(hex_val[-16:-12]), "little")
            self._color_known = True

        elif cmd == 0x58:
            try:
                self.name = values.decode("utf-8").rstrip("\x00")
            except UnicodeDecodeError:
                _LOGGER.warning(
                    "Could not decode bulb name notification for bulb %s",
                    self.addr,
                    exc_info=True,
                )
                self.name = "Unknown"


# ----------------------------------------------------------------------
# Discovery helpers
# ----------------------------------------------------------------------
async def _discover(timeout: float) -> list:
    devices = await BleakScanner.discover(timeout=timeout)
    bulbs = []
    for dev in devices:
        if _is_avea_device(dev):
            bulb = Bulb(dev.address)
            bulb._device = dev
            if getattr(dev, "name", None):
                bulb.name = dev.name
            bulbs.append(bulb)
    return bulbs


def _is_avea_device(device) -> bool:
    name_sources = [getattr(device, "name", None)]
    metadata = getattr(device, "metadata", {}) or {}
    name_sources.append(metadata.get("local_name"))

    for name in name_sources:
        if name and "Avea" in name:
            return True

    for value in metadata.get("manufacturer_data", {}).values():
        try:
            decoded = bytes(value).decode("utf-8", errors="ignore")
        except (TypeError, ValueError):
            continue
        if "Avea" in decoded:
            return True

    for uuid in metadata.get("uuids", []) or []:
        if isinstance(uuid, str) and "Avea" in uuid:
            return True

    return False


def _run_async(factory: Callable[[], Awaitable]):
    try:
        return asyncio.run(factory())
    except RuntimeError:
        result = {}
        error = {}

        def runner():
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                result["value"] = loop.run_until_complete(factory())
            except Exception as exc:
                error["exc"] = exc
            finally:
                loop.close()

        thread = threading.Thread(target=runner)
        thread.start()
        thread.join()
        if "exc" in error:
            raise error["exc"]
        return result.get("value")


def discover_avea_bulbs(timeout: float = 4.0):
    return _run_async(lambda: _discover(timeout))


# ----------------------------------------------------------------------
# Utility functions
# ----------------------------------------------------------------------

def compute_brightness(brightness):
    """Return the payload for the specified brightness."""
    value = hex(int(brightness))[2:]
    value = value.zfill(4)
    value = value[2:] + value[:2]
    return bytes.fromhex("57" + value)


def compute_color(w=2000, r=0, g=0, b=0):
    """Return the payload for the specified colors."""
    color = "35"
    fading = "1101"
    unknown = "0a00"
    white = (int(w) | int(0x8000)).to_bytes(2, byteorder="little").hex()
    red = (int(r) | int(0x3000)).to_bytes(2, byteorder="little").hex()
    green = (int(g) | int(0x2000)).to_bytes(2, byteorder="little").hex()
    blue = (int(b) | int(0x1000)).to_bytes(2, byteorder="little").hex()

    return bytes.fromhex(color + fading + unknown + white + red + green + blue)


def compute_transition_table(init, target, iterations):
    """Compute a list of values for a smooth transition between 2 numbers."""
    iterations = max(1, int(iterations))
    if iterations == 1:
        return [target]
    delta = target - init
    values = []
    for n in range(1, iterations + 1):
        frac = n / iterations
        eased = (1 - math.cos(math.pi * frac)) / 2
        values.append(round(init + delta * eased))
    return values


def check_bounds(value):
    """Check if the given value is out-of-bounds (0 to 4095)."""
    try:
        ivalue = int(value)
    except (TypeError, ValueError):
        _LOGGER.warning("Value %r was not a number; using default value 0", value)
        return 0

    if ivalue > 4095:
        return 4095
    if ivalue < 0:
        return 0
    return ivalue
