"""
Debounced ZHA quirk for the Tuya TS0601 ZigBee garage door / gate opener.

Sold by Home Better as HB-GATE-01:
  https://homebetter.com.au/products/zigbee-garage-door-gate-opener/
Write-up, symptoms and install steps:
  https://homebetter.com.au/guides/zigbee-garage-door-opener-zha-quirk/

Why this exists: a gate motor is usually the furthest thing in the house from
the coordinator. On a marginal link the command gets retried and the Tuya MCU
re-reports the same datapoint, so one press becomes two pulses — and a garage
door that gets two pulses stops half open. This quirk makes the relay a real
momentary pulse, debounces both the command and the attribute report, and maps
the bundled contact sensor to a proper IAS Zone binary_sensor.

Drop it in your ZHA custom quirks path, restart Home Assistant, then
reconfigure the device. Provided as-is under the MIT licence.
"""

import asyncio
import time
from typing import Any, Dict, Optional, Union

from zigpy.profiles import zgp, zha
from zigpy.quirks import CustomDevice
import zigpy.types as t
from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time
from zigpy.zcl.clusters.security import IasZone
from zigpy.zcl import foundation

from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
)

from zhaquirks.tuya import TuyaLocalCluster
from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster
from zhaquirks.tuya.ts0601_dimmer import TuyaOnOffNM

ZONE_TYPE = 0x0001

# ── Tuning ───────────────────────────────────────────────────────────────
# COMMAND_DEBOUNCE: how long after a pulse we ignore further presses. Must be
#   longer than one full pulse and longer than your ZHA retry window. 3s is
#   comfortable for a roller door; drop it if you genuinely need faster.
# REPORT_DEBOUNCE: window for collapsing identical on_off reports from the MCU.
# PULSE_SECONDS: how long the dry contact stays closed. Most motors want a
#   short pulse; lengthen only if yours misses it.
COMMAND_DEBOUNCE = 3.0
REPORT_DEBOUNCE = 1.5
PULSE_SECONDS = 0.8


class ContactSwitchCluster(TuyaLocalCluster, IasZone):
    """Tuya ContactSwitch Sensor."""

    _CONSTANT_ATTRIBUTES = {ZONE_TYPE: IasZone.ZoneType.Contact_Switch}

    def _update_attribute(self, attrid, value):
        self.debug("_update_attribute '%s': %s", attrid, value)
        super()._update_attribute(attrid, value)


