r"""
Generic module for models of the django app *mini_buildd*.

Naming conventions
==================

Model class and field names
---------------------------
All model class names and all field names must be **human
readable with no abbreviations** (as django, per default,
displays the internal names intelligently to the end user).

*Model class names* must be in **CamelCase**.

*Field names* must be all **lowercase** and **separated by underscores**.

For example, **don't** try to do sort of "grouping" using names like::

  email_notify
  email_allow_regex

This should rather read::

  notify
  allow_emails_to

To group fields together for the end user, use AdminModel's *fieldset* option.

Methods
-------
Any methods that represent mini-buildd logic should go into the models
directly, but must be prefixed with "mbd\_". This avoids conflicts
with method names form the django model's class, but still keeps the
logic where it belongs.

"""

import datetime
import re
import logging

import django.db.models
import django.contrib.admin
import django.contrib.messages
import django.template.response
import django.utils.timezone
import django.utils.html

import mini_buildd
import mini_buildd.config
from mini_buildd.builtins import mbd_cssmap

LOG = logging.getLogger(__name__)


def mbd_message(request, exception, prefix="", level=django.contrib.messages.ERROR, level_followups=django.contrib.messages.WARNING):
    """Convey exception to user via django messaging, with extra multiline ('followups') formatting support"""
    if request is not None:
        for s in str(mini_buildd.e2http(exception)).split("\n"):
            django.contrib.messages.add_message(request, level, django.utils.html.format_html(f"{prefix}{s}"))
            level = level_followups
            prefix = "↳ "


class Model(django.db.models.Model):
    """
    Abstract father model for all mini-buildd models.

    This just makes sure no config is changed or deleted while
    the daemon is running.
    """

    MBD_HELP_EXTRA_OPTIONS = ""  # Set this to non-empty in derived models to "activate"
    extra_options = django.db.models.TextField(blank=True, editable=True,
                                               help_text="Extra options (in the form ``<KEY>: <VALUE>``, one per line):")

    # - 1.0.x: Daemon: Kept last packages as pickled data (base64 encoded).
    # - 2.0.x: Daemon: Used to save a command line for api call "setup".
    MBD_HELP_PICKLED_DATA = ""  # Set this to non-empty in derived models to "activate"
    pickled_data = django.db.models.TextField(blank=True,
                                              verbose_name="Multi Purpose Field")

    class Meta():
        abstract = True
        app_label = "mini_buildd"

    class Admin(django.contrib.admin.ModelAdmin):
        @classmethod
        def _mbd_on_change(cls, obj):
            """Global actions to take when an object changes."""
            for o in obj.mbd_get_reverse_dependencies():
                if o.LETHAL_DEPENDENCIES:
                    o.mbd_set_changed()
                    o.save()

        @classmethod
        def _mbd_on_activation(cls, obj):
            """Global actions to take when an object becomes active."""

        @classmethod
        def _mbd_on_deactivation(cls, obj):
            """Global actions to take when an object becomes inactive."""

        def save_model(self, request, obj, form, change):
            if change:
                self._mbd_on_change(obj)
            super().save_model(request, obj, form, change)

        def delete_model(self, request, obj):
            self._mbd_on_change(obj)

            is_prepared_func = getattr(obj, "mbd_is_prepared", None)
            if is_prepared_func and is_prepared_func():
                self.mbd_remove(obj)

            super().delete_model(request, obj)

        # All below is only to automatically disable/reorder and get custom help texts for 'pickled_data' and 'extra_options'.
        def _mbd_help_fields(self):
            return {"pickled_data": self.model.MBD_HELP_PICKLED_DATA, "extra_options": self.model.MBD_HELP_EXTRA_OPTIONS}

        def get_fields(self, *args, **kwargs):
            """Make 'extra_options' and 'pickled_data' opt-on and last fields."""
            fields = super().get_fields(*args, **kwargs)

            for field, help_text in self._mbd_help_fields().items():
                if field in fields:
                    fields.remove(field)
                    if help_text:
                        fields.append(field)

            return fields

        def __init__(self, *args, **kwargs):
            """Postprocess fields help_text."""
            super().__init__(*args, **kwargs)

            help_fields = self._mbd_help_fields()
            patched = "_mbd_help_text_monkey_patched"
            for field_obj in self.model._meta.fields:
                if not hasattr(field_obj, patched):
                    if field_obj.name in help_fields and help_fields[field_obj.name]:
                        field_obj.help_text = f"{field_obj.help_text}\n{help_fields[field_obj.name]}"
                    field_obj.help_text = f"<pre>{django.utils.html.escape(field_obj.help_text)}</pre>"
                    setattr(field_obj, patched, True)

    def mbd_class_name(self):
        """Allow to get class name in templates."""
        return self.__class__.__name__

    def mbd_get_extra_options(self):
        """In case there are multiple entries, the last wins."""
        result = {}
        for line in self.extra_options.splitlines():
            lkey, _lsep, lvalue = line.partition(":")
            if lkey:
                result[lkey] = lvalue.lstrip()
        return result

    def mbd_get_extra_option(self, key, default=None):
        return self.mbd_get_extra_options().get(key, default)

    @classmethod
    def mbd_validate_regex(cls, regex, value, field_name):
        if not re.match(regex, value):
            raise django.core.exceptions.ValidationError({field_name: f"Field does not match regex {regex}"})

    def mbd_get_dependencies(self):
        return []

    def mbd_get_reverse_dependencies(self):
        return []

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

    @classmethod
    def mbd_diff(cls, obj, **defaults):
        default = cls(**defaults)

        diff = {}
        for field_name in defaults:
            try:
                current_value = getattr(obj, field_name, None)
                default_value = getattr(default, field_name, None)
                if current_value != default_value:
                    diff[field_name] = {
                        "current": str(current_value),
                        "default": str(default_value),
                    }
            except BaseException as e:
                diff[field_name] = {
                    "not_comparable": f"{field_name}: {e}",
                }
        return diff

    @classmethod
    def mbd_get_default(cls, field_name):
        return cls._meta.get_field(field_name).get_default()  # pylint: disable=protected-access


