import inspect
import sys
import threading
import typing
from abc import ABC, abstractmethod
from queue import SimpleQueue

from motion.cursor import Cursor
from motion.utils import TriggerElement, logger


class CustomDict(dict):
    def __init__(
        self,
        trigger_name: str,
        dict_type: str,
        *args: typing.Any,
        **kwargs: typing.Any,
    ) -> None:
        self.trigger_name = trigger_name
        self.dict_type = dict_type
        super().__init__(*args, **kwargs)

    def __getitem__(self, key: str) -> object:
        try:
            return super().__getitem__(key)
        except KeyError:
            raise KeyError(
                f"Key `{key}` not found in {self.dict_type} for trigger {self.trigger_name}."
            )


class Trigger(ABC):
    def __init__(
        self,
        cursor: Cursor,
        name: str,
        version: int,
        params: dict = {},
        routes_only: bool = False,
    ):
        self.name = name

        # Validate number of arguments in each trigger and set up routes
        route_list = self.routes()
        if not isinstance(route_list, list):
            raise TypeError(
                f"routes() of trigger {name} should return a list of motion.Route objects."
            )

        seen_keys = set()
        for r in route_list:
            if f"{r.relation}.{r.key}" in seen_keys:
                raise ValueError(
                    f"Duplicate route {r.relation}.{r.key} in trigger {name}."
                )

            r.validateTrigger(self)
            seen_keys.add(f"{r.relation}.{r.key}")

        self.route_map = {}
        for r in self.routes():
            if r.relation != "":
                self.route_map[f"{r.relation}.{r.key}"] = r
            else:
                self.route_map[f"_cron.{r.key}"] = r

        if routes_only:
            return

        # Set up params dictionary
        self._params = CustomDict(self.name, "params", params)

        # Set up initial state
        if len(inspect.signature(self.setUp).parameters) != 1:
            raise ValueError(f"setUp() of trigger {name} should have 1 argument")

        self._state = CustomDict(self.name, "state", {})
        self._version = version
        self._last_fit_id = -sys.maxsize - 1

        initial_state = self.setUp(cursor)
        if not isinstance(initial_state, dict):
            raise TypeError(f"setUp() of trigger {self.name} should return a dict.")
        self.update(initial_state)

        # Set up fit queue
        self._fit_queue = SimpleQueue()  # type: SimpleQueue
        self._fit_thread = threading.Thread(
            target=self.processFitQueue,
            daemon=True,
            name=f"{name}_fit_thread",
        )
        self._fit_thread.start()

    @classmethod
    def getRouteKeys(cls) -> list:
        obj: Trigger = cls(None, "", 0, routes_only=True)  # type: ignore

        return list(obj.route_map.keys())

    @abstractmethod
    def routes(self) -> list:
        pass

    @abstractmethod
    def setUp(self, cursor: Cursor) -> dict:
        raise TypeError(f"Please implement setUp() for trigger {self.name}.")

    @property
    def params(self) -> dict:
        return self._params

    @property
    def state(self) -> dict:
        return self._state

    @property
    def version(self) -> int:
        return self._version

    @property
    def last_fit_id(self) -> int:
        return self._last_fit_id

    def update(self, new_state: dict) -> None:
        if new_state:
            self._state.update(new_state)
            self._version += 1

    def processFitQueue(self) -> None:
        while True:
            (
                cursor,
                trigger_name,
                triggered_by,
                fit_event,
            ) = self._fit_queue.get()

            new_state = self.route_map[
                f"{triggered_by.relation}.{triggered_by.key}"
            ].fit(cursor, triggered_by)

            if not isinstance(new_state, dict):
                fit_event.set()
                raise TypeError(
                    f"fit() of trigger {self.name} should return a dict of state updates."
                )

            old_version = self.version
            self.update(new_state)

            logger.info(
                f"Finished running trigger {trigger_name} for identifier {triggered_by.identifier} and key {triggered_by.key}."
            )

            cursor.logTriggerExecution(trigger_name, old_version, "fit", triggered_by)

            fit_event.set()

    def fitWrapper(
        self,
        cursor: Cursor,
        trigger_name: str,
        triggered_by: TriggerElement,
    ) -> threading.Event:
        fit_event = threading.Event()
        self._fit_queue.put((cursor, trigger_name, triggered_by, fit_event))

        return fit_event
