import functools
from http.client import responses
import logging
from itertools import takewhile
from urllib.parse import urljoin
import uuid

import requests
from requests.adapters import HTTPAdapter
from urllib3 import Retry

from widen_client.entities.assets import Assets
from widen_client.entities.metadata import Metadata
from widen_client.entities.uploads import Uploads


logger = logging.getLogger(__name__)


class CustomRetry(Retry):
    def get_backoff_time(self):
        """Formula for computing the current backoff

        :rtype: float
        """
        # We want to consider only the last consecutive errors sequence (Ignore redirects).
        consecutive_errors_len = len(
            list(
                takewhile(lambda x: x.redirect_location is None, reversed(self.history))
            )
        )
        if consecutive_errors_len <= 1:
            return 0

        backoff_value = self.backoff_factor * (2 ** consecutive_errors_len)
        return min(self.BACKOFF_MAX, backoff_value)


class WidenError(Exception):
    def __init__(self, message, request_info=None):
        self.request_info = request_info
        super().__init__(message)


class WidenAttributeError(Exception):
    pass


def _get_error_data_from_response(response):
    """
    Construct a dictionary with error data from a >= 400 level response
    """
    try:
        error_data = response.json()
    except ValueError:
        # in case of a 500 error, the response might not be a JSON
        error_data = {'response': response}

    error_data.update({
        key: value
        for key, value in response.headers.items()
        if key.startswith('X-Ratelimit')
    })
    return error_data


def _enabled_or_noop(fn):
    @functools.wraps(fn)
    def wrapper(self, *args, **kwargs):
        if self.enabled:
            return fn(self, *args, **kwargs)
        return {}, 503
    return wrapper


