"""Cluster handlers module for Zigbee Home Automation."""

from __future__ import annotations

from collections.abc import Awaitable, Callable, Coroutine, Iterator
import contextlib
from dataclasses import dataclass
from enum import Enum
import functools
import logging
from typing import TYPE_CHECKING, Any, Final, ParamSpec, TypedDict

import zigpy.exceptions
from zigpy.typing import UNDEFINED, UndefinedType
import zigpy.util
import zigpy.zcl
from zigpy.zcl import (
    AttributeReadEvent,
    AttributeReportedEvent,
    AttributeUpdatedEvent,
    AttributeWrittenEvent,
)
from zigpy.zcl.foundation import (
    CommandSchema,
    ConfigureReportingResponseRecord,
    Status,
    ZCLAttributeDef,
)

from zha.application.const import (
    ZHA_CLUSTER_HANDLER_MSG,
    ZHA_CLUSTER_HANDLER_MSG_BIND,
    ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
)
from zha.application.helpers import safe_read
from zha.event import EventBase
from zha.exceptions import ZHAException
from zha.mixins import LogMixin
from zha.zigbee.cluster_handlers.const import (
    ARGS,
    ATTRIBUTE_ID,
    ATTRIBUTE_NAME,
    ATTRIBUTE_VALUE,
    CLUSTER_HANDLER_ATTRIBUTE_UPDATED,
    CLUSTER_HANDLER_EVENT,
    CLUSTER_HANDLER_ZDO,
    CLUSTER_ID,
    CLUSTER_READS_PER_REQ,
    COMMAND,
    PARAMS,
    REPORT_CONFIG_ATTR_PER_REQ,
    SIGNAL_ATTR_UPDATED,
    UNIQUE_ID,
    VALUE,
)

if TYPE_CHECKING:
    from zha.zigbee.endpoint import Endpoint

_LOGGER = logging.getLogger(__name__)
RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3)
UNPROXIED_CLUSTER_METHODS = {"general_command"}


_P = ParamSpec("_P")
_FuncType = Callable[_P, Awaitable[Any]]
_ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]]


@contextlib.contextmanager
def wrap_zigpy_exceptions() -> Iterator[None]:
    """Wrap zigpy exceptions in `ZHAException` exceptions."""
    try:
        yield
    except TimeoutError as exc:
        raise ZHAException("Failed to send request: device did not respond") from exc
    except zigpy.exceptions.ZigbeeException as exc:
        message = "Failed to send request"

        if str(exc):
            message = f"{message}: {exc}"

        raise ZHAException(message) from exc


def retry_request[**P](
    func: Callable[P, Awaitable[Any]],
) -> Callable[P, Coroutine[Any, Any, Any]]:
    """Send a request with retries and wrap expected zigpy exceptions."""

    @functools.wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
        with wrap_zigpy_exceptions():
            return await RETRYABLE_REQUEST_DECORATOR(func)(*args, **kwargs)

    return wrapper


class AttrReportConfig(TypedDict, total=True):
    """Configuration to report for the attributes."""

    # An attribute name
    attr: str
    # The config for the attribute reporting configuration consists of a tuple for
    # (minimum_reported_time_interval_s, maximum_reported_time_interval_s, value_delta)
    config: tuple[int, int, int | float]


def parse_and_log_command(cluster_handler, tsn, command_id, args):
    """Parse and log a zigbee cluster command."""
    try:
        name = cluster_handler.cluster.server_commands[command_id].name
    except KeyError:
        name = f"0x{command_id:02X}"

    cluster_handler.debug(
        "received '%s' command with %s args on cluster_id '%s' tsn '%s'",
        name,
        args,
        cluster_handler.cluster.cluster_id,
        tsn,
    )
    return name


class ClusterHandlerStatus(Enum):
    """Status of a cluster handler."""

    CREATED = 1
    CONFIGURED = 2
    INITIALIZED = 3


