import base64
import datetime

import pytz
import requests
import zeep
from OpenSSL import crypto
from OpenSSL.crypto import X509, FILETYPE_ASN1
from zeep.transports import Transport


# TODO try better to avoid monkey patching like this!, e.g. by creating a subclass of OpenSSL.crypto.X509. See
#  https://stackoverflow.com/q/61175330/320594. Consider the Ruby version where the extensions to the OpenSSL module
#  classes do not affect the calling user environment.
def monkey_patch_OpenSSL_crypto_X509():
    def hex2(n):
        x = "%x" % (n,)
        return ("0" * (len(x) % 2)) + x

    def x509_get_serial_number_hex(self):
        return hex2(self.get_serial_number()).upper()

    def x509_name_to_str(x509_name):
        # TODO ensure that we are getting the DN string in the same format generated by the Ruby version. Try to use
        #  a standard API for this instead of writing it by hand.
        x509_name_as_str = ""
        first = True
        for name, value in x509_name.get_components():
            if first:
                first = False
            else:
                x509_name_as_str += ","
            # NOTE that depending on 'EjbcaClient.escape_dn_attr_value' creates some sort of cyclic dependency.
            x509_name_as_str += "{0:s}={1:s}".format(name.decode(), Client.escape_dn_attr_value(value.decode()))
        return x509_name_as_str

    def x509_get_issuer_str(self):
        return x509_name_to_str(self.get_issuer())

    def x509_get_subject_str(self):
        return x509_name_to_str(self.get_subject())

    def asn1time_to_datetime(asn1time):
        asn1time = asn1time.decode("utf-8")
        # TODO confirm if this date is guaranteed to always come in UTC.
        datetime_obj = datetime.datetime.strptime(asn1time, "%Y%m%d%H%M%SZ")
        # Localizing to UTC for clarity.
        datetime_obj = pytz.utc.localize(datetime_obj)
        return datetime_obj

    def x509_get_notBefore_datetime(self):
        return asn1time_to_datetime(self.get_notBefore())

    def x509_get_notAfter_datetime(self):
        return asn1time_to_datetime(self.get_notAfter())

    setattr(X509, "get_serial_number_hex", x509_get_serial_number_hex)
    setattr(X509, "get_notBefore_datetime", x509_get_notBefore_datetime)
    setattr(X509, "get_notAfter_datetime", x509_get_notAfter_datetime)
    setattr(X509, "get_issuer_str", x509_get_issuer_str)
    setattr(X509, "get_subject_str", x509_get_subject_str)


monkey_patch_OpenSSL_crypto_X509()


class Client(object):
    STATUS_NEW = 10
    TOKEN_TYPE_P12 = "P12"
    REVOCATION_REASON_UNSPECIFIED = 0

    def __init__(self, wsdl_url, ws_trusted_anchors, ws_client_certificate, ws_client_key):
        session = requests.Session()
        # TODO check if it is possible to use a password protected key.
        session.cert = ws_client_certificate, ws_client_key
        # TODO determine if 'ws_trusted_anchors' are used in addition or in place of the default system trusted
        #  anchors (if any).
        session.verify = ws_trusted_anchors
        self.zeep_client = zeep.Client(wsdl_url, transport=(Transport(session=session)))

    @classmethod
    def escape_dn_attr_value(cls, val):
        return val.replace(",", "\\,")

    def request_pfx(self, ca_name, cert_profile, ee_profile, ejbca_username, email_address, subject_dn,
                    subject_alt_name, validity_days, pfx_password):
        end_user = self.__create_end_user(ca_name, cert_profile, ee_profile, ejbca_username, pfx_password,
                                          email_address, subject_dn, subject_alt_name, validity_days)
        self.zeep_client.service.editUser(end_user)
        ws_resp = self.zeep_client.service.pkcs12Req(ejbca_username, pfx_password, None, "2048", "RSA")
        pfx_bytes = base64.decodebytes(ws_resp.keystoreData)
        pkcs12 = crypto.load_pkcs12(pfx_bytes, pfx_password)
        # TODO if needed, reconstruct the PFX setting its friendly name as in the Ruby version.
        return {"pfx": pfx_bytes, "cert": (pkcs12.get_certificate())}

    def revoke_cert(self, cert):
        issuer_dn = cert.get_issuer_str()
        serial_number = cert.get_serial_number_hex()
        self.zeep_client.service.revokeCert(issuer_dn, serial_number, self.REVOCATION_REASON_UNSPECIFIED)

    def get_revocation_status(self, cert):
        issuer_dn = cert.get_issuer_str()
        serial_number = cert.get_serial_number_hex()
        revocation_status = self.zeep_client.service.checkRevokationStatus(issuer_dn,
                                                                           serial_number)
        # TODO evaluate to update 'revocation_status.revocationDate' to UTC as it has been observed that it can
        #  contain a timezone (e.g. '2020-04-14 18:10:43.955000-05:00') and it can be confusing when compared to the
        #  return of functions like 'x509_get_notBefore_datetime'.
        if revocation_status is None:
            raise ValueError("Certificate with serial number " + serial_number + " doesn't exists for " + issuer_dn)
        if revocation_status.reason != -1:
            return revocation_status

    def get_all_certs(self, ejbca_username):
        certs = self.zeep_client.service.findCerts(ejbca_username, False)
        # TODO instead of fully loading in memory the list to be returned, create some sort of iterator as it is
        #  being done in the Ruby version.
        ret = []
        for cert in certs:
            cert_as_der = base64.decodebytes(cert.certificateData)
            ret.append(crypto.load_certificate(FILETYPE_ASN1, cert_as_der))
        return ret

    def __create_end_user(self, ca_name, cert_profile, ee_profile, ejbca_username, password, email_address, subject_dn,
                          subject_alt_name, validity_days):
        # TODO compare with using a dictionary instead of a XSD type. See
        #  https://python-zeep.readthedocs.io/en/master/datastructures.html#creating-objects.
        end_user_type = self.zeep_client.get_type("ns0:userDataVOWS")
        # TODO check why does zeep (in contrast to Ruby's Savon) require to provide values for these attributes which
        #  apparently haven't a default value in the WSDL.
        end_user = end_user_type(clearPwd=False, keyRecoverable=False, sendNotification=False)
        end_user.username = ejbca_username
        end_user.password = password
        end_user.status = self.STATUS_NEW
        end_user.tokenType = self.TOKEN_TYPE_P12
        end_user.email = email_address
        end_user.subjectDN = subject_dn
        end_user.subjectAltName = subject_alt_name
        end_user.caName = ca_name
        end_user.certificateProfileName = cert_profile
        end_user.endEntityProfileName = ee_profile
        end_user.extendedInformation = [{"name": "customdata_ENDTIME", "value": ("{}:0:0".format(validity_days))}]
        return end_user
