import os
import re
import contextlib
import tempfile
import shutil
import glob
import logging

import gpg
import gpg.errors
import gpg.constants.sig.mode

import mini_buildd.config
import mini_buildd.misc
import mini_buildd.call

LOG = logging.getLogger(__name__)


class Colons():
    """
    Provide a colon->name mapping for the gpg script-parsable '--with-colons' output.

    See /usr/share/doc/gnupg/DETAILS.gz.
    """

    def __init__(self, colons_line):
        self._colons = colons_line.split(":")

    def __str__(self):
        return f"{self.type}: {self.key_id}: {self.user_id}"

    def _get(self, index):
        return mini_buildd.misc.list_get(self._colons, index, "")

    @property
    def type(self):
        return self._get(0)

    @property
    def key_id(self):
        return self._get(4)

    @property
    def creation_date(self):
        return self._get(5)

    @property
    def expiration_date(self):
        return self._get(6)

    @property
    def user_id(self):
        """Fingerprint for 'fpr' type."""
        return self._get(9)


class BaseGnuPG():
    @classmethod
    def get_flavor(cls):
        """
        Ugly-parse GPG binary flavor(=major.minor).

        "1.4" ("classic"), "2.0" ("stable") or "2.1" ("modern") from
        "gpg --version" output (like "gpg (GnuPG) 2.1.14"). Don't fail
        but return "unknown" if anything nasty happens.

        """
        try:
            version_info = mini_buildd.call.Call(["gpg", "--version"]).check().stdout.splitlines()
            version_line = version_info[0].split(" ")
            return version_line[2][0:3]
        except BaseException as e:
            LOG.warning(f"Can't parse GPG flavor: {e}")
            return "unknown"

    def __init__(self, home):
        self.flavor = self.get_flavor()
        self.home = home
        self.gpg_cmd = ["gpg",
                        "--homedir", home,
                        "--display-charset", mini_buildd.config.CHAR_ENCODING,
                        "--batch"]
        self.context = gpg.Context(home_dir=self.home)

        LOG.info(f"GPG {self.flavor}: {self.gpg_cmd}")

    def gen_secret_key(self, template):
        flavor_additions = {"2.1": "\n%no-protection\n",
                            "2.2": "\n%no-protection\n"}

        with tempfile.TemporaryFile() as t:
            t.write(template.encode(mini_buildd.config.CHAR_ENCODING))
            t.write(flavor_additions.get(self.flavor, "").encode(mini_buildd.config.CHAR_ENCODING))
            t.seek(0)
            mini_buildd.call.Call(self.gpg_cmd + ["--gen-key"], stdin=t).check()

    def export(self, dest_file, identity=""):
        with mini_buildd.fopen(dest_file, "w") as f:
            mini_buildd.call.Call(self.gpg_cmd + ["--export"] + ([identity] if identity else []), stdout=f).check()

    def get_pub_key(self, identity):
        return mini_buildd.call.Call(self.gpg_cmd + ["--armor", "--export", identity]).check().stdout

    def get_colons(self, type_regex, list_arg="--list-public-keys", identity=None):
        for line in mini_buildd.call.Call(self.gpg_cmd + [list_arg, "--with-colons", "--fixed-list-mode", "--with-fingerprint"] + ([] if identity is None else [identity])).check().stdout.splitlines():
            colons = Colons(line)
            if re.match(type_regex, colons.type):
                yield colons

    def get_pub_keys_infos(self):
        infos = []
        info = None
        for colons in [Colons(line) for line in mini_buildd.call.Call(self.gpg_cmd + ["--list-public-keys", "--with-colons", "--fixed-list-mode", "--with-fingerprint"]).check().stdout.splitlines()]:
            if colons.type == "pub":  # Start new info section
                info = {"key": colons.key_id, "created": colons.creation_date, "expires": colons.expiration_date, "fingerprints": [], "subkeys": []}
                infos.append(info)
            elif info is not None:
                if colons.type == "sub":
                    info["subkeys"].append(colons.key_id)
                elif colons.type == "uid":
                    info["user"] = colons.user_id
                elif colons.type == "fpr":
                    info["fingerprints"].append(colons.user_id)
        return infos

    def get_pub_keys(self):
        result = {}
        for info in self.get_pub_keys_infos():
            for key in [info["key"]] + info["fingerprints"] + info["subkeys"]:
                result[key] = info
        return result

    def get_first_sec_colon(self, type_regex):
        try:
            return next(self.get_colons(type_regex, list_arg="--list-secret-keys"))
        except StopIteration:
            return Colons("")

    def get_first_sec_key(self):
        return self.get_first_sec_colon("^sec$").key_id

    def get_first_sec_key_fingerprint(self):
        return self.get_first_sec_colon("^fpr$").user_id

    def get_first_sec_key_user_id(self):
        return self.get_first_sec_colon("^uid$").user_id

    def add_pub_key(self, key):
        with tempfile.TemporaryFile() as t:
            t.write(key.encode(mini_buildd.config.CHAR_ENCODING))
            t.seek(0)
            mini_buildd.call.Call(self.gpg_cmd + ["--import"], stdin=t).check()

    def add_keyring(self, keyring):
        if os.path.exists(keyring):
            self.gpg_cmd += ["--keyring", keyring]
        else:
            LOG.warning(f"Skipping non-existing keyring file: {keyring}")

    def verify(self, signature, data=None):
        try:
            mini_buildd.call.Call(self.gpg_cmd + ["--verify", signature] + ([data] if data else [])).check()
        except BaseException as e:
            raise mini_buildd.HTTPBadRequest("GnuPG authorization failed") from e

    @classmethod
    def gpgme_sig2info(cls, signature):
        """Human-readable info combining actual keyid (if subkey, gpgme only provides fpr of subkey) and gpgme reason."""
        try:
            _key, info = PublicKeyCache().keyinfo(signature.fpr)
            return f"{gpg.errors.GpgError(signature.status).code_str}: {info['key']} ({','.join(info['subkeys'])}): {info['user']}"
        except BaseException as e:
            LOG.warning(f"Can't get keyinfo for gpgme signature: {e}")
            return f"{signature}"

    @classmethod
    def log_signatures(cls, signatures, key=None):
        """Log signatures in human-readable info."""
        for s in signatures:
            LOG.info(f"SIGNATURE[{key}]: {cls.gpgme_sig2info(s)}")

    def gpgme_verify(self, signed_message, signature=None):
        try:
            return self.context.verify(signed_message.encode(mini_buildd.config.CHAR_ENCODING), signature)
        except gpg.errors.GpgError as e:
            raise mini_buildd.HTTPUnauthorized(f"GPG verify failed: {e}")

    def verify_release(self, signed_data, signature=None, accept_expired=False, needs_all=False, log_key=None):
        def verify(result):
            accept = [gpg.errors.NO_ERROR] + [gpg.errors.KEY_EXPIRED] if accept_expired else []  # pylint: disable=no-member
            accepted, failed = [], []
            for s in result.signatures:
                (accepted if gpg.errors.GpgError(s.status).code in accept else failed).append(s)

            if not accepted:
                raise mini_buildd.HTTPUnauthorized(f"GPG verify failed (no accepted signature found): {[self.gpgme_sig2info(s) for s in result.signatures]}")
            if needs_all and failed:
                raise mini_buildd.HTTPUnauthorized(f"GPG verify failed (needs all, but some signatures not accepted): {[self.gpgme_sig2info(s) for s in result.signatures]}")
            return result.signatures

        signed_data.seek(0)
        if signature is not None:
            signature.seek(0)

        try:
            _dat, result = self.context.verify(signed_data, signature)
            self.log_signatures(result.signatures, key=log_key)
            return verify(result)
        except (gpg.errors.BadSignatures, gpg.errors.MissingSignatures) as e:
            return verify(e.result)

    def sign(self, file_name, identity=None):
        # 1st: copy the unsigned file and add an extra new line
        # (Like 'debsign' from devscripts does: dpkg-source <= squeeze will have problems without the newline)
        unsigned_file = file_name + ".asc"
        shutil.copyfile(file_name, unsigned_file)
        with mini_buildd.fopen(unsigned_file, "a") as unsigned:
            unsigned.write("\n")

        # 2nd: Sign the file copy
        signed_file = file_name + ".signed"

        def failed_cleanup():
            if os.path.exists(signed_file):
                os.remove(signed_file)

        # .. error:: **compat** (:debbug:`849551`): Retrying sign call -- workaround for mystery bug
        mini_buildd.call.call_with_retry(self.gpg_cmd
                                         + ["--armor", "--textmode", "--clearsign", "--output", signed_file]
                                         + (["--local-user", identity] if identity else []) + [unsigned_file],
                                         retry_max_tries=5,
                                         retry_sleep=1,
                                         retry_failed_cleanup=failed_cleanup)

        # 3rd: Success, move to orig file and cleanup
        os.rename(signed_file, file_name)
        os.remove(unsigned_file)

    def gpgme_sign(self, message):
        signed, _ = self.context.sign(message.encode(mini_buildd.config.CHAR_ENCODING), mode=gpg.constants.sig.mode.CLEAR)  # pylint: disable=no-member
        return signed.decode(mini_buildd.config.CHAR_ENCODING)


