import asyncio
import re
import time
from ast import literal_eval
from asyncio import CancelledError, Task
from collections import Counter, defaultdict
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import (
    Any,
    DefaultDict,
    Literal,
    Optional,
    TypeVar,
    overload,
)

import appdaemon.utils as utils
import cx_core.integration as integration_module
import cx_version
from appdaemon.adapi import ADAPI
from appdaemon.plugins.hass.hassapi import Hass
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import (
    ActionEvent,
    ActionFunction,
    CustomActionsMapping,
    DefaultActionsMapping,
    PredefinedActionsMapping,
)
from cx_core.action_type import ActionsMapping, parse_actions
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData, Integration

DEFAULT_ACTION_DELTA = 300  # In milliseconds
DEFAULT_MULTIPLE_CLICK_DELAY = 500  # In milliseconds
MULTIPLE_CLICK_TOKEN = "$"

MODE_SINGLE = "single"
MODE_RESTART = "restart"
MODE_QUEUED = "queued"
MODE_PARALLEL = "parallel"

T = TypeVar("T")


def action(method: Callable[..., Awaitable[Any]]) -> ActionFunction:
    @wraps(method)
    async def _action_impl(controller: "Controller", *args: Any, **kwargs: Any) -> None:
        continue_call = await controller.before_action(method.__name__, *args, **kwargs)
        if continue_call:
            await method(controller, *args, **kwargs)

    return _action_impl


def run_in(fn: Callable[..., Any], delay: float, **kwargs: Any) -> "Task[None]":
    """
    It runs the function (fn) in running event loop in `delay` seconds.
    This function has been created because the default run_in function
    from AppDaemon does not accept microseconds.
    """

    async def inner() -> None:
        await asyncio.sleep(delay)
        await fn(kwargs)

    task = asyncio.create_task(inner())
    return task


