import inspect
import collections
import logging
import shlex
import time
from contextlib import closing

import django.core.exceptions
import django.db.models

import mini_buildd.net
import mini_buildd.gnupg
import mini_buildd.client
from mini_buildd.api import Status, PubKey, Handshake

import mini_buildd.models.base

LOG = logging.getLogger(__name__)


class GnuPGPublicKey(mini_buildd.models.base.StatusModel):
    key_id = django.db.models.CharField(max_length=100, blank=True, default="",
                                        help_text="Give a key id here to retrieve the actual key automatically per configured key server.")
    key = django.db.models.TextField(blank=True, default="",
                                     help_text="ASCII-armored GnuPG public key. Leave the key id blank if you fill this manually.")

    key_long_id = django.db.models.CharField(max_length=254, blank=True, default="")
    key_created = django.db.models.CharField(max_length=254, blank=True, default="")
    key_expires = django.db.models.CharField(max_length=254, blank=True, default="")
    key_name = django.db.models.CharField(max_length=254, blank=True, default="")
    key_fingerprint = django.db.models.CharField(max_length=254, blank=True, default="")

    class Meta(mini_buildd.models.base.StatusModel.Meta):
        abstract = True
        app_label = "mini_buildd"

    class Admin(mini_buildd.models.base.StatusModel.Admin):
        search_fields = ["key_id", "key_long_id", "key_name", "key_fingerprint"]
        readonly_fields = ["key_long_id", "key_created", "key_expires", "key_name", "key_fingerprint"]

    def __str__(self):
        return f"{self.key_long_id if self.key_long_id else self.key_id}: {self.key_name}"

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        if self.key_id and len(self.key_id) < 8:
            raise django.core.exceptions.ValidationError({"key_id": "The key id, if given, must be at least 8 bytes long"})

    @classmethod
    def mbd_filter_key(cls, key_id):
        regex = rf"{key_id[-8:]}$"
        return cls.objects.filter(django.db.models.Q(key_long_id__iregex=regex) | django.db.models.Q(key_id__iregex=regex))

    def mbd_prepare(self):
        key, info = mini_buildd.gnupg.PublicKeyCache().keyinfo(self.key_id, self.key)
        self.key, self.key_long_id, self.key_name, self.key_fingerprint, self.key_created, self.key_expires = key, info["key"], info["user"], info["fingerprints"][0], info["created"], info["expires"]

    def mbd_remove(self):
        self.key_long_id = ""
        self.key_created = ""
        self.key_expires = ""
        self.key_name = ""
        self.key_fingerprint = ""

    def mbd_sync(self):
        self._mbd_remove_and_prepare()

    def mbd_check(self):
        """Check that we actually have the key and long_id. This should always be true after "prepare"."""
        if not self.key and not self.key_long_id:
            raise mini_buildd.HTTPBadRequest("GnuPG key with inconsistent state -- try remove,prepare to fix.")

    def mbd_verify(self, signed_message):
        with closing(mini_buildd.gnupg.TmpGnuPG(tmpdir_options={"prefix": "gnupg-pubkey-"})) as gnupg:
            gnupg.add_pub_key(self.key)
            data, _ = gnupg.gpgme_verify(signed_message)
            return data


class AptKey(GnuPGPublicKey):
    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)

        if self.key_id:
            matching_key = self.mbd_filter_key(self.key_id)
            if matching_key.count() > 0 and self.id != matching_key.get().id:
                raise django.core.exceptions.ValidationError({"key_id": f"Another such key id already exists: {matching_key[0]}"})


class KeyringKey(GnuPGPublicKey):
    """
    Abstract class for GnuPG keys that influence the daemon's keyring.

    This basically means changes to remotes and users may be
    done on the fly (without stopping the daemon), to make this
    maintenance practically usable.
    """

    class Meta(mini_buildd.models.base.StatusModel.Meta):
        abstract = True
        app_label = "mini_buildd"

    class Admin(GnuPGPublicKey.Admin):
        @classmethod
        def _mbd_on_change(cls, obj):
            pass

        @classmethod
        def _mbd_on_activation(cls, obj):
            cls._mbd_on_change(obj)

        @classmethod
        def _mbd_on_deactivation(cls, obj):
            cls._mbd_on_change(obj)


class Uploader(KeyringKey):
    user = django.db.models.OneToOneField(django.contrib.auth.models.User,
                                          on_delete=django.db.models.CASCADE)
    may_upload_to = django.db.models.ManyToManyField("Repository", blank=True)

    class Admin(KeyringKey.Admin):
        search_fields = KeyringKey.Admin.search_fields + ["user__username"]
        readonly_fields = KeyringKey.Admin.readonly_fields + ["user"]
        filter_horizontal = ("may_upload_to",)

    def __str__(self):
        return f"'{self.user}' may upload to '{','.join([r.identity for r in self.may_upload_to.all()])}' with key '{super().__str__()}'"