class TuyaOnOffInching(TuyaOnOffNM):
    """Tuya OnOff Inching Switch with no manufacturer cluster."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # A freshly paired device has an empty attribute cache, and ZHA can
        # silently skip creating the switch entity when it cannot read an
        # initial value. Seed a default (off) so on_off is readable from the
        # moment the cluster exists.
        self._update_attribute(0x0000, 0)

    def _update_attribute(self, attrid, value):
        if attrid == 0x0000:
            now = time.time()
            last_time = getattr(self, "_last_attr_time", 0.0)
            last_val = getattr(self, "_last_attr_val", None)

            # The MCU likes to repeat itself on a weak link. An identical
            # report inside the window is the same event, not a second one.
            if (now - last_time < REPORT_DEBOUNCE) and (value == last_val):
                self.debug("Duplicate attribute update debounced: %s", value)
                return

            self._last_attr_time = now
            self._last_attr_val = value

        super()._update_attribute(attrid, value)

    async def command(
        self,
        command_id: Union[foundation.GeneralCommand, int, t.uint8_t],
        *args,
        manufacturer: Optional[Union[int, t.uint16_t]] = None,
        expect_reply: bool = True,
        tries: int = 1,
        tsn: Optional[Union[int, t.uint8_t]] = None,
        **kwargs: Any,
    ):
        now = time.time()
        last_cmd_time = getattr(self, "_last_cmd_time", 0.0)

        # The debounce that actually saves the door. Answer SUCCESS rather than
        # raising: ZHA indexes the result, so it has to be the two-element
        # (status, status) shape a real command returns.
        if now - last_cmd_time < COMMAND_DEBOUNCE:
            self.debug("Command debounced to prevent spam")
            return (foundation.Status.SUCCESS, foundation.Status.SUCCESS)

        # The relay is momentary; the quirk owns the off half of the pulse, so
        # an OFF from outside is meaningless and would only confuse the MCU.
        if command_id == 0:
            return (foundation.Status.SUCCESS, foundation.Status.SUCCESS)

        if command_id == 1:
            self._last_cmd_time = now
            super_command = super().command

            async def execute_inching():
                # Inching in software, so the pulse length is ours and does not
                # depend on whatever the device's own inching setting happens
                # to be after a factory reset.
                try:
                    await super_command(
                        1, *args, manufacturer=manufacturer, expect_reply=False, tries=1, tsn=tsn, **kwargs
                    )
                    await asyncio.sleep(PULSE_SECONDS)
                    await super_command(
                        0, *args, manufacturer=manufacturer, expect_reply=False, tries=1, tsn=tsn, **kwargs
                    )
                except Exception as e:
                    self.debug("Inching execution error: %s", e)
                finally:
                    self._update_attribute(0x0000, 0)

            asyncio.create_task(execute_inching())

            # Return straight away; the pulse runs as a background task so the
            # service call never blocks and never times out.
            return (foundation.Status.SUCCESS, foundation.Status.SUCCESS)

        return await super().command(
            command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tries=tries, tsn=tsn, **kwargs
        )


class TuyaGarageManufCluster(TuyaMCUCluster):
    """Tuya garage door opener."""

    attributes = TuyaMCUCluster.attributes.copy()
    attributes.update(
        {
            0xEF02: ("dp_2", t.uint32_t, True),
            0xEF04: ("dp_4", t.uint32_t, True),
            0xEF05: ("dp_5", t.uint32_t, True),
            0xEF0B: ("dp_11", t.Bool, True),
            0xEF0C: ("dp_12", t.enum8, True),
        }
    )

    dp_to_attribute: Dict[int, DPToAttributeMapping] = {
        # DP 1 is the relay, DP 3 is the reed contact. Mapping DP 3 onto an IAS
        # Zone on endpoint 2 is what turns "there is a sensor in the box" into
        # a real binary_sensor in Home Assistant.
        1: DPToAttributeMapping(TuyaOnOffInching.ep_attribute, "on_off"),
        2: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "dp_2"),
        3: DPToAttributeMapping(
            ContactSwitchCluster.ep_attribute,
            "zone_status",
            lambda x: IasZone.ZoneStatus.Alarm_1 if x else 0,
            endpoint_id=2,
        ),
        4: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "dp_4"),
        5: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "dp_5"),
        11: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "dp_11"),
        12: DPToAttributeMapping(TuyaMCUCluster.ep_attribute, "dp_12"),
    }

    data_point_handlers = {
        1: "_dp_2_attr_update",
        2: "_dp_2_attr_update",
        3: "_dp_2_attr_update",
        4: "_dp_2_attr_update",
        5: "_dp_2_attr_update",
        11: "_dp_2_attr_update",
        12: "_dp_2_attr_update",
    }


class TuyaGarageSwitchTO(CustomDevice):
    """Tuya Garage switch."""

    signature = {
        MODELS_INFO: [
            ("_TZE200_nklqjk62", "TS0601"),
            ("_TZE200_wfxuhoea", "TS0601"),
            ("_TZE204_nklqjk62", "TS0601"),
        ],
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaGarageManufCluster.cluster_id,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            242: {
                PROFILE_ID: zgp.PROFILE_ID,
                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }

    replacement = {
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Groups.cluster_id,
                    Scenes.cluster_id,
                    TuyaGarageManufCluster,
                    TuyaOnOffInching,
                ],
                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
            },
            2: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
                INPUT_CLUSTERS: [ContactSwitchCluster],
                OUTPUT_CLUSTERS: [],
            },
            242: {
                PROFILE_ID: zgp.PROFILE_ID,
                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                INPUT_CLUSTERS: [],
                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
            },
        },
    }