@dataclass(kw_only=True, frozen=True)
class ClusterAttributeUpdatedEvent:
    """Event to signal that a cluster attribute has been updated."""

    attribute_id: int
    attribute_name: str
    attribute_value: Any
    cluster_handler_unique_id: str
    cluster_id: int
    event_type: Final[str] = CLUSTER_HANDLER_EVENT
    event: Final[str] = CLUSTER_HANDLER_ATTRIBUTE_UPDATED


@dataclass(kw_only=True, frozen=True)
class ClusterBindEvent:
    """Event generated when the cluster is bound."""

    cluster_name: str
    cluster_id: int
    success: bool
    cluster_handler_unique_id: str
    event_type: Final[str] = ZHA_CLUSTER_HANDLER_MSG
    event: Final[str] = ZHA_CLUSTER_HANDLER_MSG_BIND


@dataclass(kw_only=True, frozen=True)
class ClusterConfigureReportingEvent:
    """Event generates when a cluster configures attribute reporting."""

    cluster_name: str
    cluster_id: int
    attributes: dict[str, dict[str, Any]]
    cluster_handler_unique_id: str
    event_type: Final[str] = ZHA_CLUSTER_HANDLER_MSG
    event: Final[str] = ZHA_CLUSTER_HANDLER_MSG_CFG_RPT


@dataclass(kw_only=True, frozen=True)
class ClusterInfo:
    """Cluster information."""

    id: int
    name: str
    type: str
    commands: dict[int, str]


@dataclass(kw_only=True, frozen=True)
class ClusterHandlerInfo:
    """Cluster handler information."""

    class_name: str
    generic_id: str
    endpoint_id: str
    cluster: ClusterInfo
    id: str
    unique_id: str
    status: ClusterHandlerStatus
    value_attribute: str | None = None


