"""Helpers for Zigbee Home Automation."""

from __future__ import annotations

import ast
import asyncio
import binascii
import collections
from collections.abc import Callable
import contextlib
import dataclasses
from dataclasses import dataclass
import datetime
import enum
import logging
import re
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar

import voluptuous as vol
import zigpy.exceptions
import zigpy.types
import zigpy.util
import zigpy.zcl
from zigpy.zcl.foundation import CommandSchema
import zigpy.zdo.types as zdo_types

from zha.application import Platform
from zha.application.const import (
    CLUSTER_TYPE_IN,
    CLUSTER_TYPE_OUT,
    CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY,
    CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS,
)
from zha.async_ import gather_with_limited_concurrency
from zha.decorators import periodic

if TYPE_CHECKING:
    from zha.application.gateway import Gateway
    from zha.zigbee.cluster_handlers import ClusterHandler
    from zha.zigbee.device import Device

_ClusterHandlerT = TypeVar("_ClusterHandlerT", bound="ClusterHandler")
_T = TypeVar("_T")
_R = TypeVar("_R")
_P = ParamSpec("_P")
_LOGGER = logging.getLogger(__name__)


@dataclass
class BindingPair:
    """Information for binding."""

    source_cluster: zigpy.zcl.Cluster
    target_ieee: zigpy.types.EUI64
    target_ep_id: int

    @property
    def destination_address(self) -> zdo_types.MultiAddress:
        """Return a ZDO multi address instance."""
        return zdo_types.MultiAddress(
            addrmode=3, ieee=self.target_ieee, endpoint=self.target_ep_id
        )


async def safe_read(
    cluster: zigpy.zcl.Cluster,
    attributes: list[int | str],
    allow_cache: bool = True,
    only_cache: bool = False,
    manufacturer=None,
):
    """Swallow all exceptions from network read.

    If we throw during initialization, setup fails. Rather have an entity that
    exists, but is in a maybe wrong state, than no entity. This method should
    probably only be used during initialization.
    """
    try:
        result, _ = await cluster.read_attributes(
            attributes,
            allow_cache=allow_cache,
            only_cache=only_cache,
            manufacturer=manufacturer,
        )
        return result
    except Exception:  # pylint: disable=broad-except
        return {}


async def get_matched_clusters(
    source_zha_device: Device, target_zha_device: Device
) -> list[BindingPair]:
    """Get matched input/output cluster pairs for 2 devices."""
    from zha.zigbee.cluster_handlers.registries import (  # pylint: disable=import-outside-toplevel
        BINDABLE_CLUSTERS,
    )

    source_clusters = source_zha_device.async_get_std_clusters()
    target_clusters = target_zha_device.async_get_std_clusters()
    clusters_to_bind = []

    for endpoint_id in source_clusters:
        for cluster_id in source_clusters[endpoint_id][CLUSTER_TYPE_OUT]:
            if cluster_id not in BINDABLE_CLUSTERS:
                continue
            if target_zha_device.nwk == 0x0000:
                cluster_pair = BindingPair(
                    source_cluster=source_clusters[endpoint_id][CLUSTER_TYPE_OUT][
                        cluster_id
                    ],
                    target_ieee=target_zha_device.ieee,
                    target_ep_id=target_zha_device.device.application.get_endpoint_id(
                        cluster_id, is_server_cluster=True
                    ),
                )
                clusters_to_bind.append(cluster_pair)
                continue
            for t_endpoint_id in target_clusters:
                if cluster_id in target_clusters[t_endpoint_id][CLUSTER_TYPE_IN]:
                    cluster_pair = BindingPair(
                        source_cluster=source_clusters[endpoint_id][CLUSTER_TYPE_OUT][
                            cluster_id
                        ],
                        target_ieee=target_zha_device.ieee,
                        target_ep_id=t_endpoint_id,
                    )
                    clusters_to_bind.append(cluster_pair)
    return clusters_to_bind


