# coding=utf-8
from __future__ import absolute_import, division, print_function, \
  unicode_literals

import json
from abc import ABCMeta, abstractmethod as abstract_method
from collections import deque
from inspect import isabstract as is_abstract
from logging import DEBUG, Logger
from socket import getdefaulttimeout as get_default_timeout
from typing import Container, Dict, List, Optional, Text, Tuple, Union

from requests import Response, codes, request
from six import PY2, binary_type, iteritems, moves as compat, text_type, \
  with_metaclass

from iota.exceptions import with_context
from iota.json import JsonEncoder

__all__ = [
  'API_VERSION',
  'AdapterSpec',
  'BadApiResponse',
  'InvalidUri',
]

if PY2:
  # Fix an error when importing this package using the ``imp`` library
  # (note: ``imp`` is deprecated since Python 3.4 in favor of
  # ``importlib``).
  # https://docs.python.org/3/library/imp.html
  # https://travis-ci.org/iotaledger/iota.lib.py/jobs/191974244
  __all__ = map(binary_type, __all__)


API_VERSION = '1'
"""
API protocol version.
https://github.com/iotaledger/iota.lib.py/issues/84
"""


# Custom types for type hints and docstrings.
AdapterSpec = Union[Text, 'BaseAdapter']

# Load SplitResult for IDE type hinting and autocompletion.
if PY2:
  # noinspection PyCompatibility,PyUnresolvedReferences
  from urlparse import SplitResult
else:
  # noinspection PyCompatibility,PyUnresolvedReferences
  from urllib.parse import SplitResult


class BadApiResponse(ValueError):
  """
  Indicates that a non-success response was received from the node.
  """
  pass


class InvalidUri(ValueError):
  """
  Indicates that an invalid URI was provided to `resolve_adapter`.
  """
  pass


adapter_registry = {} # type: Dict[Text, AdapterMeta]
"""
Keeps track of available adapters and their supported protocols.
"""


def resolve_adapter(uri):
  # type: (AdapterSpec) -> BaseAdapter
  """
  Given a URI, returns a properly-configured adapter instance.
  """
  if isinstance(uri, BaseAdapter):
    return uri

  parsed = compat.urllib_parse.urlsplit(uri) # type: SplitResult

  if not parsed.scheme:
    raise with_context(
      exc = InvalidUri(
        'URI must begin with "<protocol>://" (e.g., "udp://").',
      ),

      context = {
        'parsed': parsed,
        'uri':    uri,
      },
    )

  try:
    adapter_type = adapter_registry[parsed.scheme]
  except KeyError:
    raise with_context(
      exc = InvalidUri('Unrecognized protocol {protocol!r}.'.format(
        protocol = parsed.scheme,
      )),

      context = {
        'parsed': parsed,
        'uri':    uri,
      },
    )

  return adapter_type.configure(parsed)


class AdapterMeta(ABCMeta):
  """
  Automatically registers new adapter classes in ``adapter_registry``.
  """
  # noinspection PyShadowingBuiltins
  def __init__(cls, what, bases=None, dict=None):
    super(AdapterMeta, cls).__init__(what, bases, dict)

    if not is_abstract(cls):
      for protocol in getattr(cls, 'supported_protocols', ()):
        # Note that we will not overwrite existing registered adapters.
        adapter_registry.setdefault(protocol, cls)

  def configure(cls, parsed):
    # type: (Union[Text, SplitResult]) -> HttpAdapter
    """
    Creates a new instance using the specified URI.

    :param parsed:
      Result of :py:func:`urllib.parse.urlsplit`.
    """
    return cls(parsed)


