import copy
import json
import logging
import os

import requests
from requests.auth import AuthBase, HTTPBasicAuth
from requests.exceptions import HTTPError, RequestException

from .paths import ABOUT_PATH, AUTH_PATH

logger = logging.getLogger(__name__)

SUPPORTED_SERVER_VERSION = "8.9"


def clean_host_name(host_name):
    """
    Clean up host name, removes trailing "/" and adds "http://" if needed.

    @return: cleaned-up host name.
    @rtype: str
    """
    host_name = host_name[:-1] if host_name.endswith("/") else host_name
    if not host_name.startswith("http"):
        # all LD production servers use https.
        host_name = "https://" + host_name
    return host_name


class VerifiedRequester(object):
    """
    Mixin to handle HTTP verbs that assures the client version matches the
    LiveDesign server's expectations.
    """
    _verified_version = False

    def __init__(self, verify_ssl=None):
        if verify_ssl is None:
            self.verify_ssl_param = VerifiedRequester.locate_ssl_certificate() or True
        else:
            self.verify_ssl_param = verify_ssl

    @staticmethod
    def locate_ssl_certificate():
        """
        Chooses an appropriate certificate bundle with which to do SSL
        certificate verification. If a deployment relies on a self-signed SSL
        certificate, schrodinger-trusted.crt should be created by Chef
        containing a modified bundle of root certificates including the
        self-signed certificate that is to be trusted.

        :returns: str containing the path to a cert, or
                  None if none of the potential bundles are found.
        """
        paths = [
            '/etc/ssl/certs/schrodinger-trusted.crt',  # Generated by Chef
            # `requests` does not look for OS-dependent locations for certs
            '/etc/ssl/certs/ca-certificates.crt',  # Ubuntu
            '/etc/ssl/certs/ca-bundle.crt',  # CentOS/RHEL
            '/etc/ssl/certs/ca-bundle.trust.crt',  # CentOS/RHEL
        ]
        for path in paths:
            if os.path.exists(path):
                return path
        return None

    def get(self, service_path, path, **kwargs):
        return self.invoke_request(service_path, path, http_method=requests.get, **kwargs)

    def post(self, service_path, path, **kwargs):
        return self.invoke_request(service_path, path, http_method=requests.post, **kwargs)

    def delete(self, service_path, path, **kwargs):
        return self.invoke_request(service_path, path, http_method=requests.delete, **kwargs)

    def put(self, service_path, path, **kwargs):
        return self.invoke_request(service_path, path, http_method=requests.put, **kwargs)

    def patch(self, service_path, path, **kwargs):
        return self.invoke_request(
            service_path, path + "/patch", http_method=requests.put, **kwargs)

    def get_extra_request_params(self):
        """
        Any extra params to send in the request

        Returns:
            dict
        """
        return {}

    def _verify_version(self):
        # Have to set self._verified_version before calling self.get(),
        # otherwise causes infinite recursion.
        self._verified_version = True
        response = self.get(ABOUT_PATH, '')
        if not isinstance(response, dict) or 'seurat_version_number' not in response:
            raise RequestException('Live Design server returned an inappropriate response '
                                   'to a version request: {!r}'.format(response))
        ld_version = response['seurat_version_number']
        # If we have the correct version just return
        if SUPPORTED_SERVER_VERSION in ld_version:
            return

        self._verified_version = False
        # Form the error message
        error_msg = ('LiveDesign version {0} is not supported.\nSupported server '
                     'version is {1}.'.format(ld_version, SUPPORTED_SERVER_VERSION))
        logger.warn(error_msg)
        raise RuntimeError(error_msg)

    @staticmethod
    def decode_to_string(content):
        """
        Checks if the passed in content is in bytes format, if so decodes it into a string

        :param content: The content to check and convert to string if required
        :return: The decoded string or the string as is in case if it was already a string
        """
        if isinstance(content, bytes):
            try:
                decoded_content = content.decode('utf-8')
            except UnicodeDecodeError:
                decoded_content = content.decode('iso-8859-1')
        else:
            decoded_content = content
        return decoded_content

    @staticmethod
    def raise_for_status(response):
        """
        Raises error of type :class:`HTTPError`, if one occurred.

        :param response: The response we want to check for errors
        """

        http_error_msg = ''
        reason = VerifiedRequester.decode_to_string(response.reason)
        content = VerifiedRequester.decode_to_string(response.content)

        if 400 <= response.status_code < 500:
            http_error_msg = '%s Client Error: %s for url: %s' % (response.status_code, reason, response.url)
        elif 500 <= response.status_code < 600:
            http_error_msg = '%s Server Error: %s for url: %s' % (response.status_code, reason, response.url)

        if http_error_msg:
            http_error_msg = http_error_msg + '\nResponse body: %s' % content
            raise HTTPError(http_error_msg, response=response)

    def invoke_request(self,
                       service_path,
                       path,
                       http_method,
                       params=None,
                       data=None,
                       json_data=None,
                       files=None,
                       json_response=True,
                       request_specific_headers={},
                       whole_response=False):
        """
        Make a general request to the Seurat Server.

        Args:
            service_path:
            path: A string that is the addition to the Seurat Base URL
                specifying the method (e.g. '/scientists')
            http_method: The actual function from the restclient library to
                be invoked
            params: A dict mapping query string parameters to their
                respective values (default: {})
            data: POST params (dict or plain string).  If it is a dict, then it is FORM-encoded by requests
            json: POST params (dict or Model). Only one of data and json can be passed
            files: The files to upload

        Returns:
            The JSON-loaded response from the server, with no alterations

        Raises:
            requests.exceptions.HTTPError if request fails
            ValueError if both json and data are specified
        """
        if json_data and data:
            raise ValueError("Can't call with both data and json")
        # Lazily checks that the client version matches the LiveDesign
        # server version the first time an API call is made
        if not self._verified_version:
            self._verify_version()

        if params is None:
            params = {}
        else:
            params = copy.copy(params)

        url = '%s%s%s' % (self.host, service_path, path)

        logger.debug("About to request %s" % url)
        logger.debug("Using params %s" % params)
        logger.debug("Using data %s" % data)

        if 'type' not in params:
            params['_type'] = 'json'

        if json_response:
            headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
        else:
            headers = {}

        headers.update(request_specific_headers)
        logger.debug("Using headers %s" % headers)

        proxies = {
            # "http": "http://127.0.0.1:8888",
        }
        request_kwargs = self.get_extra_request_params()

        # NOTE(mrosenbe): requests has a 'json' parameter which will call json.dumps
        #                 however, it does not provide a way to pass in a custom encoder,
        #                 so we do that explicitly here. This is the recommended way of
        #                 using a custom encoder: https://github.com/psf/requests/issues/2755
        cls = getattr(self, 'model_encoder', None)
        if json_data:
            data = json.dumps(json_data, cls=cls)
            headers['Content-Type'] = 'application/json'

        response = http_method(
            url,
            params=params,
            data=data,
            headers=headers,
            files=files,
            proxies=proxies,
            verify=self.verify_ssl_param,
            **request_kwargs)
        if response.status_code != 200:
            logger.warning("Response: \n%s" % VerifiedRequester.decode_to_string(response.content))
        VerifiedRequester.raise_for_status(response)
        if whole_response:
            return response
        # Sometimes if there's no response content we don't need to JSON-load it.
        if not response.content:
            return response.content
        if json_response:
            return response.json()

        return response.content