class GnuPG(BaseGnuPG):
    def update(self):
        self.pub_key = self.get_pub_key()
        self.pub_key_id = self.get_first_sec_key()

    def __init__(self, template, fullname, email):
        super().__init__(home=mini_buildd.config.ROUTES["home"].path.join(".gnupg"))
        self.template = f"""{template}
Name-Real: {fullname}
Name-Email: {email}
"""
        self.update()

    def prepare(self):
        if not self.pub_key:
            LOG.info("Generating GnuPG secret key (this might take some time)...")
            self.gen_secret_key(self.template)
            self.update()
        else:
            LOG.info("GnuPG key already prepared...")

    def remove(self):
        if os.path.exists(self.home):
            shutil.rmtree(self.home)
            LOG.info(f"GnuPG setup removed: {self.home}")

    def get_pub_key(self, identity=None):
        return super().get_pub_key("mini-buildd")


class TmpGnuPG(BaseGnuPG, mini_buildd.misc.TmpDir):
    r"""
    Temporary GnuPG. Use with contextlib.closing() to guarantee dir is purged afterwards.

    >>> import contextlib
    >>> # mini_buildd.config.DEBUG.append("keep")  # Enable 'keep' for debugging only
    >>> gnupg_home = tempfile.TemporaryDirectory(prefix="test-home")
    >>> mini_buildd.config.ROUTES = mini_buildd.config.Routes(gnupg_home.name)
    >>> dummy = shutil.copy2("test-data/gpg/secring.gpg", gnupg_home.name)
    >>> dummy = shutil.copy2("test-data/gpg/pubring.gpg", gnupg_home.name)
    >>> gnupg = BaseGnuPG(home=gnupg_home.name)

    >>> gnupg.get_first_sec_colon("sec").type
    'sec'
    >>> gnupg.get_first_sec_key_user_id()
    'Üdo Ümlaut <test@key.org>'
    >>> gnupg.get_first_sec_key()  #doctest: +ELLIPSIS
    'AF95FC80FC40A82E'
    >>> gnupg.get_first_sec_key_fingerprint()  #doctest: +ELLIPSIS
    '4FB13BDD777C046D72D4E7D3AF95FC80FC40A82E'

    >>> with contextlib.closing(mini_buildd.gnupg.TmpGnuPG(tmpdir_options={"prefix": "d17-"})) as tgnupg, tempfile.NamedTemporaryFile(prefix="d0-") as t, tempfile.NamedTemporaryFile(prefix="d1-") as export:
    ...     gnupg.export(export.name)
    ...     dummy = t.write(b"A test file\n")
    ...     t.flush()
    ...     gnupg.sign(file_name=t.name, identity="test@key.org")
    ...     gnupg.verify(t.name)
    ...     pub_key = gnupg.get_pub_key(identity="test@key.org")
    ...     tgnupg.add_pub_key(pub_key)
    ...     tgnupg.verify(t.name)
    >>>
    >>> gnupg_home.cleanup()
    """

    def __init__(self, tmpdir_options=None, **kwargs):
        mini_buildd.misc.TmpDir.__init__(self, **(tmpdir_options if tmpdir_options else {}))
        super().__init__(home=self.tmpdir.name, **kwargs)


