"""
:mod:`tornado_rest_client.api`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This package provides a quick way of creating custom API clients for JSON-based
REST APIs. The majority of the work is in the creation of a
:attr:`RestConsumer.CONFIG` dictionary for the class. This dictionary
dynamically configures the object at instantiation time with the appropriate
:func:`~tornado.gen.coroutine` wrapped HTTP fetch methods.

.. autoclass:: RestConsumer
   :members:
   :private-members:
   :noindex:
.. autoclass:: RestClient
   :members:
   :private-members:
   :noindex:
.. autoclass:: SimpleTokenRestClient
   :members:
   :inherited-members:
   :show-inheritance:
   :noindex:
"""

import logging

log = logging.getLogger(__name__)

import functools
import json
import types

from typing import Dict, Optional
from urllib.parse import urlencode

from tornado import gen, httpclient, httputil

from tornado_rest_client import exceptions, utils


def retry(func=None, retries=3, delay=0.25):
    """Coroutine-compatible retry decorator.

    This decorator provides a simple retry mechanism that compares the
    exceptions it received against a configuration list stored in the
    calling-object(:attr:`RestClient.EXCEPTIONS`), and then performs the action
    defined in that list. For example, an :exc:`~tornado.httpclient.HTTPError`
    with a '500' code might want to retry 3 times. On the otherhand, a
    `401`/`403` might want to throw an
    :exc:`~tornado_rest_client.exceptions.InvalidCredentials` exception.

    Examples:

    >>> @gen.coroutine
    ... @retry
    ... def some_func(self):
    ...     yield ...

    >>> @gen.coroutine
    ... @retry(retries=5):
    ... def some_func(self):
    ...     yield ...
    """

    def decorate(func):
        @functools.wraps(func)
        def wrapper(self, *args, **kwargs):
            # Try #1!
            i = 1

            # Get a list of private kwargs to mask
            private_kwargs = getattr(self, "_private_kwargs", [])

            # For security purposes, create a patched kwargs string that
            # removes passwords from the arguments. This is never guaranteed to
            # work (an API could have 'foo' as their password field, and we
            # just won't know ...), but we make a best effort here.
            safe_kwargs = dict(kwargs)
            remove = [k for k in safe_kwargs if k in private_kwargs]
            for k in remove:
                safe_kwargs[k] = "****"

            while True:
                # Don't log out the first try as a 'Try' ... just do it
                if i > 1:
                    log.debug(
                        "Try (%s/%s) of %s(%s, %s)", i, retries, func, args, safe_kwargs
                    )

                # Attempt the method. Catch any exception listed in
                # self.EXCEPTIONS.

                try:
                    ret = yield gen.coroutine(func)(self, *args, **kwargs)
                    raise gen.Return(ret)
                except tuple(  # pylint: disable=catching-non-exception
                    self.EXCEPTIONS.keys()
                ) as generic_exc:
                    error = str(generic_exc)
                    if hasattr(generic_exc, "message"):
                        error = generic_exc.message
                    log.warning("Exception raised on try %s: %s", i, error)

                    # If we've run out of retry attempts, raise the exception
                    if i >= retries:
                        log.debug("Raising exception: %s", generic_exc)
                        raise generic_exc  # pylint: disable=raising-non-exception

                    # Gather the config for this exception-type from
                    # self.EXCEPTIONS. Iterate through the data and see if we
                    # have a matching exception string.
                    exc_conf = self.EXCEPTIONS[type(generic_exc)].copy()

                    # An empty string for the key is the default exception
                    # It's optional, but can match before others match, so we
                    # pop it before searching.
                    default_exc = exc_conf.pop("", False)
                    log.debug("Searching through %s", exc_conf)
                    matched_excs = [
                        exc
                        for key, exc in list(exc_conf.items())
                        if key in str(generic_exc)
                    ]

                    log.debug("Matched exceptions: %s", matched_excs)
                    if matched_excs:
                        if matched_excs[0] is not None:
                            specific_exc = matched_excs[0]
                            log.debug("Matched exception: %s", specific_exc)
                            raise specific_exc(
                                generic_exc
                            )  # pylint: disable=raise-missing-from
                    elif default_exc is not False:
                        raise default_exc(
                            str(generic_exc)
                        )  # pylint: disable=raise-missing-from
                    elif default_exc is False:
                        # Reaching this part means no exception was matched
                        # and no default was specified.
                        log.debug(
                            "No explicit behavior for this exception found. Raising."
                        )
                        raise generic_exc  # pylint: disable=raising-non-exception

                    log.debug("Exception is retryable!")
                    i = i + 1
                    log.debug("Retrying in %s...", delay)
                    yield gen.sleep(delay)

                log.debug("Retrying..")

        return wrapper

    # http://stackoverflow.com/questions/3888158/
    # python-making-decorators-with-optional-arguments
    if func:
        return decorate(func)

    return decorate


