import asyncio
import logging
import threading

from queue import Queue
from typing import Any, Callable, Optional

from everest.framework import Module, RuntimeSession
from everest.framework import error

class ProbeModule:
    """
    Probe module tool for integration testing, which is a thin abstraction over the C++ bindings from everestpy
    You need to declare the requirements for the probe module with the fixtures starting EVerest ("test_connections" argument),
    but you do not need to specify the interfaces provided by the probe - simply specify the implementation ID when registering cmd handlers and publishing vars.
    """

    def __init__(self, session: RuntimeSession, module_id="probe"):
        """
        Construct a probe module and connect it to EVerest. This does not mark the module as ready yet.
        - session: runtime session information (path to EVerest installation and location of run config file)
        - module_id: the module ID to register with EVerest. By default, this will be "probe".
        - start: whether to start the module immediately. Set to false if you need to add implementations or subscriptions before starting.
        """
        logging.info("ProbeModule init start")
        m = Module(module_id, session)
        self._setup = m.say_hello()
        self._mod = m
        self._ready_event = threading.Event()
        self._started = False

    def start(self):
        """
        Send the "ready" signal for the probe module.
        You should do this after implementing all commands needed in your test.
        """
        if self._started:
            raise RuntimeError("Called start(), but ProbeModule is started already!")
        self._started = True
        # subscribe to session events
        self._mod.init_done(self._ready)
        logging.info("Probe module initialized")

    async def call_command(self, connection_id: str, command_name: str, args: dict) -> Any:
        """
        Call a command on another module.
        - connection_id: the id of the connection, as specified for the probe module in the runtime config
        - command_name: the name of the command to execute
        - args: the arguments for the command, as a name->value mapping
        returns: the result of the command invocation
        """

        interface = self._setup.connections[connection_id][0]
        try:
            async with asyncio.timeout(30):
                return await asyncio.to_thread(lambda: self._mod.call_command(interface, command_name, args))
        except TimeoutError as e:
            error_message = f"Timeout in calling {connection_id}.{command_name}: {type(e)}: {e}. This might be caused by the other module/EVerest exiting abnormally."
            logging.error(error_message)
            raise RuntimeError(error_message)
        except Exception as e:
            logging.info(
                f"Exception in calling {connection_id}.{command_name}: {type(e)}: {e}")

    def implement_command(self, implementation_id: str, command_name: str, handler: Callable[[dict], Any]):
        """
        Set up an implementation for a command.
        - implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
        - command_name: the name of the command to execute
        - handler: a function to handle the command, which takes a dict of arguments as input, and returns the return value as a dict (json object)
        Note: The handler runs in a separate thread!
        
        
        !!! WARNING: UNIMPLEMENTED COMMANDS MAY CAUSE EVEREST TO HANG !!!
        ----
        In the MQTT-based protocol used by EVerest, commands are initiated by publishing requests to a specific MQTT topic.
        The implementor is subscribed to this topic, and when they are done executing a command, they publish a result on the same topic. 
        To "implement" a command really just means to subscribe to the command's topic and attach a handler to process incoming requests there.
        If you do not implement a command, but another module tries to call it anyway, the command request won't reach anyone, and the caller will be stuck forever waiting for a response.
        If your tests hang, make sure you have implemented all commands which are called in the probe - the EVerest framework does not check this. 
        """
        self._mod.implement_command(implementation_id, command_name, handler)

    def publish_variable(self, implementation_id: str, variable_name: str, value: Any):
        """
        Publish a variable from an interface the probe module implements.
        - implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
        - variable_name: the name of the variable
        - value: the value to publish
        """
        self._mod.publish_variable(implementation_id, variable_name, value)

    def subscribe_variable(self, connection_id: str, variable_name: str, handler: Callable[[dict], None]):
        """
        Subscribe to a variable implemented by a module required by the probe module.
        - connection_id: the id of the connection, as specified for the probe module in the runtime config
        - variable_name: the name of the variable
        - handler: a function to handle incoming values for the variable, accepting a dict as an input, and returning nothing.
        Note: The handler runs in a separate thread!
        """
        self._mod.subscribe_variable(self._setup.connections[connection_id][0], variable_name, handler)

    def subscribe_variable_to_queue(self, connection_id: str, var_name: str):
        """
        The same as subscribe_variable, but incoming values will be pushed to a queue
        """
        queue = Queue()
        self._mod.subscribe_variable(self._setup.connections[connection_id][0], var_name,
                                     lambda message, _queue=queue: _queue.put(message))
        return queue

    def raise_error(self, implementation_id: str, error_obj: error.Error):
        """
        Raise an error from an interface the probe module implements.
        - implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
        - error_obj: the Error object to raise
        """
        self._mod.raise_error(implementation_id, error_obj)

    def clear_error(self, implementation_id: str, error_type: str, sub_type: Optional[str] = None):
        """
        Clear an error from an interface the probe module implements.
        - implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
        - error_type: the type of the error to clear
        - sub_type: optional sub-type of the error to clear
        """
        if sub_type is not None:
            self._mod.clear_error(implementation_id, error_type, sub_type)
        else:
            self._mod.clear_error(implementation_id, error_type)

    def subscribe_error(self, connection_id: str, error_type: str, 
                       callback: Callable[[error.Error], None], 
                       clear_callback: Callable[[error.Error], None]):
        """
        Subscribe to a specific error type from a module required by the probe module.
        - connection_id: the id of the connection, as specified for the probe module in the runtime config
        - error_type: the type of errors to subscribe to
        - callback: a function to handle when the error is raised, accepting an Error object
        - clear_callback: a function to handle when the error is cleared, accepting an Error object
        """
        self._mod.subscribe_error(self._setup.connections[connection_id][0], error_type, callback, clear_callback)

    def subscribe_all_errors(self, connection_id: str,
                            callback: Callable[[error.Error], None],
                            clear_callback: Callable[[error.Error], None]):
        """
        Subscribe to all errors from a module required by the probe module.
        - connection_id: the id of the connection, as specified for the probe module in the runtime config
        - callback: a function to handle when any error is raised, accepting an Error object
        - clear_callback: a function to handle when any error is cleared, accepting an Error object
        """
        self._mod.subscribe_all_errors(self._setup.connections[connection_id][0], callback, clear_callback)

    def _ready(self):
        """
        Internal function: callback triggered by the EVerest framework when all modules have been initialized
        This is equivalent to the ready() method in C++ modules
        """
        self._ready_event.set()

    async def wait_for_event(self, timeout: float):
        """
        Helper to make threading.Event behave similar to asyncio.Event, which is awaitable and raising TimeoutError.
        - timeout: Time to for ready_event
        """
        self._ready_event.wait(timeout)
        if not self._ready_event.is_set():
            raise TimeoutError("Waiting for ready: timeout")

    async def wait_to_be_ready(self, timeout=3.0):
        """
        Convenience method which allows you to wait until the _ready() callback is triggered (i.e. until EVerest is up and running)
        """
        if not self._started:
            raise RuntimeError("Called wait_to_be_ready(), but probe module has not been started yet! "
                               "Please use start() to start the module first.")
        await self.wait_for_event(timeout)
