import tempfile
import urllib.request
import urllib.error
import logging
import contextlib
import functools
import copy
from contextlib import closing

import django.db.models
import django.utils.timezone
import django.core.exceptions

import debian.deb822

import mini_buildd.dist
import mini_buildd.files
import mini_buildd.call
import mini_buildd.gnupg

import mini_buildd.models.base
import mini_buildd.models.gnupg

LOG = logging.getLogger(__name__)


class Archive(mini_buildd.models.base.Model):
    URLOPEN_TIMEOUT = 15  # seconds

    url = django.db.models.URLField(primary_key=True, max_length=512,
                                    default="http://ftp.debian.org/debian/",
                                    help_text="""\
The URL of an apt archive (there must be a 'dists/' infrastructure below).

Use the 'directory' notation with exactly one trailing slash (like 'http://example.org/path/').
""")
    ping = django.db.models.FloatField(default=-1.0, editable=False)

    class Meta(mini_buildd.models.base.Model.Meta):
        ordering = ["url"]

    class Admin(mini_buildd.models.base.Model.Admin):
        search_fields = ["url"]

    def __str__(self):
        return f"{self.url} (ping {round(self.ping, 2)} ms)"

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        if self.url[-1] != "/" or self.url[-2] == "/":  # pylint: disable=unsubscriptable-object
            raise django.core.exceptions.ValidationError({"url": f"Invalid archive url '{self.url}': Must have exactly one trailing slash (like 'http://example.org/path/')."})

    class ReleaseFile():
        @classmethod
        def _download(cls, file_, url):
            with urllib.request.urlopen(url, timeout=Archive.URLOPEN_TIMEOUT) as response:
                file_.write(response.read())
                file_.flush()
                file_.seek(0)

        def __init__(self, url_base):
            self.url_base = url_base

            # pylint: disable=consider-using-with  # See close(), use this class with contextlib.closing()
            self.release_file = tempfile.NamedTemporaryFile()
            self.signature_file = tempfile.NamedTemporaryFile()
            self.release_url, self.signature_url = None, None

            try:
                self.release_url = self.url_base + "InRelease"
                self._download(self.release_file, self.release_url)
            except urllib.error.HTTPError:
                try:
                    self.release_url = self.url_base + "Release"
                    self._download(self.release_file, self.release_url)
                    self.signature_url = self.release_url + ".gpg"
                    self._download(self.signature_file, self.signature_url)
                except urllib.error.HTTPError as e:
                    raise mini_buildd.HTTPNotFound(f"{self.url_base}: Neither 'InRelease' nor 'Release[.gpg]' found") from e

            self.release = debian.deb822.Release(self.release_file)

        def close(self):
            for t in [self.release_file, self.signature_file]:
                if t is not None:
                    t.close()

        def match(self, source):
            LOG.debug(f"match(): {self.release_url} matches {source}?")

            # Check release file fields
            for key, value in list(source.mbd_release_file_values().items()):
                if value != self.release.get(key):
                    raise mini_buildd.HTTPNotFound(f"'{self.release_url}' does not match (has '{key}: {self.release.get(key)}', we need '{value}')")

            # Pre-Check 'Valid-Until'
            #
            # Some Release files contain an expire date via the
            # 'Valid-Until' tag. If such an expired archive is used,
            # builds will fail. Furthermore, it could be only the
            # selected archive not updating, while the source may be
            # perfectly fine from another archive.
            #
            # This pre-check avoids such a situation, or at least it
            # can be fixed by re-checking the source.
            try:
                valid_until = mini_buildd.misc.Datetime.parse(self.release["Valid-Until"])
                if valid_until < mini_buildd.misc.Datetime.now():
                    if source.mbd_get_extra_option("X-Check-Valid-Until", "yes").lower() in ("no", "false", "0"):
                        LOG.warning(f"{self.release_url} expired, but source marked to ignore valid-until (Valid-Until='{valid_until}').")
                    else:
                        LOG.warning(f"{self.release_url} expired, maybe the archive has problems? (Valid-Until='{valid_until}').")
                        raise mini_buildd.HTTPUnavailable(f"{self.release_url} expired")
            except KeyError:
                pass  # We can assume Release file has no "Valid-Until", and be quiet
            except BaseException as e:
                mini_buildd.log_exception(LOG, f"Ignoring error checking 'Valid-Until' on {self.release_url}", e)

        def verify(self, gnupg):
            return gnupg.gpgme_verify_release(self.release_file, self.signature_file if self.signature_url is not None else None, accept_expired=True)

    def mbd_ping(self):
        """Ping and update the ping value."""
        try:
            t0 = django.utils.timezone.now()
            # Append dists to URL for ping check: Archive may be
            # just fine, but not allow to access to base URL
            # (like ourselves ;). Any archive _must_ have dists/ anyway.
            try:
                with urllib.request.urlopen(f"{self.url}/dists/", timeout=self.URLOPEN_TIMEOUT) as _:
                    pass
            except urllib.error.HTTPError as e:
                # Allow HTTP 4xx client errors through; these might be valid use cases like:
                # 404 Usage Information: apt-cacher-ng
                if not 400 <= e.code <= 499:
                    raise

            delta = django.utils.timezone.now() - t0
            self.ping = delta.total_seconds() * (10 ** 3)
            self.save()
        except Exception as e:
            self.ping = -1.0
            self.save()
            raise mini_buildd.HTTPUnavailable(f"'{self}' does not ping: {e}") from e

    def mbd_get_reverse_dependencies(self):
        """Return all sources (and their deps) that use us."""
        result = list(self.source_set.all())
        for s in self.source_set.all():
            result += s.mbd_get_reverse_dependencies()
        return result


