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

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

from mini_buildd import call, config, daemon, util

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 util.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 GpgmeVerifyFailed(util.HTTPUnauthorized):
    @classmethod
    def sig_info(cls, signature):
        """Human-readable info combining actual keyid (if subkey, gpgme only provides fpr of subkey) and gpgme reason"""
        try:
            _key, info = daemon.get().public_key_cache.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("Can't get keyinfo for gpgme signature: %s", e)
            return f"{signature}"

    @classmethod
    def sigs_info(cls, signatures):
        return "Signatures: " + ", ".join([cls.sig_info(s) for s in signatures])

    def __init__(self, detail, signatures):
        self.signatures = signatures if signatures is not None else []
        super().__init__(f"GPGME verify failed: {detail}: {self.sigs_info(self.signatures)}")


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 = 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("Can't parse GPG flavor: %s", e)
            return "unknown"

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

        LOG.debug("GPG %s: %s", 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",
            "2.3": "\n%no-protection\n",
            "2.4": "\n%no-protection\n",
        }

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

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

    def get_pub_key(self, identity):
        return 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 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 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(config.CHAR_ENCODING))
            t.seek(0)
            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("Skipping non-existing keyring file: %s", keyring)

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

    def gpgme_verify(self, signed_message, signature=None):
        try:
            return self.context.verify(signed_message.encode(config.CHAR_ENCODING), signature)
        except gpg.errors.GpgError as e:
            raise GpgmeVerifyFailed(f"{e}", e.results.signatures if e.results is not None else None) from e

    def gpgme_verify_release(self, signed_data, signature=None, accept_expired=False, needs_all=False, name=None):
        def verify(result):
            accept = [gpg.errors.NO_ERROR] + [gpg.errors.KEY_EXPIRED] if accept_expired else []
            accepted, failed = [], []
            for s in result.signatures:
                LOG.info("%s signed with: %s", name, GpgmeVerifyFailed.sig_info(s))  # This helps maintaining setup's predefinded APT keys in dist.py
                (accepted if gpg.errors.GpgError(s.status).code in accept else failed).append(s)

            if not accepted:
                raise GpgmeVerifyFailed("No accepted signature found", result.signatures)
            if needs_all and failed:
                raise GpgmeVerifyFailed("Needs all, but some signatures not accepted", 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)
            return verify(result)
        except (gpg.errors.BadSignatures, gpg.errors.MissingSignatures) as e:
            return verify(e.result)
        except gpg.errors.GpgError as e:
            raise GpgmeVerifyFailed(f"{e}", e.results.signatures if e.results is not None else None) from e

    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 util.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)

        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(config.CHAR_ENCODING), mode=gpg.constants.sig.mode.CLEAR)
        return signed.decode(config.CHAR_ENCODING)


class GnuPG(BaseGnuPG):
    r"""
    GnuPG in "home" from config.ROUTES

    >>> # Test non-interactive key generation
    >>> gnupg_home = tempfile.TemporaryDirectory(prefix="test-home")
    >>> LOG.info(f"Testing GnuPG() in %s", gnupg_home.name)
    >>> config.ROUTES = config.Routes(gnupg_home.name)
    >>> gnupg = GnuPG("Key-Type: RSA\nKey-Length: 4096\nExpire-Date: 0", "y", "z")
    >>> gnupg.prepare()
    """

    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=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.debug("GnuPG key already prepared...")

    def remove(self):
        if os.path.exists(self.home):
            shutil.rmtree(self.home)
            LOG.info("GnuPG setup removed: %s", self.home)

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


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

    >>> import contextlib
    >>> # config.DEBUG.append("keep")  # Enable 'keep' for debugging only
    >>> gnupg_home = tempfile.TemporaryDirectory(prefix="test-home")
    >>> config.ROUTES = 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(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):
        util.TmpDir.__init__(self, **(tmpdir_options if tmpdir_options else {}))
        super().__init__(home=self.tmpdir, **kwargs)


class PublicKeyCache(TmpGnuPG):
    """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:
                call.Call(self.gpg_cmd + ["--import", keyring]).check()
                LOG.debug("PublicKeyCache: Imported %s", keyring)
            except BaseException as e:
                LOG.warning("PublicKeyCache: Failed to import %s: %s", keyring, e)

    @classmethod
    def key2id(cls, key):
        with contextlib.closing(TmpGnuPG()) as g:
            g.add_pub_key(key)
            for colons in g.get_colons(type_regex="^pub$"):
                return colons.key_id
        raise util.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 = daemon.get_model().gnupg_keyserver
                if not keyserver:
                    raise util.HTTPBadRequest(f"Key '{key_id}' not in installed archive keys, and no keyserver configured")
                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.debug("PublicKeyCache: '%s' imported from key server '%s'.", key_id, 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