class StatusModel(Model):
    """Abstract model class for all models that carry a status. See Manual: :ref:`administrator:Models`."""

    # The main statuses: removed, prepared, active
    STATUS_REMOVED = 0
    STATUS_PREPARED = 1
    STATUS_ACTIVE = 2
    STATUS_CHOICES = [
        (STATUS_REMOVED, "Removed"),
        (STATUS_PREPARED, "Prepared"),
        (STATUS_ACTIVE, "Active")]
    status = django.db.models.IntegerField(choices=STATUS_CHOICES, default=STATUS_REMOVED, editable=False)

    # Statuses of the prepared data, relevant for status "Prepared" only.
    # For "Removed" it's always NONE, for "Active" it's always the stamp of the last check.
    CHECK_NONE = datetime.datetime(datetime.MINYEAR, 1, 1, tzinfo=datetime.timezone.utc)
    CHECK_CHANGED = datetime.datetime(datetime.MINYEAR, 1, 2, tzinfo=datetime.timezone.utc)
    CHECK_FAILED = datetime.datetime(datetime.MINYEAR, 1, 3, tzinfo=datetime.timezone.utc)
    CHECK_REACTIVATE = datetime.datetime(datetime.MINYEAR, 1, 4, tzinfo=datetime.timezone.utc)
    _CHECK_MAX = CHECK_REACTIVATE
    CHECK_STRINGS = {
        CHECK_NONE: {"char": "-", "string": "Unchecked -- please run check"},
        CHECK_CHANGED: {"char": "*", "string": "Changed -- please prepare again"},
        CHECK_FAILED: {"char": "x", "string": "Failed -- please fix and check again"},
        CHECK_REACTIVATE: {"char": "A", "string": "Failed in active state -- will auto-activate when check succeeds again"}}
    last_checked = django.db.models.DateTimeField(default=CHECK_NONE, editable=False)

    LETHAL_DEPENDENCIES = True

    # Obsoleted by CHECK_REACTIVATE prepared data state (but we need to keep it to not change the db scheme)
    auto_reactivate = django.db.models.BooleanField(default=False, editable=False)

    class Meta(Model.Meta):
        abstract = True

    class Admin(Model.Admin):
        def save_model(self, request, obj, form, change):
            if change:
                obj.mbd_set_changed()
            super().save_model(request, obj, form, change)

        @classmethod
        def _mbd_run_dependencies(cls, obj, func, **kwargs):
            """
            Run action for all dependencies.

            But don't fail and run all checks for models with
            LETHAL_DEPENDENCIES set to False. Practical use case is
            the Daemon model only, where we want to run all checks on
            all dependencies, but not fail ourselves.
            """
            for o in obj.mbd_get_dependencies():
                try:
                    func(o, **kwargs)
                except BaseException as e:
                    if obj.LETHAL_DEPENDENCIES:
                        raise
                    mini_buildd.log_exception(LOG, f"Check on '{o}' failed", e)

        @classmethod
        def mbd_prepare(cls, obj):
            if not obj.mbd_is_prepared():
                # Fresh prepare
                cls._mbd_run_dependencies(obj, cls.mbd_prepare)
                obj.mbd_prepare()
                obj.status, obj.last_checked = obj.STATUS_PREPARED, obj.CHECK_NONE
                obj.save()
            elif obj.mbd_is_changed():
                # Update data on change
                cls._mbd_run_dependencies(obj, cls.mbd_prepare)
                obj.mbd_sync()
                obj.status, obj.last_checked = obj.STATUS_PREPARED, obj.CHECK_NONE
                obj.save()

        @classmethod
        def mbd_check(cls, obj, force=False, needs_activation=False):
            if obj.mbd_is_prepared() and not obj.mbd_is_changed():
                try:
                    # Also run for all status dependencies
                    cls._mbd_run_dependencies(obj, cls.mbd_check,
                                              force=force,
                                              needs_activation=obj.mbd_is_active() or obj.last_checked == obj.CHECK_REACTIVATE)

                    if force or not obj.mbd_is_checked():
                        obj.mbd_check()

                        # Handle special flags
                        reactivated = False
                        if obj.last_checked == obj.CHECK_REACTIVATE:
                            obj.status = StatusModel.STATUS_ACTIVE
                            reactivated = True

                        # Finish up
                        obj.last_checked = django.utils.timezone.now()
                        obj.save()

                        # Run activation hook if reactivated
                        if reactivated:
                            cls._mbd_on_activation(obj)

                    if needs_activation and not obj.mbd_is_active():
                        raise mini_buildd.HTTPBadRequest(f"Not active, but a (tobe-)active item depends on it. Activate this first: {obj}")
                except BaseException:
                    # Check failed, auto-deactivate and re-raise exception
                    obj.last_checked = max(obj.last_checked, obj.CHECK_FAILED)
                    if obj.mbd_is_active():
                        obj.status, obj.last_checked = obj.STATUS_PREPARED, obj.CHECK_REACTIVATE
                    obj.save()
                    raise
            else:
                raise mini_buildd.HTTPBadRequest(f"Can't check removed or changed object (run 'prepare' first): {obj}")

        @classmethod
        def mbd_activate(cls, obj):
            if not obj.mbd_is_active():
                if obj.mbd_is_prepared() and obj.mbd_is_checked():
                    cls._mbd_run_dependencies(obj, cls.mbd_activate)
                    obj.status = obj.STATUS_ACTIVE
                    obj.save()
                elif obj.mbd_is_prepared() and obj.last_checked in [obj.CHECK_FAILED, obj.CHECK_NONE]:
                    obj.last_checked = obj.CHECK_REACTIVATE
                    obj.save()
                else:
                    raise mini_buildd.HTTPBadRequest(f"Prepare and check first: {obj}")

            if obj.mbd_is_active():
                cls._mbd_on_activation(obj)

        @classmethod
        def mbd_deactivate(cls, obj):
            obj.status = min(obj.STATUS_PREPARED, obj.status)
            if obj.last_checked == obj.CHECK_REACTIVATE:
                obj.last_checked = obj.CHECK_FAILED
            obj.save()
            cls._mbd_on_deactivation(obj)

        @classmethod
        def mbd_remove(cls, obj):
            if obj.mbd_is_prepared():
                obj.mbd_remove()
                obj.status, obj.last_checked = obj.STATUS_REMOVED, obj.CHECK_NONE
                obj.save()

        @classmethod
        def mbd_action(cls, request, queryset, action, **kwargs):
            """
            Try to run action on each object in queryset.

            Emit error message on failure, but don't fail ourself.
            """
            for o in queryset:
                try:
                    getattr(cls, "mbd_" + action)(o, **kwargs)
                    msg = f"{action} succeeded: {o}"
                    LOG.info(msg)
                    if request is not None:
                        django.contrib.messages.add_message(request, django.contrib.messages.INFO, msg)
                except BaseException as e:
                    msg = f"{action} failed: {o}"
                    mini_buildd.log_exception(LOG, msg, e)
                    mbd_message(request, e, msg + ": ")

        @classmethod
        def mbd_actions(cls, request, queryset, actions):
            for action in actions:
                cls.mbd_action(request, queryset, action)

        def mbd_action_prepare(self, request, queryset):
            self.mbd_action(request, queryset, "prepare")
        mbd_action_prepare.short_description = "Prepare"

        def mbd_action_check(self, request, queryset):
            self.mbd_action(request, queryset, "check", force=True)
        mbd_action_check.short_description = "Check"

        def mbd_action_activate(self, request, queryset):
            self.mbd_action(request, queryset, "activate")
        mbd_action_activate.short_description = "Activate"

        def mbd_action_deactivate(self, request, queryset):
            self.mbd_action(request, queryset, "deactivate")
        mbd_action_deactivate.short_description = "Deactivate"

        def mbd_action_remove(self, request, queryset):
            if request.POST.get("confirm"):
                self.mbd_action(request, queryset, "remove")
                return None
            return django.template.response.TemplateResponse(
                request,
                "admin/mini_buildd/mbd_action_remove_confirm.html",
                {
                    "title": ("Are you sure?"),
                    "queryset": queryset,
                    "action": "mbd_action_remove",
                    "desc": ("Unpreparing means all the data associated by preparation will be\n"
                             "removed from the system. Especially for repositories,\n"
                             "this would mean losing all packages!\n"),
                    "action_checkbox_name": django.contrib.admin.helpers.ACTION_CHECKBOX_NAME})
        mbd_action_remove.short_description = "Remove"

        def mbd_action_pc(self, request, queryset):
            self.mbd_actions(request, queryset, ["prepare", "check"])
        mbd_action_pc.short_description = "PC"

        def mbd_action_pca(self, request, queryset):
            self.mbd_actions(request, queryset, ["prepare", "check", "activate"])
        mbd_action_pca.short_description = "PCA"

        @classmethod
        def mbd_pca_all(cls, request):
            """Run prepare, check, and activate for all objects of this model."""
            cls.mbd_actions(request, cls.mbd_model.objects.all(), ["prepare", "check", "activate"])

        @classmethod
        def colored_status(cls, obj):
            return django.utils.html.format_html(f'<span class="{mbd_cssmap("model_status", obj.get_status_display())} {"mbd-attention" if obj.mbd_needs_attention() else ""}" title="{obj.mbd_get_status_display(typ="string")}">{obj.mbd_get_status_display(typ="char")}</span>')

        actions = [mbd_action_prepare, mbd_action_check, mbd_action_pc, mbd_action_activate, mbd_action_pca, mbd_action_deactivate, mbd_action_remove]
        list_display = ["colored_status", "__str__"]
        list_display_links = ["__str__"]

    def mbd_set_changed(self):
        if self.mbd_is_active():
            self.status = self.STATUS_PREPARED
            LOG.warning(f"Deactivated due to changes: {self}")
        self.last_checked = self.CHECK_CHANGED
        LOG.warning(f"Marked as changed: {self}")

    #
    # Action hooks helpers
    #
    def _mbd_remove_and_prepare(self):
        mini_buildd.models.base.StatusModel.Admin.mbd_remove(self)
        mini_buildd.models.base.StatusModel.Admin.mbd_prepare(self)

    #
    # Status abstractions and helpers
    #
    def mbd_is_prepared(self):
        return self.status >= self.STATUS_PREPARED

    def mbd_is_active(self):
        return self.status >= self.STATUS_ACTIVE

    def mbd_is_checked(self):
        return self.last_checked > self._CHECK_MAX

    def mbd_is_changed(self):
        return self.last_checked == self.CHECK_CHANGED

    def mbd_needs_attention(self):
        return self.last_checked == self.CHECK_REACTIVATE or self.mbd_is_changed()

    @classmethod
    def mbd_get_active(cls):
        return cls.objects.filter(status__gte=cls.STATUS_ACTIVE)

    @classmethod
    def mbd_get_active_or_auto_reactivate(cls):
        return cls.objects.filter(django.db.models.Q(status__gte=cls.STATUS_ACTIVE) | django.db.models.Q(last_checked=cls.CHECK_REACTIVATE))

    @classmethod
    def mbd_get_prepared(cls):
        return cls.objects.filter(status__gte=cls.STATUS_PREPARED)

    @classmethod
    def mbd_get_needs_attention(cls):
        return [o for o in cls.objects.all() if o.mbd_needs_attention()]

    @classmethod
    def mbd_attention(cls):
        """Return human-readable list of objects needing attention (one by line)."""
        return [f"{obj.__class__.__name__}: {obj} needs attention." for obj in cls.mbd_get_needs_attention()]

    def mbd_get_check_display(self, typ="string"):
        return {"string": self.last_checked.strftime("Checked on %Y-%m-%d %H:%M"), "char": "C"}[typ] if self.mbd_is_checked() else self.CHECK_STRINGS[self.last_checked][typ]

    def mbd_get_status_display(self, typ="string"):
        return f"{self.get_status_display()} ({self.mbd_get_check_display(typ)})"
