import logging
import sys
import time

from contextlib import contextmanager, suppress
from functools import wraps


class ServiceException(Exception):
  status_code = 500


class NotFound(ServiceException):
  status_code = 404


class ClientException(ServiceException):
  status_code = 400


class InvalidInput(ClientException):
  pass


class AuthenticationException(ClientException):
  pass


class AuthorizationException(ServiceException):
  status_code = 401


class ConflictException(ServiceException):
  status_code = 409


class InvalidAuthorizationHeader(AuthorizationException):
  pass


class InvalidTokenSignature(AuthorizationException):
  pass


class ExpiredToken(AuthorizationException):
  pass


class ThrottledException(ServiceException):
  status_code = 429


def _is_match(exc, exc_type_handler):
  with suppress(TypeError):
    return issubclass(type(exc), exc_type_handler)
  return exc_type_handler(exc)


@contextmanager
def ignore_exceptions(*exc_type_handlers, log_msg=None):
  """
  Use this decorator / context manager to ignore specific exceptions. Non-ignored exceptions will be raised as
  normal.

  Parameters:
  - exc_type_handlers: (variable arity) specific exception types to ignore - either an exception class
    (subclass of BaseException, includes sub-classes), or a single-argument callable that will be passed the
    exception instance and should return True if it is a match

  Note the difference from 'contextlib.suppress' is the ability to use callables as exc_types.

  Examples:

  with ignore_exceptions(TypeError, lambda e: e.code == '404'):
    ...

  @ignore_exceptions(TypeError, lambda e: e.code == '404')
  def might_raise_exceptions():
    ...
  """

  try:
    yield
  except Exception as e:
    should_raise = bool(exc_type_handlers)
    for exc_type_handler in exc_type_handlers:
      if _is_match(e, exc_type_handler):
        should_raise = False
    if should_raise:
      raise
    if log_msg is not None:
      logging.exception(log_msg)


def retry(*exc_type_handlers, max=3, delay_s=0.1, exp_backoff=1.1):
  """
  Use this decorator to retry exceptions. By default, it retries all exceptions; use the 'exc_type_handlers'
  parameter to be more restrictive. If the retries are exhausted, the exception from the first execution will
  be raised.

  Parameters:
  - exc_type_handlers: (variable arity) a list of the specific exception types to retry - either an exception
    class (subclass of BaseException, includes sub-classes), or a single-argument callable that will be passed
    the exception instance and should return True if it is a match. Note that you can use these callables to
    do work before retrying the call, making it easy to implement a try, except, resolve, try-again pattern
    (the exception type handler can do work with side-effects to resolve the exception).
  - max: the max number of times to retry the call
  - delay_s: the initial delay between retries (delays between successive retry calls are calculated by
    multiplying the current 'delay_s' by the 'exp_backoff')
  - exp_backoff: the multiplier to increase the delay after the first retry

  Examples:

  @retry()
  def test():
    print("Will retry any exception 'max' times")
    raise Exception()

  @retry(TypeError)
  def test():
    print("Will not retry")
    raise Exception()

  should_raise = True
  def should_retry(e):
    global should_raise
    print("Resolving the exception as a side-effect")
    should_raise = False
    return True
  @retry(should_retry, max=1, delay_s=0)
  def test():
    if should_raise:
      print("Will retry after resolving the exception in the handler")
      raise Exception()
  """

  def can_retry(e):
    if not exc_type_handlers:
      return True
    for exc_type_handler in exc_type_handlers:
      if _is_match(e, exc_type_handler):
        return True
    return False

  def decorator(f):
    @wraps(f)
    def run(*args, **kwargs):
      i = 1
      cur_delay_s = delay_s
      first_exc = None

      while True:
        try:
          return f(*args, **kwargs)
        except Exception as e:
          if not can_retry(e):
            raise
          if not first_exc:
            # Preserve the original stack trace so it is not muddied by our logic here
            first_exc = sys.exc_info()
          if (i > max):
            break

        # TODO: if applicable, this log line should indicate what handler matched the exception (at least by
        # index) - successive retries may result in different exceptions, but only the first exception will be
        # raised
        msg = "Call to function '%s' raised exception - will retry after %.2fs delay (will be attempt #%i)..." % (f.__name__, cur_delay_s, i + 1)
        logging.info(msg, exc_info=True)

        time.sleep(cur_delay_s)
        i += 1
        cur_delay_s *= exp_backoff

      if first_exc:
        # We exhausted our retries, now time to raise the first exception
        exc_class, exc, tb = first_exc
        raise exc_class.with_traceback(exc, tb)

    return run
  return decorator
