import base64
import logging
import warnings
from datetime import datetime, timedelta
from xml.etree import ElementTree

import requests
import requests.adapters
from cryptography.hazmat.backends.openssl.backend import backend
from cryptography.x509 import load_der_x509_certificate
from django.conf import settings as django_settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict

try:
    from django.urls import reverse
except ImportError:  # Django < 1.10
    from django.core.urlresolvers import reverse

logger = logging.getLogger("django_auth_adfs")

AZURE_AD_SERVER = "login.microsoftonline.com"


class ConfigLoadError(Exception):
    pass


class Settings(object):
    def __init__(self):
        # Set defaults
        self.AUDIENCE = None  # Required
        self.BOOLEAN_CLAIM_MAPPING = {}
        self.CA_BUNDLE = True
        self.CLAIM_MAPPING = {}
        self.CLIENT_ID = None  # Required
        self.CLIENT_SECRET = None
        self.CONFIG_RELOAD_INTERVAL = 24  # hours
        self.DISABLE_SSO = False
        self.GROUP_TO_FLAG_MAPPING = {}
        self.GROUPS_CLAIM = "group"
        self.LOGIN_EXEMPT_URLS = []
        self.MIRROR_GROUPS = False
        self.RELYING_PARTY_ID = None  # Required
        self.RETRIES = 3
        self.SERVER = None  # Required
        self.TENANT_ID = None  # Required
        self.TIMEOUT = 5
        self.USERNAME_CLAIM = "winaccountname"

        required_settings = [
            "AUDIENCE",
            "CLIENT_ID",
            "RELYING_PARTY_ID",
            "USERNAME_CLAIM",
        ]

        deprecated_settings = {
            "AUTHORIZE_PATH": "This setting is automatically loaded from ADFS.",
            "ISSUER": "This setting is automatically loaded from ADFS.",
            "LOGIN_REDIRECT_URL": "Instead use the standard Django settings with the same name.",
            "REDIR_URI": "This setting is automatically determined based on the URL configuration of Django.",
            "SIGNING_CERT": "The token signing certificates are automatically loaded from ADFS.",
            "TOKEN_PATH": "This setting is automatically loaded from ADFS.",
        }

        if not hasattr(django_settings, "AUTH_ADFS"):
            msg = "The configuration directive 'AUTH_ADFS' was not found in your Django settings"
            raise ImproperlyConfigured(msg)

        # Handle deprecated settings
        for setting, message in deprecated_settings.items():
            if setting in django_settings.AUTH_ADFS:
                warnings.warn("Setting {} is deprecated and it's value was ignored. {}".format(setting, message),
                              DeprecationWarning)
                del django_settings.AUTH_ADFS[setting]

        if "CERT_MAX_AGE" in django_settings.AUTH_ADFS:
            django_settings.AUTH_ADFS["CONFIG_RELOAD_INTERVAL"] = django_settings.AUTH_ADFS["CERT_MAX_AGE"]
            warnings.warn('Setting CERT_MAX_AGE has been renamed to CONFIG_RELOAD_INTERVAL. The value was copied.',
                          DeprecationWarning)
            del django_settings.AUTH_ADFS["CERT_MAX_AGE"]

        if "GROUP_CLAIM" in django_settings.AUTH_ADFS:
            django_settings.AUTH_ADFS["GROUPS_CLAIM"] = django_settings.AUTH_ADFS["GROUP_CLAIM"]
            warnings.warn('Setting GROUP_CLAIM has been renamed to GROUPS_CLAIM. The value was copied.',
                          DeprecationWarning)
            del django_settings.AUTH_ADFS["GROUP_CLAIM"]

        if "RESOURCE" in django_settings.AUTH_ADFS:
            django_settings.AUTH_ADFS["RELYING_PARTY_ID"] = django_settings.AUTH_ADFS["RESOURCE"]
            del django_settings.AUTH_ADFS["RESOURCE"]

        if "TENANT_ID" in django_settings.AUTH_ADFS:
            # If a tenant ID was set, switch to Azure AD mode
            if "SERVER" in django_settings.AUTH_ADFS:
                raise ImproperlyConfigured("The SERVER cannot be set when TENANT_ID is set.")
            self.SERVER = AZURE_AD_SERVER
            self.USERNAME_CLAIM = "upn"
            self.GROUPS_CLAIM = "groups"
            self.CLAIM_MAPPING = {"first_name": "given_name",
                                  "last_name": "family_name",
                                  "email": "email"}

        # Overwrite defaults with user settings
        for setting, value in django_settings.AUTH_ADFS.items():
            if hasattr(self, setting):
                setattr(self, setting, value)
            else:
                msg = "'{0}' is not a valid configuration directive for django_auth_adfs."
                raise ImproperlyConfigured(msg.format(setting))

        # Validate required settings
        if not self.TENANT_ID and not self.SERVER:
            msg = "Exactly one of the settings TENANT_ID or SERVER must be set"
            raise ImproperlyConfigured(msg)
        elif self.TENANT_ID is None:
            # For on premises ADFS, the tenant ID is set to adfs
            # On AzureAD the adfs part in the URL happens to be replace by the tenant ID.
            self.TENANT_ID = "adfs"

        for setting in required_settings:
            if not getattr(self, setting):
                msg = "django_auth_adfs setting '{0}' has not been set".format(setting)
                raise ImproperlyConfigured(msg)

        # Validate setting conflicts
        usermodel = get_user_model()
        if usermodel.USERNAME_FIELD in self.CLAIM_MAPPING.keys():
            raise ImproperlyConfigured("You cannot set the username field of the user model from "
                                       "the CLAIM_MAPPING setting. Instead use the USERNAME_CLAIM setting.")


