# -*- coding: utf-8 -*-
import logging
import socket
import weakref
from uuid import uuid4

import amqp
import gevent
from gevent.event import AsyncResult

from base import safe_repr, AmqpRpcAbstract, PrettyCaller, safe_spawn
from consumer import FailsafeAmqpConsumer
from exception import (
    NoResult, RemoteException, WrongResult, WrongRequest)
from publisher import FailsafeAmqpPublisher
from serializer import RpcJsonSerializer


class AmqpRpcClient(AmqpRpcAbstract):
    """Client class for AMQP RPC.

    :param service_name: Namespace of your RPC service.
    :type service_name: str
    :param uuid: Unique identifier for current client instance.
        If omited then will be generated random id.
    :type uuid: str
    :param serializer: Class for generating AMQP messages.
        JSON messages generated by default.
    :type serializer: ``AbstractRpcSerializer``
    :param timeout: Default timeout for
        :py:attr:`~amphora.AmqpRpcClient.call`
    :type timeout: float
    :param amqp_host: IP address or hostname of AMQP server.
        By default ``127.0.0.1``.
    :type amqp_host: str
    :param amqp_port: TCP port of AMQP server. By default 5672.
    :type amqp_port: int
    :param amqp_user: Username for authentication in AMQP.
        By default ``guest``.
    :type amqp_user: str
    :param amqp_password: Password for authentication in AMQP.
        By default ``guest``.
    :type amqp_password: str
    :param amqp_vhost: Virtual host for authentication in AMQP.
        By default ``/``.
    :type amqp_host: str"""
    logger = logging.getLogger('amphora.AmqpRpcClient')
    request_exchange_type = 'direct'

    def __init__(self, service_name, uuid=None, serializer=RpcJsonSerializer,
                 timeout=60, autostart=True, **kwargs):
        from gevent import socket as gevent_socket
        assert socket.socket == gevent_socket.socket, \
            "Program must be monkey-patched by gevent"

        super(AmqpRpcClient, self).__init__()
        self.service_name = service_name
        self.uuid = uuid or str(uuid4())
        self.serializer = serializer
        self.call_timeout = timeout

        self._function_settings = {}

        # {request_id::str: result_holder::AsyncResult}
        self._request_ids = weakref.WeakValueDictionary()

        self.request_exchange = 'rpc_{0}_request'.format(self.service_name)
        self.request_queue = 'rpc_{0}_request'.format(self.service_name)
        self.response_exchange = 'rpc_{0}_response'.format(self.service_name)
        self.response_queue = 'rpc_{0}_{1}_response'.format(
            self.service_name, self.uuid)
        self.additional_queue_template = 'rpc_' + service_name + '_queue_{0}'

        self.publisher = FailsafeAmqpPublisher(**kwargs)
        self.publisher.before_start_cycle.add_handler(self._prepare_publisher)

        self.consumer = FailsafeAmqpConsumer(**kwargs)
        self.consumer.before_start_cycle.add_handler(self._prepare_consumer)
        self.consumer.on_message.add_handler(self._on_message)
        if autostart:
            safe_spawn(self.serve)

    @property
    def defer(self):
        """Call remote procedure without blocking current greenlet.

        :param function_name: You can specify function name directly as string.
        :type function_name: str
        :param routing_key: Routing key for remote call.
        :type routing_key: str
        :param wait_publish: Wait until request message published.
        :type wait_publish: bool
        :returns: Helper object for calling remote procedures.
        :rtype: :py:class:`amphora.PrettyCaller`

        When helper object called, it returns ``gevent.event.AsyncResult``
        instance. When remote function completes returned value
        stores into `AsyncResult`.

        You can specify routing key for this call in two ways:

        * By specifying argument `routing_key`.
        * Usung method :py:meth:`~amphora.AmqpRpcClient.tune_function`.

        Examples:

        .. code-block:: python

           client = amphora.AmqpRpcClient("example")

           # Remote call of function with name "send_email"
           async_result = client.defer.send_email('root@example.com', "Hello!")
           print async_result.get()

           # Remote call of function with name "show_message"
           # with custom routing key
           client.create_new_request_queue("user12345")
           client.defer(routing_key="user12345").show_message(
                "Hello, user12345!")

           # Remote call of functions with names "send_message.sms"
           client.defer.send_message.sms("+12345678901", "Message")

           # You can use helper objects like any normal python objects.
           # This code calls "send_message.email.text"
           # and "send_message.email.html"
           email_sender = client.defer.send_message.email
           email_sender.text("root@example.com", "Message")
           email_sender.html("user@example.com", "Message")

           # If you don't want to generate function name with helper
           for format in ("html", "text"):
               client.defer(function_name="send_message.email." + format)(
                   "root@example.com", "Hello!")
        """
        return PrettyCaller(self._defer_handler)

    @property
    def call(self):
        """Call remote procedure with blocking current greenlet.

        :param function_name: You can specify function name directly as string.
        :type function_name: str
        :param timeout: Timeout for waiting for result of remote call.
           If not specified then used timeout specified in constructor.
        :type timeout: float
        :param routing_key: Routing key for remote call.
        :type routing_key: str
        :raises amphora.RemoteException: When remote function
            raises exception.
        :raises amphora.WrongRequest: When server can't parse request.
            For example when you trying to call function not registered
            in server.
        :raises amphora.NoResult: When call timed out.
        :returns: Helper object for calling remote procedures.
        :rtype: :py:class:`amphora.PrettyCaller`

        When helper object called, it waits until remote function
        completes and returns result of remote function.

        Look for examples and explanations
        into :py:attr:`~amphora.AmqpRpcClient.defer` documentation."""
        return PrettyCaller(self._call_handler)

    def _defer_handler(self, name, args, kwargs, options):
        routing_key = options.get('routing_key')
        wait_publish = options.get('wait_publish', True)
        if routing_key is not None:
            assert isinstance(routing_key, str)
        self.logger.debug("Execute call %r(*%r, **%r), routing_key %r",
                          name, args, kwargs, routing_key)
        args = args if args is not None else ()
        kwargs = kwargs if kwargs is not None else {}
        if routing_key is None:
            try:
                altered_path = self._get_tuned(name, args, kwargs)
            except KeyError:
                pass
            else:
                routing_key = str(altered_path['routing_key'])

        call_body = self.serializer.serialize_call(name, args, kwargs)
        request_id = str(uuid4())
        message = amqp.Message(
            body=call_body,
            reply_to=self.uuid,
            message_id=request_id,
            delivery_mode=2)  # PERSISTENT
        self._request_ids[request_id] = result_holder = AsyncResult()
        self.publisher.publish_message(
            message, self.request_exchange, routing_key,
            nowait=not wait_publish)
        return result_holder

    def _call_handler(self, name, args, kwargs, options):
        timeout = options.get('timeout')
        if timeout is None:
            timeout = self.call_timeout
        result_holder = self._defer_handler(name, args, kwargs, options)
        try:
            return result_holder.get(block=True, timeout=timeout)
        except gevent.Timeout:
            raise NoResult

    def create_new_request_queue(self, queue, routing_key=None,
                                 x_expires=None, nowait=False):
        """Asynchronously create new queue and bind it
        to request exchange.

        :param queue: Queue label that will be used for
            generating queue name.
        :type queue: str
        :param routing_key: Routing key for binding to request exchange.
        :type routing_key: str
        :param x_expires: Queue TTL in microseconds.
            `<http://www.rabbitmq.com/ttl.html>`_
        :type x_expires: int or None
        :param nowait: Should block current greenlet until queue created and bound?
            If True then don't block.
        :type nowait: bool

        If routing key not specified then routing key will be
        the same as queue name.

        Then name of the queue passes to template ``rpc_{queue}_request``.

        For example, if you call ``create_new_request_queue('test')``
        then creates queue ``rpc_test_request`` and binds to
        request exchange via routing key ``test``."""
        if routing_key is None:
            routing_key = queue
        self.logger.debug("Creating new request queue %s[%s]",
                          queue, routing_key)
        queue = self.additional_queue_template.format(queue)
        if routing_key is None:
            routing_key = queue
        self.publisher.new_queue(
            queue, self.request_exchange, routing_key, x_expires, nowait)

    def remove_request_queue(self, queue, nowait=False):
        """Asynchronously removes queue.

        :param queue: Queue label that will be used for
            generating queue name.
        :type queue: str
        :param nowait: Should block current greenlet until queue deleted?
            If True then don't block.
        :type nowait: bool

        The name of the queue generates like in
        :py:meth:`~amphora.AmqpRpcClient.create_new_request_queue`.

        .. warning::

           If you call :py:meth:`~remove_request_queue` and then
           immediately call :py:meth:`~create_new_request_queue`
           then queue will be deleted but may not be created."""
        queue = self.additional_queue_template.format(queue)
        self._remove_request_queue(queue, nowait)

    def _remove_request_queue(self, queue, nowait=False):
        self.publisher.delete_queue(queue, nowait)

    def tune_function(self, func_name):
        """Set the function that will calculate queue and routing key
        for specified function by passed args.

        Your tuning function should return dict with two keys:
        ``"queue"`` and ``"routing_key"``. (``queue`` is deprecated).

        Example:

        .. code-block:: python

           from amphora import AmqpRpcClient

           def calculate(args, kwargs):  # Note, no * or **
               user_id = str(kwargs['user_id'])
               return {'queue': user_id, 'routing_key': user_id}

           client = AmqpRpcClient('test')
           client.tune_function('show_message')(calculate)

           # Will be called with routing key "12345"
           client.defer.show_message("Hello!", user_id=12345)
        """
        def tmp(func):
            self._function_settings[func_name] = func
        tmp.__name__ = 'tune_' + func_name
        return tmp

    def _prepare_publisher(self, publisher):
        publisher.new_exchange(
            self.request_exchange, self.request_exchange_type, nowait=True)
        publisher.new_queue(
            self.request_queue, self.request_exchange, nowait=True)

    def _prepare_consumer(self, consumer):
        consumer.new_exchange(self.response_exchange, 'direct', nowait=True)
        consumer.new_queue(
            self.response_queue, self.response_exchange,
            routing_key=self.uuid, nowait=True)
        self.consumer.start_consume(self.response_queue, nowait=True)

    def _on_message(self, message):
        message_id = message.properties.get('message_id')
        result_holder = self._request_ids.get(message_id)
        try:
            result = self.serializer.deserialize_result(message.body)
        except (RemoteException, WrongRequest, WrongResult) as e:
            if result_holder is not None:
                if self.logger.isEnabledFor(logging.DEBUG):
                    self.logger.debug("When decoding message %s",
                                      safe_repr(message.body), exc_info=1)
                result_holder.set_exception(e)
            else:
                self.logger.exception("When decoding message %s",
                                      safe_repr(message.body))
        else:
            if result_holder is not None:
                if self.logger.isEnabledFor(logging.DEBUG):
                    self.logger.debug("Message %s fits result holder %r",
                                      safe_repr(message.body), result_holder)
                result_holder.set(result)
        message.ack()

    def _get_tuned(self, func_name, args, kwargs):
        results = self._function_settings[func_name](args, kwargs)
        assert set(results) == set(['queue', 'routing_key'])
        return results


class AmqpRpcFanoutClient(AmqpRpcClient):
    """Client for :py:class:`~amphora.AmqpRpcFanoutServer`.
    API is the same as for :py:class:`~amphora.AmqpRpcClient`."""
    request_exchange_type = 'fanout'