def convert_zcl_value(value: Any, field_type: Any) -> Any:
    """Convert user input to ZCL value."""
    if issubclass(field_type, enum.Flag):
        if isinstance(value, str):
            with contextlib.suppress(ValueError):
                value = int(value)

        if isinstance(value, int):
            value = field_type(value)
        elif isinstance(value, str):
            # List of flags: `SomeFlag.field1 | field2`
            value = [v.strip() for v in value.split(".", 1)[-1].split("|")]

        if isinstance(value, list):
            new_value = 0

            for flag in value:
                if isinstance(flag, str):
                    new_value |= field_type[flag.replace(" ", "_")]
                else:
                    new_value |= flag

            value = field_type(new_value)
    elif issubclass(field_type, enum.Enum):
        value = (
            field_type[value.replace(" ", "_").split(".", 1)[-1]]
            if isinstance(value, str)
            else field_type(value)
        )
    elif issubclass(field_type, zigpy.types.SerializableBytes):
        if value.startswith(("b'", 'b"')):
            value = ast.literal_eval(value)
        else:
            value = bytes.fromhex(value)

        value = field_type(value)
    elif issubclass(field_type, int):
        if isinstance(value, str) and value.startswith("0x"):
            value = int(value, 16)

        value = field_type(value)
    else:
        value = field_type(value)

    return value


def convert_to_zcl_values(
    fields: dict[str, Any], schema: CommandSchema
) -> dict[str, Any]:
    """Convert user input to ZCL values."""
    converted_fields: dict[str, Any] = {}
    for field in schema.fields:
        if field.name not in fields:
            continue

        value = fields[field.name]
        new_value = converted_fields[field.name] = convert_zcl_value(value, field.type)

        _LOGGER.debug(
            "Converted ZCL schema field(%s) value from: %s to: %s",
            field.name,
            value,
            new_value,
        )

    return converted_fields


def async_is_bindable_target(source_zha_device: Device, target_zha_device: Device):
    """Determine if target is bindable to source."""
    from zha.zigbee.cluster_handlers.registries import (  # pylint: disable=import-outside-toplevel
        BINDABLE_CLUSTERS,
    )

    if target_zha_device.nwk == 0x0000:
        return True

    source_clusters = source_zha_device.async_get_std_clusters()
    target_clusters = target_zha_device.async_get_std_clusters()

    for endpoint_id in source_clusters:
        for t_endpoint_id in target_clusters:
            matches = set(
                source_clusters[endpoint_id][CLUSTER_TYPE_OUT].keys()
            ).intersection(target_clusters[t_endpoint_id][CLUSTER_TYPE_IN].keys())
            if any(bindable in BINDABLE_CLUSTERS for bindable in matches):
                return True
    return False


def convert_install_code(value: str) -> zigpy.types.KeyData:
    """Convert string to install code bytes and validate length."""

    try:
        code = binascii.unhexlify(value.replace("-", "").lower())
    except binascii.Error as exc:
        raise vol.Invalid(f"invalid hex string: {value}") from exc

    if len(code) != 18:  # 16 byte code + 2 crc bytes
        raise vol.Invalid("invalid length of the install code")

    link_key = zigpy.util.convert_install_code(code)
    if link_key is None:
        raise vol.Invalid("invalid install code")

    return link_key


QR_CODES = (
    # Consciot
    r"^([\da-fA-F]{16})\|([\da-fA-F]{36})$",
    # Enbrighten
    r"""
        ^Z:
        ([0-9a-fA-F]{16})  # IEEE address
        \$I:
        ([0-9a-fA-F]{36})  # install code
        $
    """,
    # Aqara
    r"""
        \$A:
        ([0-9a-fA-F]{16})  # IEEE address
        \$I:
        ([0-9a-fA-F]{36})  # install code
        $
    """,
    # Bosch
    r"""
        ^RB01SG
        [0-9a-fA-F]{34}
        ([0-9a-fA-F]{16}) # IEEE address
        DLK
        ([0-9a-fA-F]{36}|[0-9a-fA-F]{32}) # install code / link key
        $
    """,
)


