#
"""
A plugin that allows you to use SAML2 SSO as authentication
and SAML2 attribute aggregations as metadata collector in your
WSGI application.

"""
from io import StringIO
import logging
import platform
import shelve
import sys
import traceback
from urllib import parse

from paste.httpexceptions import HTTPInternalServerError
from paste.httpexceptions import HTTPNotImplemented
from paste.httpexceptions import HTTPRedirection
from paste.httpexceptions import HTTPSeeOther
from paste.request import construct_url
from paste.request import parse_dict_querystring
from repoze.who.interfaces import IAuthenticator
from repoze.who.interfaces import IChallenger
from repoze.who.interfaces import IIdentifier
from repoze.who.interfaces import IMetadataProvider
from zope.interface import implementer

from saml2 import BINDING_HTTP_POST
from saml2 import BINDING_HTTP_REDIRECT
from saml2 import ecp
from saml2 import element_to_extension_element
from saml2 import xmldsig as ds
from saml2.client import Saml2Client
from saml2.client_base import ECP_SERVICE
from saml2.client_base import MIME_PAOS
from saml2.config import config_factory
from saml2.extension.pefim import SPCertEnc
from saml2.httputil import SeeOther
from saml2.httputil import getpath
from saml2.ident import code
from saml2.ident import decode
from saml2.profile import paos
from saml2.s_utils import sid
from saml2.samlp import Extensions


# from saml2.population import Population
# from saml2.attribute_resolver import AttributeResolver

logger = logging.getLogger(__name__)

PAOS_HEADER_INFO = f'ver="{paos.NAMESPACE}";"{ECP_SERVICE}"'


def construct_came_from(environ):
    """The URL that the user used when the process where interupted
    for single-sign-on processing."""

    came_from = environ.get("PATH_INFO")
    qstr = environ.get("QUERY_STRING", "")
    if qstr:
        came_from += f"?{qstr}"
    return came_from


def exception_trace(tag, exc, log):
    message = traceback.format_exception(*sys.exc_info())
    log.error(f"[{tag}] ExcList: {''.join(message)}")
    log.error(f"[{tag}] Exception: {exc}")


class ECP_response:
    code = 200
    title = "OK"

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

    # noinspection PyUnusedLocal
    def __call__(self, environ, start_response):
        start_response(f"{self.code} {self.title}", [("Content-Type", "text/xml")])
        return [self.content]