class ClusterHandler(LogMixin, EventBase):
    """Base cluster handler for a Zigbee cluster."""

    REPORT_CONFIG: tuple[AttrReportConfig, ...] = ()
    BIND: bool = True

    # Dict of attributes to read on cluster handler initialization.
    # Dict keys -- attribute ID or names, with bool value indicating whether a cached
    # attribute read is acceptable.
    ZCL_INIT_ATTRS: dict[str, bool] = {}

    def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None:
        """Initialize ClusterHandler."""
        super().__init__()
        self._generic_id = f"cluster_handler_0x{cluster.cluster_id:04x}"
        self._endpoint: Endpoint = endpoint
        self._cluster: zigpy.zcl.Cluster = cluster
        self._id: str = f"{endpoint.id}:0x{cluster.cluster_id:04x}"
        unique_id: str = endpoint.unique_id.replace("-", ":")
        self._unique_id: str = f"{unique_id}:0x{cluster.cluster_id:04x}"
        if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG:
            attr_def: ZCLAttributeDef = self.cluster.attributes_by_name[
                self.REPORT_CONFIG[0]["attr"]
            ]
            self.value_attribute = attr_def.name
        self._status: ClusterHandlerStatus = ClusterHandlerStatus.CREATED
        self.data_cache: dict[str, Any] = {}
        self._unsubs: list[Callable[[], None]] = []

    def on_add(self) -> None:
        """Call when cluster handler is added."""
        self._cluster.add_listener(self)
        for event_type in (
            AttributeReadEvent,
            AttributeReportedEvent,
            AttributeUpdatedEvent,
            AttributeWrittenEvent,
        ):
            self._unsubs.append(
                self._cluster.on_event(
                    event_type.event_type, self._handle_attribute_updated_event
                )
            )

    def on_remove(self) -> None:
        """Call when cluster handler will be removed."""
        self._cluster.remove_listener(self)
        for unsub in self._unsubs:
            unsub()
        self._unsubs.clear()

    def _handle_attribute_updated_event(
        self,
        event: AttributeReadEvent
        | AttributeReportedEvent
        | AttributeUpdatedEvent
        | AttributeWrittenEvent,
    ) -> None:
        """Handle attribute updated event from zigpy."""
        self.debug(
            "cluster_handler[%s] attribute_updated - cluster[%s] attr[%s] value[%s]",
            self.name,
            self.cluster.name,
            event.attribute_name,
            event.value,
        )
        self.emit(
            CLUSTER_HANDLER_ATTRIBUTE_UPDATED,
            ClusterAttributeUpdatedEvent(
                attribute_id=event.attribute_id,
                attribute_name=event.attribute_name,
                attribute_value=event.value,
                cluster_handler_unique_id=self.unique_id,
                cluster_id=self.cluster.cluster_id,
            ),
        )

    @classmethod
    def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool:  # pylint: disable=unused-argument
        """Filter the cluster match for specific devices."""
        return True

    @functools.cached_property
    def info_object(self) -> ClusterHandlerInfo:
        """Return info about this cluster handler."""
        return ClusterHandlerInfo(
            class_name=self.__class__.__name__,
            generic_id=self._generic_id,
            endpoint_id=self._endpoint.id,
            cluster=ClusterInfo(
                id=self._cluster.cluster_id,
                name=self._cluster.name,
                type="client" if self._cluster.is_client else "server",
                commands=self._cluster.commands,
            ),
            id=self._id,
            unique_id=self._unique_id,
            status=self._status.name,
            value_attribute=getattr(self, "value_attribute", None),
        )

    @functools.cached_property
    def id(self) -> str:
        """Return cluster handler id unique for this device only."""
        return self._id

    @functools.cached_property
    def generic_id(self) -> str:
        """Return the generic id for this cluster handler."""
        return self._generic_id

    @functools.cached_property
    def unique_id(self) -> str:
        """Return the unique id for this cluster handler."""
        return self._unique_id

    @functools.cached_property
    def cluster(self) -> zigpy.zcl.Cluster:
        """Return the zigpy cluster for this cluster handler."""
        return self._cluster

    @functools.cached_property
    def name(self) -> str:
        """Return friendly name."""
        return self.cluster.ep_attribute or self._generic_id

    @property
    def status(self) -> ClusterHandlerStatus:
        """Return the status of the cluster handler."""
        return self._status

    def __hash__(self) -> int:
        """Make this a hashable."""
        return hash(self._unique_id)

    async def bind(self) -> None:
        """Bind a zigbee cluster.

        This also swallows ZigbeeException exceptions that are thrown when
        devices are unreachable.
        """
        try:
            res = await RETRYABLE_REQUEST_DECORATOR(self.cluster.bind)()
            self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0])
            self._endpoint.device.emit(
                ZHA_CLUSTER_HANDLER_MSG_BIND,
                ClusterBindEvent(
                    cluster_name=self.cluster.name,
                    cluster_id=self.cluster.cluster_id,
                    cluster_handler_unique_id=self.unique_id,
                    success=res[0] == 0,
                ),
            )
        except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex:
            self.debug(
                "Failed to bind '%s' cluster: %s",
                self.cluster.ep_attribute,
                str(ex),
                exc_info=ex,
            )
            self._endpoint.device.emit(
                ZHA_CLUSTER_HANDLER_MSG_BIND,
                ClusterBindEvent(
                    cluster_name=self.cluster.name,
                    cluster_id=self.cluster.cluster_id,
                    cluster_handler_unique_id=self.unique_id,
                    success=False,
                ),
            )

    async def configure_reporting(self) -> None:
        """Configure attribute reporting for a cluster.

        This also swallows ZigbeeException exceptions that are thrown when
        devices are unreachable.
        """
        event_data = {}
        kwargs = {}
        if (
            self.cluster.cluster_id >= 0xFC00
            and self._endpoint.device.manufacturer_code
        ):
            kwargs["manufacturer"] = self._endpoint.device.manufacturer_code

        for attr_report in self.REPORT_CONFIG:
            attr, config = attr_report["attr"], attr_report["config"]

            try:
                attr_name = self.cluster.find_attribute(attr).name
            except KeyError:
                attr_name = attr

            event_data[attr_name] = {
                "min": config[0],
                "max": config[1],
                "id": attr,
                "name": attr_name,
                "change": config[2],
                "status": None,
            }

        to_configure = [*self.REPORT_CONFIG]
        chunk, rest = (
            to_configure[:REPORT_CONFIG_ATTR_PER_REQ],
            to_configure[REPORT_CONFIG_ATTR_PER_REQ:],
        )
        while chunk:
            reports = {rec["attr"]: rec["config"] for rec in chunk}
            try:
                res = await RETRYABLE_REQUEST_DECORATOR(
                    self.cluster.configure_reporting_multiple
                )(reports, **kwargs)
                self._configure_reporting_status(reports, res[0], event_data)
            except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex:
                self.debug(
                    "failed to set reporting on '%s' cluster for: %s",
                    self.cluster.ep_attribute,
                    str(ex),
                )
                break
            chunk, rest = (
                rest[:REPORT_CONFIG_ATTR_PER_REQ],
                rest[REPORT_CONFIG_ATTR_PER_REQ:],
            )

        self._endpoint.device.emit(
            ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
            ClusterConfigureReportingEvent(
                cluster_name=self.cluster.name,
                cluster_id=self.cluster.cluster_id,
                cluster_handler_unique_id=self.unique_id,
                attributes=event_data,
            ),
        )

    def _configure_reporting_status(
        self,
        attrs: dict[str, tuple[int, int, float | int]],
        res: list | tuple,
        event_data: dict[str, dict[str, Any]],
    ) -> None:
        """Parse configure reporting result."""
        if isinstance(res, (Exception, ConfigureReportingResponseRecord)):
            # assume default response
            self.debug(
                "attr reporting for '%s' on '%s': %s",
                attrs,
                self.name,
                res,
            )
            for attr in attrs:
                event_data[attr]["status"] = Status.FAILURE.name
            return
        if res[0].status == Status.SUCCESS and len(res) == 1:
            self.debug(
                "Successfully configured reporting for '%s' on '%s' cluster: %s",
                attrs,
                self.name,
                res,
            )
            # 2.5.8.1.3 Status Field
            # The status field specifies the status of the Configure Reporting operation attempted on this attribute,
            # as detailed in 2.5.7.3. Note that attribute status records are not included for successfully configured
            # attributes, in order to save bandwidth. In the case of successful configuration of all attributes,
            # only a single attribute status record SHALL be included in the command, with the status field set to
            # SUCCESS and the direction and attribute identifier fields omitted.
            for attr in attrs:
                event_data[attr]["status"] = Status.SUCCESS.name
            return

        for record in res:
            event_data[self.cluster.find_attribute(record.attrid).name]["status"] = (
                record.status.name
            )
        failed = [
            self.cluster.find_attribute(record.attrid).name
            for record in res
            if record.status != Status.SUCCESS
        ]
        self.debug(
            "Failed to configure reporting for '%s' on '%s' cluster: %s",
            failed,
            self.name,
            res,
        )
        success = set(attrs) - set(failed)
        self.debug(
            "Successfully configured reporting for '%s' on '%s' cluster",
            set(attrs) - set(failed),
            self.name,
        )
        for attr in success:
            event_data[attr]["status"] = Status.SUCCESS.name

    async def async_configure(self) -> None:
        """Set cluster binding and attribute reporting."""
        if self._endpoint.device.skip_configuration:
            self.debug("skipping cluster handler configuration")
            self._status = ClusterHandlerStatus.CONFIGURED
            return

        if self.BIND:
            self.debug("Performing cluster binding")
            await self.bind()

        if self.cluster.is_server:
            self.debug("Configuring cluster attribute reporting")
            await self.configure_reporting()

        self.debug("finished cluster handler configuration")
        self._status = ClusterHandlerStatus.CONFIGURED

    async def async_initialize(self, from_cache: bool) -> None:
        """Initialize cluster handler."""
        if not from_cache and self._endpoint.device.skip_configuration:
            self.debug("Skipping cluster handler initialization")
            self._status = ClusterHandlerStatus.INITIALIZED
            return

        self.debug("initializing cluster handler: from_cache: %s", from_cache)
        cached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if cached]
        uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached]
        uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG])

        if cached:
            self.debug("initializing cached cluster handler attributes: %s", cached)
            await self._get_attributes(
                True, cached, from_cache=True, only_cache=from_cache
            )
        if uncached:
            self.debug(
                "initializing uncached cluster handler attributes: %s - from cache[%s]",
                uncached,
                from_cache,
            )
            await self._get_attributes(
                True, uncached, from_cache=from_cache, only_cache=from_cache
            )

        ch_specific_init = getattr(
            self, "async_initialize_cluster_handler_specific", None
        )
        if ch_specific_init:
            self.debug(
                "Performing cluster handler specific initialization: %s", uncached
            )
            await ch_specific_init(from_cache=from_cache)

        self.debug("finished cluster handler initialization")
        self._status = ClusterHandlerStatus.INITIALIZED

    def cluster_command(self, tsn, command_id, args) -> None:
        """Handle commands received to this cluster."""

    def zdo_command(self, *args, **kwargs) -> None:
        """Handle ZDO commands on this cluster."""

    def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None:
        """Compatibility method for emitting ZHA events from quirks until they are updated."""
        self.emit_zha_event(command, arg)

    def emit_zha_event(self, command: str, arg: list | dict | CommandSchema) -> None:
        """Relay events to listeners."""

        args: list | dict
        if isinstance(arg, CommandSchema):
            args = [a for a in arg if a is not None]
            params = arg.as_dict()
        elif isinstance(arg, (list, dict)):
            # Quirks can directly send lists and dicts to ZHA this way
            args = arg
            params = {}
        else:
            raise TypeError(f"Unexpected emit_zha_event {command!r} argument: {arg!r}")

        self._endpoint.emit_zha_event(
            {
                UNIQUE_ID: self.unique_id,
                CLUSTER_ID: self.cluster.cluster_id,
                COMMAND: command,
                # Maintain backwards compatibility with the old zigpy response format
                ARGS: args,
                PARAMS: params,
            }
        )

    async def async_update(self) -> None:
        """Retrieve latest state from cluster."""

    def _get_attribute_name(self, attrid: int) -> str | int:
        if attrid not in self.cluster.attributes:
            return attrid

        return self.cluster.attributes[attrid].name

    async def get_attribute_value(self, attribute, from_cache=True) -> Any:
        """Get the value for an attribute."""
        result = await safe_read(
            self._cluster,
            [attribute],
            allow_cache=from_cache,
            only_cache=from_cache,
        )
        return result.get(attribute)

    async def _get_attributes(
        self,
        raise_exceptions: bool,
        attributes: list[str],
        from_cache: bool = True,
        only_cache: bool = True,
    ) -> dict[int | str, Any]:
        """Get the values for a list of attributes."""
        chunk = attributes[:CLUSTER_READS_PER_REQ]
        rest = attributes[CLUSTER_READS_PER_REQ:]
        result = {}
        while chunk:
            try:
                self.debug("Reading attributes in chunks: %s", chunk)
                read, _ = await RETRYABLE_REQUEST_DECORATOR(
                    self.cluster.read_attributes
                )(
                    chunk,
                    allow_cache=from_cache,
                    only_cache=only_cache,
                    manufacturer=UNDEFINED,  # some quirks override default with None
                )
                self.debug("Got attributes: %s", read)
                result.update(read)
            except (TimeoutError, zigpy.exceptions.ZigbeeException) as ex:
                self.debug(
                    "failed to get attributes '%s' on '%s' cluster: %s",
                    chunk,
                    self.cluster.ep_attribute,
                    str(ex),
                )
                if raise_exceptions:
                    raise
            chunk = rest[:CLUSTER_READS_PER_REQ]
            rest = rest[CLUSTER_READS_PER_REQ:]
        return result

    async def get_attributes(
        self,
        attributes: list[str],
        from_cache: bool = True,
        only_cache: bool = True,
    ) -> dict[int | str, Any]:
        """Get the values for a list of attributes and raise no exceptions."""
        return await self._get_attributes(False, attributes, from_cache, only_cache)

    async def write_attributes_safe(
        self,
        attributes: dict[str, Any],
        manufacturer: int | UndefinedType | None = UNDEFINED,
    ) -> None:
        """Wrap `write_attributes` to throw an exception on attribute write failure."""

        res = await self.write_attributes(attributes, manufacturer=manufacturer)
        for record in res[0]:
            if record.status != Status.SUCCESS:
                try:
                    name = self.cluster.attributes[record.attrid].name
                    value = attributes.get(name, "unknown")
                except KeyError:
                    name = f"0x{record.attrid:04x}"
                    value = "unknown"

                raise ZHAException(
                    f"Failed to write attribute {name}={value}: {record.status}",
                )

    def log(self, level, msg, *args, **kwargs) -> None:
        """Log a message."""
        msg = f"[%s:%s]: {msg}"
        args = (self._endpoint.device.nwk, self._id) + args
        _LOGGER.log(level, msg, *args, stacklevel=3, **kwargs)

    def __getattr__(self, name):
        """Get attribute or a decorated cluster command."""
        if (
            hasattr(self._cluster, name)
            and callable(getattr(self._cluster, name))
            and name not in UNPROXIED_CLUSTER_METHODS
        ):
            command = getattr(self._cluster, name)
            wrapped_command = retry_request(command)
            wrapped_command.__name__ = name

            return wrapped_command
        return self.__getattribute__(name)