class TokenAuth(AuthBase):

    def __init__(self, token):
        self.token = token

    def __call__(self, r):
        r.headers['Authorization'] = 'Bearer {}'.format(self.token)
        return r


class _AuthApiClient(VerifiedRequester):
    """
    DEPRECATED Class to handle API calls, uses username and password for authentication
    in each request.
    """

    def __init__(self, host, username, password, verify_ssl=None):
        super(_AuthApiClient, self).__init__(verify_ssl)

        self.host = clean_host_name(host)
        self.username = username
        self.password = password

    def get_extra_request_params(self):
        """ Override to add auth """
        auth = HTTPBasicAuth(self.username, self.password)
        return {'auth': auth}


class SessionApiClient(VerifiedRequester):
    """
    Class to handle API calls, uses session for authentication
    in each request.

    This is safer than the AuthApiClient for server-based architectures that
    need to keep a client in the database since we do not want to store a
    username and password.

    """

    def __init__(self, host, username=None, password=None, session_id=None, token=None,
                 model_encoder=None, verify_ssl=None):
        super(SessionApiClient, self).__init__(verify_ssl)

        self.host = clean_host_name(host)
        self.model_encoder = model_encoder
        if token is not None:
            self.token = token
            self.session_id = None
        elif session_id is None and (username and password):
            self.token = None
            self.session_id = self.login(username, password)
        elif session_id is not None:
            self.token = None
            self.session_id = session_id
        else:
            raise TypeError(
                "{mytype} requires one of 'token', 'session_id', or both 'username' and 'password'"
                    .format(mytype=type(self).__name__))

    def get_extra_request_params(self):
        """ Override to add session in cookie """
        params = {}
        if self.session_id:
            params['cookies'] = {'JSESSIONID': self.session_id}
        if self.token:
            params['auth'] = TokenAuth(self.token)
        return params

    def login(self, username, password):
        """
        Login to LiveDesign and get the session ID

        Note: We cannot use the self.post since invoke_request requires the
        session ID... which we are trying to get here.
        """
        url = '%s%s/%s' % (self.host, AUTH_PATH, 'login')
        data = {'username': username, 'password': password}

        logger.info("About to request %s" % url)
        response = requests.post(url, data=data, allow_redirects=False, verify=self.verify_ssl_param)
        if response.status_code == 301:
            raise RequestException('You must use HTTPS', response=response)
        response.raise_for_status()

        return response.cookies.get('JSESSIONID')
