import contextlib
import glob
import logging
import os
import re
import shutil
import time
import urllib.request

import debian.changelog
import debian.deb822
import debian.debian_support

from mini_buildd import call, changes, config, daemon, files, util

LOG = logging.getLogger(__name__)


class Changelog(debian.changelog.Changelog):
    r"""
    Changelog class with some extra functions

    >>> cl = Changelog(util.fopen("test-data/changelog"), max_blocks=100)
    >>> cl.find_first_not("mini-buildd@buildd.intra")
    ('Stephan Sürken <absurd@debian.org>', '1.0.0-2')

    >>> cl = Changelog(util.fopen("test-data/changelog.ported"), max_blocks=100)
    >>> cl.find_first_not("mini-buildd@buildd.intra")
    ('Stephan Sürken <absurd@debian.org>', '1.0.0-2')

    >>> cl = Changelog(util.fopen("test-data/changelog.oneblock"), max_blocks=100)
    >>> cl.find_first_not("mini-buildd@buildd.intra")
    ('Stephan Sürken <absurd@debian.org>', '1.0.1-1~')

    >>> cl = Changelog(util.fopen("test-data/changelog.oneblock.ported"), max_blocks=100)
    >>> cl.find_first_not("mini-buildd@buildd.intra")
    ('Mini Buildd <mini-buildd@buildd.intra>', '1.0.1-1~')
    """

    def find_first_not(self, author):
        """Find (author,version+1) of the first changelog block not by given author"""
        result = (None, None)
        for index, block in enumerate(self._blocks):
            result = (block.author,
                      f"{self._blocks[index + 1].version}" if len(self._blocks) > (index + 1) else f"{block.version}~")
            if author not in block.author:
                break
        return result


class DebianVersion(debian.debian_support.Version):
    @classmethod
    def _sub_rightmost(cls, pattern, repl, string):
        last_match = None
        for last_match in re.finditer(pattern, string):
            pass
        return string[:last_match.start()] + repl + string[last_match.end():] if last_match else string + repl

    @classmethod
    def _get_rightmost(cls, pattern, string):
        last_match = None
        for last_match in re.finditer(pattern, string):
            pass
        return string[last_match.start():last_match.end()] if last_match else ""

    @classmethod
    def stamp(cls):
        # 20121218151309
        return time.strftime("%Y%m%d%H%M%S", time.gmtime())

    @classmethod
    def stamp_regex(cls, stamp=None):
        return fr"[0-9]{{{len(stamp if stamp else cls.stamp())}}}"

    def gen_internal_rebuild(self):
        r"""
        Generate an 'internal rebuild' version

        If the version is not already a rebuild version, just
        append the rebuild appendix, otherwise replace the old
        one. For example::

          1.2.3 -> 1.2.3+rebuilt20130215100453
          1.2.3+rebuilt20130215100453 -> 1.2.3+rebuilt20130217120517

        Code samples:

        >>> regex = rf"^1\.2\.3\+rebuilt{DebianVersion.stamp_regex()}$"
        >>> bool(re.match(regex, DebianVersion("1.2.3").gen_internal_rebuild()))
        True
        >>> bool(re.match(regex, DebianVersion("1.2.3+rebuilt20130215100453").gen_internal_rebuild()))
        True
        """
        stamp = self.stamp()
        return self._sub_rightmost(r"\+rebuilt" + self.stamp_regex(stamp),
                                   "+rebuilt" + stamp,
                                   self.full_version)

    def gen_external_port(self, default_version):
        """
        Generate an 'external port' version

        This currently just appends the given default version
        appendix. For example:

        1.2.3 -> 1.2.3~test60+1
        """
        return f"{self.full_version}{default_version}"

    def gen_internal_port(self, from_mandatory_version_regex, to_default_version):
        r"""
        Generate an 'internal port' version

        Tests for the (recommended) Default layout:

        >>> sid_regex = r"~testSID\+[1-9]"
        >>> sid_default = "~testSID+1"
        >>> sid_exp_regex = r"~testSID\+0"
        >>> sid_exp_default = "~testSID+0"
        >>> wheezy_regex = r"~test70\+[1-9]"
        >>> wheezy_default = "~test70+1"
        >>> wheezy_exp_regex = r"~test70\+0"
        >>> wheezy_exp_default = "~test70+0"
        >>> squeeze_regex = r"~test60\+[1-9]"
        >>> squeeze_default = "~test60+1"
        >>> squeeze_exp_regex = r"~test60\+0"
        >>> squeeze_exp_default = "~test60+0"

        sid->wheezy ports:

        >>> DebianVersion("1.2.3-1~testSID+1").gen_internal_port(sid_regex, wheezy_default)
        '1.2.3-1~test70+1'
        >>> DebianVersion("1.2.3-1~testSID+4").gen_internal_port(sid_regex, wheezy_default)
        '1.2.3-1~test70+4'
        >>> DebianVersion("1.2.3-1~testSID+4fud15").gen_internal_port(sid_regex, wheezy_default)
        '1.2.3-1~test70+4fud15'
        >>> DebianVersion("1.2.3-1~testSID+0").gen_internal_port(sid_exp_regex, wheezy_exp_default)
        '1.2.3-1~test70+0'
        >>> DebianVersion("1.2.3-1~testSID+0exp2").gen_internal_port(sid_exp_regex, wheezy_exp_default)
        '1.2.3-1~test70+0exp2'

        wheezy->squeeze ports:

        >>> DebianVersion("1.2.3-1~test70+1").gen_internal_port(wheezy_regex, squeeze_default)
        '1.2.3-1~test60+1'
        >>> DebianVersion("1.2.3-1~test70+4").gen_internal_port(wheezy_regex, squeeze_default)
        '1.2.3-1~test60+4'
        >>> DebianVersion("1.2.3-1~test70+4fud15").gen_internal_port(wheezy_regex, squeeze_default)
        '1.2.3-1~test60+4fud15'
        >>> DebianVersion("1.2.3-1~test70+0").gen_internal_port(wheezy_exp_regex, squeeze_exp_default)
        '1.2.3-1~test60+0'
        >>> DebianVersion("1.2.3-1~test70+0exp2").gen_internal_port(wheezy_exp_regex, squeeze_exp_default)
        '1.2.3-1~test60+0exp2'

        No version restrictions: just add default version

        >>> DebianVersion("1.2.3-1").gen_internal_port(".*", "~port+1")
        '1.2.3-1~port+1'
        """
        from_apdx = self._get_rightmost(from_mandatory_version_regex, self.full_version)
        from_apdx_plus_revision = self._get_rightmost(r"\+[0-9]", from_apdx)
        if from_apdx and from_apdx_plus_revision:
            actual_to_default_version = self._sub_rightmost(r"\+[0-9]", from_apdx_plus_revision, to_default_version)
        else:
            actual_to_default_version = to_default_version
        return self._sub_rightmost(from_mandatory_version_regex, actual_to_default_version, self.full_version)