def create_http_method(name, http_method):  # pylint: disable=unused-argument
    """Creates the *GET*/*PUT*/*DELETE*/*POST* function for a RestConsumer.

    This method is called by :func:`RestConsumer._create_http_methods` to
    create a method for the :class:`RestConsumer` object with the appropriate
    name and HTTP method (:func:`http_get`, :func:`http_put`,
    :func:`http_delete`, :func:`http_post`)

    :param str name: Full name of the function to create (ie, `http_get`)
    :param str http_method: Name of the method (ie, `get`)

    :return: A method appropriately configured and named.
    """

    @gen.coroutine
    def method(self, *args, **kwargs):
        # We don't support un-named args. Throw an exception.
        if args:
            raise exceptions.InvalidOptions("Must pass named-args (kwargs)")

        ret = yield self._client.fetch(  # pylint: disable=protected-access
            url=f"{self.ENDPOINT}{self._path}",  # pylint: disable=protected-access
            method=http_method.upper(),
            params=kwargs,
            auth_username=self.CONFIG.get("auth", {}).get("user"),
            auth_password=self.CONFIG.get("auth", {}).get("pass"),
        )
        raise gen.Return(ret)

    method.__name__ = http_method
    return method


def create_consumer_method(name, config):  # pylint: disable=unused-argument
    """Creates a method that returns a configured RestConsumer object.

    RestConsumer objects themselves can have references to other RestConsumer
    objects. For example, the
    :class:`~tornado_rest_consumer.client.slack.Slack` object has no
    :func:`http_*` methods itself, but it does have methods like
    :func:`~tornadeo_rest_consumer.client.slack.Slack.auth_test` which return a
    fresh :class:`RestConsumer` object that points to the `/api/auth.test` API
    endpoint and provide :func:`http_post` as a function

    The method created here accepts any args (`*args, **kwargs`) and passes
    them on to the :class:`RestConsumer` object being created. This allows for
    passing in unique resource identifiers (ie, the `%res%` in
    `/v2/rooms/%res%/history`).

    :param str name: The name of the method to create (ie, `auth_test`)
    :param dict config: The dictionary of :attr:`~RestConsumer.CONFIG` data
        specific to the API endpoint that we are configuring (should include
        `path` and `http_methods` keys).

    :return: A method that returns a fresh RestConsumer object
    """

    def method(self, *args, **kwargs):
        # Merge the supplied kwargs to the method with any kwargs supplied to
        # the RestConsumer parent object. This ensures that tokens replaced in
        # the 'path' variables are passed all the way down the instantiation
        # chain.
        merged_kwargs = dict(
            list(self._kwargs.items())
            + list(kwargs.items())  # pylint: disable=protected-access
        )

        return self.__class__(
            name=name,
            config=self._attrs[name],  # pylint: disable=protected-access
            client=self._client,  # pylint: disable=protected-access
            *args,
            **merged_kwargs,
        )

    method.__name__ = name
    return method


