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

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

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

LOG = logging.getLogger(__name__)


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

    >>> cl = Changelog(mini_buildd.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(mini_buildd.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(mini_buildd.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(mini_buildd.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(mini_buildd.misc.TmpDir):
    """Copies a package template into a temporary directory (under 'package/')."""

    def __init__(self, template):
        super().__init__()
        self.template = template
        self.package_dir = os.path.join(self.tmpdir.name, "package")
        shutil.copytree(os.path.join(mini_buildd.config.PACKAGE_TEMPLATES, template), self.package_dir)
        self.package_version = DebianVersion.stamp()
        self.package_name = None

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


class KeyringPackage(TemplatePackage):
    def __init__(self):
        super().__init__("archive-keyring")
        model = mini_buildd.get_daemon().model
        gnupg = mini_buildd.get_daemon().gnupg

        self.key_id = gnupg.get_first_sec_key()
        LOG.debug(f"KeyringPackage using key: '{self.key_id}'")

        self.package_name = f"{model.identity}-archive-keyring"
        self.environment = mini_buildd.call.taint_env(
            {"DEBEMAIL": model.email_address,
             "DEBFULLNAME": model.mbd_fullname(),
             "GNUPGHOME": gnupg.home})

        # 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 mini_buildd.fopen(old_file, "r") as of, mini_buildd.fopen(new_file, "w") as nf:
                    nf.write(mini_buildd.misc.subst_placeholders(of.read(),
                                                                 {"ID": model.identity,
                                                                  "KEY_ID": self.key_id,
                                                                  "MAINT": f"{model.mbd_fullname()} <{model.email_address}>"}))
                os.rename(new_file, old_file)

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

        # Generate sources.lists
        for r in mini_buildd.mdls().repository.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 mini_buildd.fopen(file_name, "w") as f:
                                f.write(apt_line.get(type_) + "\n")

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

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

        mini_buildd.call.Call(["dpkg-source",
                               "-b",
                               "package"],
                              cwd=self.tmpdir.name,
                              env=self.environment).check()


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

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


_DEFAULT_PORT_OPTIONS = ["lintian-mode=ignore"]


def _port(dsc_url, package, 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"{mini_buildd.changes.Upload.Options.KEYWORD}: {o}")
        return changelog_entries

    with mini_buildd.misc.TemporaryDirectory(prefix="port-") as tmpdir:
        dfn = mini_buildd.files.DebianName(package, version)
        env = mini_buildd.call.taint_env({"DEBEMAIL": mini_buildd.get_daemon().model.email_address,
                                          "DEBFULLNAME": mini_buildd.get_daemon().model.mbd_fullname(),
                                          "GNUPGHOME": mini_buildd.get_daemon().gnupg.home})

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

        # Get SHA1 of original dsc file
        original_dsc_sha1sum = mini_buildd.misc.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"
        mini_buildd.call.Call(["dpkg-source",
                               "-x",
                               os.path.basename(dsc_url),
                               dst],
                              cwd=tmpdir,
                              env=env).check()

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

        # Get version and author from original changelog; use the first block not
        with mini_buildd.fopen(os.path.join(dst_path, "debian", "changelog"), "r") as cl:
            original_author, original_version = Changelog(cl, max_blocks=100).find_first_not(mini_buildd.get_daemon().model.email_address)
        LOG.debug(f"Port: Found original version/author: {original_version}/{original_author}")

        # Change changelog in DST
        mini_buildd.call.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,
                              env=env).check()

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

        # Repack DST
        mini_buildd.call.Call(["dpkg-source", "-b", dst], cwd=tmpdir, env=env).check()
        dsc = os.path.join(tmpdir, dfn.dsc())
        mini_buildd.get_daemon().gnupg.sign(dsc)

        # Gen changes file name
        changes = os.path.join(tmpdir, dfn.changes("source"))

        # Generate Changes file
        with mini_buildd.fopen(changes, "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.
            mini_buildd.call.Call(["dpkg-genchanges",
                                   "-S",
                                   "-sa",
                                   f"-v{original_version}",
                                   f"-D{mini_buildd.misc.CField('Originally-Changed-By').fullname}={original_author}"],
                                  cwd=dst_path,
                                  env=env,
                                  stdout=out).check()

        # Sign and upload
        mini_buildd.get_daemon().gnupg.sign(changes)
        mini_buildd.changes.Base(changes).upload(mini_buildd.get_daemon().model.mbd_get_ftp_endpoint(), force=True)
        return diststr, package, version


def port(package, from_diststr, to_diststr, version, options=None):
    # check from_diststr
    _from_dist, from_repository, from_distribution, from_suite = mini_buildd.models.repository.parse_diststr(from_diststr)
    p = from_repository.mbd_reprepro.find(package, distribution=from_diststr, version=version)

    # check to_diststr
    _to_dist, to_repository, to_distribution, to_suite = mini_buildd.models.repository.parse_diststr(to_diststr, check_uploadable=True)

    # Ponder version to use
    v = DebianVersion(p["version"])
    if to_diststr == from_diststr:
        port_version = v.gen_internal_rebuild()
    else:
        port_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))

    _component, path = from_repository.mbd_find_dsc_path(from_distribution, package, p["version"])
    if not path:
        raise mini_buildd.HTTPBadRequest(f"Can't find DSC for {package}-{p['version']} in pool")

    return _port(mini_buildd.config.URIS["repositories"]["static"].url_join(path), package, to_diststr, port_version, options=options, allow_unauthenticated=True)


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

            v = DebianVersion(dsc["Version"])
            return _port(dsc_url,
                         dsc["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 mini_buildd.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:
            mini_buildd.log_exception(LOG, f"FAILED: {info}", e)
            return None, None, None  # mimics result of port functions.