class BaseAdapter(with_metaclass(AdapterMeta)):
  """
  Interface for IOTA API adapters.

  Adapters make it easy to customize the way an StrictIota instance
  communicates with a node.
  """
  supported_protocols = () # type: Tuple[Text]
  """
  Protocols that ``resolve_adapter`` can use to identify this adapter
  type.
  """

  def __init__(self):
    super(BaseAdapter, self).__init__()

    self._logger = None # type: Logger

  @abstract_method
  def get_uri(self):
    # type: () -> Text
    """
    Returns the URI that this adapter will use.
    """
    raise NotImplementedError(
      'Not implemented in {cls}.'.format(cls=type(self).__name__),
    )

  @abstract_method
  def send_request(self, payload, **kwargs):
    # type: (dict, dict) -> dict
    """
    Sends an API request to the node.

    :param payload:
      JSON payload.

    :param kwargs:
      Additional keyword arguments for the adapter.

    :return:
      Decoded response from the node.

    :raise:
      - :py:class:`BadApiResponse` if a non-success response was
        received.
    """
    raise NotImplementedError(
      'Not implemented in {cls}.'.format(cls=type(self).__name__),
    )

  def set_logger(self, logger):
    # type: (Logger) -> BaseAdapter
    """
    Attaches a logger instance to the adapter.
    The adapter will send information about API requests/responses to
    the logger.
    """
    self._logger = logger
    return self

  def _log(self, level, message, context=None):
    # type: (int, Text, Optional[dict]) -> None
    """
    Sends a message to the instance's logger, if configured.
    """
    if self._logger:
      self._logger.log(level, message, extra={'context': context or {}})


class HttpAdapter(BaseAdapter):
  """
  Sends standard HTTP requests.
  """
  supported_protocols = ('http', 'https',)

  DEFAULT_HEADERS = {
    'Content-type': 'application/json',

    # https://github.com/iotaledger/iota.lib.py/issues/84
    'X-IOTA-API-Version': API_VERSION,
  }
  """
  Default headers sent with every request.
  These can be overridden on a per-request basis, by specifying values
  in the ``headers`` kwarg.
  """

  def __init__(self, uri):
    # type: (Union[Text, SplitResult]) -> None
    super(HttpAdapter, self).__init__()

    if isinstance(uri, text_type):
      uri = compat.urllib_parse.urlsplit(uri) # type: SplitResult

    if uri.scheme not in self.supported_protocols:
      raise with_context(
        exc = InvalidUri('Unsupported protocol {protocol!r}.'.format(
          protocol = uri.scheme,
        )),

        context = {
          'uri': uri,
        },
      )

    if not uri.hostname:
      raise with_context(
        exc = InvalidUri(
          'Empty hostname in URI {uri!r}.'.format(
            uri = uri.geturl(),
          ),
        ),

        context = {
          'uri': uri,
        },
      )

    try:
      # noinspection PyStatementEffect
      uri.port
    except ValueError:
      raise with_context(
        exc = InvalidUri(
          'Non-numeric port in URI {uri!r}.'.format(
            uri = uri.geturl(),
          ),
        ),

        context = {
          'uri': uri,
        },
      )

    self.uri = uri

  @property
  def node_url(self):
    # type: () -> Text
    """
    Returns the node URL.
    """
    return self.uri.geturl()

  def get_uri(self):
    # type: () -> Text
    return self.uri.geturl()

  def send_request(self, payload, **kwargs):
    # type: (dict, dict) -> dict
    kwargs.setdefault('headers', {})
    for key, value in iteritems(self.DEFAULT_HEADERS):
      kwargs['headers'].setdefault(key, value)

    response = self._send_http_request(
      # Use a custom JSON encoder that knows how to convert Tryte values.
      payload = JsonEncoder().encode(payload),

      url = self.node_url,
      **kwargs
    )

    return self._interpret_response(response, payload, {codes['ok']})

  def _send_http_request(self, url, payload, method='post', **kwargs):
    # type: (Text, Optional[Text], Text, dict) -> Response
    """
    Sends the actual HTTP request.

    Split into its own method so that it can be mocked during unit
    tests.
    """
    kwargs.setdefault('timeout', get_default_timeout())

    self._log(
      level = DEBUG,

      message = 'Sending {method} to {url}: {payload!r}'.format(
        method  = method,
        payload = payload,
        url     = url,
      ),

      context = {
        'request_method':   method,
        'request_kwargs':   kwargs,
        'request_payload':  payload,
        'request_url':      url,
      },
    )

    response = request(method=method, url=url, data=payload, **kwargs)

    self._log(
      level = DEBUG,

      message = 'Receiving {method} from {url}: {response!r}'.format(
        method    = method,
        response  = response.content,
        url       = url,
      ),

      context = {
        'request_method':   method,
        'request_kwargs':   kwargs,
        'request_payload':  payload,
        'request_url':      url,

        'response_headers': response.headers,
        'response_content': response.content,
      },
    )

    return response

  def _interpret_response(self, response, payload, expected_status):
    # type: (Response, dict, Container[int]) -> dict
    """
    Interprets the HTTP response from the node.

    :param response:
      The response object received from :py:meth:`_send_http_request`.

    :param payload:
      The request payload that was sent (used for debugging).

    :param expected_status:
      The response should match one of these status codes to be
      considered valid.
    """
    raw_content = response.text
    if not raw_content:
      raise with_context(
        exc = BadApiResponse(
          'Empty {status} response from node.'.format(
            status = response.status_code,
          ),
        ),

        context = {
          'request': payload,
        },
      )

    try:
      decoded = json.loads(raw_content) # type: dict
    # :bc: py2k doesn't have JSONDecodeError
    except ValueError:
      raise with_context(
        exc = BadApiResponse(
          'Non-JSON {status} response from node: {raw_content}'.format(
            status      = response.status_code,
            raw_content = raw_content,
          )
        ),

        context = {
          'request':      payload,
          'raw_response': raw_content,
        },
      )

    if not isinstance(decoded, dict):
      raise with_context(
        exc = BadApiResponse(
          'Malformed {status} response from node: {decoded!r}'.format(
            status  = response.status_code,
            decoded = decoded,
          ),
        ),

        context = {
          'request':  payload,
          'response': decoded,
        },
      )

    if response.status_code in expected_status:
      return decoded

    error = None
    try:
      if response.status_code == codes['bad_request']:
        error = decoded['error']
      elif response.status_code == codes['internal_server_error']:
        error = decoded['exception']
    except KeyError:
      pass

    raise with_context(
      exc = BadApiResponse(
        '{status} response from node: {error}'.format(
          error   = error or decoded,
          status  = response.status_code,
        ),
      ),

      context = {
        'request':  payload,
        'response': decoded,
      },
    )