class TemplatePackage(util.TmpDir):
    """Copies a package template into a temporary directory (under 'package/')"""

    def __init__(self, template, package_version=None):
        super().__init__(prefix="template-")
        self.template = template
        self.package_dir = os.path.join(self.tmpdir, "package")
        shutil.copytree(os.path.join(config.PACKAGE_TEMPLATES, template), self.package_dir)
        self.package_version = DebianVersion.stamp() if package_version is None else package_version
        self.package_name = None

    @property
    def dsc(self):
        return glob.glob(os.path.join(self.tmpdir, "*.dsc"))[0]

    @classmethod
    def call(cls, *args, **kwargs):
        environment = call.taint_env(
            {
                "DEBEMAIL": daemon.get_model().email_address,
                "DEBFULLNAME": daemon.get_model().mbd_fullname(),
                "GNUPGHOME": daemon.get().gnupg.home,
            })
        return call.Call(*args, **kwargs, env=environment)


class KeyringPackage(TemplatePackage):
    def __init__(self, **kwargs):
        super().__init__("archive-keyring", **kwargs)

        self.key_id = daemon.get().gnupg.get_first_sec_key()
        LOG.debug("KeyringPackage using key: '%s'", self.key_id)

        self.package_name = f"{daemon.get_model().identity}-archive-keyring"

        # Replace %ID%, %MAINT% and %KEY_ID% in all files in package dir.
        for root, _dirs, _files in os.walk(self.package_dir):
            for f in _files:
                old_file = os.path.join(root, f)
                new_file = old_file + ".new"
                with util.fopen(old_file, "r") as of, util.fopen(new_file, "w") as nf:
                    nf.write(util.subst_placeholders(of.read(),
                                                     {"ID": daemon.get_model().identity,
                                                      "KEY_ID": self.key_id,
                                                      "MAINT": f"{daemon.get_model().mbd_fullname()} <{daemon.get_model().email_address}>"}))
                os.rename(new_file, old_file)

        # Export public GnuPG key into the package
        daemon.get().gnupg.export(os.path.join(self.package_dir, self.package_name + ".gpg"), identity=self.key_id)

        # Generate sources.lists
        for r in util.models().Repository.objects.all():
            for d in r.distributions.all():
                for s in r.layout.suiteoption_set.all():
                    for rb in [None] + list(range(s.rollback)):
                        file_base = f"sources.list.d/{d.base_source.codename}/{r.identity}_{s.suite.name}{'' if rb is None else f'-rollback{rb}'}"
                        for type_, appendix in [("deb", ""), ("deb-src", "_src")]:
                            apt_line = r.mbd_get_apt_line(d, s, rollback=rb)
                            file_name = os.path.join(self.package_dir, f"{file_base}{appendix}.list")
                            os.makedirs(os.path.dirname(file_name), exist_ok=True)
                            with util.fopen(file_name, "w") as f:
                                f.write(apt_line.get(type_) + "\n")

        # Generate dput-ng profile
        with util.fopen(os.path.join(self.package_dir, f"{daemon.get_model().mbd_dput_name()}.json"), "w") as f:
            f.write(daemon.get_model().mbd_get_dputng_profile())

        # Generate SSL certificate
        certs_dir = os.path.join(self.package_dir, "certs")
        os.mkdir(certs_dir)
        if util.http_endpoint().is_ssl():
            with util.fopen(os.path.join(self.package_dir, os.path.join(certs_dir, "mini-buildd.crt")), "w") as f:
                f.write(util.http_endpoint().get_certificate())

        # Generate changelog entry
        self.call(["debchange",
                   "--create",
                   "--package", self.package_name,
                   "--newversion", self.package_version,
                   f"Automatic keyring package for archive '{daemon.get_model().identity}'."],
                  cwd=self.package_dir).check()

        self.call(["dpkg-source",
                   "-b",
                   "package"],
                  cwd=self.tmpdir).check()


