import copy
import json
import logging
import os
import ssl
import sys
import tempfile
import subprocess

import requests
from requests import Session

from ldclient.base import version_str_as_tuple
from requests.auth import AuthBase, HTTPBasicAuth
from requests.exceptions import HTTPError, RequestException, SSLError

from .paths import ABOUT_PATH, AUTH_PATH, GENERATE_ACCESS_TOKEN_PATH

from ..exceptions import SchrodingerException

logger = logging.getLogger(__name__)

SUPPORTED_SERVER_VERSION = "2022.1"


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
    if host_name.endswith("/livedesign"):
        host_name = host_name + "/api"
    if not host_name.endswith("/livedesign/api"):
        host_name = host_name + "/livedesign/api"
    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
    _tmpdir = None

    def setup_verification(self, verify_ssl=None, ping_url=None):
        """
        Tries very hard to find an ssl certificate that will work, and records it for the rest of
        the VerifiedRequester's lifetime

        :type verify_ssl: :class:`str` or `bool` or `NoneType`
        :param verify_ssl: if anything other than None, gets passed into every request as the `verify` argument.
                           If none, then a series of methods are tried to attempt to find a valid certificate for
                           communicating with the server

        :type ping_url: :class:`str`
        :param ping_url: The URL to attempt to connect to LD with to verify that the SSL certificates are valid.
                         The request doesn't need to return a 200, it only needs to be able to connect.
        """
        if verify_ssl is not None:
            self.verify_ssl_param = verify_ssl
            return

        if self.host.startswith('http://'):
            self.verify_ssl_param = None
            return

        ssl_candidates_attempted = []
        for verify_ssl_candidate, candidate_desc in self.generate_verify_ssl_candidates(
        ):
            try:
                requests.get(ping_url, verify=verify_ssl_candidate)
                self.verify_ssl_param = verify_ssl_candidate
                return
            except SSLError as e:
                candidate_pretty = "{} ({})".format(verify_ssl_candidate,
                                                    candidate_desc)
                ssl_candidates_attempted.append(str(candidate_pretty))
                if logger.isEnabledFor(logging.INFO):
                    message = "the attempt to use verify={} failed with {}, trying the next one".format(
                        candidate_pretty, e)
                    logger.info(message)
                last_exception = e

        message = (
            "Your LiveDesign instance ({}) is using SSL. ".format(self.host) +
            "LDClient attempted to find a valid SSL certificate to communicate with, and failed. "
            + "Attempted settings and files were:\n" +
            '\n'.join(ssl_candidates_attempted) +
            "\nPlease install a certificate in one of these locations")

        logger.error(message)
        raise SchrodingerException(message) from last_exception

    def generate_verify_ssl_candidates(self):
        """
        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: Tuple of (str containing the path to a cert, or True, or
                  None if none of the potential bundles are found) and
                  a description of the setting
        """
        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):
                yield (path, 'System Path')
        yield (True, 'Forced on')
        yield (self.generate_system_certs(), 'Compiled from system store')
        if sys.platform == 'darwin':
            yield (self.generate_darwin_keychain_certs(),
                   'Compiled from darwin system store')
        yield (None, 'Default from the python requests library')

    # Technically, this is only for windows, but the method it uses should work on any system, so there's no harm in trying
    # uses python's SSL bindings to load the system certificates, then writes all of them out to a new .pem file, and returns
    # that value to the caller to try. On windows, this explicitly uses C code to grab the certificates from system paths
    def generate_system_certs(self):
        if self._tmpdir is None:
            self._tmpdir = tempfile.mkdtemp()
        # ctx is an SSLContext. You can use them to create SSL-enabled sockets, for communicating with servers.
        # requests doesn't provide any way to just give urllib3 a SSLContext (although it does use them internally),
        # so we'd have to create a HTPAdapter, and pass generate + pass in the SSLContext from there. It'll take a bit
        # of wrangling to figure out, and won't aleviate our needs for writing .pem files to disk, we'd still need to do
        # that on MacOS.
        ctx = ssl.create_default_context()
        # load_default_certs was added in 3.4: https://docs.python.org/3/library/ssl.html#ssl.SSLContext.load_default_certs
        # so, we don't have a *great* way of supporting this on older pythons (although a quick test shows that it exists on python2.7)
        ctx.load_default_certs()
        result = os.path.join(self._tmpdir, "system.crt")
        with open(result, 'w') as outf:
            # returns a list of bytes objects, encoded in the DER format. if binary_form=False (the default),
            # it instead returns dicts that have information about the certificate, but apparently not the certificate itself
            for der in ctx.get_ca_certs(binary_form=True):
                # convert it to PEM (which appears to be what requests wants to load), and outputs that into a file
                outf.write(ssl.DER_cert_to_PEM_cert(der))
        return result

    def generate_darwin_keychain_certs(self):
        # Uses the MacOS-provided program "security" to load the certificates out of the MacOS keychain.
        # There's an API for it, but it would require python bindings to be written, and after a package exists, we'd
        # need some way of installing it. ldclient isn't always used in a virtualenv, so that could get tricky.
        if self._tmpdir is None:
            self._tmpdir = tempfile.mkdtemp()
        # security has a way of listing these paths, but it outputs something else instead of the second one.
        # it looks like a per-user path.
        # security list-keychains
        # "/Users/mrosenbe/Library/Keychains/login.keychain-db"
        # "/Library/Keychains/System.keychain"
        # I assume that the first one only includes things like stored passwords, not SSL certificates
        keychain_paths = [
            '/Library/Keychains/System.keychain',
            '/System/Library/Keychains/SystemRootCertificates.keychain'
        ]
        result = os.path.join(self._tmpdir, "keychain.crt")
        with open(result, "w") as outf:
            # the older 'call' is used for compatibility with as many versions as possible.
            subprocess.call(
                ["/usr/bin/security", "find-certificate", "-a", "-p"] +
                keychain_paths,
                stdout=outf)
        return result

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

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

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

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

    def patch(self, service_path, path, **kwargs):
        return self.invoke_request(service_path,
                                   path + "/patch",
                                   http_method=self.session.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 (
                'livedesign_release_candidate_git_branch' not in response):
            raise RequestException(
                'Live Design server returned an inappropriate response '
                'to a version request: {!r}'.format(response))
        ld_version = response['livedesign_release_candidate_git_branch']
        # If we have the correct version just return
        if tuple([int(v) for v in ld_version.split('.')[:2]
                 ]) == version_str_as_tuple(SUPPORTED_SERVER_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 json_response and '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)
        # refetch access token if the access token currently present is expired
        if (response.status_code == 401 and hasattr(self, 'refresh_token') and
                self.refresh_token is not None):
            self.access_token = self.get_access_token(self.refresh_token)
            request_kwargs = self.get_extra_request_params()
            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

    def get_access_token(self, refresh_token):
        """
        Get access_token using refresh_token
        """
        url = '%s%s' % (self.host, GENERATE_ACCESS_TOKEN_PATH)
        logger.info("Fetching access token from refresh token")
        response = requests.get(url,
                                auth=TokenAuth(refresh_token),
                                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 VerifiedRequester.decode_to_string(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,
                 refresh_token=None,
                 model_encoder=None,
                 verify_ssl=None):
        super(SessionApiClient, self).__init__()
        self.host = clean_host_name(host)
        self.setup_verification(
            verify_ssl, self.host + AUTH_PATH + "/non_existent_endpoint")
        self.model_encoder = model_encoder

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

        self._verify_version()

    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)
        if self.refresh_token:
            params['auth'] = TokenAuth(self.access_token)
        return params

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

        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 = self.session.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')
