import os
import copy
import shutil
import glob
import re
import logging

import django.db
import django.contrib.auth.models

import debian.debian_support

import mini_buildd.config
import mini_buildd.misc
import mini_buildd.dist
import mini_buildd.files
import mini_buildd.gnupg
import mini_buildd.reprepro

import mini_buildd.models.base
import mini_buildd.models.distribution

LOG = logging.getLogger(__name__)


class EmailAddress(mini_buildd.models.base.Model):
    address = django.db.models.EmailField(primary_key=True, max_length=255)
    name = django.db.models.CharField(blank=True, max_length=255)

    class Meta(mini_buildd.models.base.Model.Meta):
        verbose_name_plural = "Email addresses"

    def __str__(self):
        return f"{self.name} <{self.address}>"


class Repository(mini_buildd.models.base.StatusModel):
    identity = django.db.models.CharField(primary_key=True, max_length=50, default="test",
                                          help_text="""\
The id of the reprepro repository, placed in
'repositories/<ID>'. It can also be used in 'version enforcement
string' (true for the default layout) -- in this context, it
plays the same role as the well-known 'bpo' version string from
Debian backports.
""")

    layout = django.db.models.ForeignKey(mini_buildd.models.distribution.Layout,
                                         on_delete=django.db.models.CASCADE)
    distributions = django.db.models.ManyToManyField(mini_buildd.models.distribution.Distribution)

    allow_unauthenticated_uploads = django.db.models.BooleanField(default=False,
                                                                  help_text="Allow unauthenticated user uploads.")

    extra_uploader_keyrings = django.db.models.TextField(blank=True,
                                                         help_text="""\
Extra keyrings, line by line, to be allowed as uploaders (in addition to configured django users).

Example:
---
# Allow Debian maintainers (must install the 'debian-keyring' package)"
/usr/share/keyrings/debian-keyring.gpg"
# Allow from some local keyring file"
/etc/my-schlingels.gpg"
---
""")

    notify = django.db.models.ManyToManyField(EmailAddress,
                                              blank=True,
                                              help_text="Addresses that get all notification emails unconditionally.")
    notify_changed_by = django.db.models.BooleanField(default=False,
                                                      help_text="Notify the address in the 'Changed-By' field of the uploaded changes file.")
    notify_maintainer = django.db.models.BooleanField(default=False,
                                                      help_text="Notify the address in the 'Maintainer' field of the uploaded changes file.")

    reprepro_morguedir = django.db.models.BooleanField(default=False,
                                                       help_text="Move files deleted from repo pool to 'morguedir' (see reprepro).")

    external_home_url = django.db.models.URLField(blank=True)

    LETHAL_DEPENDENCIES = False

    class Meta(mini_buildd.models.base.StatusModel.Meta):
        verbose_name_plural = "Repositories"

    class Admin(mini_buildd.models.base.StatusModel.Admin):
        readonly_fields = []
        filter_horizontal = ("distributions", "notify",)

        def get_readonly_fields(self, _request, obj=None):
            """Forbid change identity on existing repository."""
            fields = copy.copy(self.readonly_fields)
            if obj:
                fields.append("identity")
            return fields

    def __str__(self):
        return f"{self.identity}: {' '.join([d.base_source.codename + ('' if d.mbd_is_active() else '*') for d in self.mbd_sorted_distributions()])}"

    def mbd_json(self):
        return {
            "codenames": [d.base_source.codename for d in self.distributions.all()],
            "layout": {
                s.suite.name: {
                    "uploadable": s.uploadable,
                    "experimental": s.experimental,
                    "migrates_to": s.migrates_to.suite.name if s.migrates_to else None,
                } for s in self.layout.suiteoption_set.all()},
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.mbd_path = mini_buildd.config.ROUTES["repositories"].path.join(self.identity)
        self.mbd_reprepro = mini_buildd.reprepro.Reprepro(basedir=self.mbd_path)

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        self.mbd_validate_regex(r"^[a-z0-9]+$", self.identity, "identity")

    def mbd_get_diststr(self, distribution, suite, rollback=None):
        diststr = f"{distribution.base_source.codename}-{self.identity}-{suite.suite.name}"

        if rollback is not None:
            if rollback not in list(range(suite.rollback)):
                raise mini_buildd.HTTPBadRequest(f"{diststr}: Rollback number out of range: {rollback} ({list(range(suite.rollback))})")
            diststr += f"-rollback{rollback}"

        return diststr

    def mbd_get_diststrs(self, frollbacks=None, **suiteoption_filter):
        """List of all distribution strings (except rollbacks) in this repo, optionally matching a suite options filter (unstable, experimental,...)"""
        diststrs = []
        for d in self.distributions.all():
            for s in self.layout.suiteoption_set.filter(**suiteoption_filter):
                diststrs.append(self.mbd_get_diststr(d, s))
                if frollbacks is not None:
                    for r in frollbacks(s):
                        diststrs.append(self.mbd_get_diststr(d, s, rollback=r))
        return diststrs

    def mbd_get_meta_distributions(self, distribution, suite_option):
        try:
            result = []
            for p in self.layout.mbd_get_extra_option("Meta-Distributions", "").split():
                meta, d = p.split("=")
                dist = f"{d.split('-')[0]}-{self.identity}-{d.split('-')[1]}"
                if dist == self.mbd_get_diststr(distribution, suite_option):
                    result.append(meta)
            return result
        except BaseException as e:
            raise mini_buildd.HTTPBadRequest(f"Please fix syntax error in extra option 'Meta-Distributions' in layout '{self.layout}'") from e

    def mbd_get_apt_pin(self, distribution, suite):
        return f"release n={self.mbd_get_diststr(distribution, suite)}, o={mini_buildd.get_daemon().model.mbd_get_archive_origin()}"

    def mbd_get_apt_preferences(self, distribution, suite, prio=1):
        return f"Package: *\nPin: {self.mbd_get_apt_pin(distribution, suite)}\nPin-Priority: {prio}\n"

    @classmethod
    def mbd_get_apt_keys(cls, distribution):
        result = mini_buildd.get_daemon().gnupg.pub_key
        for e in distribution.extra_sources.all():
            for k in e.source.apt_keys.all():
                result += k.key
        return result

    def mbd_get_internal_suite_dependencies(self, suite_option):
        result = []

        # Add ourselves
        result.append(suite_option)

        if suite_option.experimental:
            # Add all non-experimental suites
            for s in self.layout.suiteoption_set.all().filter(experimental=False):
                result.append(s)
        else:
            # Add all suites that we migrate to
            s = suite_option.migrates_to
            while s:
                result.append(s)
                s = s.migrates_to

        return result

    @classmethod
    def __mbd_subst_placeholders(cls, value, repository, distribution):
        return mini_buildd.misc.subst_placeholders(
            value,
            {"IDENTITY": repository.identity,
             "CODEVERSION": distribution.base_source.codeversion})

    def mbd_get_mandatory_version_regex(self, distribution, suite_option):
        return self.__mbd_subst_placeholders(
            self.layout.experimental_mandatory_version_regex if suite_option.experimental else self.layout.mandatory_version_regex,
            self, distribution)

    def mbd_get_default_version(self, distribution, suite_option):
        return self.__mbd_subst_placeholders(
            self.layout.experimental_default_version if suite_option.experimental else self.layout.default_version,
            self, distribution)

    def mbd_get_apt_line(self, distribution, suite_option, rollback=None, snapshot=None):
        diststr = self.mbd_get_diststr(distribution, suite_option, rollback=rollback)
        if snapshot:
            snapshots = self.mbd_reprepro.get_snapshots(diststr)
            if snapshot not in snapshots:
                raise mini_buildd.HTTPBadRequest(f"No such snapshot for {diststr}: '{snapshot}' (available: {','.join(snapshots)})")
            diststr += os.path.join("/", "snapshots", snapshot)

        return mini_buildd.files.AptLine(mini_buildd.config.URIS["repositories"]["static"].url_join(),
                                         self.identity,
                                         diststr,
                                         distribution.components.all(),
                                         comment=f"{mini_buildd.get_daemon().model.mbd_get_archive_origin()} '{diststr}': {self.mbd_get_apt_pin(distribution, suite_option)}")

    def mbd_get_apt_build_sources_list(self, distribution, suite_option):
        sources_list = mini_buildd.files.SourcesList()

        sources_list.append(distribution.base_source.mbd_get_apt_line(limit_components=distribution.components.all()))
        for e in distribution.extra_sources.all():
            sources_list.append(e.source.mbd_get_apt_line(limit_components=distribution.components.all()))
        for s in self.mbd_get_internal_suite_dependencies(suite_option):
            sources_list.append(self.mbd_get_apt_line(distribution, s))

        return sources_list

    def mbd_get_apt_build_preferences(self, distribution, suite_option, internal_apt_priority_override=None):
        result = ""

        # Get preferences for all extra (prioritized) sources
        for e in distribution.extra_sources.all():
            result += e.mbd_get_apt_preferences() + "\n"

        # Get preferences for all internal sources
        internal_prio = internal_apt_priority_override if internal_apt_priority_override else distribution.mbd_get_extra_option("Internal-APT-Priority", 1)
        for s in self.mbd_get_internal_suite_dependencies(suite_option):
            result += self.mbd_get_apt_preferences(distribution, s, prio=internal_prio) + "\n"

        return result

    def _mbd_reprepro_conf_options(self):
        return mini_buildd.files.File("options",
                                      snippet=(f"gnupghome {mini_buildd.config.ROUTES['home'].path.join('.gnupg')}\n"
                                               f"{'morguedir +b/morguedir' if self.reprepro_morguedir else ''}\n"))

    def _mbd_reprepro_conf_distributions(self):
        dist_template = ("Codename: {distribution}\n"
                         "Suite: {distribution}\n"
                         "Label: {distribution}\n"
                         "AlsoAcceptFor: {meta_distributions}\n"
                         "Origin: {origin}\n"
                         "Components: {components}\n"
                         "UDebComponents: {components}\n"
                         "Architectures: source {architectures}\n"
                         "Description: {desc}\n"
                         "SignWith: default\n"
                         "NotAutomatic: {na}\n"
                         "ButAutomaticUpgrades: {bau}\n"
                         "DebIndices: Packages Release . .gz .xz\n"
                         "DscIndices: Sources Release . .gz .xz\n"
                         "Contents: .gz\n\n")

        result = ""
        for d in self.distributions.all():
            for s in self.layout.suiteoption_set.all():
                result += dist_template.format(
                    distribution=self.mbd_get_diststr(d, s),
                    meta_distributions=" ".join(self.mbd_get_meta_distributions(d, s)),
                    origin=mini_buildd.get_daemon().model.mbd_get_archive_origin(),
                    components=" ".join(d.mbd_get_components()),
                    architectures=" ".join([x.name for x in d.architectures.all()]),
                    desc=self.mbd_get_diststr(d, s),
                    na="yes" if s.not_automatic else "no",
                    bau="yes" if s.but_automatic_upgrades else "no")

                for r in range(s.rollback):
                    result += dist_template.format(
                        distribution=self.mbd_get_diststr(d, s, r),
                        meta_distributions="",
                        origin=mini_buildd.get_daemon().model.mbd_get_archive_origin(),
                        components=" ".join(d.mbd_get_components()),
                        architectures=" ".join([x.name for x in d.architectures.all()]),
                        desc=self.mbd_get_diststr(d, s, r),
                        na="yes",
                        bau="no")

        return mini_buildd.files.File("distributions", snippet=result)

    def mbd_reprepro_update_config(self):
        if mini_buildd.files.Dir(os.path.join(self.mbd_path, "conf")).add(self._mbd_reprepro_conf_options()) \
                                                                     .add(self._mbd_reprepro_conf_distributions()).update():
            self.mbd_reprepro.reindex(self.mbd_get_diststrs())

    def mbd_dsc_pool_path(self, source, version, raise_exception=True):
        """Get DSC pool path of an installed source (``<repo>/pool/...``)."""
        repositories_path = mini_buildd.config.ROUTES["repositories"].path
        dscs = glob.glob(repositories_path.join(self.identity, "pool", "*", "*", source, mini_buildd.files.DebianName(source, version).dsc()))

        LOG.debug(f"Repository {self.identity}: Found pool DSCs for '{source}-{version}': {dscs}")

        if len(dscs) > 1:  # This should not really ever happen. Pool-wise it could however with different components (main, contrib, etc)
            LOG.warning(f"Found multiple DSCs for '{source}-{version}' in pool (only the 1st found will be used)")
            for d in dscs:
                LOG.warning(f"↳ {d}")

        if len(dscs) < 1:
            msg = f"Can't find DSC for '{source}-{version}' in pool of repository '{self.identity}'"
            if raise_exception:
                raise mini_buildd.HTTPBadRequest(msg)
            LOG.warning(msg)
            return None

        return repositories_path.removeprefix(dscs[0])

    def _mbd_package_shift_rollbacks(self, distribution, suite_option, package_name):
        diststr = self.mbd_get_diststr(distribution, suite_option)
        ls = self.mbd_reprepro.ls(package_name)
        if ls.filter_rollbacks(version=ls[diststr]["version"]):
            LOG.info(f"{package_name}_{ls[diststr]['version']}: Already in rollbacks, skipping rollback")
        else:
            for r in range(suite_option.rollback - 1, -1, -1):
                src = self.mbd_get_diststr(distribution, suite_option, None if r == 0 else r - 1)
                dst = self.mbd_get_diststr(distribution, suite_option, r)
                LOG.info(f"Rollback: Moving {package_name}: {src} to {dst}")
                try:
                    self.mbd_reprepro.migrate(package_name, src, dst)
                except BaseException as e:
                    mini_buildd.log_exception(LOG, "Rollback failed (ignoring)", e)

    def _mbd_package_migrate(self, package, distribution, suite, rollback=None, version=None, log_event=True):
        src_diststr = self.mbd_get_diststr(distribution, suite)
        dst_diststr = None
        pkg_ls = self.mbd_reprepro.ls(package)

        if rollback is not None:
            dst_diststr = src_diststr
            LOG.info(f"Rollback restore of '{package}' from rollback {rollback} to '{dst_diststr}'")

            pkg_ls.filter(diststr=dst_diststr, raise_if_found=f"Package '{package}' exists in '{dst_diststr}': Remove first to restore rollback")
            rob_diststr = self.mbd_get_diststr(distribution, suite, rollback=rollback)
            pkg_ls.filter(diststr=rob_diststr, version=version, raise_if_not_found="No such rollback source")

            # Actually migrate package in reprepro
            self.mbd_reprepro.migrate(package, rob_diststr, dst_diststr, version)
        else:
            # Get src and dst dist strings, and check we are configured to migrate
            if not suite.migrates_to:
                raise mini_buildd.HTTPBadRequest(f"You can't migrate from '{src_diststr}'")
            dst_diststr = self.mbd_get_diststr(distribution, suite.migrates_to)

            # Check if package is in src_dst
            pkg_ls.filter(diststr=src_diststr, version=version, raise_if_not_found="No such source")

            # Check that version is not already migrated
            if version is not None and pkg_ls.filter(diststr=dst_diststr, version=version):
                raise mini_buildd.HTTPBadRequest(f"Version '{version}' already migrated to '{dst_diststr}'")

            # Shift rollbacks in the destination distributions
            if pkg_ls.filter(diststr=dst_diststr):
                self._mbd_package_shift_rollbacks(distribution, suite.migrates_to, package)

            # Actually migrate package in reprepro
            self.mbd_reprepro.migrate(package, src_diststr, dst_diststr, version)

        if log_event:
            pkg_ls = self.mbd_reprepro.ls(package)
            mini_buildd.get_daemon().events.log(mini_buildd.events.Type.MIGRATED, pkg_ls.changes(dst_diststr, None).create_events())

    def mbd_package_migrate(self, package, distribution, suite, full=False, rollback=None, version=None):
        if full:
            while suite.migrates_to is not None:
                self._mbd_package_migrate(package, distribution, suite, rollback=rollback, version=version)
                suite = suite.migrates_to
        else:
            self._mbd_package_migrate(package, distribution, suite, rollback=rollback, version=version)

    def mbd_package_remove(self, package, distribution, suite, rollback=None, version=None, without_rollback=False):
        diststr = self.mbd_get_diststr(distribution, suite, rollback)
        src_pkg = self.mbd_reprepro.ls(package)
        src_pkg.filter(diststr=diststr, version=version, raise_if_not_found="No such source")

        if rollback is None:
            if not without_rollback:
                self._mbd_package_shift_rollbacks(distribution, suite, package)
            # Remove package
            self.mbd_reprepro.remove(package, diststr, version)
        else:
            # Rollback removal
            self.mbd_reprepro.remove(package, diststr, version)

            # Fix up empty rollback dist
            for r in range(rollback, suite.rollback - 1):
                src_diststr = self.mbd_get_diststr(distribution, suite, r + 1)
                dst_diststr = self.mbd_get_diststr(distribution, suite, r)
                try:
                    self.mbd_reprepro.migrate(package, src_diststr, dst_diststr)
                    self.mbd_reprepro.remove(package, src_diststr)
                except BaseException as e:
                    mini_buildd.log_exception(LOG, f"Rollback: Moving '{package}' from '{src_diststr}' to '{dst_diststr}' FAILED (ignoring)", e, logging.WARN)

        # Notify
        mini_buildd.get_daemon().events.log(mini_buildd.events.Type.REMOVED, src_pkg.changes(diststr, None).create_events())

    def mbd_package_precheck(self, distribution, suite_option, package, version):
        # 1st, check that the given version matches the distribution's version restrictions
        mandatory_regex = self.mbd_get_mandatory_version_regex(distribution, suite_option)
        if not re.compile(mandatory_regex).search(version):
            raise mini_buildd.HTTPBadRequest(f"Version restrictions failed for suite '{suite_option.suite.name}': '{mandatory_regex}' not in '{version}'")

        pkg_ls = self.mbd_reprepro.ls(package)
        diststr = self.mbd_get_diststr(distribution, suite_option)

        # 2nd: Check whether the very same version is already in any distribution
        pkg_ls.filter(version=version, raise_if_found="Source already installed")

        # 3rd: Check that distribution's current version is smaller than the to-be installed version
        pkg_in_dist = pkg_ls.filter(diststr=diststr)
        if pkg_in_dist:
            pkg_in_dist_version = pkg_in_dist[diststr]["version"]
            if debian.debian_support.Version(version) < debian.debian_support.Version(pkg_in_dist_version):
                raise mini_buildd.HTTPBadRequest(f"Package '{package}' has greater version '{pkg_in_dist_version}' installed in '{diststr}'")

    def _mbd_buildresult_install(self, buildresult, diststr):
        # Don't try install if skipped
        if buildresult.cget("Sbuild-Status") == "skipped":
            LOG.debug(f"Skipped: {buildresult}")
        else:
            with mini_buildd.misc.tmp_dir(prefix="buildresult-untar-") as tmpdir:
                buildresult.untar(tmpdir)
                self.mbd_reprepro.install(" ".join(glob.glob(os.path.join(tmpdir, "*.changes"))), diststr)
                LOG.info(f"Installed: {buildresult}")

    def mbd_package_install(self, distribution, suite_option, changes, buildresults):
        """Install a dict arch:buildresult of successful build results."""
        # Get the full distribution str
        diststr = self.mbd_get_diststr(distribution, suite_option)

        # Check that all mandatory archs are present
        missing_mandatory_archs = [arch for arch in distribution.mbd_get_mandatory_architectures() if arch not in buildresults]
        if missing_mandatory_archs:
            raise mini_buildd.HTTPBadRequest(f"{len(missing_mandatory_archs)} mandatory architecture(s) missing: {' '.join(missing_mandatory_archs)}")

        # Get the (source) package name
        package = changes["Source"]
        LOG.debug(f"Package install: Package={package}")

        # Shift current package up in the rollback distributions (unless this is the initial install)
        is_installed = self.mbd_reprepro.ls(package).filter(diststr=diststr)
        if is_installed:
            self._mbd_package_shift_rollbacks(distribution, suite_option, package)

        # First, install the dsc
        self.mbd_reprepro.install_dsc(changes.dsc_file_path(), diststr)

        # Second, install all build results; if any buildresult fails to install, rollback changes to repository
        try:
            for buildresult in list(buildresults.values()):
                self._mbd_buildresult_install(buildresult, diststr)
        except Exception as e:
            self.mbd_reprepro.remove(package, diststr)
            if is_installed:
                self._mbd_package_migrate(package, distribution, suite_option, rollback=0, log_event=False)
            mini_buildd.log_exception(LOG, "Binary install failed", e)
            raise mini_buildd.HTTPInternal(f"Binary install failed for {package}: {mini_buildd.e2http(e)}")

    def mbd_sorted_distributions(self):
        return sorted(self.distributions.all())

    def mbd_icodenames(self):
        for d in self.mbd_sorted_distributions():
            yield d.base_source.codename

    def mbd_prepare(self):
        """Idempotent repository preparation. This may be used as-is as mbd_sync."""
        # Architecture sanity checks
        for d in self.mbd_sorted_distributions():
            if not d.architectureoption_set.all().filter(optional=False):
                raise mini_buildd.HTTPBadRequest(f"{d}: There must be at least one mandatory architecture!")
            if len(d.architectureoption_set.all().filter(optional=False, build_architecture_all=True)) != 1:
                raise mini_buildd.HTTPBadRequest(f"{d}: There must be exactly one one arch-all architecture!")

        # Check that the codenames of the distribution are unique
        codenames = []
        for d in self.distributions.all():
            if d.base_source.codename in codenames:
                raise mini_buildd.HTTPBadRequest(f"Multiple distribution codename in: {d}")
            codenames.append(d.base_source.codename)

            # Check for mandatory component "main"
            if not d.components.all().filter(name="main"):
                raise mini_buildd.HTTPBadRequest(f"Mandatory component 'main' missing in: {d}")

        self.mbd_reprepro_update_config()

    def mbd_sync(self):
        self.mbd_prepare()

    def mbd_remove(self):
        if os.path.exists(self.mbd_path):
            shutil.rmtree(self.mbd_path)

    def mbd_check(self):
        self.mbd_reprepro_update_config()
        self.mbd_reprepro.check()

        # Check for ambiguity with other repos in meta distribution maps
        get_meta_distribution_map()

    def mbd_get_dependencies(self):
        result = []
        for d in self.distributions.all():
            result.append(d.base_source)
            result += [e.source for e in d.extra_sources.all()]
        return result


def get_meta_distribution_map():
    """Get a dict of the meta distributions: meta -> actual."""
    result = {}
    for r in Repository.objects.all():
        for d in r.distributions.all():
            for s in r.layout.suiteoption_set.all():
                for m in r.mbd_get_meta_distributions(d, s):
                    diststr = r.mbd_get_diststr(d, s)
                    if m in result:
                        raise mini_buildd.HTTPInternal(f"Ambiguous Meta-Distributions ({m}={diststr} or {result[m]}). "
                                                       f"Please check Repositories and Layouts (see Layouts/Meta-Distributions in Administrators Manual).")
                    result[m] = diststr

    LOG.debug(f"Got meta distribution map: {result}")
    return result


def map_distribution(diststr):
    """Map incoming distribution to internal."""
    return get_meta_distribution_map().get(diststr, diststr)


def get(identity):
    """Get repository object with user error handling."""
    repository = Repository.objects.filter(pk=identity).first()
    if repository is None:
        raise mini_buildd.HTTPBadRequest(f"No such repository: {identity}")
    return repository


def parse_dist(dist, check_uploadable=False):
    repository = get(dist.repository)

    distribution = repository.distributions.all().filter(base_source__codename__exact=dist.codename).first()
    if distribution is None:
        raise mini_buildd.HTTPBadRequest(f"No distribution for codename '{dist.codename}' in repository '{repository.identity}'")

    suite = repository.layout.suiteoption_set.filter(suite__name=dist.suite).first()
    if suite is None:
        raise mini_buildd.HTTPBadRequest(f"No distribution for suite '{dist.suite}' in repository '{repository.identity}'")

    if check_uploadable:
        if dist.rollback_no is not None or not suite.uploadable:
            raise mini_buildd.HTTPBadRequest(f"Distribution '{dist.get()}' not uploadable")

        if not distribution.mbd_is_active():
            raise mini_buildd.HTTPUnavailable(f"Distribution '{dist.get()}' not active")

        if not repository.mbd_is_active():
            raise mini_buildd.HTTPUnavailable(f"Repository '{repository.identity}' not active")

    return repository, distribution, suite


def parse_diststr(diststr, check_uploadable=False):
    return parse_dist(mini_buildd.dist.Dist(diststr), check_uploadable=check_uploadable)