class Controller(Hass, Mqtt):  # type: ignore[misc]
    """
    This is the parent Controller, all controllers must extend from this class.
    """

    args: dict[str, Any]
    integration: Integration
    actions_mapping: ActionsMapping
    action_handles: DefaultDict[ActionEvent, Optional["Task[None]"]]
    action_delay_handles: dict[ActionEvent, str | None]
    multiple_click_actions: set[ActionEvent]
    action_delay: dict[ActionEvent, int]
    action_delta: dict[ActionEvent, int]
    action_times: dict[str, float]
    previous_states: dict[ActionEvent, str | None]
    multiple_click_action_times: dict[str, float]
    click_counter: Counter[ActionEvent]
    multiple_click_action_delay_tasks: DefaultDict[ActionEvent, Optional["Task[None]"]]
    multiple_click_delay: int

    async def initialize(self) -> None:
        self.log(f"🎮 ControllerX {cx_version.__version__}", ascii_encode=False)
        await self.init()

    async def init(self) -> None:
        controllers_ids: list[str] = self.get_list(self.args["controller"])
        self.integration = self.get_integration(self.args["integration"])

        if "mapping" in self.args and "merge_mapping" in self.args:
            raise ValueError("`mapping` and `merge_mapping` cannot be used together")

        custom_mapping: CustomActionsMapping | None = self.args.get("mapping", None)
        merge_mapping: CustomActionsMapping | None = self.args.get(
            "merge_mapping", None
        )

        if custom_mapping is None:
            default_actions_mapping = self.get_default_actions_mapping(self.integration)
            self.actions_mapping = self.parse_action_mapping(default_actions_mapping)  # type: ignore[arg-type]
        else:
            self.actions_mapping = self.parse_action_mapping(custom_mapping)

        if merge_mapping is not None:
            self.actions_mapping.update(self.parse_action_mapping(merge_mapping))

        # Filter actions with include and exclude
        if "actions" in self.args and "excluded_actions" in self.args:
            raise ValueError("`actions` and `excluded_actions` cannot be used together")
        include: list[ActionEvent] = self.get_list(
            self.args.get("actions", list(self.actions_mapping.keys()))
        )
        exclude: list[ActionEvent] = self.get_list(
            self.args.get("excluded_actions", [])
        )
        self.actions_mapping = self.filter_actions(
            self.actions_mapping, set(include), set(exclude)
        )

        # Action delay
        self.action_delay = self.get_mapping_per_action(
            self.actions_mapping,
            custom=self.args.get("action_delay"),
            default=0,
        )
        self.action_delay_handles = defaultdict(lambda: None)
        self.action_handles = defaultdict(lambda: None)

        # Action delta
        self.action_delta = self.get_mapping_per_action(
            self.actions_mapping,
            custom=self.args.get("action_delta"),
            default=DEFAULT_ACTION_DELTA,
        )
        self.action_times = defaultdict(float)

        # Previous state
        self.previous_states = self.get_mapping_per_action(
            self.actions_mapping,
            custom=self.args.get("previous_state"),
            default=None,
        )

        # Multiple click
        self.multiple_click_actions = self.get_multiple_click_actions(
            self.actions_mapping
        )
        self.multiple_click_delay = self.args.get(
            "multiple_click_delay", DEFAULT_MULTIPLE_CLICK_DELAY
        )
        self.multiple_click_action_times = defaultdict(float)
        self.click_counter = Counter()
        self.multiple_click_action_delay_tasks = defaultdict(lambda: None)

        # Mode
        self.mode = self.get_mapping_per_action(
            self.actions_mapping, custom=self.args.get("mode"), default=MODE_SINGLE
        )

        # Listen for device changes
        for controller_id in controllers_ids:
            await self.integration.listen_changes(controller_id)

    def filter_actions(
        self,
        actions_mapping: ActionsMapping,
        include: set[ActionEvent],
        exclude: set[ActionEvent],
    ) -> ActionsMapping:
        allowed_actions = include - exclude
        return {
            key: value
            for key, value in actions_mapping.items()
            if key in allowed_actions
        }

    @staticmethod
    def get_option(value: str, options: list[str], ctx: str | None = None) -> str:
        if value in options:
            return value
        else:
            raise ValueError(
                f"{f'{ctx} - ' if ctx is not None else ''}`{value}` is not an option. "
                f"The options are {options}"
            )

    def parse_integration(
        self, integration: str | dict[str, Any] | Any
    ) -> dict[str, str]:
        if isinstance(integration, str):
            return {"name": integration}
        elif isinstance(integration, dict):
            if "name" in integration:
                return integration
            else:
                raise ValueError("'name' attribute is mandatory")
        else:
            raise ValueError(
                f"Type {type(integration)} is not supported for `integration` attribute"
            )

    def get_integration(self, integration: str | dict[str, Any]) -> Integration:
        parsed_integration = self.parse_integration(integration)
        kwargs = {k: v for k, v in parsed_integration.items() if k != "name"}
        integrations = integration_module.get_integrations(self, kwargs)
        integration_argument = self.get_option(
            parsed_integration["name"], [i.name for i in integrations]
        )
        return next(i for i in integrations if i.name == integration_argument)

    def get_default_actions_mapping(
        self, integration: Integration
    ) -> DefaultActionsMapping:
        actions_mapping = integration.get_default_actions_mapping()
        if actions_mapping is None:
            raise ValueError(
                f"This controller does not support {integration.name}. Use `mapping` to define the actions."
            )
        return actions_mapping

    @overload
    def get_list(self, entities: list[T]) -> list[T]:
        pass

    @overload
    def get_list(self, entities: T) -> list[T]:
        pass

    def get_list(self, entities: list[T] | T) -> list[T]:
        if isinstance(entities, (list, tuple)):
            return list(entities)
        return [entities]

    @overload
    def get_mapping_per_action(
        self,
        actions_mapping: ActionsMapping,
        *,
        custom: T | dict[ActionEvent, T] | None,
        default: None,
    ) -> dict[ActionEvent, T | None]:
        pass

    @overload
    def get_mapping_per_action(
        self,
        actions_mapping: ActionsMapping,
        *,
        custom: T | dict[ActionEvent, T] | None,
        default: T,
    ) -> dict[ActionEvent, T]:
        pass

    def get_mapping_per_action(
        self,
        actions_mapping: ActionsMapping,
        *,
        custom: T | dict[ActionEvent, T] | None,
        default: None | T,
    ) -> dict[ActionEvent, T | None] | dict[ActionEvent, T]:
        if custom is not None and not isinstance(custom, dict):
            default = custom
        mapping = {action: default for action in actions_mapping}
        if custom is not None and isinstance(custom, dict):
            mapping.update(custom)
        return mapping

    def parse_action_mapping(self, mapping: CustomActionsMapping) -> ActionsMapping:
        return {
            event: parse_actions(self, action)
            for event, action in mapping.items()
            if action is not None
        }

    def get_multiple_click_actions(self, mapping: ActionsMapping) -> set[ActionEvent]:
        to_return: set[ActionEvent] = set()
        for key in mapping.keys():
            if not isinstance(key, str) or MULTIPLE_CLICK_TOKEN not in key:
                continue
            splitted = key.split(MULTIPLE_CLICK_TOKEN)
            assert 1 <= len(splitted) <= 2
            action_key, _ = splitted
            try:
                to_return.add(int(action_key))
            except ValueError:
                to_return.add(action_key)
        return to_return

    def format_multiple_click_action(
        self, action_key: ActionEvent, click_count: int
    ) -> str:
        return (
            str(action_key) + MULTIPLE_CLICK_TOKEN + str(click_count)
        )  # e.g. toggle$2

    async def _render_template(self, template: str) -> Any:
        result = await self.render_template(template)
        if result is None:
            raise ValueError(f"Template {template} returned None")
        try:
            return literal_eval(result)
        except (SyntaxError, ValueError):
            return result

    _TEMPLATE_RE = re.compile(r"\s*\{\{.*\}\}")

    def contains_templating(self, template: str) -> bool:
        is_template = self._TEMPLATE_RE.search(template) is not None
        if not is_template:
            self.log(f"`{template}` is not recognized as a template", level="DEBUG")
        return is_template

    async def render_value(self, value: Any) -> Any:
        if isinstance(value, str) and self.contains_templating(value):
            return await self._render_template(value)
        else:
            return value

    async def render_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]:
        new_attributes: dict[str, Any] = {}
        for key, value in attributes.items():
            new_value = await self.render_value(value)
            if isinstance(value, dict):
                new_value = await self.render_attributes(value)
            new_attributes[key] = new_value
        return new_attributes

    async def call_service(
        self, service: str, render_template: bool = True, **attributes: Any
    ) -> Any | None:
        service = service.replace(".", "/")
        to_log = ["\n", f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m"]
        if service != "template/render" and render_template:
            attributes = await self.render_attributes(attributes)
        for attribute, value in attributes.items():
            if isinstance(value, float):
                value = f"{value:.2f}"
            to_log.append(f"  - {attribute}: {value}")
        self.log("\n".join(to_log), level="INFO", ascii_encode=False)
        return await ADAPI.call_service(self, service, **attributes)

    @utils.sync_decorator  # type: ignore[untyped-decorator]
    async def get_state(
        self,
        entity_id: str | None = None,
        attribute: str | Literal["all"] | None = None,
        default: Any | None = None,
        namespace: str | None = None,
        copy: bool = True,
        **kwargs: dict[str, Any],  # left in intentionally for compatibility
    ) -> Any | dict[str, Any] | None:
        rendered_entity_id = await self.render_value(entity_id)
        return await super().get_state(
            rendered_entity_id, attribute, default=default, copy=copy
        )

    async def handle_action(
        self,
        action_key: str,
        previous_state: str | None = None,
        extra: EventData | None = None,
    ) -> None:
        if (
            action_key in self.actions_mapping
            and self.previous_states[action_key] is not None
            and previous_state != self.previous_states[action_key]
        ):
            self.log(
                f"🎮 `{action_key}` not triggered because previous action was `{previous_state}`",
                level="DEBUG",
                ascii_encode=False,
            )
            return
        if (
            action_key in self.actions_mapping
            and action_key not in self.multiple_click_actions
        ):
            previous_call_time = self.action_times[action_key]
            now = time.time() * 1000
            self.action_times[action_key] = now
            if now - previous_call_time > self.action_delta[action_key]:
                await self.call_action(action_key, extra=extra)
        elif action_key in self.multiple_click_actions:
            now = time.time() * 1000
            previous_call_time = self.multiple_click_action_times.get(action_key, now)
            self.multiple_click_action_times[action_key] = now
            if now - previous_call_time > self.multiple_click_delay:
                pass

            previous_task = self.multiple_click_action_delay_tasks[action_key]
            if previous_task is not None:
                previous_task.cancel()

            self.click_counter[action_key] += 1
            click_count = self.click_counter[action_key]

            new_task = run_in(
                self.multiple_click_call_action,
                self.multiple_click_delay / 1000,
                action_key=action_key,
                extra=extra,
                click_count=click_count,
            )
            self.multiple_click_action_delay_tasks[action_key] = new_task
        else:
            self.log(
                f"🎮 Button event triggered, but not registered: `{action_key}`",
                level="DEBUG",
                ascii_encode=False,
            )

    async def multiple_click_call_action(self, kwargs: dict[str, Any]) -> None:
        action_key: ActionEvent = kwargs["action_key"]
        extra: EventData = kwargs["extra"]
        click_count: int = kwargs["click_count"]
        self.multiple_click_action_delay_tasks[action_key] = None
        self.log(
            f"🎮 {action_key} clicked `{click_count}` time(s)",
            level="DEBUG",
            ascii_encode=False,
        )
        self.click_counter[action_key] = 0
        click_action_key = self.format_multiple_click_action(action_key, click_count)
        if click_action_key in self.actions_mapping:
            await self.call_action(click_action_key, extra=extra)
        elif action_key in self.actions_mapping and click_count == 1:
            await self.call_action(action_key, extra=extra)

    async def call_action(
        self, action_key: ActionEvent, extra: EventData | None = None
    ) -> None:
        self.log(
            f"🎮 Button event triggered: `{action_key}`",
            level="INFO",
            ascii_encode=False,
        )
        self.log(
            f"Extra:\n{extra}",
            level="DEBUG",
        )
        delay = self.action_delay[action_key]
        if delay > 0:
            handle = self.action_delay_handles[action_key]
            if handle is not None:
                await self.cancel_timer(handle)
            self.log(
                f"🕒 Running action(s) from `{action_key}` in {delay} seconds",
                level="INFO",
                ascii_encode=False,
            )
            new_handle = await self.run_in(
                self.action_timer_callback, delay, action_key=action_key, extra=extra
            )
            self.action_delay_handles[action_key] = new_handle
        else:
            await self.action_timer_callback({"action_key": action_key, "extra": extra})

    async def _apply_mode_strategy(self, action_key: ActionEvent) -> bool:
        previous_task = self.action_handles[action_key]
        if previous_task is None or previous_task.done():
            return False
        if self.mode[action_key] == MODE_SINGLE:
            self.log(
                f"There is already an action executing for `{action_key}`. "
                "If you want a different behaviour change `mode` parameter, "
                "the default value is `single`.",
                level="WARNING",
            )
            return True
        elif self.mode[action_key] == MODE_RESTART:
            previous_task.cancel()
        elif self.mode[action_key] == MODE_QUEUED:
            await previous_task
        elif self.mode[action_key] == MODE_PARALLEL:
            pass
        else:
            raise ValueError(
                f"`{self.mode[action_key]}` is not a possible value for `mode` parameter."
                "Possible values: `single`, `restart`, `queued` and `parallel`."
            )
        return False

    async def action_timer_callback(self, kwargs: dict[str, Any]) -> None:
        action_key: ActionEvent = kwargs["action_key"]
        extra: EventData = kwargs["extra"]
        self.action_delay_handles[action_key] = None
        skip = await self._apply_mode_strategy(action_key)
        if skip:
            return
        action_types = self.actions_mapping[action_key]
        task = asyncio.create_task(self.call_action_types(action_types, extra))
        self.action_handles[action_key] = task
        try:
            await task
        except CancelledError:
            self.log(
                f"Task(s) from `{action_key}` was/were canceled and executed again",
                level="DEBUG",
            )

    async def call_action_types(
        self, action_types: list[ActionType], extra: EventData | None = None
    ) -> None:
        for action_type in action_types:
            self.log(
                f"🏃 Running `{action_type}` now",
                level="INFO",
                ascii_encode=False,
            )
            await action_type.run(extra=extra)

    async def before_action(self, action: str, *args: str, **kwargs: Any) -> bool:
        """
        Controllers have the option to implement this function, which is called
        everytime before an action is called and it has the check_before_action decorator.
        It should return True if the action shoul be called.
        Otherwise it should return False.
        """
        return True

    def get_z2m_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the states that a controller can take and the functions as values.
        This is used for zigbee2mqtt support.
        """
        return None

    def get_deconz_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the event id that a controller can take and the functions as values.
        This is used for deCONZ support.
        """
        return None

    def get_zha_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for ZHA support.
        """
        return None

    def get_zha_action(self, data: EventData) -> str | None:
        """
        This method can be override for controllers that do not support
        the standard extraction of the actions on cx_core/integration/zha.py
        """
        return None

    def get_lutron_caseta_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for Lutron support.
        """
        return None

    def get_state_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for State integration support.
        """
        return None

    def get_homematic_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for Homematic support.
        """
        return None

    def get_shelly_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for Shelly support.
        """
        return None

    def get_shellyforhass_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for Shelly for HASS support.
        """
        return None

    def get_tasmota_actions_mapping(self) -> DefaultActionsMapping | None:
        """
        Controllers can implement this function. It should return a dict
        with the command that a controller can take and the functions as values.
        This is used for Tasmota support.
        """
        return None

    def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
        return {}