@implementer(IChallenger, IIdentifier, IAuthenticator, IMetadataProvider)
class SAML2Plugin:
    def __init__(
        self,
        rememberer_name,
        config,
        saml_client,
        wayf,
        cache,
        sid_store=None,
        discovery="",
        idp_query_param="",
        sid_store_cert=None,
    ):
        self.rememberer_name = rememberer_name
        self.wayf = wayf
        self.saml_client = saml_client
        self.conf = config
        self.cache = cache
        self.discosrv = discovery
        self.idp_query_param = idp_query_param
        self.logout_endpoints = [parse.urlparse(ep).path for ep in config.endpoint("single_logout_service")]
        try:
            self.metadata = self.conf.metadata
        except KeyError:
            self.metadata = None
        if sid_store:
            self.outstanding_queries = shelve.open(sid_store, writeback=True, protocol=2)
        else:
            self.outstanding_queries = {}
        if sid_store_cert:
            self.outstanding_certs = shelve.open(sid_store_cert, writeback=True, protocol=2)
        else:
            self.outstanding_certs = {}

        self.iam = platform.node()

    def _get_rememberer(self, environ):
        rememberer = environ["repoze.who.plugins"][self.rememberer_name]
        return rememberer

    # #### IIdentifier ####
    def remember(self, environ, identity):
        rememberer = self._get_rememberer(environ)
        return rememberer.remember(environ, identity)

    # #### IIdentifier ####
    def forget(self, environ, identity):
        rememberer = self._get_rememberer(environ)
        return rememberer.forget(environ, identity)

    def _get_post(self, environ):
        """
        Get the posted information

        :param environ: A dictionary with environment variables
        """

        body = ""
        try:
            length = int(environ.get("CONTENT_LENGTH", "0"))
        except ValueError:
            length = 0
        if length != 0:
            body = environ["wsgi.input"].read(length)  # get the POST variables
            environ["s2repoze.body"] = body  # store the request body for later
            # use by pysaml2
            environ["wsgi.input"] = StringIO(body)  # restore the request body
            # as a stream so that everything seems untouched

        post = parse.parse_qs(body)  # parse the POST fields into a dict

        logger.debug("identify post: %s", post)

        return post

    def _wayf_redirect(self, came_from):
        sid_ = sid()
        self.outstanding_queries[sid_] = came_from
        logger.info("Redirect to WAYF function: %s", self.wayf)
        return -1, HTTPSeeOther(headers=[("Location", f"{self.wayf}?{sid_}")])

    # noinspection PyUnusedLocal
    def _pick_idp(self, environ, came_from):
        """
        If more than one idp and if none is selected, I have to do wayf or
        disco
        """

        # check headers to see if it's an ECP request
        #        headers = {
        #                    'Accept' : 'text/html; application/vnd.paos+xml',
        #                    'PAOS'   : 'ver="%s";"%s"' % (paos.NAMESPACE,
        # SERVICE)
        #                    }

        _cli = self.saml_client

        logger.info("[_pick_idp] %s", environ)
        if "HTTP_PAOS" in environ:
            if environ["HTTP_PAOS"] == PAOS_HEADER_INFO:
                if MIME_PAOS in environ["HTTP_ACCEPT"]:
                    # Where should I redirect the user to
                    # entityid -> the IdP to use
                    # relay_state -> when back from authentication

                    logger.info("- ECP client detected -")

                    _relay_state = construct_came_from(environ)
                    _entityid = _cli.config.ecp_endpoint(environ["REMOTE_ADDR"])

                    if not _entityid:
                        return -1, HTTPInternalServerError(detail="No IdP to talk to")
                    logger.info("IdP to talk to: %s", _entityid)
                    return ecp.ecp_auth_request(_cli, _entityid, _relay_state)
                else:
                    return -1, HTTPInternalServerError(detail="Faulty Accept header")
            else:
                return -1, HTTPInternalServerError(detail="unknown ECP version")

        idps = self.metadata.with_descriptor("idpsso")

        logger.info("IdP URL: %s", idps)

        idp_entity_id = query = None

        for key in ["s2repoze.body", "QUERY_STRING"]:
            query = environ.get(key)
            if query:
                try:
                    _idp_entity_id = dict(parse.parse_qs(query))[self.idp_query_param][0]
                    if _idp_entity_id in idps:
                        idp_entity_id = _idp_entity_id
                    break
                except KeyError:
                    logger.debug("No IdP entity ID in query: %s", query)

        if idp_entity_id is None:
            if len(idps) == 1:
                # idps is a dictionary
                idp_entity_id = idps.keys()[0]
            elif not len(idps):
                return -1, HTTPInternalServerError(detail="Misconfiguration")
            else:
                idp_entity_id = ""
                logger.info("ENVIRON: %s", environ)

                if self.wayf:
                    if query:
                        try:
                            wayf_selected = dict(parse.parse_qs(query))["wayf_selected"][0]
                        except KeyError:
                            return self._wayf_redirect(came_from)
                        idp_entity_id = wayf_selected
                    else:
                        return self._wayf_redirect(came_from)
                elif self.discosrv:
                    if query:
                        idp_entity_id = _cli.parse_discovery_service_response(query=environ.get("QUERY_STRING"))
                    else:
                        sid_ = sid()
                        self.outstanding_queries[sid_] = came_from
                        logger.debug("Redirect to Discovery Service function")
                        eid = _cli.config.entityid
                        ret = _cli.config.getattr("endpoints", "sp")["discovery_response"][0][0]
                        ret += f"?sid={sid_}"
                        loc = _cli.create_discovery_service_request(self.discosrv, eid, **{"return": ret})
                        return -1, SeeOther(loc)

                else:
                    return -1, HTTPNotImplemented(detail="No WAYF or DJ present!")

        logger.info("Chosen IdP: '%s'", idp_entity_id)
        return 0, idp_entity_id

    # #### IChallenger ####
    # noinspection PyUnusedLocal
    def challenge(self, environ, _status, _app_headers, _forget_headers):
        _cli = self.saml_client

        if "REMOTE_USER" in environ:
            name_id = decode(environ["REMOTE_USER"])

            _cli = self.saml_client

            if "samlsp.logout" in environ:
                responses = _cli.global_logout(name_id)
                return self._handle_logout(responses)

        if "samlsp.pending" in environ:
            response = environ["samlsp.pending"]
            if isinstance(response, HTTPRedirection):
                response.headers += _forget_headers
            return response

        # logger = environ.get('repoze.who.logger','')

        # Which page was accessed to get here
        came_from = construct_came_from(environ)
        environ["myapp.came_from"] = came_from
        logger.debug("[sp.challenge] RelayState >> '%s'", came_from)

        # Am I part of a virtual organization or more than one ?
        try:
            vorg_name = environ["myapp.vo"]
        except KeyError:
            try:
                vorg_name = _cli.vorg._name
            except AttributeError:
                vorg_name = ""

        logger.info("[sp.challenge] VO: %s", vorg_name)

        # If more than one idp and if none is selected, I have to do wayf
        (done, response) = self._pick_idp(environ, came_from)
        # Three cases: -1 something went wrong or Discovery service used
        #               0 I've got an IdP to send a request to
        #               >0 ECP in progress
        logger.debug("_idp_pick returned: %s", done)
        if done == -1:
            return response
        elif done > 0:
            self.outstanding_queries[done] = came_from
            return ECP_response(response)
        else:
            entity_id = response
            logger.info("[sp.challenge] entity_id: %s", entity_id)
            # Do the AuthnRequest
            _binding = BINDING_HTTP_REDIRECT
            try:
                srvs = _cli.metadata.single_sign_on_service(entity_id, _binding)
                logger.debug("srvs: %s", srvs)
                dest = srvs[0]["location"]
                logger.debug("destination: %s", dest)

                extensions = None
                cert = None

                if _cli.config.generate_cert_func is not None:
                    cert_str, req_key_str = _cli.config.generate_cert_func()
                    cert = {"cert": cert_str, "key": req_key_str}
                    spcertenc = SPCertEnc(x509_data=ds.X509Data(x509_certificate=ds.X509Certificate(text=cert_str)))
                    extensions = Extensions(extension_elements=[element_to_extension_element(spcertenc)])

                if _cli.authn_requests_signed:
                    _sid = sid()
                    req_id, msg_str = _cli.create_authn_request(
                        dest,
                        vorg=vorg_name,
                        sign=_cli.authn_requests_signed,
                        message_id=_sid,
                        extensions=extensions,
                    )
                    _sid = req_id
                else:
                    req_id, req = _cli.create_authn_request(
                        dest,
                        vorg=vorg_name,
                        sign=False,
                        extensions=extensions,
                    )
                    msg_str = f"{req}"
                    _sid = req_id

                if cert is not None:
                    self.outstanding_certs[_sid] = cert

                ht_args = _cli.apply_binding(
                    _binding,
                    msg_str,
                    destination=dest,
                    relay_state=came_from,
                    sign=_cli.authn_requests_signed,
                )

                logger.debug("ht_args: %s", ht_args)
            except Exception as exc:
                logger.exception(f"Failed to construct the AuthnRequest: {str(exc)}")
                raise Exception(f"Failed to construct the AuthnRequest: {exc}")

            try:
                path_info = environ.get("PATH_INFO")
                ret = _cli.config.getattr("endpoints", "sp")["discovery_response"][0][0]
                if path_info in ret and ret.split(path_info)[1] == "":
                    query = parse.parse_qs(environ["QUERY_STRING"])
                    result_sid = query["sid"][0]
                    came_from = self.outstanding_queries[result_sid]
            except Exception:
                pass
            # remember the request
            self.outstanding_queries[_sid] = came_from

            if not ht_args["data"] and ht_args["headers"][0][0] == "Location":
                logger.debug("redirect to: %s", ht_args["headers"][0][1])
                return HTTPSeeOther(headers=ht_args["headers"])
            else:
                return ht_args["data"]

    def _construct_identity(self, session_info):
        cni = code(session_info["name_id"])
        identity = {
            "login": cni,
            "password": "",
            "repoze.who.userid": cni,
            "user": session_info["ava"],
        }
        logger.debug("Identity: %s", identity)

        return identity

    def _eval_authn_response(self, environ, post, binding=BINDING_HTTP_POST):
        logger.info("Got AuthN response, checking..")
        logger.info("Outstanding: %s", self.outstanding_queries)

        try:
            # Evaluate the response, returns a AuthnResponse instance
            try:
                authresp = self.saml_client.parse_authn_request_response(
                    post["SAMLResponse"][0],
                    binding,
                    self.outstanding_queries,
                    self.outstanding_certs,
                )

            except Exception as excp:
                logger.exception(f"Exception: {excp}")
                raise

            session_info = authresp.session_info()
        except TypeError as excp:
            logger.exception(f"Exception: {excp}")
            return None

        if session_info["came_from"]:
            logger.debug("came_from << %s", session_info["came_from"])
            try:
                path, query = session_info["came_from"].split("?")
                environ["PATH_INFO"] = path
                environ["QUERY_STRING"] = query
            except ValueError:
                environ["PATH_INFO"] = session_info["came_from"]

        logger.info("Session_info: %s", session_info)
        return session_info

    def do_ecp_response(self, body, environ):
        response, _relay_state = ecp.handle_ecp_authn_response(self.saml_client, body)

        environ["s2repoze.relay_state"] = _relay_state.text
        session_info = response.session_info()
        logger.info("Session_info: %s", session_info)

        return session_info

    # #### IIdentifier ####
    def identify(self, environ):
        """
        Tries to do the identification
        """
        # logger = environ.get('repoze.who.logger', '')

        session_info = None

        uri = environ.get("REQUEST_URI", construct_url(environ))
        query = parse_dict_querystring(environ)

        logger.debug("[sp.identify] uri: %s", uri)
        logger.debug("[sp.identify] query: %s", query)

        is_request = "SAMLRequest" in query
        is_response = "SAMLResponse" in query
        has_content_length = environ.get("CONTENT_LENGTH")

        if not has_content_length and not is_request and not is_response:
            logger.debug("[identify] get or empty post")
            return None

        if is_request or is_response:
            post = query
            binding = BINDING_HTTP_REDIRECT
        else:
            post = self._get_post(environ)
            binding = BINDING_HTTP_POST

        try:
            logger.debug("[sp.identify] post keys: %s", post.keys())
        except (TypeError, IndexError):
            pass

        try:
            path = getpath(environ)
            logout = False
            if path in self.logout_endpoints:
                logout = True

            if logout and is_request:
                print("logout request received")
                if binding == BINDING_HTTP_REDIRECT:
                    saml_request = post["SAMLRequest"]
                else:
                    saml_request = post["SAMLRequest"][0]
                try:
                    response = self.saml_client.handle_logout_request(
                        saml_request, self.saml_client.users.subjects()[0], binding
                    )
                    environ["samlsp.pending"] = self._handle_logout(response)
                    return {}
                except Exception:
                    import traceback

                    traceback.print_exc()
            elif not is_response:
                logger.info("[sp.identify] --- NOT SAMLResponse ---")
                # Not for me, put the post back where next in line can find it
                environ["post.fieldstorage"] = post
                # restore wsgi.input incase that is needed
                # only of s2repoze.body is present
                if "s2repoze.body" in environ:
                    environ["wsgi.input"] = StringIO(environ["s2repoze.body"])
                return {}
            else:
                logger.info("[sp.identify] --- SAMLResponse ---")
                # check for SAML2 authN response
                try:
                    if logout:
                        response = self.saml_client.parse_logout_request_response(post["SAMLResponse"][0], binding)
                        if response:
                            action = self.saml_client.handle_logout_response(response)

                            if type(action) == dict:
                                request = self._handle_logout(action)
                            else:
                                request = HTTPSeeOther(headers=[("Location", "/")])
                            if request:
                                environ["samlsp.pending"] = request
                            return {}
                    else:
                        session_info = self._eval_authn_response(environ, post, binding=binding)
                except Exception as err:
                    environ["s2repoze.saml_error"] = err
                    return {}
        except TypeError as exc:
            # might be a ECP (=SOAP) response
            body = environ.get("s2repoze.body", None)
            if body:
                # might be a ECP response
                try:
                    session_info = self.do_ecp_response(body, environ)
                except Exception as err:
                    environ["post.fieldstorage"] = post
                    environ["s2repoze.saml_error"] = err
                    return {}
            else:
                exception_trace("sp.identity", exc, logger)
                environ["post.fieldstorage"] = post
                return {}

        if session_info:
            environ["s2repoze.sessioninfo"] = session_info
            identity_info = self._construct_identity(session_info)
        else:
            identity_info = None

        return identity_info

    # IMetadataProvider
    def add_metadata(self, environ, identity):
        """Add information to the knowledge I have about the user"""
        name_id = identity["repoze.who.userid"]
        if isinstance(name_id, str):
            try:
                # Make sure that userids authenticated by another plugin
                # don't cause problems here.
                name_id = decode(name_id)
            except Exception:
                pass

        _cli = self.saml_client
        logger.debug("[add_metadata] for %s", name_id)
        try:
            logger.debug("Issuers: %s", _cli.users.sources(name_id))
        except KeyError:
            pass

        if "user" not in identity:
            identity["user"] = {}
        try:
            (ava, _) = _cli.users.get_identity(name_id)
            # now = time.gmtime()
            logger.debug("[add_metadata] adds: %s", ava)
            identity["user"].update(ava)
        except KeyError:
            pass

        if "pysaml2_vo_expanded" not in identity and _cli.vorg:
            # is this a Virtual Organization situation
            for vo in _cli.vorg.values():
                try:
                    if vo.do_aggregation(name_id):
                        # Get the extended identity
                        identity["user"] = _cli.users.get_identity(name_id)[0]
                        # Only do this once, mark that the identity has been
                        # expanded
                        identity["pysaml2_vo_expanded"] = 1
                except KeyError:
                    logger.exception("Failed to do attribute aggregation, " "missing common attribute")
        logger.debug("[add_metadata] returns: %s", dict(identity))

        if not identity["user"]:
            # remove cookie and demand re-authentication
            pass

    # used 2 times : one to get the ticket, the other to validate it
    @staticmethod
    def _service_url(environ, qstr=None):
        if qstr is not None:
            url = construct_url(environ, querystring=qstr)
        else:
            url = construct_url(environ)
        return url

    # #### IAuthenticatorPlugin ####
    # noinspection PyUnusedLocal
    def authenticate(self, environ, identity=None):
        if identity:
            if (
                identity.get("user")
                and environ.get("s2repoze.sessioninfo")
                and identity.get("user") == environ.get("s2repoze.sessioninfo").get("ava")
            ):
                return identity.get("login")
            tktuser = identity.get("repoze.who.plugins.auth_tkt.userid", None)
            if tktuser and self.saml_client.is_logged_in(decode(tktuser)):
                return tktuser
            return None
        else:
            return None

    @staticmethod
    def _handle_logout(responses):
        if "data" in responses:
            ht_args = responses
        else:
            ht_args = responses[responses.keys()[0]][1]
        if not ht_args["data"] and ht_args["headers"][0][0] == "Location":
            logger.debug("redirect to: %s", ht_args["headers"][0][1])
            return HTTPSeeOther(headers=ht_args["headers"])
        else:
            return ht_args["data"]


def make_plugin(
    remember_name=None,  # plugin for remember
    cache="",  # cache
    # Which virtual organization to support
    virtual_organization="",
    saml_conf="",
    wayf="",
    sid_store="",
    identity_cache="",
    discovery="",
    idp_query_param="",
):
    if saml_conf == "":
        raise ValueError("must include saml_conf in configuration")

    if remember_name is None:
        raise ValueError("must include remember_name in configuration")

    conf = config_factory("sp", saml_conf)

    scl = Saml2Client(
        config=conf,
        identity_cache=identity_cache,
        virtual_organization=virtual_organization,
    )

    plugin = SAML2Plugin(remember_name, conf, scl, wayf, cache, sid_store, discovery, idp_query_param)
    return plugin