class Architecture(mini_buildd.models.base.Model):
    name = django.db.models.CharField(primary_key=True, max_length=50)

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


@functools.total_ordering
class Component(mini_buildd.models.base.Model):
    """
    A Debian component (like 'main', 'contrib', 'non-free').

    Sorting: 'main' should be first, the others in no special
    order. You may just use standard python sorted() on an iterable
    of components.
    """

    name = django.db.models.CharField(primary_key=True, max_length=50)

    def __str__(self):
        return str(self.name)

    __hash__ = mini_buildd.models.base.Model.__hash__

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return self.name == other.name
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, self.__class__):
            return self.name == "main"
        return NotImplemented


@functools.total_ordering
class Source(mini_buildd.models.base.StatusModel):
    MBD_HELP_EXTRA_OPTIONS = """
=================================================
Origin, Codename, Suite, Archive, Version, Label
=================================================
If needed, you may use these fields (same as in a Release file) to
further specify this source. These are also later used to pin the
source via apt.

For some sources, ``Codename`` (as we use it above) does
not match resp. value as given in the Release file. When
``Codename`` is overridden in this manner, be sure to also
add one further flag to identify the source -- else apt pinning later
would likely not be unambiguous. Real world examples that need this
extra handling are ``Debian Security`` (``buster`` or
older), ``Ubuntu Security and Backports``:
---
Codename: bionic
Suite: bionic-backports
---
=================================================
X-Check-Valid-Until: no
=================================================
Some sources have a ``Valid-Until`` field that is no longer
updated. If you really still want to use it anyway, use:
---
X-Check-Valid-Until: no
---
This will, 1st, ignore mini-buildd's own 'Valid-Until check' and 2nd,
create apt lines for this source with the ``[check-valid-until=no]``
option. I.e., at least from ``stretch`` onwards, the check is disabled
per source. For ``jessie`` or worse (where this apt option does not
work), a global workaround via schroot is still in place.

=================================================
X-Remove-From-Component: <prefix>
=================================================
Some (actually, we only know of ``Debian Security``) sources
have weird ``Components`` that need to be fixed to work with
mini-buildd. For example, ``Debian Security`` (buster or older)
needs:

---
Codename: stretch
Label: Debian-Security
X-Remove-From-Component: updates/
---

Fwiw, in ``Debian Security`` for ``bullseye`` or
younger, Codename is now unambiguous (like
``bullseye-security``), but the component oddity remains; so
these cases still need:

---
X-Remove-From-Component: updates/
---
"""

    # Identity
    origin = django.db.models.CharField(max_length=60, default="Debian",
                                        help_text="The exact string of the 'Origin' field of the resp. Release file.")
    codename = django.db.models.CharField(max_length=60, default="sid",
                                          help_text="""\
The name of the directory below ``dists/`` in archives this source
refers to. This is also the 3rd part of an apt line.

With no extra options given, this source will be identified comparing
``Origin`` and ``Codename`` with the values of the ``Release`` file
found.

Some sources need some more care via ``Extra options``, see below.

""")

    # Apt Secure
    apt_keys = django.db.models.ManyToManyField(mini_buildd.models.gnupg.AptKey, blank=True,
                                                help_text=("At least one key this source's 'Release' file is signed with (Hint: If you leave this empty and run 'check' on it, you will get a message listing all signer keys)."))

    # Extra
    description = django.db.models.CharField(max_length=100, editable=False, blank=True, default="")
    codeversion = django.db.models.CharField(max_length=50, editable=False, blank=True, default="")
    codeversion_override = django.db.models.CharField(
        max_length=50, blank=True, default="",
        help_text=("Save this as empty string to have the codeversion re-guessed on check, or\n"
                   "put your own override value here if the guessed string is broken. The\n"
                   "codeversion is only used for base sources.\n"))
    archives = django.db.models.ManyToManyField(Archive, blank=True)
    components = django.db.models.ManyToManyField(Component, blank=True)
    architectures = django.db.models.ManyToManyField(Architecture, blank=True)

    class Meta(mini_buildd.models.base.StatusModel.Meta):
        unique_together = ("origin", "codename")
        ordering = ["origin", "-codeversion", "codename"]

    class Admin(mini_buildd.models.base.StatusModel.Admin):
        list_display = mini_buildd.models.base.StatusModel.Admin.list_display + ["origin", "codeversion", "codename"]
        search_fields = ["origin", "codeversion", "codename"]
        ordering = ["origin", "-codeversion", "codename"]

        readonly_fields = ["codeversion", "archives", "components", "architectures", "description"]
        filter_horizontal = ("apt_keys",)

        def get_readonly_fields(self, _request, obj=None):
            """Forbid to change identity on existing source (usually a bad idea; repos/chroots that refer to us may break)."""
            fields = copy.copy(self.readonly_fields)
            if obj:
                fields.append("origin")
                fields.append("codename")
            return fields

        @classmethod
        def mbd_filter_active_base_sources(cls):
            """Filter active base sources; needed in chroot and distribution wizards."""
            return Source.objects.filter(status__gte=Source.STATUS_ACTIVE,
                                         origin__in=mini_buildd.dist.SETUP["origin"].keys(),
                                         codename__regex=r"^[a-z]+$").exclude(codename="experimental")

    def __str__(self):
        return f"{self.origin} '{self.codename}' ({self.codeversion})"

    def mbd_release_file_values(self):
        """Compute a dict of values a matching release file must have."""
        values = {k: v for k, v in self.mbd_get_extra_options().items() if not k.startswith("X-")}  # Keep "X-<header>" for special purposes. All other keys are like in a Release file.

        # Set Origin and Codename (may be overwritten) from fields
        values["Origin"] = self.origin
        if not values.get("Codename"):
            values["Codename"] = self.codename

        return values

    def mbd_get_archive(self):
        """Get fastest archive."""
        oa_list = self.archives.all().filter(ping__gte=0.0).order_by("ping")
        if oa_list:
            return oa_list[0]
        raise mini_buildd.HTTPBadRequest(f"{self}: No archive found (network down? ``Archive`` missing? ``Source`` with incorrect keys?)")

    def mbd_get_apt_line(self, limit_components=None):
        components = [c for c in self.components.all() if limit_components is None or c in limit_components]
        return mini_buildd.files.AptLine(self.mbd_get_archive().url,
                                         "",
                                         self.codename,
                                         components,
                                         options=f"check-valid-until={self.mbd_get_extra_option('X-Check-Valid-Until', 'yes')}",
                                         comment=f"{self}: {self.mbd_get_apt_pin()}")

    def mbd_get_apt_pin(self):
        """Apt 'pin line' (for use in a apt 'preference' file)."""
        # See man apt_preferences for the field/pin mapping
        supported_fields = {"Origin": "o", "Codename": "n", "Suite": "a", "Archive": "a", "Version": "v", "Label": "l"}
        pins = []
        for key, value in list(self.mbd_release_file_values().items()):
            k = supported_fields.get(key)
            if k:
                pins.append(f"{k}={value}")
        return "release " + ", ".join(pins)

    def mbd_prepare(self):
        self.clean()

    def mbd_sync(self):
        self._mbd_remove_and_prepare()

    def mbd_remove(self):
        self.archives.set([])
        self.components.set([])
        self.architectures.set([])
        self.description = ""

    def mbd_check(self):
        """Rescan all archives, and check that there is at least one working."""
        self.archives.set([])

        # Gather failed information to debug if no archive was found
        failed = {}

        with contextlib.closing(mini_buildd.gnupg.TmpGnuPG(tmpdir_options={"prefix": "gnupg-sourcecheck-"})) as gpg:
            for k in self.apt_keys.all():
                gpg.add_pub_key(k.key)

            for archive in Archive.objects.all():
                try:
                    archive.mbd_ping()

                    with closing(Archive.ReleaseFile(f"{archive.url}/dists/{self.codename}/")) as release_file:
                        release_file.match(self)
                        release_file.verify(gpg)

                        # Implicitly save ping value for this archive
                        self.archives.add(archive)
                        self.description = release_file.release.get("Description", "")

                        # Set codeversion
                        self.codeversion = ""
                        if self.codeversion_override:
                            self.codeversion = self.codeversion_override
                        else:
                            self.codeversion = mini_buildd.dist.guess_codeversion(release_file.release.get("Origin"), release_file.release.get("Codename"), release_file.release.get("Version"))
                            self.codeversion_override = self.codeversion

                        # Set architectures and components (may be auto-added)
                        if release_file.release.get("Architectures"):
                            for a in release_file.release["Architectures"].split(" "):
                                new_arch, _created = Architecture.objects.get_or_create(name=a)
                                self.architectures.add(new_arch)
                        if release_file.release.get("Components"):
                            for c in release_file.release["Components"].split(" "):
                                new_component, _created = Component.objects.get_or_create(name=c.replace(self.mbd_get_extra_option("X-Remove-From-Component", ""), ""))
                                self.components.add(new_component)
                except mini_buildd.HTTPError as e:
                    mini_buildd.log_exception(LOG, f"{self}: Skipping {archive}", e, level=logging.DEBUG)
                    failed[archive] = e

        # Check that at least one archive can be found
        try:
            fastest_archive = self.mbd_get_archive()
            LOG.debug(f"{self}: Fastest archive: {fastest_archive}")
        except mini_buildd.HTTPError as e:
            errs = '\n'.join([f'{archive}: {error}' for archive, error in failed.items()])
            raise mini_buildd.HTTPNotFound(f"{e.detail}:\n{errs}") from e

    def mbd_get_dependencies(self):
        return self.apt_keys.all()

    def mbd_get_reverse_dependencies(self):
        """Return all chroots and repositories that use us."""
        result = list(self.chroot_set.all())
        for d in self.distribution_set.all():
            result += d.mbd_get_reverse_dependencies()
        return result

    __hash__ = mini_buildd.models.base.StatusModel.__hash__

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return mini_buildd.dist.Codename(self.codename, origin=self.origin) == mini_buildd.dist.Codename(other.codename, origin=other.origin)
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, self.__class__):
            return mini_buildd.dist.Codename(self.codename, origin=self.origin) < mini_buildd.dist.Codename(other.codename, origin=other.origin)
        return NotImplemented

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        if self.mbd_get_extra_option("Origin"):
            raise django.core.exceptions.ValidationError({"extra_options": "You may not override 'Origin', just use the origin field."})


class PrioritySource(mini_buildd.models.base.Model):
    source = django.db.models.ForeignKey(Source,
                                         on_delete=django.db.models.CASCADE)
    priority = django.db.models.IntegerField(default=1,
                                             help_text="A apt pin priority value (see 'man apt_preferences')."
                                             "Examples: 1=not automatic, 1001=downgrade'")

    class Meta(mini_buildd.models.base.Model.Meta):
        unique_together = ('source', 'priority')

    def __str__(self):
        return f"{self.source} with prio={self.priority}"

    def mbd_get_apt_preferences(self):
        return f"Package: *\nPin: {self.source.mbd_get_apt_pin()}\nPin-Priority: {self.priority}\n"