def cb_create_user_profile(sender, instance, created, **kwargs):
    """Automatically create a user profile with every user that is created."""
    if created:
        Uploader.objects.create(user=instance)


django.db.models.signals.post_save.connect(cb_create_user_profile, sender=django.contrib.auth.models.User)


class Remote(KeyringKey):
    http = django.db.models.CharField(primary_key=True, max_length=255, default="tcp:host=YOUR_REMOTE_HOST:port=8066",
                                      help_text=f"HTTP Remote Endpoint: {inspect.getdoc(mini_buildd.net.ClientEndpoint)}")

    wake_command = django.db.models.CharField(max_length=255, default="", blank=True, help_text="Any command to wake this remote. For example ``/usr/bin/wakeonlan -i DEST_IP MAC``.")

    class Admin(KeyringKey.Admin):
        search_fields = KeyringKey.Admin.search_fields + ["http"]
        readonly_fields = KeyringKey.Admin.readonly_fields + ["key", "key_id"]

    def mbd_url(self):
        return mini_buildd.net.ClientEndpoint(self.http, protocol="http").geturl()

    def mbd_api(self, command, timeout=None):
        return mini_buildd.client.Client(self.mbd_url()).api(command, timeout=timeout)

    def __str__(self):
        return self.mbd_url()

    def mbd_get_status(self, timeout=2, wake=False, wake_sleep=5, wake_attempts=2):
        attempt = 0
        while True:
            try:
                return self.mbd_api(Status(), timeout=timeout)
            except mini_buildd.HTTPUnavailable as e:
                if not wake or attempt >= wake_attempts:
                    raise
                if not self.wake_command:
                    raise mini_buildd.HTTPUnavailable(f"{e.rfc7807.detail} (no wake command configured for remote {self})")

                LOG.info(f"Trying to wake '{self.mbd_url()}' via '{self.wake_command}' (#{attempt}/{wake_attempts})...")
                mini_buildd.call.Call(shlex.split(self.wake_command)).check()
                attempt += 1
                time.sleep(wake_sleep)

    def mbd_prepare(self):
        # Be sure to wake if needed
        self.mbd_get_status(wake=True)

        # We prepare the GPG data from downloaded key data, so key_id _must_ be empty (see super(mbd_prepare))
        self.key_id = ""
        self.key = self.mbd_api(PubKey())

        if self.key:
            LOG.warning("Downloaded remote key integrated: Please check key manually before activation!")
        else:
            raise mini_buildd.HTTPBadRequest(f"Empty remote key from '{self.mbd_url()}' -- maybe the remote is not prepared yet?")
        super().mbd_prepare()

    def mbd_check(self):
        """Check whether the remote mini-buildd is up, running and serving for us."""
        super().mbd_check()

        # Get status, wake if needed
        status = self.mbd_get_status(wake=True)

        # GnuPG handshake
        signed_message = self.mbd_api(Handshake(signed_message=mini_buildd.get_daemon().handshake_message()))
        self.mbd_verify(signed_message)

        # Check if our instance is activated on remote
        if mini_buildd.http_endpoint().geturl() not in status["remotes"]:
            raise mini_buildd.HTTPBadRequest(f"{self}: Seems to be deactivated on remote instance.")

        # Check if our instance is powered on
        if status["load"] < 0.0:
            raise mini_buildd.HTTPBadRequest(f"{self}: Remote is powered off.")


Builder = collections.namedtuple("Builder", "remote status exception ourselves")


class Builders(list):
    def __init__(self, check=False):
        super().__init__()
        self.add(Remote(http=mini_buildd.http_endpoint().geturl(), status=Remote.STATUS_ACTIVE, last_checked=django.utils.timezone.now()), ourselves=True)
        for r in Remote.objects.all():
            self.add(r, check=check)

    def add(self, r, ourselves=False, check=False):
        try:
            if check:
                r.mbd_check(force=True)
            status = r.mbd_get_status()
            self.append(Builder(r, status, None, ourselves))
        except BaseException as e:
            LOG.warning(f"{r}: {e}")
            self.append(Builder(r, None, mini_buildd.e2http(e), ourselves))

    def get(self, codename, arch):
        """Get a dict {<load>: <status>} of active builders for <codename>/<arch>."""
        return {builder.status["load"]: builder.status for builder in self if builder.remote.mbd_is_active() and builder.status and builder.status["load"] >= 0.0 and f"{codename}:{arch}" in builder.status["chroots"]}
