import copy
import os
import shlex
import contextlib
import glob
import logging
from contextlib import closing

import django.db.models

import mini_buildd.config
import mini_buildd.files
import mini_buildd.schroot
import mini_buildd.misc
import mini_buildd.call

import mini_buildd.models.base
import mini_buildd.models.source

LOG = logging.getLogger(__name__)


class Chroot(mini_buildd.models.base.StatusModel):
    PERSONALITIES = {'i386': 'linux32'}

    MBD_HELP_EXTRA_OPTIONS = """
Debootstrap-Command: <ALT_COMMAND>: Alternate command to run instead of standard debootstrap

  For example, ``Debootstrap-Command: /usr/sbin/mini-buildd-debootstrap-uname-2.6`` may be used as workaround for urold (``<= lenny``) distributions.

Debootstrap-Extra-Options: <EXTRA_OPTIONS>: Additional debootrap options

  For example, ``--include=foo,bar`` may be used to have these packages pre-installed in build chroots.
"""

    source = django.db.models.ForeignKey(mini_buildd.models.source.Source,
                                         on_delete=django.db.models.CASCADE,
                                         help_text=("Base source to create the chroot from; its codename must be 'debootstrapable'.\n"
                                                    "\n"
                                                    "Examples: Debian 'squeeze', Debian 'wheezy', Ubuntu 'hardy'.\n"))
    architecture = django.db.models.ForeignKey(mini_buildd.models.source.Architecture,
                                               on_delete=django.db.models.CASCADE,
                                               help_text=("Chroot's architecture; using the same arch as the host system\n"
                                                          "will always work, other architectures may work if supported. An\n"
                                                          "'amd64' host, for example, will also allow for architecture\n"
                                                          "'i386'.\n"))
    personality = django.db.models.CharField(max_length=50, editable=False, blank=True, default="",
                                             help_text=("Schroot 'personality' value (see 'schroot'); for 32bit chroots\n"
                                                        "running on a 64bit host, this must be 'linux32'.\n"))
    personality_override = django.db.models.CharField(max_length=50, blank=True, default="",
                                                      help_text=("Leave empty unless you want to override the automated way (via\n"
                                                                 "an internal mapping). Please report manual overrides so it can\n"
                                                                 "go to the default mapping.\n"))

    class Meta(mini_buildd.models.base.StatusModel.Meta):
        unique_together = ("source", "architecture")
        ordering = ["source", "architecture"]

    class Admin(mini_buildd.models.base.StatusModel.Admin):
        search_fields = ["source__codename", "architecture__name"]
        readonly_fields = ["personality"]

        def get_readonly_fields(self, _request, obj=None):
            """Forbid change source/arch on existing chroot (we would loose the path to the associated data)."""
            fields = copy.copy(self.readonly_fields)
            if obj:
                fields.append("source")
                fields.append("architecture")
            return fields

    def mbd_key(self):
        return f"{self.source.codename}:{self.architecture.name}"

    def __str__(self):
        return f"{self.source.origin} '{self.mbd_key()}'"

    @property
    def mbd_path(self):
        # Note: Property as this code must not be in __init__(): self.source, self.architecture may not be initialized (error on "add" in django admin).
        return mini_buildd.config.ROUTES["chroots"].path.join(self.source.codename, self.architecture.name)

    def mbd_get_backend(self):
        """Get actual class of (saved) instance (even if operating on base class ``Chroot`` only)."""
        for cls in ["filechroot", "dirchroot", "looplvmchroot", "lvmchroot", "btrfssnapshotchroot"]:
            if hasattr(self, cls):
                return getattr(self, cls)
        raise mini_buildd.HTTPBadRequest("No chroot backend found")

    def mbd_schroot_name(self):
        """
        Schroot name w/o namespace (see ``schroot --list``).

        Must produce same as :func:`~mini_buildd.changes.Buildrequest.schroot_name`.
        """
        return f"mini-buildd-{self.source.codename}-{self.architecture.name}"

    def mbd_get_tmp_dir(self):
        return os.path.join(self.mbd_path, "tmp")

    def mbd_get_schroot_conf_file(self):
        return os.path.join(self.mbd_path, "schroot.conf")

    def mbd_get_keyring_file(self):
        """Get keyring file path. Holds all keys from the source to verify the release via debootstrap's --keyring option."""
        return os.path.join(self.mbd_path, "keyring.gpg")

    def mbd_get_system_schroot_conf_file(self):
        return os.path.join("/etc/schroot/chroot.d", self.mbd_schroot_name() + ".conf")

    def mbd_get_pre_sequence(self):
        """Get preliminary sequence. Subclasses may implement this to do define an extra preliminary sequence."""
        LOG.debug(f"{self}: No pre-sequence defined.")
        return []

    def mbd_get_sequence(self):
        # In case a source was removed, we might not be able to get an archive URL for debootstrap.
        # We should be able to assemble the sequence anyway to be able to remove that chroot.
        try:
            debootstrap_url = self.source.mbd_get_archive().url
        except BaseException as e:
            LOG.warning(f"{self}: Can't get archive URL from source (source removed?): {e}")
            debootstrap_url = "no_archive_url_found_maybe_source_is_removed"

        debootstrap_extra_options = shlex.split(self.mbd_get_extra_option("Debootstrap-Extra-Options", ""))

        return [
            (["/bin/mkdir", "--verbose", self.mbd_get_tmp_dir()],
             ["/bin/rm", "--recursive", "--one-file-system", "--force", self.mbd_get_tmp_dir()])] + self.mbd_get_backend().mbd_get_pre_sequence() + [
                 ([self.mbd_get_extra_option("Debootstrap-Command", "/usr/sbin/debootstrap"),
                   "--variant", "buildd",
                   "--keyring", self.mbd_get_keyring_file(),
                   "--arch", self.architecture.name,
                   *debootstrap_extra_options,
                   self.source.codename,
                   self.mbd_get_tmp_dir(),
                   debootstrap_url],
                  ["/bin/umount", "-v", os.path.join(self.mbd_get_tmp_dir(), "proc"), os.path.join(self.mbd_get_tmp_dir(), "sys")])] + self.mbd_get_backend().mbd_get_post_sequence() + [
                      (["/bin/cp", "--verbose", self.mbd_get_schroot_conf_file(), self.mbd_get_system_schroot_conf_file()],
                       ["/bin/rm", "--verbose", self.mbd_get_system_schroot_conf_file()])]

    def mbd_prepare(self):
        os.makedirs(self.mbd_path, exist_ok=True)

        # Set personality
        self.personality = self.personality_override if self.personality_override else self.PERSONALITIES.get(self.architecture.name, "linux")

        mini_buildd.files.File("schroot.conf",
                               snippet=(f"[{self.mbd_schroot_name()}]\n"
                                        f"description=Mini-Buildd chroot {self.mbd_schroot_name()}\n"
                                        f"setup.fstab=mini-buildd/fstab\n"
                                        f"groups=sbuild\n"
                                        f"users=mini-buildd\n"
                                        f"root-groups=sbuild\n"
                                        f"root-users=mini-buildd\n"
                                        f"source-root-users=mini-buildd\n"
                                        f"personality={self.personality}\n"
                                        f"\n"
                                        f"# Backend specific config\n"
                                        f"{self.mbd_get_backend().mbd_get_schroot_conf()}\n")).save_as(self.mbd_get_schroot_conf_file())

        # Gen keyring file to use with debootstrap
        with contextlib.closing(mini_buildd.gnupg.TmpGnuPG(tmpdir_options={"prefix": "gnupg-debootstrap-"})) as gpg:
            for k in self.source.apt_keys.all():
                gpg.add_pub_key(k.key)
            gpg.export(self.mbd_get_keyring_file())

        mini_buildd.call.call_sequence(self.mbd_get_sequence(), run_as_root=True)

    def mbd_remove(self):
        mini_buildd.call.call_sequence(self.mbd_get_sequence(), rollback_only=True, run_as_root=True)
        mini_buildd.misc.rmdirs(self.mbd_path)

    def mbd_sync(self):
        self._mbd_remove_and_prepare()

    def mbd_backend_check(self):
        """Run backend check. Subclasses may implement this to do extra backend-specific checks."""
        LOG.debug(f"{self}: No backend check implemented.")

    def mbd_apt_line(self):
        return self.source.mbd_get_apt_line(limit_components=[mini_buildd.models.source.Component(name="main")]).get()

    def mbd_check(self):
        with closing(mini_buildd.schroot.Session(self.mbd_schroot_name(), "source")) as session:
            # Check for the old sudo workaround (chroots created by mini-buildd <= 1.0.4)
            session.check_sudo_workaround()

            # Basic function checks
            session.info()

            # Globally configure debconf frontend to noninteractive.
            # This should avoid possible stalls due to debconf interaction on apt commands on the chroot.
            # (While rare, this definitely occasionally happened with rolling distributions)
            # There is no good way via schroot to convey environment (like DEBIAN_FRONTEND) for specific commands.
            # Also, a generic approach via schroot.conf seems unwise (would need --preserve-environment, and then to manually maintain 'environment-filter' regex).
            session.set_debconf("debconf/frontend", "noninteractive")

            # Backend checks
            self.mbd_get_backend().mbd_backend_check()

            # Update base apt line (source might have switched to another archive).
            session.update_file("/etc/apt/sources.list", self.mbd_apt_line())

            # "apt update/upgrade" check
            for args, fatal in [(["update"], True),
                                (["--ignore-missing", "dist-upgrade"], True),
                                (["--purge", "autoremove"], False),
                                (["clean"], True)]:
                try:
                    session.run(
                        [
                            "--directory", "/",
                            "--",
                        ] + shlex.split(mini_buildd.sbuild.APT_GET) + args)
                except BaseException as e:
                    LOG.warning(f"'apt-get {' '.join(args)}' not supported in this chroot: {e}")
                    if fatal:
                        raise

    def mbd_get_dependencies(self):
        return [self.source]