class MockAdapter(BaseAdapter):
  """
  An mock adapter used for simulating API responses.

  To use this adapter, you must first "seed" the responses that the
  adapter should return for each request.  The adapter will then return
  the appropriate seeded response each time it "sends" a request.
  """
  supported_protocols = ('mock',)

  # noinspection PyUnusedLocal
  @classmethod
  def configure(cls, uri):
    return cls()

  def __init__(self):
    super(MockAdapter, self).__init__()

    self.responses  = {} # type: Dict[Text, deque]
    self.requests   = [] # type: List[dict]

  def get_uri(self):
    return 'mock://'

  def seed_response(self, command, response):
    # type: (Text, dict) -> MockAdapter
    """
    Sets the response that the adapter will return for the specified
    command.

    You can seed multiple responses per command; the adapter will put
    them into a FIFO queue.  When a request comes in, the adapter will
    pop the corresponding response off of the queue.

    Example::

       adapter.seed_response('sayHello', {'message': 'Hi!'})
       adapter.seed_response('sayHello', {'message': 'Hello!'})

       adapter.send_request({'command': 'sayHello'})
       # {'message': 'Hi!'}

       adapter.send_request({'command': 'sayHello'})
       # {'message': 'Hello!'}
    """
    if command not in self.responses:
      self.responses[command] = deque()

    self.responses[command].append(response)
    return self

  def send_request(self, payload, **kwargs):
    # type: (dict, dict) -> dict
    # Store a snapshot so that we can inspect the request later.
    self.requests.append(dict(payload))

    command = payload['command']

    try:
      response = self.responses[command].popleft()
    except KeyError:
      raise with_context(
        exc = BadApiResponse(
          'No seeded response for {command!r} '
          '(expected one of: {seeds!r}).'.format(
            command = command,
            seeds   = list(sorted(self.responses.keys())),
          ),
        ),

        context = {
          'request': payload,
        },
      )
    except IndexError:
      raise with_context(
        exc = BadApiResponse(
          '{command} called too many times; no seeded responses left.'.format(
            command = command,
          ),
        ),

        context = {
          'request': payload,
        },
      )

    error = response.get('exception') or response.get('error')
    if error:
      raise with_context(BadApiResponse(error), context={'request': payload})

    return response