def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, zigpy.types.KeyData]:
    """Try to parse the QR code.

    if successful, return a tuple of a EUI64 address and install code.
    """

    for code_pattern in QR_CODES:
        match = re.search(code_pattern, qr_code, re.VERBOSE)
        if match is None:
            continue

        ieee_hex = binascii.unhexlify(match[1])
        ieee = zigpy.types.EUI64(ieee_hex[::-1])

        # Bosch supplies (A) device specific link key (DSLK) or (A) install code + crc
        if "RB01SG" in code_pattern and len(match[2]) == 32:
            link_key_hex = binascii.unhexlify(match[2])
            link_key = zigpy.types.KeyData(link_key_hex)
            return ieee, link_key
        install_code = match[2]
        # install_code sanity check
        link_key = convert_install_code(install_code)
        return ieee, link_key

    raise vol.Invalid(f"couldn't convert qr code: {qr_code}")


@dataclass(kw_only=True, slots=True)
class LightOptions:
    """ZHA light options."""

    default_light_transition: float = dataclasses.field(default=0)
    enable_enhanced_light_transition: bool = dataclasses.field(default=False)
    enable_light_transitioning_flag: bool = dataclasses.field(default=True)
    always_prefer_xy_color_mode: bool = dataclasses.field(default=True)
    group_members_assume_state: bool = dataclasses.field(default=True)


@dataclass(kw_only=True, slots=True)
class DeviceOptions:
    """ZHA device options."""

    enable_identify_on_join: bool = dataclasses.field(default=True)
    consider_unavailable_mains: int = dataclasses.field(
        default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS
    )
    consider_unavailable_battery: int = dataclasses.field(
        default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY
    )
    enable_mains_startup_polling: bool = dataclasses.field(default=True)


@dataclass(kw_only=True, slots=True)
class AlarmControlPanelOptions:
    """ZHA alarm control panel options."""

    master_code: str = dataclasses.field(default="1234")
    failed_tries: int = dataclasses.field(default=3)
    arm_requires_code: bool = dataclasses.field(default=False)


@dataclass(kw_only=True, slots=True)
class CoordinatorConfiguration:
    """ZHA coordinator configuration."""

    path: str
    baudrate: int = dataclasses.field(default=115200)
    flow_control: str = dataclasses.field(default="hardware")
    radio_type: str = dataclasses.field(default="ezsp")


@dataclass(kw_only=True, slots=True)
class QuirksConfiguration:
    """ZHA quirks configuration."""

    enabled: bool = dataclasses.field(default=True)
    custom_quirks_path: str | None = dataclasses.field(default=None)


@dataclass(kw_only=True, slots=True)
class DeviceOverridesConfiguration:
    """ZHA device overrides configuration."""

    type: Platform


@dataclass(kw_only=True, slots=True)
class ZHAConfiguration:
    """ZHA configuration."""

    coordinator_configuration: CoordinatorConfiguration = dataclasses.field(
        default_factory=CoordinatorConfiguration
    )
    quirks_configuration: QuirksConfiguration = dataclasses.field(
        default_factory=QuirksConfiguration
    )
    device_overrides: dict[str, DeviceOverridesConfiguration] = dataclasses.field(
        default_factory=dict
    )
    light_options: LightOptions = dataclasses.field(default_factory=LightOptions)
    device_options: DeviceOptions = dataclasses.field(default_factory=DeviceOptions)
    alarm_control_panel_options: AlarmControlPanelOptions = dataclasses.field(
        default_factory=AlarmControlPanelOptions
    )


@dataclasses.dataclass(kw_only=True, slots=True)
class ZHAData:
    """ZHA data stored in `gateway.data`."""

    config: ZHAConfiguration
    zigpy_config: dict[str, Any] = dataclasses.field(default_factory=dict)
    platforms: collections.defaultdict[Platform, list] = dataclasses.field(
        default_factory=lambda: collections.defaultdict(list)
    )
    gateway: Gateway | None = dataclasses.field(default=None)
    device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field(
        default_factory=dict
    )
    allow_polling: bool = dataclasses.field(default=False)
    local_timezone: datetime.tzinfo = dataclasses.field(default=datetime.UTC)