class DirChroot(Chroot):
    """Directory chroot backend."""

    UNION_AUFS = 0
    UNION_OVERLAYFS = 1
    UNION_UNIONFS = 2
    UNION_OVERLAY = 3
    UNION_CHOICES = (
        (UNION_AUFS, "aufs"),
        (UNION_OVERLAYFS, "overlayfs"),
        (UNION_UNIONFS, "unionfs"),
        (UNION_OVERLAY, "overlay"))
    union_type = django.db.models.IntegerField(choices=UNION_CHOICES,
                                               default=mini_buildd.misc.guess_default_dirchroot_backend(overlay=UNION_OVERLAY, aufs=UNION_AUFS),
                                               help_text="""\
See 'man 5 schroot.conf'
""")

    class Meta(Chroot.Meta):
        pass

    class Admin(Chroot.Admin):
        pass

    def mbd_backend_flavor(self):
        return self.get_union_type_display()

    def mbd_get_chroot_dir(self):
        return os.path.join(self.mbd_path, "source")

    def mbd_get_schroot_conf(self):
        return (f"type=directory\n"
                f"directory={self.mbd_get_chroot_dir()}\n"
                f"union-type={self.get_union_type_display()}\n")

    def mbd_get_post_sequence(self):
        return [
            (["/bin/mv",
              "--verbose",
              self.mbd_get_tmp_dir(),
              self.mbd_get_chroot_dir()],
             ["/bin/rm", "--recursive", "--one-file-system", "--force", self.mbd_get_chroot_dir()])]