class ZDOClusterHandler(LogMixin):
    """Cluster handler for ZDO events."""

    def __init__(self, device) -> None:
        """Initialize ZDOClusterHandler."""
        self.name = CLUSTER_HANDLER_ZDO
        self._cluster = device.device.endpoints[0]
        self._zha_device = device
        self._status = ClusterHandlerStatus.CREATED
        self._unique_id = f"{str(device.ieee)}:{device.name}_ZDO"

    def on_add(self) -> None:
        """Call when cluster handler is added."""
        self._cluster.add_listener(self)

    def on_remove(self) -> None:
        """Call when cluster handler will be removed."""
        self._cluster.remove_listener(self)

    @property
    def unique_id(self):
        """Return the unique id for this cluster handler."""
        return self._unique_id

    @property
    def cluster(self):
        """Return the aigpy cluster for this cluster handler."""
        return self._cluster

    @property
    def status(self):
        """Return the status of the cluster handler."""
        return self._status

    def device_announce(self, zigpy_device):
        """Device announce handler."""

    def permit_duration(self, duration):
        """Permit handler."""

    async def async_initialize(self, from_cache):  # pylint: disable=unused-argument
        """Initialize cluster handler."""
        self._status = ClusterHandlerStatus.INITIALIZED

    async def async_configure(self):
        """Configure cluster handler."""
        self._status = ClusterHandlerStatus.CONFIGURED

    def log(self, level, msg, *args, **kwargs):
        """Log a message."""
        msg = f"[%s:ZDO](%s): {msg}"
        args = (self._zha_device.nwk, self._zha_device.model) + args
        _LOGGER.log(level, msg, *args, **kwargs)


class ClientClusterHandler(ClusterHandler):
    """ClusterHandler for Zigbee client (output) clusters."""

    def __init__(self, *args, **kwargs) -> None:
        """Initialize ClientClusterHandler."""
        super().__init__(*args, **kwargs)
        self._unique_id += "_CLIENT"
        self._generic_id += "_client"
        self._id += "_client"

    def _handle_attribute_updated_event(
        self,
        event: AttributeReadEvent
        | AttributeReportedEvent
        | AttributeUpdatedEvent
        | AttributeWrittenEvent,
    ) -> None:
        """Handle an attribute updated on this cluster."""
        super()._handle_attribute_updated_event(event)

        self.emit_zha_event(
            SIGNAL_ATTR_UPDATED,
            {
                ATTRIBUTE_ID: event.attribute_id,
                ATTRIBUTE_NAME: event.attribute_name or "Unknown",
                ATTRIBUTE_VALUE: event.value,
                VALUE: event.value,
            },
        )

    def cluster_command(self, tsn, command_id, args):
        """Handle a cluster command received on this cluster."""
        if (
            self._cluster.server_commands is not None
            and self._cluster.server_commands.get(command_id) is not None
        ):
            self.emit_zha_event(self._cluster.server_commands[command_id].name, args)