class TestPackage(TemplatePackage):
    def __init__(self, template, auto_ports=None):
        super().__init__(template)
        self.package_name = template

        self.call(["debchange",
                   "--newversion", self.package_version,
                   f"Version update '{self.package_version}'."],
                  cwd=self.package_dir).check()
        if auto_ports:
            self.call(["debchange",
                       f"MINI_BUILDD_OPTION: auto-ports={','.join(auto_ports)}"],
                      cwd=self.package_dir).check()
        self.call(["dpkg-source", "-b", "package"], cwd=self.tmpdir).check()


_DEFAULT_PORT_OPTIONS = ["lintian-mode=ignore"]


def _port(dsc_url, source, diststr, version, comments=None, options=None, allow_unauthenticated=False):
    def changelog_entries():
        """Merge comments and options into plain changelog_entries"""
        changelog_entries = [] if comments is None else comments
        for o in _DEFAULT_PORT_OPTIONS if options is None else options:
            changelog_entries.append(f"{changes.Upload.Options.KEYWORD}: {o}")
        return changelog_entries

    with util.tmp_dir(prefix="port-") as tmpdir:
        dfn = files.DebianName(source, version)

        # Download DSC via dget.
        dget = ["dget", "--download-only"]
        if allow_unauthenticated:
            dget.append("--allow-unauthenticated")
        try:
            TemplatePackage.call(dget + [dsc_url], cwd=tmpdir).check()
        except Exception as e:
            raise util.HTTPBadRequest("Given URL can't be downloaded (maybe retry allowing unauthenticated)") from e

        # Get SHA1 of original dsc file
        original_dsc_sha1sum = util.Hash(os.path.join(tmpdir, os.path.basename(dsc_url))).sha1()

        # Unpack DSC (note: dget does not support -x to a dedcicated dir).
        dst = "debian_source_tree"
        TemplatePackage.call(["dpkg-source",
                              "-x",
                              os.path.basename(dsc_url),
                              dst],
                             cwd=tmpdir).check()

        dst_path = os.path.join(tmpdir, dst)

        # Get version and author from original changelog; use the first block not
        with util.fopen(os.path.join(dst_path, "debian", "changelog"), "r") as cl:
            original_author, original_version = Changelog(cl, max_blocks=100).find_first_not(daemon.get_model().email_address)
        LOG.debug("Port: Found original version/author: %s/%s", original_version, original_author)

        # Change changelog in DST
        TemplatePackage.call(["debchange",
                              "--newversion", version,
                              "--force-distribution",
                              "--force-bad-version",
                              "--preserve",
                              "--dist", diststr,
                              f"Automated port via mini-buildd (no changes). Original DSC's SHA1: {original_dsc_sha1sum}."],
                             cwd=dst_path).check()

        for entry in changelog_entries():
            TemplatePackage.call(["debchange",
                                  "--append",
                                  entry],
                                 cwd=dst_path).check()

        # Repack DST
        TemplatePackage.call(["dpkg-source", "-b", dst], cwd=tmpdir).check()
        dsc = os.path.join(tmpdir, dfn.dsc())
        daemon.get().gnupg.sign(dsc)

        # Generate Changes file
        changes_file_path = os.path.join(tmpdir, dfn.changes("source"))
        with util.fopen(changes_file_path, "w+") as out:
            # Note: dpkg-genchanges has a home-brewed options parser. It does not allow, for example, "-v 1.2.3", only "-v1.2.3", so we need to use *one* sequence item for that.
            TemplatePackage.call(["dpkg-genchanges",
                                  "-S",
                                  "-sa",
                                  f"-v{original_version}",
                                  f"-D{util.CField('Originally-Changed-By').fullname}={original_author}"],
                                 cwd=dst_path,
                                 stdout=out).check()

        # Sign and upload
        daemon.get().gnupg.sign(changes_file_path)
        changes.Base(changes_file_path).upload(daemon.get_model().mbd_get_ftp_endpoint(), force=True)
        return {"dsc_url": dsc_url, "source": source, "distribution": diststr, "version": version}