class WidenClient:
    """
    Low-level client for interfacing with Widen v2 API.

    This client layer is responsible for authenticating, handling constructing/making requests, logging API calls,
    and catching request/connection exceptions, and catching/raising some select REST responses (4xx+, 204, etc.)

    Documentation: https://widenv2.docs.apiary.io/#
    """

    def __init__(
            self,
            enabled=True,
            access_token=None,
            timeout=60,
            retries_after_server_error=0,
            backoff_factor=1.0,
            request_hooks=None,
            request_headers=None
    ):
        """
        Create a session and setup access token in request header.

        :param enabled: Whether the client should execute any requests
        :param access_token: Widen access token
        :param timeout: (optional) Seconds to wait for the server to send data before timing out (default to 60)
        :param retries_after_server_error: (optional) Number of times to retry request after receiving a server error.
            The error statuses that will be retried are:
                - 429 (Too Many Requests)
                - 502 (Bad Gateway)
                - 504 (Gateway Timeout)
        response (defaults to 0, which is to not retry)
        :param backoff_factor: (optional) A backoff factor to apply between attempts after the second try, following the
        formula: {backoff factor} * (2 ** ({number of total retries} - 1))
        :param request_hooks: (optional) Hooks for :py:func:`requests.requests`.
        :param request_headers: (optional) Headers for :py:func:`requests.requests`.
        """

        super(WidenClient).__init__()
        self.enabled = enabled
        self.base_url = f'https://api.widencollective.com/v2/'
        self.timeout = timeout

        self.request_headers = request_headers or requests.utils.default_headers()
        self.request_hooks = request_hooks or requests.hooks.default_hooks()

        self.session = self._get_session(
            access_token=access_token,
            retries_after_server_error=retries_after_server_error,
            backoff_factor=backoff_factor
        )

        self.widen_api_request_correlation_id = uuid.uuid4()

    def _get_session(self, access_token=None, retries_after_server_error=0, backoff_factor=1.0):
        """
        Create a session and setup access token in request header.

        :param access_token: Widen API Access Token
        :param retries_after_server_error: (optional) Number of times to retry request after receiving a server error.
            The error statuses that will be retried are:
                - 429 (Too Many Requests)
                - 502 (Bad Gateway)
                - 504 (Gateway Timeout)
        :param backoff_factor: (optional) A backoff factor to apply between attempts after the second try, following the
        formula: {backoff factor} * (2 ** ({number of total retries} - 1))
        """
        session = requests.Session()
        session.encoding = 'utf8'
        session.headers.update({
            'Authorization': f'Bearer {access_token}',
        })

        if retries_after_server_error:
            retry_strategy = CustomRetry(
                total=retries_after_server_error,
                backoff_factor=backoff_factor,
                status_forcelist=[429, 502, 504],
                method_whitelist=['HEAD', 'GET', 'POST', 'PUT', 'DELETE' 'OPTIONS']
            )

            adapter = HTTPAdapter(max_retries=retry_strategy)
            session.mount("https://", adapter)

        return session

    @_enabled_or_noop
    def _get(self, url):
        """
        Handle authenticated GET requests

        :param url: The url for the endpoint including path parameters
        :return: The JSON output from the API and status code
        """

        url = urljoin(self.base_url, url)
        try:
            response = self._make_request(**dict(
                method='GET',
                url=url,
                timeout=self.timeout,
                hooks=self.request_hooks,
                headers=self.request_headers
            ))
        except requests.exceptions.RequestException as exception:
            raise exception
        except requests.exceptions.ReadTimeout as exception:
            request_info = {
                'method': 'PUT',
                'url': url,
            }
            raise WidenError(_get_error_data_from_response(exception.response), request_info=request_info)
        else:
            if response.status_code >= 400:
                request_info = {
                    'method': 'GET',
                    'url': url,
                }
                raise WidenError(_get_error_data_from_response(response), request_info=request_info)

            if response.status_code == 204:
                return {}, response.status_code

            return response.json(), response.status_code

    @_enabled_or_noop
    def _post(self, url, data=None, files=None):
        """
        Handle authenticated POST requests

        :param url: The url for the endpoint including path parameters
        :param data: The request body parameters
        :return: The JSON output from the API and status code
        """
        url = urljoin(self.base_url, url)
        post_params = {
            'method': 'POST',
            'url': url,
            'files': files,
            'timeout': self.timeout,
            'hooks': self.request_hooks,
            'headers': self.request_headers
        }

        if files:
            post_params.update({'data': data})
        else:
            self.session.headers.update({'Content-Type': 'application/json'})
            post_params.update({'json': data})
        try:
            response = self._make_request(**post_params)
        except requests.exceptions.RequestException as exception:
            raise exception
        except requests.exceptions.ReadTimeout as exception:
            request_info = {
                'method': 'PUT',
                'url': url,
                'json': data,
            }
            raise WidenError(_get_error_data_from_response(exception.response), request_info=request_info)
        else:
            if response.status_code >= 400:
                request_info = {
                    'method': 'POST',
                    'url': url,
                    'files': files,
                }

                # No need to include file data for exception capturing.
                if not files:
                    request_info.update({'json': data})

                raise WidenError(_get_error_data_from_response(response), request_info=request_info)

            if response.status_code == 204:
                return {}, response.status_code

            return response.json(), response.status_code

    def _make_request(self, **kwargs):
        logger.info(
            f'Request to Widen API [{self.widen_api_request_correlation_id}]: '
            f'{kwargs.get("method")} {kwargs.get("url")}'
        )

        if kwargs.get('json'):
            logger.info(f'Request to Widen API [{self.widen_api_request_correlation_id}]: {kwargs.get("json")}')

        response = self.session.request(**kwargs)

        if response.status_code >= 400:
            error_data = _get_error_data_from_response(response)
            logger.info(
                f'Response from Widen API [{self.widen_api_request_correlation_id}]: '
                f'{response.status_code} {responses[response.status_code]}: {error_data}'
            )
        else:
            logger.info(
                f'Response from Widen API [{self.widen_api_request_correlation_id}]: '
                f'{response.status_code} {responses[response.status_code]}'
            )

        # Once the request is finished it set a new correlation id
        self.widen_api_request_correlation_id = uuid.uuid4()

        return response

    @_enabled_or_noop
    def _put(self, url, data=None):
        """
        Handle authenticated PUT requests

        :param url: The url for the endpoint including path parameters
        :param data: The request body parameters
        :return: The JSON output from the API and status code
        """
        url = urljoin(self.base_url, url)
        self.session.headers.update({'Content-Type': 'application/json'})

        try:
            response = self._make_request(**dict(
                method='PUT',
                url=url,
                json=data,
                timeout=self.timeout,
                hooks=self.request_hooks,
                headers=self.request_headers
            ))
        except requests.exceptions.RequestException as exception:
            raise exception
        except requests.exceptions.ReadTimeout as exception:
            request_info = {
                'method': 'PUT',
                'url': url,
                'json': data,
            }
            raise WidenError(_get_error_data_from_response(exception.response), request_info=request_info)
        else:
            if response.status_code >= 400:
                request_info = {
                    'method': 'PUT',
                    'url': url,
                    'json': data,
                }
                raise WidenError(_get_error_data_from_response(response), request_info=request_info)

            if response.status_code == 204:
                return {}, response.status_code

            return response.json(), response.status_code

    @_enabled_or_noop
    def _delete(self, url):
        """
        Handle authenticated DELETE requests.

        :param url: The url for the endpoint including path parameters
        :return: The JSON output from the API and status code
        """

        url = urljoin(self.base_url, url)

        try:
            response = self._make_request(**dict(
                method='DELETE',
                url=url,
                timeout=self.timeout,
                hooks=self.request_hooks,
                headers=self.request_headers
            ))
        except requests.exceptions.RequestException as exception:
            raise exception
        except requests.exceptions.ReadTimeout as exception:
            request_info = {
                'method': 'PUT',
                'url': url,
            }
            raise WidenError(_get_error_data_from_response(exception.response), request_info=request_info)
        else:
            if response.status_code >= 400:
                request_info = {
                    'method': 'DELETE',
                    'url': url,
                }
                raise WidenError(_get_error_data_from_response(response), request_info=request_info)

            if response.status_code == 204:
                return {}, response.status_code

            return response.json(), response.status_code


class Widen(WidenClient):
    """
    Widen class for interfacing with Widen v2 API
    """

    def __init__(self, *args, **kwargs):
        """
        Initialize client class and attach all available entity endpoints
        """

        super().__init__(*args, **kwargs)

        self.assets = Assets(self)
        self.uploads = Uploads(self)
        self.metadata = Metadata(self)