class RestConsumer:
    """Async REST API Consumer object.

    The generic RestConsumer object (with no parameters passed in) looks at
    the :attr:`CONFIG` dictionary and dynamically generates access methods
    for the various API methods.

    The *GET*, *PUT*, *POST* and *DELETE* methods optionally listed in
    `CONFIG['http_methods']` represent the possible types of HTTP methods
    that the `CONFIG['path']` supports. For each one of these listed, a
    :func:`~tornado.gen.coroutine` wrapped :func:`http_get`,
    :func:`http_put`, :func:`http_post`, or :func:`http_delete` method will
    be created.

    For each item listed in `CONFIG['attrs']`, an access method is created
    that creates and returns a new RestConsumer object that's configured for
    this endpoint. These methods are not asynchronous, but are non-blocking.

    :param str name: Name of the resource method (default: None)
    :param dict config: The dictionary object with the configuration for this
      API endpoint call.
    :param RestClient client: The `RestClient` compatible object used to
      actually fire off HTTP requests.
    :param dict kwargs: Any named arguments that should be passed along in the
      web request through the :func:`replace_path_tokens` method. This allows
      for string replacement in URL paths, like `/api/%resource_id%/terminate`
      to have the `%resource_id%` token replaced with something you've
      passed in here.
    """

    #: The URL of the API Endpoint.
    #: (for example: http://httpbin.org)
    ENDPOINT = None

    #: The configuration dictionary for the REST API. This dictionary
    #: consists of a root object that has three possible named keys: `path`,
    #: `http_methods` and `attrs`.
    #:
    #: * *path*: The API Endpoint that any of the HTTP methods should talk to.
    #: * *http_methods*: A dictionary of HTTP methods that are supported.
    #: * *attrs*: A dictionary of other methods to create that reference other
    #:   API URLs.
    #: * *new*: Set to True if you want to create an access property rather
    #:   an access method. Only works if your path has no token replacement in
    #:   it.
    #:
    #: This data can be nested as much as you'd like
    #:
    #: >>> CONFIG = {
    #: ...     'path': '/', 'http_methods': {'get': {}},
    #: ...     'new': True,
    #: ...     'attrs': {
    #: ...         'getter': {'path': '/get', 'http_methods': {'get': {}}},
    #: ...         'poster': {'path': '/post', 'http_methods': {'post': {}}},
    #: ...     }
    #: ... }:
    CONFIG = {}

    def __init__(self, name=None, config=None, client=None, **kwargs):
        """Initializes the RestConsumer."""
        # If these aren't passed in, then get them from the class definition
        name = name or self.__class__.__name__
        config = config or self.CONFIG

        # Get the basic options for this particular REST endpoint access object
        self._path = config.get("path", None)
        self._http_methods = config.get("http_methods", None)
        self._attrs = config.get("attrs", None)
        self._kwargs = kwargs

        # If no client was supplied, then we use our default
        self._client = client or RestClient()

        # Ensure that any tokens that need filling-in in the self._path setting
        # are pulled from the **kwargs passed into this init. This is used on
        # API paths like Hipchats '/v2/room/%(res)/...' URLs.
        self._path = self.replace_path_tokens(self._path, kwargs)

        # Create all of the methods based on the self._http_methods and
        # self._attrs dict.
        self._create_http_methods()
        self._create_consumer_methods()

        # Log some things
        log.debug("%s/%s initialized", self.__class__.__name__, self._client)

    def __repr__(self):
        return f"{self.__class__.__name__}({self})"

    def __str__(self):
        return str(self._path)

    def replace_path_tokens(self, path, tokens):
        """Search and replace `%xxx%` with values from tokens.

        Used to replace any values of `%xxx%` with `'xxx`' from tokens. Can
        replace one, or many fields at aonce.

        :param str path: String of the path
        :param dict tokens: A dictionary of tokens to search through.
        :return: A modified string
        """
        if not path:
            return path

        try:
            path = utils.populate_with_tokens(path, tokens)
        except LookupError as exc:
            raise TypeError(  # pylint: disable=raise-missing-from
                f"Path ({path}), tokens: ({tokens}) error: {exc}"
            )

        return path

    def _create_http_methods(self):
        """Create :func:`~tornado.gen.coroutine` wrapped HTTP methods.

        Iterates through the methods described in `self._http_methods` and
        creates :func:`~tornado.gen.coroutine` wrapped access methods that
        perform these actions.
        """
        if not self._http_methods:
            return

        for http_method in list(self._http_methods.keys()):
            full_method_name = f"http_{http_method}"
            method = create_http_method(full_method_name, http_method)
            setattr(self, full_method_name, types.MethodType(method, self))

    def _create_consumer_methods(self):
        """Creates access methods to the attributes in `self._attrs`.

        Iterates through the attributes described in `self._attrs` and creates
        access methods that return :class:`RestConsumer` objects for those
        attributes.
        """
        if not self._attrs:
            return

        for name in list(self._attrs.keys()):
            method = create_consumer_method(name, self._attrs[name])

            if "new" in self._attrs[name]:
                try:
                    setattr(self, name, method(self))
                except TypeError:
                    setattr(self, name, types.MethodType(method, self))
            else:
                setattr(self, name, types.MethodType(method, self))