class FileChroot(Chroot):
    """File chroot backend."""

    COMPRESSION_NONE = 0
    COMPRESSION_GZIP = 1
    COMPRESSION_BZIP2 = 2
    COMPRESSION_XZ = 3
    COMPRESSION_CHOICES = (
        (COMPRESSION_NONE, "no compression"),
        (COMPRESSION_GZIP, "gzip"),
        (COMPRESSION_BZIP2, "bzip2"),
        (COMPRESSION_XZ, "xz"))

    compression = django.db.models.IntegerField(choices=COMPRESSION_CHOICES, default=COMPRESSION_NONE)

    TAR_ARGS = {
        COMPRESSION_NONE: [],
        COMPRESSION_GZIP: ["--gzip"],
        COMPRESSION_BZIP2: ["--bzip2"],
        COMPRESSION_XZ: ["--xz"]}
    TAR_SUFFIX = {
        COMPRESSION_NONE: "tar",
        COMPRESSION_GZIP: "tar.gz",
        COMPRESSION_BZIP2: "tar.bz2",
        COMPRESSION_XZ: "tar.xz"}

    class Meta(Chroot.Meta):
        pass

    class Admin(Chroot.Admin):
        pass

    def mbd_backend_flavor(self):
        return self.TAR_SUFFIX[self.compression]

    def mbd_get_tar_file(self):
        return os.path.join(self.mbd_path, "source." + self.TAR_SUFFIX[self.compression])

    def mbd_get_schroot_conf(self):
        return (f"type=file\n"
                f"file={self.mbd_get_tar_file()}\n")

    def mbd_get_post_sequence(self):
        return [
            (["/bin/tar",
              "--create",
              "--directory", self.mbd_get_tmp_dir(),
              "--file", self.mbd_get_tar_file()] + self.TAR_ARGS[self.compression] + ["."],
             []),
            (["/bin/rm", "--recursive", "--one-file-system", "--force", self.mbd_get_tmp_dir()],
             [])]


