#!/usr/bin/env python3

"""
The R2lab sidecar is a websocket service that runs on
``wss://r2lab.inria.fr:999/`` and that exposes the status of the testbed.

This module implements client classes, for interacting with the sidecar service.
The core of the implementation is asynchronous and ``asyncio``-friendly, but for
convenience some features are also available to synchronous code through
wrappers.
"""

# pylint: disable=w1203

import logging

import asyncio
import websockets

from .sidecar_payload import SidecarPayload as Payload


default_sidecar_url = 'wss://r2lab.inria.fr:999/'

# provide a simpler way to turn on debugging
logging.basicConfig(level=logging.INFO)

def _websockets_logging_to_stdout(level):
    logger = logging.getLogger('sidecar')
    logger.setLevel(level)
    channel = logging.StreamHandler()
    channel.setLevel(level)
    formatter = logging.Formatter(
        "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
        datefmt="%m-%d %H:%M:%S")
    channel.setFormatter(formatter)
    logger.addHandler(channel)
    return logger


# when doing
# async with AsyncSidecarProxy(url) as proxy:
# the proxy variable actually points at the underlying protocol
# so that's where to add our send / receive methods

class SidecarProtocol(websockets.client.WebSocketClientProtocol):

    """
    The SidecarProtocol class is an asyncio-compliant implementation
    of the R2lab sidecar system.

    It inherits ``websockets.client.WebSocketClientProtocol``
    as documented here:
    https://websockets.readthedocs.io/en/stable/api.html#module-websockets.client
    """

    async def send_payload(self, payload):
        """
        Send a :class:`~r2lab.sidecar_payload.SidecarPayload`
        object.
        """
        return await self.send(payload.string)

    async def recv_payload(self):
        """
        Receives and returns a
        :class:`~r2lab.sidecar_payload.SidecarPayload` object.
        """
        wired = await self.recv()
        return Payload(string=wired)


    async def send_umbrella(self, category, action, message):
        """
        Set one payload, constructed from its parts
        """
        return await self.send_payload(
            Payload(
                umbrella=dict(category=category, action=action, message=message)))

    async def recv_umbrella(self):
        """
        Read one payload, and returns it as a dict with 3 keys.
        """
        payload = await self.recv_payload()
        return payload.umbrella


    async def _probe_category(self, category):
        # send a request and wait for answer
        # as opposed to socketio, we may receive other traffic here
        # since all goes into the same pipe
        # so, wait until we receive corresponding 'info'
        # improvement could be to repeat the 'request' after a timeout
        infos = None
        await self.send_umbrella(category, 'request', "PLEASE")
        while True:
            umbrella = await self.recv_umbrella()
            logging.debug(f"receives answer={umbrella}")
            if (umbrella['category'] == category
                    and umbrella['action'] == 'info'):
                infos = umbrella['message']
                info_by_id = {info['id']: info for info in infos}
                return info_by_id

    async def _set_triples(self, category, triples):
        # build the corresponding infos - a list of the form
        # [ { 'id' : id, 'attibute' : value, ..}, ...]
        # and emit that on the proper channel
        # for that we start with a hash id -> info
        # send infos on proper channel and json-encoded
        payload = Payload().fill_from_triples(category, triples)
        await self.send_payload(payload)


    # nodes

    async def nodes_status(self):
        """
        A function call that returns the JSON nodes status for the complete testbed.

        Returns:
            A python dictionary indexed by integers 1 to 37, whose values are
            dictionaries with keys corresponding to each node's attributes at that time.

        Example:
            Get the complete testbed status::

                async with SidecarAsyncClient() as sidecar:
                    nodes_status = await sidecar.nodes_status()
                print(nodes_status[1]['usrp_type'])

        """
        return await self._probe_category('nodes')

    async def set_nodes_triples(self, *triples):
        """
        Parameters:
          triples: each argument is expected to be a tuple (or list)
            of the form ``id, attribute, value``. The same node
            id can be used in several triples.

        Example:
            To mark node 1 as unavailable and node 2 as turned off::

                await sidecar.set_nodes_triples(
                    (1, 'available', 'ok'),
                    (2, 'cmc_on_off', 'off'),
                   )
        """
        return await self._set_triples('nodes', triples)

    async def set_node_attribute(self, id, attribute, value):
        """
        Parameters:
            id: a node_id as an int or str
            attribute(str): the name of the attribute to be written
            value(str): the new value

        Example:
            To mark node 1 as unavailable::

                await sidecar.set_node_attribute(1, 'available', 'ko')
        """
        return await self.set_nodes_triples((id, attribute, value))


    # phones

    async def phones_status(self):
        "Just like ``nodes_status`` but on phones"
        return await self._probe_category('phones')

    async def set_phones_triples(self, *triples):
        "Identical to ``set_nodes_triples`` but on phones"
        return await self._set_triples('phones', triples)

    async def set_phone_attribute(self, id, attribute, value):
        """
        Similar to ``set_node_attribute`` on a phone

        Example:
            To mark phone 2 as being turned off (although this is constantly
            recomputed by the phones monitor)::

                await sidecar.set_phone_attribute(2, 'airplane_mode', 'on')
        """
        return await self.set_phones_triples((id, attribute, value))



class SidecarAsyncClient(websockets.connect):

    """
    This class behaves as an asynchronous context manager for
    talking with the R2lab sidecar server.

    Example:
        Set a node as available from some asynchronous code::

            async with SidecarSyncClient(url) as sidecar:
                await sidecar.set_node_attribute(1, 'available', 'ok')

        In this example, the ``sidecar`` object is
        a :class:`~r2lab.sidecar.SidecarProtocol` instance.
    """

    def __init__(self, url, *args, **kwds):
        if 'create_protocol' in kwds:
            logging.error("should not overwrite create_protocol")
        super().__init__(url, create_protocol=SidecarProtocol,
                         *args, **kwds)



# -------- synchronous wrapper

class SidecarSyncClient:
    """
    A synchronous wrapper to perform the same operations
    from sequential code without having to worry about the
    event loop, asynchronous context manager and coroutine business.

    Example:
        Set a node as available from some synchronous code::

            with SidecarSyncClient(url) as sidecar:
                sidecar.set_node_attribute(1, 'available', 'ok')

    .. warning::
      This is a convenience only, it would be unwise, obviously,
      to call this from asynchronous code; if it works at all.
      Use :class:`~r2lab.sidecar.SidecarAsyncClient` instead
      in this use case.
    """

    def __init__(self, url, *args, **kwds):
        self.aclient = SidecarAsyncClient(url, *args, **kwds)
        self.proto = None

    def connect(self):
        if self.proto:
            logging.warning("SyncClient already connected")
        async def coro():
            self.proto = await self.aclient
        asyncio.get_event_loop().run_until_complete(coro())

    def close(self):
        if not self.proto:
            logging.warning("SyncClient not connected")
        else:
            async def coro():
                await self.proto.close()
                self.proto = None
            asyncio.get_event_loop().run_until_complete(coro())


    # of course we can't inherit from the async class as-is
    # so let's wrap the async methods
    def __getattr__(self, method):
        #print(f"SyncClient resolving method {method}")
        if method not in dir(SidecarProtocol):
            raise AttributeError(f"no such method {method} in SidecarProtocol")
        def wrapper(*args, **kwds):
            async def coro():
                return await getattr(self.proto, method)(*args, **kwds)
            return asyncio.get_event_loop().run_until_complete(coro())
        return wrapper

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, exc_type, exc, traceback):
        self.close()