class RestClient:
    """Simple Async REST client for the RestConsumer.

    Implements a :class:`~tornado.httpclient.AsyncHTTPClient`, some convinience
    methods for URL escaping, and a single :func:`~RestClient.fetch` method
    that can handle GET/POST/PUT/DELETEs.

    :param dict headers: Headers to pass in on every HTTP request
    """

    #: Dictionary describing the exception handling behavior for HTTP calls.
    #: The dictionary should look like this:
    #:
    #: >>> {
    #: ...     <exception type... aka httpclient.HTTPError>: {
    #: ...         `<string to match in exception.message>`: <raises exc>,
    #: ...         '<this string triggers a retry>': None,
    #: ...         '': <all other strings trigger this exception>
    #: ...     }
    #:
    EXCEPTIONS: Dict[Exception, Optional[Dict]] = {
        httpclient.HTTPError: {
            "401": exceptions.InvalidCredentials,
            "403": exceptions.InvalidCredentials,
            "500": None,
            "502": None,
            "503": None,
            "504": None,
            # Rrepresents a standard HTTP Timeout
            "599": None,
            "": exceptions.RecoverableFailure,
        }
    }

    # Combined Connect and Request Timeout settings. Note, None
    # is the default -- but actually times out after 20s (due to
    # a bug in Tornado). Use a very high number or 0 to indicate no
    # timeout.
    TIMEOUT = None

    # If the APi expects that you send a JSON body with data rather than
    # passing url arguments, set this to true.
    JSON_BODY = False

    def __init__(
        self,
        client=None,
        headers=None,
        timeout=TIMEOUT,
        json=None,  # pylint: disable=redefined-outer-name
        allow_nonstandard_methods=False,
    ):
        self._client = client or httpclient.AsyncHTTPClient()
        self._private_kwargs = ["auth_password"]
        self.headers = headers
        self.timeout = timeout
        self.allow_nonstandard_methods = allow_nonstandard_methods
        self.json = json

        if (
            (self.json is True or self.JSON_BODY) and self.json is not False
        ) and not self.headers:
            self.headers = {"Content-Type": "application/json"}

    def _generate_escaped_url(self, url, args):
        """Generates a fully escaped URL string.

        Sorts the arguments so that the returned string is predictable and in
        alphabetical order. Effectively wraps the
        :func:`tornado.httputil.url_concat` method and properly strips out
        `None` values, as well as lowercases `Bool` values.

        :param str url: The URL to append the arguments to
        :param dict args: Key/Value arguments. Values should be primitives.

        :return: URL encoded string like this: `<url>?foo=bar&abc=xyz`
        """

        # Remove keys from the arguments where the value is None
        args = dict((k, v) for k, v in list(args.items()) if v)

        # Convert all Bool values to lowercase strings
        for key, value in list(args.items()):
            if isinstance(value, bool):
                args[key] = str(value).lower()

        # Now generate the URL
        full_url = httputil.url_concat(url, sorted(args.items()))
        log.debug("Generated URL: %s", full_url)

        return full_url

    @gen.coroutine
    @retry
    def fetch(
        self,
        url,
        method,
        params=None,
        auth_username=None,
        auth_password=None,
        timeout=None,
    ):
        """Executes a web request asynchronously and yields the body.

        :param str url: The full url path of the API call
        :param dict params: Arguments (k/v pairs) to submit either as POST data
            or URL argument options.
        :param str method: GET/PUT/POST/DELETE
        :param str auth_username: HTTP auth username
        :param str auth_password: HTTP auth password
        :yields: String of the returned text from the web service.
        """

        if params is None:
            params = {}
        # Start with empty post data. If we're doing a PUT/POST, then just pass
        # args directly into the ch() method and let it take care of
        # things. If we're doing a GET/DELETE though, convert kwargs into a
        # modified URL string and pass that into the fetch() method.
        if timeout is None:
            timeout = self.timeout
        body = None
        if method in ("PUT", "POST"):
            if not ((self.json is True or self.JSON_BODY) and self.json is not False):
                body = urlencode(params)
            else:
                body = json.dumps(params)
        elif method in ("GET", "DELETE") and params:
            url = self._generate_escaped_url(url, params)

        # Generate the full request URL and log out what we're doing...
        log.debug("Making %s request to %s. Data: %s", method, url, body)

        # Create the http_request object
        http_request = httpclient.HTTPRequest(
            url=url,
            method=method,
            body=body,
            headers=self.headers,
            auth_username=auth_username,
            auth_password=auth_password,
            follow_redirects=True,
            request_timeout=timeout,
            connect_timeout=timeout,
            allow_nonstandard_methods=self.allow_nonstandard_methods,
            max_redirects=10,
        )

        # Execute the request and raise any exception. Exceptions are not
        # caught here because they are unique to the API endpoints, and thus
        # should be handled by the individual callers of this method.
        log.debug("HTTP Request: %s", http_request)
        try:
            http_response = yield self._client.fetch(http_request)
        except httpclient.HTTPError as exc:
            log.critical("Request for %s failed: %s", url, exc)
            raise
        log.debug("HTTP Response: %s", http_response.body)

        try:
            body = json.loads(http_response.body)
        except ValueError:
            raise gen.Return(http_response.body)  # pylint: disable=raise-missing-from

        # Receive a successful return
        raise gen.Return(body)


class SimpleTokenRestClient(RestClient):
    """Simple RestClient with a token for HTTP authentication.

    Used in most simple APIs where a token is provided to the end user.

    :param dict tokens: A dict with the token name/value(s) to append to every
      web request.
    """

    def __init__(self, tokens, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._tokens = tokens
        for key in list(tokens.keys()):
            self._private_kwargs.append(key)

    @gen.coroutine
    def fetch(self, *args, **kwargs):
        if "params" not in kwargs:
            kwargs["params"] = {}

        kwargs["params"].update(self._tokens)
        ret = yield super().fetch(*args, **kwargs)
        raise gen.Return(ret)