class LVMChroot(Chroot):
    """LVM chroot backend."""

    class Meta(Chroot.Meta):
        pass

    class Admin(Chroot.Admin):
        pass

    volume_group = django.db.models.CharField(max_length=80, default="auto",
                                              help_text="Give a pre-existing LVM volume group name. Just leave it on 'auto' for loop lvm chroots.")
    filesystem = django.db.models.CharField(max_length=10, default="ext2")
    snapshot_size = django.db.models.IntegerField(default=4,
                                                  help_text="Snapshot device file size in GB.")

    def mbd_backend_flavor(self):
        return f"lvm={self.volume_group}/{self.filesystem}/{self.snapshot_size}G"

    def mbd_get_volume_group(self):
        try:
            return self.looplvmchroot.mbd_get_volume_group()
        except BaseException:
            return self.volume_group

    def mbd_get_lvm_device(self):
        return f"/dev/{self.mbd_get_volume_group()}/{self.mbd_schroot_name()}"

    def mbd_get_schroot_conf(self):
        return (f"type=lvm-snapshot\n"
                f"device={self.mbd_get_lvm_device()}\n"
                f"mount-options=-t {self.filesystem} -o noatime,user_xattr\n"
                f"lvm-snapshot-options=--size {self.snapshot_size}G\n")

    def mbd_get_pre_sequence(self):
        if not os.path.exists("/sbin/lvcreate"):
            raise mini_buildd.HTTPUnavailable("LVM not installed")

        return [
            (["/sbin/lvcreate", "--size", f"{self.snapshot_size}G", "--name", self.mbd_schroot_name(), self.mbd_get_volume_group()],
             ["/sbin/lvremove", "--verbose", "--force", self.mbd_get_lvm_device()]),

            (["/sbin/mkfs", "-t", self.filesystem, self.mbd_get_lvm_device()],
             []),

            (["/bin/mount", "-v", "-t", self.filesystem, self.mbd_get_lvm_device(), self.mbd_get_tmp_dir()],
             ["/bin/umount", "-v", self.mbd_get_tmp_dir()])]

    def mbd_get_post_sequence(self):
        return [(["/bin/umount", "-v", self.mbd_get_tmp_dir()], [])]

    def mbd_backend_check(self):
        mini_buildd.call.Call(["/sbin/fsck", "-a", "-t", self.filesystem, self.mbd_get_lvm_device()],
                              run_as_root=True).check()


