import asyncio
import logging
import uuid
import sys
from weakref import WeakValueDictionary, finalize

from .serializer import serialize, deserialize
from .auth import get_system_config
from .api import ClassApiMixin, PodApiMixin, ReservationApiMixin, SystemApiMixin, UserApiMixin, VmApiMixin
from .api.async_shims import AsyncShims
from .errors import error_decode, ResponseFormatError

logger = logging.getLogger(__name__)


class NetlabConnectionClosedError(Exception):
    pass


class _SubscriptionQueue(asyncio.Queue):
    def __init__(self, event, handle):
        super().__init__()
        self._event = event
        self._handle = handle
        self._not_collected_correctly = None
        self._exception = None

    def _set_exception(self, exception):
        self._exception = exception
        self.put_nowait(None)

    def get_nowait(self):
        if self._exception:
            raise self._exception
        return super().get_nowait()


class NetlabProtocol(asyncio.Protocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._response_mapping = WeakValueDictionary()
        self._event_mapping = WeakValueDictionary()
        self._line_buffer = []
        self._transport = None
        self._keep_alive_task = None
        self._keep_alive_spacing = 15
        self._conn_lost = False

    def _check_collected_correctly(self, event, handle):
        logger.warn("subscription to %s not unsubscribed correctly", event)

    @asyncio.coroutine
    def _keep_alive(self):
        while True:
            yield from asyncio.sleep(self._keep_alive_spacing)
            try:
                yield from asyncio.wait_for(self.call('internal.mbusd.ping', {}), 2.0)
            except asyncio.TimeoutError:
                logger.debug('keep alive packet lost')
                self._transport.abort()
            logger.debug('keep alive packet sent')

    def connection_made(self, transport):
        logger.debug('connection made')
        self._transport = transport
        self._keep_alive_task = asyncio.ensure_future(self._keep_alive())

    def connection_lost(self, exc):
        logger.debug('connection lost')
        self._conn_lost = True
        if self._keep_alive_task:
            self._keep_alive_task.cancel()
            # self._keep_alive_task = None

        if not exc:
            exc = NetlabConnectionClosedError()

        for response in list(self._response_mapping.values()):
            if not response.done():
                response.set_exception(exc)
        for response in list(self._event_mapping.values()):
            response._set_exception(exc)

    def data_received(self, data):
        logger.debug('data <--- %s', data)
        self._line_buffer.append(data)
        if b'\n' not in data:
            return
        raw_msg = b''.join(self._line_buffer)

        bmsg, extra = raw_msg.split(b'\n')
        self._line_buffer.clear()
        self._line_buffer.append(extra)

        msg = deserialize(bmsg.decode('utf-8'))

        if 'id' in msg:
            responder = self._response_mapping.get(uuid.UUID(msg['id']))
            if responder:
                if 'result' in msg:
                    responder.set_result(msg)
                elif 'error' in msg:
                    responder.set_exception(error_decode(msg['error']))
                else:
                    raise ResponseFormatError('message did not contain "result" or "error"')
            else:
                logger.debug('data dropped ---x %s', msg)
        elif 'handle' in msg:
            responder = self._event_mapping.get(uuid.UUID(msg['handle']))
            if responder:
                responder.put_nowait(msg)
        else:
            raise ResponseFormatError('message did not contain "handle" or "id"')

    @asyncio.coroutine
    def subscribe(self, event, criteria):
        handle = uuid.uuid4()
        logger.debug('subscription created %s %s', handle, event)

        queue = _SubscriptionQueue(event, handle)
        self._event_mapping[handle] = queue
        yield from self.call(
            'event.subscribe',
            {'event': event, 'criteria': criteria, 'handle': handle}
        )

        queue._not_collected_correctly = finalize(queue, self._check_collected_correctly, event, handle)

        return queue

    @asyncio.coroutine
    def unsubscribe(self, queue: _SubscriptionQueue):
        logger.debug('subscription deleted %s %s', queue._handle, queue._event)
        yield from self.call(
            'event.unsubscribe',
            {'handle': queue._handle, 'event': queue._event}
        )
        queue._not_collected_correctly.detach()

    @asyncio.coroutine
    def call(self, method: str, kwargs: dict):
        handle = uuid.uuid4()
        data = {
            'id': handle,
            'jsonrpc': '2.0',
            'method': method,
            'params': kwargs
        }

        if self._conn_lost:
            raise NetlabConnectionClosedError()

        future = asyncio.Future()
        self._response_mapping[handle] = future
        msg = serialize(data)
        bmsg = msg.encode('utf-8') + b'\n'
        logger.debug('data ---> %s', bmsg)
        self._transport.write(bmsg)
        return future

    @asyncio.coroutine
    def disconnect(self):
        self._transport.close()
        try:
            yield from self._keep_alive_task
        except asyncio.CancelledError:
            pass
        self._transport = None

    def alive(self):
        return not self._conn_lost


class AsyncClient(AsyncShims, ClassApiMixin, PodApiMixin, ReservationApiMixin, SystemApiMixin,
                  UserApiMixin, VmApiMixin):
    """
    A Netlab Client based on asyncio.

    :param str system: The name to identify a NETLAB+ system. Defaults to **'default'**.
    :param dict config: Configuration options. See below for all possible options for the :attr:`config`.
    :param str config_path: File path location of a JSON config file to be used instead of the default location.

    :return: A client instance with which you can use to interact with a remote NETLAB+ system.
    """

    def __init__(self, system='default', config=None, config_path=None):
        assert sys.version_info >= (3, 5), "AsyncClient requires python >= 3.5"
        self.config = config
        self.config_path = config_path
        self.system = "default"
        self._protocol = None

    @asyncio.coroutine
    def connect(self):
        """
        Authenticate with netlab. Should be called before other client calls.
        """
        config = get_system_config(self.system, self.config, self.config_path)

        loop = asyncio.get_event_loop()
        transport, protocol = yield from loop.create_connection(
            NetlabProtocol, config.host, config.port, ssl=config.ssl,
            server_hostname=config.server_hostname)

        self._protocol = protocol

        yield from self.call(
            'user.authenticate',
            user=config.user,
            token=config.token)
        yield from self._get_server_version()

    @asyncio.coroutine
    def disconnect(self):
        yield from self._protocol.disconnect()
        self._protocol = None

    @asyncio.coroutine
    def subscribe(self, event, **kwargs):
        """
        Subscribe to netlab events.

        **Required:**

        :param str method: the event name

        **Keyword Arguments:**

        All other named arguments are used to filter events.

        :return: A queue object that will be populated with events.
        :rtype: asyncio.Queue
        """
        result = yield from self._protocol.subscribe(event, kwargs)
        return result

    @asyncio.coroutine
    def unsubscribe(self, queue):
        """
        Unsubscribe to events

        **Required:**

        :param asyncio.Queue queue: The result of the subscribe call to unsubscribe to.
        """
        yield from self._protocol.unsubscribe(queue)

    @asyncio.coroutine
    def call(self, method, **kwargs):
        """
        Calls an API method.

        .. warning::
            This is a low-level API method and should not be used by most users.

        **Required:**

        :param str method: the method name

        **Keyword Arguments:**

        All other named arguments are passed as parameters to the
        API backend and are specific to the method type.  See the API
        documentation for method parameters.
        """
        result = yield from self._protocol.call(method, kwargs)

        if not method.endswith('.task'):
            return result['result']
        else:
            task_id = result['result']
            queue = yield from self.subscribe('TASK.COMPLETE', task_id=task_id)
            yield from queue.get()
            result = yield from self.call('task.check', task_id=task_id)
            yield from self.unsubscribe(queue)
            assert result['is_complete']
            if result['error'] is not None:
                raise error_decode(result['error'])
            return result['result']

    def alive(self):
        return self._protocol.alive()

    @asyncio.coroutine
    def _get_server_version(self):
        result = yield from self.call('system.status.get')
        self._server_version = result['sys_sdn_version']

    @asyncio.coroutine
    def _chain(self, value, *funcs):
        value = yield from value
        for func in funcs:
            value = func(value)
        return value

    @asyncio.coroutine
    def _list(self, data):
        result = []
        try:
            while True:
                value = yield from data.__anext__()
                result.append(value)
        except StopAsyncIteration:
            return result