def port(source, from_diststr, to_diststr, version=None, options=None):
    # check from_diststr
    from_repository, from_distribution, from_suite = util.models().parse_diststr(from_diststr)
    from_ls = from_repository.mbd_reprepro.ls(source).filter_all(diststr=from_diststr, version=version, raise_if_not_found="Source to port not found")
    if version is None:
        version = from_ls[from_diststr]["version"]

    # check to_diststr
    to_repository, to_distribution, to_suite = util.models().parse_diststr(to_diststr, check_uploadable=True)

    # Ponder version to use
    v = DebianVersion(version)
    if to_diststr == from_diststr:
        to_version = v.gen_internal_rebuild()
    else:
        to_version = v.gen_internal_port(from_repository.mbd_get_mandatory_version_regex(from_distribution, from_suite),
                                         to_repository.mbd_get_default_version(to_distribution, to_suite))

    return _port(config.URIS["repositories"]["static"].url_join(from_repository.mbd_reprepro.pool.dsc_path(source, version)),
                 source,
                 to_diststr,
                 to_version,
                 options=options,
                 allow_unauthenticated=True)


def port_ext(dsc_url, to_diststr, options=None, allow_unauthenticated=False):
    to_repository, to_distribution, to_suite = util.models().parse_diststr(to_diststr, check_uploadable=True)
    try:
        with urllib.request.urlopen(dsc_url) as response:
            dsc = debian.deb822.Dsc(response)
            source = dsc.get("Source")
            if source is None:
                raise util.HTTPBadRequest("Given URL not a DSC")

            v = DebianVersion(dsc["Version"])
            return _port(dsc_url,
                         source,
                         to_diststr,
                         v.gen_external_port(to_repository.mbd_get_default_version(to_distribution, to_suite)),
                         comments=[f"External port from: {dsc_url}"],
                         options=options,
                         allow_unauthenticated=allow_unauthenticated)
    except urllib.error.URLError as e:
        raise util.HTTPBadRequest(f"{e} ({dsc_url})")


def upload_template_package(template_package, diststr):
    """Portext macro. Used for keyring_packages and test_packages"""
    with contextlib.closing(template_package) as package:
        dsc_url = "file://" + package.dsc
        info = f"Port for {diststr}: {os.path.basename(dsc_url)}"
        try:
            return port_ext(dsc_url, diststr, allow_unauthenticated=True)
        except BaseException as e:
            util.log_exception(LOG, f"FAILED: {info}", e)
            return {"error": str(util.e2http(e)), "dsc_url": dsc_url, "source": None, "distribution": None, "version": None}  # mimics result of port functions