class PublicKeyCache(TmpGnuPG, metaclass=mini_buildd.misc.Singleton):
    """
    Public key cache, initially with apt keys installed on the system (usually, from debian-archive-keyring package).
    """

    KEYSERVER_RECV_TIMEOUT = 5  # seconds

    def __init__(self):
        super().__init__(tmpdir_options={"prefix": "gnupg-public-key-cache-"})
        for keyring in glob.glob("/usr/share/keyrings/*-archive-*.gpg"):
            try:
                mini_buildd.call.Call(self.gpg_cmd + ["--import", keyring]).check()
                LOG.info(f"PublicKeyCache: Imported {keyring}")
            except BaseException as e:
                LOG.warning(f"PublicKeyCache: Failed to import {keyring}: {e}")

    @classmethod
    def key2id(cls, key):
        with contextlib.closing(mini_buildd.gnupg.TmpGnuPG()) as g:
            g.add_pub_key(key)
            for colons in g.get_colons(type_regex="^pub$"):
                return colons.key_id
        raise mini_buildd.HTTPBadRequest("Invalid GnuPG key")

    def keyinfo(self, key_id, key=None):
        pub_keys = self.get_pub_keys()
        if key_id not in pub_keys:
            if key:
                # Add key given explicitly
                key_id = self.key2id(key)
                self.add_pub_key(key)
            elif key_id:
                # Import from keyserver
                keyserver = mini_buildd.get_daemon().model.gnupg_keyserver
                if not keyserver:
                    raise Exception(f"Key '{key_id}' not in installed archive keys, and no keyserver configured.")
                mini_buildd.call.call_with_retry(self.gpg_cmd + ["--armor", "--keyserver", keyserver, "--recv-keys", key_id], retry_max_tries=5, retry_sleep=5, timeout=self.KEYSERVER_RECV_TIMEOUT)
                LOG.info(f"PublicKeyCache: '{key_id}' imported from key server '{keyserver}'.")

            # Update pub keys
            pub_keys = self.get_pub_keys()

        key = self.get_pub_key(key_id)
        info = pub_keys[key_id]
        return key, info