class LoopLVMChroot(LVMChroot):
    """Loop LVM chroot backend."""

    class Meta(LVMChroot.Meta):
        pass

    class Admin(LVMChroot.Admin):
        pass

    loop_size = django.db.models.IntegerField(default=100,
                                              help_text="Loop device file size in GB.")

    def mbd_backend_flavor(self):
        return f"{self.loop_size}G loop: {super().mbd_backend_flavor()}"

    def mbd_get_volume_group(self):
        return f"mini-buildd-loop-{self.source.codename}-{self.architecture.name}"

    def mbd_get_backing_file(self):
        return os.path.join(self.mbd_path, "lvmloop.image")

    def mbd_get_loop_device(self):
        for f in glob.glob("/sys/block/loop[0-9]*/loop/backing_file"):
            with mini_buildd.fopen(f) as ld:
                if os.path.realpath(ld.read().strip()) == os.path.realpath(self.mbd_get_backing_file()):
                    return "/dev/" + f.split("/")[3]
        LOG.debug(f"No existing loop device for {self.mbd_get_backing_file()}, searching for free device")
        return mini_buildd.call.Call(["/sbin/losetup", "--find"], run_as_root=True).check().stdout.rstrip()

    def mbd_get_pre_sequence(self):
        loop_device = self.mbd_get_loop_device()
        LOG.debug(f"Acting on loop device: {loop_device}")
        return [
            (["/bin/dd",
              "if=/dev/zero", f"of={self.mbd_get_backing_file()}",
              f"bs={self.loop_size}M",
              "seek=1024", "count=0"],
             ["/bin/rm", "--verbose", self.mbd_get_backing_file()]),

            (["/sbin/losetup", "--verbose", loop_device, self.mbd_get_backing_file()],
             ["/sbin/losetup", "--verbose", "--detach", loop_device]),

            (["/sbin/pvcreate", "--verbose", loop_device],
             ["/sbin/pvremove", "--verbose", loop_device]),

            (["/sbin/vgcreate", "--verbose", self.mbd_get_volume_group(), loop_device],
             ["/sbin/vgremove", "--verbose", "--force", self.mbd_get_volume_group()])] + super().mbd_get_pre_sequence()


class BtrfsSnapshotChroot(Chroot):
    """Btrfs Snapshot chroot backend."""

    class Meta(Chroot.Meta):
        pass

    class Admin(Chroot.Admin):
        pass

    @classmethod
    def mbd_backend_flavor(cls):
        return "btrfs_snapshot"

    def mbd_get_chroot_dir(self):
        return os.path.join(self.mbd_path, "source")

    def mbd_get_snapshot_dir(self):
        return os.path.join(self.mbd_path, "snapshot")

    def mbd_get_schroot_conf(self):
        return (f"type=btrfs-snapshot\n"
                f"btrfs-source-subvolume={self.mbd_get_chroot_dir()}\n"
                f"btrfs-snapshot-directory={self.mbd_get_snapshot_dir()}\n")

    def mbd_get_pre_sequence(self):
        return [
            (["/bin/btrfs", "subvolume", "create", self.mbd_get_chroot_dir()],
             ["/bin/btrfs", "subvolume", "delete", self.mbd_get_chroot_dir()]),

            (["/bin/mount", "-v", "-o", "bind", self.mbd_get_chroot_dir(), self.mbd_get_tmp_dir()],
             ["/bin/umount", "-v", self.mbd_get_tmp_dir()]),

            (["/bin/mkdir", "--verbose", self.mbd_get_snapshot_dir()],
             ["/bin/rm", "--recursive", "--one-file-system", "--force", self.mbd_get_snapshot_dir()])]

    def mbd_get_post_sequence(self):
        return [(["/bin/umount", "-v", self.mbd_get_tmp_dir()], [])]


def get(codename, architecture):
    return Chroot.objects.get(source__codename=codename, architecture__name=architecture)