class GlobalUpdater:
    """Global updater for ZHA.

    This class is used to update all listeners at a regular interval. The listeners
    are `Callable` objects that are registered with the `register_update_listener` method.
    """

    _REFRESH_INTERVAL = (30, 45)
    __polling_interval: int

    def __init__(self, gateway: Gateway):
        """Initialize the GlobalUpdater."""
        self._updater_task_handle: asyncio.Task = None
        self._update_listeners: list[Callable] = []
        self._gateway: Gateway = gateway

    def start(self):
        """Start the global updater."""
        self._updater_task_handle = self._gateway.async_create_background_task(
            self.update_listeners(),
            name=f"global-updater_{self.__class__.__name__}",
            eager_start=True,
            untracked=True,
        )
        _LOGGER.debug(
            "started global updater with an interval of %s seconds",
            getattr(self, "__polling_interval"),
        )

    def stop(self):
        """Stop the global updater."""
        _LOGGER.debug("stopping global updater")
        if self._updater_task_handle:
            self._updater_task_handle.cancel()
            self._updater_task_handle = None
        _LOGGER.debug("global updater stopped")

    def register_update_listener(self, listener: Callable):
        """Register an update listener."""
        if listener in self._update_listeners:
            _LOGGER.debug(
                "listener already registered with global updater - nothing to register: %s",
                listener,
            )
            return
        self._update_listeners.append(listener)

    def remove_update_listener(self, listener: Callable):
        """Remove an update listener."""
        if listener not in self._update_listeners:
            _LOGGER.debug(
                "listener not registered with global updater - nothing to remove: %s",
                listener,
            )
            return
        self._update_listeners.remove(listener)

    @periodic(_REFRESH_INTERVAL)
    async def update_listeners(self):
        """Update all listeners."""
        _LOGGER.debug("Global updater interval starting")
        if self._gateway.config.allow_polling:
            for listener in self._update_listeners:
                _LOGGER.debug("Global updater running update callback")
                listener()
        else:
            _LOGGER.debug("Global updater interval skipped")
        _LOGGER.debug("Global updater interval finished")


class DeviceAvailabilityChecker:
    """Device availability checker for ZHA."""

    _REFRESH_INTERVAL = (30, 45)
    __polling_interval: int

    def __init__(self, gateway: Gateway):
        """Initialize the DeviceAvailabilityChecker."""
        self._gateway: Gateway = gateway
        self._device_availability_task_handle: asyncio.Task = None

    def start(self):
        """Start the device availability checker."""
        self._device_availability_task_handle = (
            self._gateway.async_create_background_task(
                self.check_device_availability(),
                name=f"device-availability-checker_{self.__class__.__name__}",
                eager_start=True,
                untracked=True,
            )
        )
        _LOGGER.debug(
            "started device availability checker with an interval of %s seconds",
            getattr(self, "__polling_interval"),
        )

    def stop(self):
        """Stop the device availability checker."""
        _LOGGER.debug("stopping device availability checker")
        if self._device_availability_task_handle:
            self._device_availability_task_handle.cancel()
            self._device_availability_task_handle = None
        _LOGGER.debug("device availability checker stopped")

    @periodic(_REFRESH_INTERVAL)
    async def check_device_availability(self):
        """Check device availability."""
        _LOGGER.debug("Device availability checker interval starting")
        if self._gateway.config.allow_polling:
            _LOGGER.debug("Checking device availability")
            # 20 because most devices will not make remote calls
            await gather_with_limited_concurrency(
                20,
                *(
                    dev._check_available()
                    for dev in self._gateway.devices.values()
                    if not dev.is_coordinator
                ),
            )
            _LOGGER.debug("Device availability checker interval finished")
        else:
            _LOGGER.debug("Device availability checker interval skipped")
