"""The Eheim Digital hub."""

from __future__ import annotations

import asyncio
from logging import getLogger
from typing import TYPE_CHECKING, Any, Callable

import aiohttp
from yarl import URL

from eheimdigital.reeflex import EheimDigitalReeflexUV

from .autofeeder import EheimDigitalAutofeeder
from .classic_led_ctrl import EheimDigitalClassicLEDControl
from .classic_vario import EheimDigitalClassicVario
from .device import EheimDigitalDevice
from .filter import EheimDigitalFilter
from .heater import EheimDigitalHeater
from .ph_control import EheimDigitalPHControl
from .types import (
    EheimDeviceType,
    EheimDigitalClientError,
    MeshNetworkPacket,
    MsgTitle,
    UsrDtaPacket,
)

if TYPE_CHECKING:
    from collections.abc import Awaitable


_LOGGER = getLogger(__package__)


class EheimDigitalHub:
    """Represent a Eheim Digital hub."""

    device_found_callback: Callable[[str, EheimDeviceType], Awaitable[None]] | None
    devices: dict[str, EheimDigitalDevice]
    loop: asyncio.AbstractEventLoop
    main: EheimDigitalDevice | None
    main_device_added_event: asyncio.Event | None = None
    receive_callback: Callable[[], Awaitable[None]] | None
    receive_task: asyncio.Task[None] | None = None
    session: aiohttp.ClientSession
    url: URL
    ws: aiohttp.ClientWebSocketResponse | None = None

    def __init__(
        self,
        *,
        host: str = "eheimdigital.local",
        session: aiohttp.ClientSession | None = None,
        loop: asyncio.AbstractEventLoop | None = None,
        receive_callback: Callable[[], Awaitable[None]] | None = None,
        main_device_added_event: asyncio.Event | None = None,
        device_found_callback: Callable[[str, EheimDeviceType], Awaitable[None]]
        | None = None,
    ) -> None:
        """Initialize a hub."""
        self.device_found_callback = device_found_callback
        self.devices = {}
        self.loop = loop or asyncio.get_event_loop()
        self.main = None
        self.main_device_added_event = main_device_added_event
        self.receive_callback = receive_callback
        self.session = session or aiohttp.ClientSession()
        self.url = URL.build(scheme="http", host=host, path="/ws")

    async def connect(self) -> None:  # pragma: no cover
        """Connect to the hub."""
        self.ws = await self.session.ws_connect(self.url)
        self.receive_task = self.loop.create_task(self.receive_messages())

    async def close(self) -> None:  # pragma: no cover
        """Close the connection."""
        if self.receive_task is not None:
            _ = self.receive_task.cancel()
        if self.ws is not None and not self.ws.closed:
            _ = await self.ws.close()

    async def add_device(self, usrdta: UsrDtaPacket) -> None:  # noqa: C901, PLR0912
        """Add a device to the device list."""
        match EheimDeviceType(usrdta["version"]):
            case EheimDeviceType.VERSION_EHEIM_EXT_HEATER:
                self.devices[usrdta["from"]] = EheimDigitalHeater(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO:
                self.devices[usrdta["from"]] = EheimDigitalClassicVario(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case EheimDeviceType.VERSION_EHEIM_EXT_FILTER:
                self.devices[usrdta["from"]] = EheimDigitalFilter(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E:
                self.devices[usrdta["from"]] = EheimDigitalClassicLEDControl(
                    self, usrdta
                )
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case EheimDeviceType.VERSION_EHEIM_PH_CONTROL:
                self.devices[usrdta["from"]] = EheimDigitalPHControl(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case EheimDeviceType.VERSION_EHEIM_FEEDER:
                self.devices[usrdta["from"]] = EheimDigitalAutofeeder(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case EheimDeviceType.VERSION_EHEIM_REEFLEX:
                self.devices[usrdta["from"]] = EheimDigitalReeflexUV(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
            case _:
                _LOGGER.warning(
                    "Found device %s with unsupported device type %s",
                    usrdta["from"],
                    EheimDeviceType(usrdta["version"]),
                )
                self.devices[usrdta["from"]] = EheimDigitalDevice(self, usrdta)
                if self.device_found_callback:
                    await self.device_found_callback(
                        usrdta["from"], EheimDeviceType(usrdta["version"])
                    )
        if self.main is None and usrdta["from"] in self.devices:
            self.main = self.devices[usrdta["from"]]
            if self.main_device_added_event:
                self.main_device_added_event.set()

    async def request_usrdta(self, mac_address: str) -> None:
        """Request the USRDTA of a device."""
        await self.send_packet({
            "title": MsgTitle.GET_USRDTA,
            "to": mac_address,
            "from": "USER",
        })

    async def send_packet(self, packet: dict[str, Any]) -> None:
        """Send a packet to the hub.

        Raises:
            EheimDigitalClientError: When there is an error with the connection.

        """
        if self.ws is not None:
            try:
                await self.ws.send_json(packet)
            except aiohttp.ClientError as err:
                raise EheimDigitalClientError from err

    async def parse_mesh_network(self, msg: MeshNetworkPacket) -> None:
        """Parse a MESH_NETWORK packet."""
        for client in msg["clientList"]:
            if client not in self.devices:
                await self.request_usrdta(client)

    async def parse_usrdta(self, msg: UsrDtaPacket) -> None:
        """Parse a USRDTA packet."""
        if msg["from"] not in self.devices:
            await self.add_device(msg)

    async def parse_message(self, msg: dict[str, Any]) -> None:
        """Parse a received message."""
        if "from" not in msg:
            _LOGGER.debug("Received message without 'from' property: %s", msg)
            return
        if "USER" in msg["from"]:
            _LOGGER.debug("Received message from other user: %s", msg)
            return
        if "title" not in msg:
            _LOGGER.debug("Received message without 'title' property: %s", msg)
            return
        match msg["title"]:
            case MsgTitle.MESH_NETWORK:
                _LOGGER.debug("Received mesh network packet: %s", msg)
                await self.parse_mesh_network(MeshNetworkPacket(**msg))
            case MsgTitle.USRDTA:
                _LOGGER.debug("Received usrdta packet: %s", msg)
                await self.parse_usrdta(UsrDtaPacket(**msg))
                if self.receive_callback:
                    await self.receive_callback()
            case _:
                _LOGGER.debug(
                    "Received packet %s for device %s: %s",
                    msg["title"],
                    msg["from"],
                    msg,
                )
                if "from" in msg and msg["from"] in self.devices:
                    await self.devices[msg["from"]].parse_message(msg)
                    if self.receive_callback:
                        await self.receive_callback()

    async def receive_messages(self) -> None:
        """Receive messages from the hub."""
        if self.ws is None or self.ws.closed:
            _LOGGER.error("receive_task called without an established connection!")
            return
        try:
            async for msg in self.ws:
                if msg.type == aiohttp.WSMsgType.TEXT:
                    msgdata: list[dict[str, Any]] | dict[str, Any] = msg.json()
                    if isinstance(msgdata, list):
                        for part in msgdata:
                            await self.parse_message(part)
                    else:
                        await self.parse_message(msgdata)
                elif msg.type == aiohttp.WSMsgType.ERROR:
                    _LOGGER.warning("Received error, reconnecting...:\n%s", msg.data)
                    await self.ws.close()
                    return
        except Exception:
            _LOGGER.exception(
                "Exception occurred on receiving messages",
                stack_info=True,
                stacklevel=5,
            )
            await self.ws.close()
            return

    async def update(self) -> None:
        """Update the device states."""
        if self.ws is None or self.ws.closed:
            _LOGGER.info("WebSocket connection to %s closed, reconnect...", self.url)
            await self.connect()
        await self.request_usrdta("ALL")
        for device in self.devices.values():
            await device.update()

    def as_dict(self) -> dict[str, Any]:
        """Return the hub as a dictionary."""
        return {
            "address": self.url.host,
            "mac_address": self.main.mac_address if self.main else None,
            "devices": {
                address: dev.as_dict() for address, dev in self.devices.items()
            },
        }