class ProviderConfig(object):
    def __init__(self):
        self._config_timestamp = None
        self._mode = None

        self.authorization_endpoint = None
        self.signing_keys = None
        self.token_endpoint = None
        self.end_session_endpoint = None
        self.issuer = None

        self.session = requests.Session()
        adapter = requests.adapters.HTTPAdapter(max_retries=settings.RETRIES)
        self.session.mount('https://', adapter)
        self.session.verify = settings.CA_BUNDLE

    def load_config(self):
        # If loaded data is too old, reload it again
        refresh_time = datetime.now() - timedelta(hours=settings.CONFIG_RELOAD_INTERVAL)
        if self._config_timestamp is None or self._config_timestamp < refresh_time:
            logger.debug("Loading django_auth_adfs ID Provider configuration.")
            try:
                loaded = self._load_openid_config()
                self._mode = "openid_connect"
            except ConfigLoadError:
                loaded = self._load_federation_metadata()
                self._mode = "oauth2"

            if not loaded:
                if self._config_timestamp is None:
                    msg = "Could not load any data from ADFS server. "\
                          "Authentication against ADFS not be possible. "\
                          "Verify your settings and the connection with the ADFS server."
                    logger.critical(msg)
                    raise RuntimeError(msg)
                else:
                    # We got data from the previous time. Log a message, but don't abort.
                    logger.warning("Could not load any data from ADFS server. Keeping previous configurations")
            self._config_timestamp = datetime.now()

            logger.info("django_auth_adfs loaded settings from ADFS server.")
            logger.info("operating mode:         " + self._mode)
            logger.info("authorization endpoint: " + self.authorization_endpoint)
            logger.info("token endpoint:         " + self.token_endpoint)
            logger.info("end session endpoint:   " + self.end_session_endpoint)
            logger.info("issuer:                 " + self.issuer)

    def _load_openid_config(self):
        config_url = "https://{}/{}/.well-known/openid-configuration".format(
            settings.SERVER, settings.TENANT_ID
        )

        try:
            logger.info("Trying to get OpenID Connect config from {}".format(config_url))
            response = self.session.get(config_url, timeout=settings.TIMEOUT)
            response.raise_for_status()
            openid_cfg = response.json()

            response = self.session.get(openid_cfg["jwks_uri"], timeout=settings.TIMEOUT)
            response.raise_for_status()
            signing_certificates = [x["x5c"][0] for x in response.json()["keys"] if x.get("use", "sig") == "sig"]
            #                               ^^^
            # https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41#section-4.7
            # The PKIX certificate containing the key value MUST be the first certificate
        except requests.HTTPError:
            raise ConfigLoadError

        self._load_keys(signing_certificates)
        try:
            self.authorization_endpoint = openid_cfg["authorization_endpoint"]
            self.token_endpoint = openid_cfg["token_endpoint"]
            self.end_session_endpoint = openid_cfg["end_session_endpoint"]
            if settings.TENANT_ID is not 'adfs':
                self.issuer = openid_cfg["issuer"]
            else:
                self.issuer = openid_cfg["access_token_issuer"]
        except KeyError:
            raise ConfigLoadError
        return True

    def _load_federation_metadata(self):
        server_url = "https://{}".format(settings.SERVER)
        base_url = "{}/{}".format(server_url, settings.TENANT_ID)
        if settings.TENANT_ID == "adfs":
            adfs_config_url = server_url + "/FederationMetadata/2007-06/FederationMetadata.xml"
        else:
            adfs_config_url = base_url + "/FederationMetadata/2007-06/FederationMetadata.xml"

        try:
            logger.info("Trying to get ADFS Metadata file {}".format(adfs_config_url))
            response = self.session.get(adfs_config_url, timeout=settings.TIMEOUT)
            response.raise_for_status()
        except requests.HTTPError:
            raise ConfigLoadError

        # Extract token signing certificates
        xml_tree = ElementTree.fromstring(response.content)
        cert_nodes = xml_tree.findall(
            "./{urn:oasis:names:tc:SAML:2.0:metadata}RoleDescriptor"
            "[@{http://www.w3.org/2001/XMLSchema-instance}type='fed:SecurityTokenServiceType']"
            "/{urn:oasis:names:tc:SAML:2.0:metadata}KeyDescriptor[@use='signing']"
            "/{http://www.w3.org/2000/09/xmldsig#}KeyInfo"
            "/{http://www.w3.org/2000/09/xmldsig#}X509Data"
            "/{http://www.w3.org/2000/09/xmldsig#}X509Certificate")
        signing_certificates = [node.text for node in cert_nodes]

        self._load_keys(signing_certificates)
        self.issuer = xml_tree.get("entityID")
        self.authorization_endpoint = base_url + "/oauth2/authorize"
        self.token_endpoint = base_url + "/oauth2/token"
        self.end_session_endpoint = base_url + "/ls/?wa=wsignout1.0"
        return True

    def _load_keys(self, certificates):
        new_keys = []
        for cert in certificates:
            logger.debug("Loading public key from certificate: " + cert)
            cert_obj = load_der_x509_certificate(base64.b64decode(cert), backend)
            new_keys.append(cert_obj.public_key())
        self.signing_keys = new_keys

    def redirect_uri(self, request):
        self.load_config()
        return request.build_absolute_uri(reverse("django_auth_adfs:callback"))

    def build_authorization_endpoint(self, request, disable_sso=None):
        """
        This function returns the ADFS authorization URL.

        Args:
            request(django.http.request.HttpRequest): A django Request object
            disable_sso(bool): Whether to disable single sign-on and force the ADFS server to show a login prompt.

        Returns:
            str: The redirect URI

        """
        self.load_config()
        redirect_to = request.GET.get(REDIRECT_FIELD_NAME, None)
        if not redirect_to:
            redirect_to = django_settings.LOGIN_REDIRECT_URL
        redirect_to = base64.urlsafe_b64encode(redirect_to.encode()).decode()
        query = QueryDict(mutable=True)
        query.update({
            "response_type": "code",
            "client_id": settings.CLIENT_ID,
            "resource": settings.RELYING_PARTY_ID,
            "redirect_uri": self.redirect_uri(request),
            "state": redirect_to,
        })
        if self._mode == "openid_connect":
            query["scope"] = "openid"
            if (disable_sso is None and settings.DISABLE_SSO) or disable_sso is True:
                query["prompt"] = "login"

        return "{0}?{1}".format(self.authorization_endpoint, query.urlencode())

    def build_end_session_endpoint(self):
        """
        This function returns the ADFS end session URL to log a user out.

        Returns:
            str: The redirect URI

        """
        self.load_config()
        return self.end_session_endpoint


settings = Settings()
provider_config = ProviderConfig()
