import json

import django
from django import forms, http
from django.conf import settings
from django.contrib import messages
from django.contrib.admin import RelatedFieldListFilter
from django.contrib.admin.actions import delete_selected
from django.contrib.admin.models import CHANGE, DELETION, LogEntry
from django.contrib.admin.options import (
    IncorrectLookupParameters,
    ModelAdmin,
    get_content_type_for_model,
)
from django.contrib.admin.utils import get_model_from_relation
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError, transaction
from django.db.models.fields.related import ForeignObjectRel, ManyToManyField
from django.utils.encoding import force_str, smart_str
from django.utils.html import format_html, mark_safe
from django.utils.translation import get_language_bidi, gettext as _, gettext_lazy
from js_asset import JS

from mptt.exceptions import InvalidMove
from mptt.forms import MPTTAdminForm, TreeNodeChoiceField
from mptt.models import MPTTModel, TreeForeignKey


__all__ = ("DraggableMPTTAdmin", "MPTTAdminForm", "MPTTModelAdmin")
IS_GRAPPELLI_INSTALLED = "grappelli" in settings.INSTALLED_APPS


class MPTTModelAdmin(ModelAdmin):
    """
    A basic admin class that displays tree items according to their position in
    the tree.  No extra editing functionality beyond what Django admin normally
    offers.
    """

    if IS_GRAPPELLI_INSTALLED:
        change_list_template = "admin/grappelli_mptt_change_list.html"
    else:
        change_list_template = "admin/mptt_change_list.html"

    form = MPTTAdminForm

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if (
            issubclass(db_field.remote_field.model, MPTTModel)
            and not isinstance(db_field, TreeForeignKey)
            and db_field.name not in self.raw_id_fields
        ):
            db = kwargs.get("using")

            limit_choices_to = db_field.get_limit_choices_to()
            defaults = {
                "form_class": TreeNodeChoiceField,
                "queryset": db_field.remote_field.model._default_manager.using(
                    db
                ).complex_filter(limit_choices_to),
                "required": False,
            }
            defaults.update(kwargs)
            kwargs = defaults
        return super().formfield_for_foreignkey(db_field, request, **kwargs)

    def get_ordering(self, request):
        """
        Changes the default ordering for changelists to tree-order.
        """
        mptt_opts = self.model._mptt_meta
        return self.ordering or (mptt_opts.tree_id_attr, mptt_opts.left_attr)

    def delete_selected_tree(self, modeladmin, request, queryset):
        """
        Deletes multiple instances and makes sure the MPTT fields get
        recalculated properly. (Because merely doing a bulk delete doesn't
        trigger the post_delete hooks.)
        """
        # If this is True, the confirmation page has been displayed
        if request.POST.get("post"):
            n = 0
            with queryset.model._tree_manager.delay_mptt_updates():
                for obj in queryset:
                    if self.has_delete_permission(request, obj):
                        obj_display = force_str(obj)
                        if django.VERSION < (5, 1):
                            self.log_deletion(request, obj, obj_display)
                        else:
                            LogEntry.objects.log_actions(
                                user_id=request.user.pk,
                                queryset=[obj],
                                action_flag=DELETION,
                                single_object=True,
                            )
                        obj.delete()
                        n += 1
            self.message_user(
                request, _("Successfully deleted %(count)d items.") % {"count": n}
            )
            # Return None to display the change list page again
            return None
        else:
            # (ab)using the built-in action to display the confirmation page
            return delete_selected(self, request, queryset)

    def get_actions(self, request):
        actions = super().get_actions(request)
        if actions is not None and "delete_selected" in actions:
            actions["delete_selected"] = (
                self.delete_selected_tree,
                "delete_selected",
                _("Delete selected %(verbose_name_plural)s"),
            )
        return actions


class DraggableMPTTAdmin(MPTTModelAdmin):
    """
    The ``DraggableMPTTAdmin`` modifies the standard Django administration
    change list to a drag-drop enabled interface.
    """

    change_list_template = None  # Back to default
    list_per_page = 2000  # This will take a really long time to load.
    list_display = ("tree_actions", "indented_title")  # Sane defaults.
    list_display_links = ("indented_title",)  # Sane defaults.
    mptt_level_indent = 20
    expand_tree_by_default = False

    def tree_actions(self, item):
        try:
            url = item.get_absolute_url()
        except Exception:  # Nevermind.
            url = ""

        return format_html(
            '<div class="drag-handle"></div>'
            '<div class="tree-node" data-pk="{}" data-level="{}"'
            ' data-url="{}"></div>',
            item.pk,
            item._mpttfield("level"),
            url,
        )

    tree_actions.short_description = ""

    def indented_title(self, item):
        """
        Generate a short title for an object, indent it depending on
        the object's depth in the hierarchy.
        """
        return format_html(
            '<div style="text-indent:{}px">{}</div>',
            item._mpttfield("level") * self.mptt_level_indent,
            item,
        )

    indented_title.short_description = gettext_lazy("title")

    def changelist_view(self, request, *args, **kwargs):
        if request.POST.get("cmd") == "move_node":
            return self._move_node(request)

        response = super().changelist_view(request, *args, **kwargs)

        try:
            response.context_data["media"] = response.context_data[
                "media"
            ] + forms.Media(
                css={
                    "all": ["mptt/draggable-admin.css"],
                },
                js=[
                    "admin/js/vendor/jquery/jquery.js",
                    "admin/js/jquery.init.js",
                    JS(
                        "mptt/draggable-admin.js",
                        {
                            "id": "draggable-admin-context",
                            "data-context": json.dumps(
                                self._tree_context(request), cls=DjangoJSONEncoder
                            ),
                        },
                    ),
                ],
            )
        except (AttributeError, KeyError):
            # Not meant for us if there is no context_data attribute (no
            # TemplateResponse) or no media in the context.
            pass

        return response

    def get_data_before_update(self, request, cut_item, pasted_on):
        mptt_opts = self.model._mptt_meta
        mptt_attr_fields = (
            "parent_attr",
            "left_attr",
            "right_attr",
            "tree_id_attr",
            "level_attr",
        )
        mptt_fields = [getattr(mptt_opts, attr) for attr in mptt_attr_fields]
        return {k: getattr(cut_item, k) for k in mptt_fields}

    def get_move_node_change_message(
        self, request, cut_item, pasted_on, data_before_update
    ):
        changed_fields = [
            k for k, v in data_before_update.items() if v != getattr(cut_item, k)
        ]
        return [{"changed": {"fields": changed_fields}}]

    @transaction.atomic
    def _move_node(self, request):
        position = request.POST.get("position")
        if position not in ("last-child", "left", "right"):
            self.message_user(
                request,
                _("Did not understand moving instruction."),
                level=messages.ERROR,
            )
            return http.HttpResponse("FAIL, unknown instruction.")

        queryset = self.get_queryset(request)
        try:
            cut_item = queryset.get(pk=request.POST.get("cut_item"))
            pasted_on = queryset.get(pk=request.POST.get("pasted_on"))
        except (self.model.DoesNotExist, TypeError, ValueError):
            self.message_user(
                request, _("Objects have disappeared, try again."), level=messages.ERROR
            )
            return http.HttpResponse("FAIL, invalid objects.")

        if not self.has_change_permission(request, cut_item):
            self.message_user(request, _("No permission"), level=messages.ERROR)
            return http.HttpResponse("FAIL, no permission.")

        data_before_update = self.get_data_before_update(request, cut_item, pasted_on)

        try:
            self.model._tree_manager.move_node(cut_item, pasted_on, position)
        except InvalidMove as e:
            self.message_user(request, "%s" % e, level=messages.ERROR)
            return http.HttpResponse("FAIL, invalid move.")
        except IntegrityError as e:
            self.message_user(
                request, _("Database error: %s") % e, level=messages.ERROR
            )
            raise

        change_message = self.get_move_node_change_message(
            request, cut_item, pasted_on, data_before_update
        )

        if django.VERSION < (5, 1):
            LogEntry.objects.log_action(
                user_id=request.user.pk,
                content_type_id=get_content_type_for_model(cut_item).pk,
                object_id=cut_item.pk,
                object_repr=str(cut_item),
                action_flag=CHANGE,
                change_message=change_message,
            )
        else:
            LogEntry.objects.log_actions(
                user_id=request.user.pk,
                queryset=[cut_item],
                action_flag=CHANGE,
                change_message=change_message,
                single_object=True,
            )
        self.message_user(request, _("%s has been successfully moved.") % cut_item)
        return http.HttpResponse("OK, moved.")

    def _tree_context(self, request):
        opts = self.model._meta

        return {
            "storageName": f"tree_{opts.app_label}_{opts.model_name}_collapsed",
            "treeStructure": self._build_tree_structure(self.get_queryset(request)),
            "levelIndent": self.mptt_level_indent,
            "messages": {
                "before": _("move node before node"),
                "child": _("move node to child position"),
                "after": _("move node after node"),
                "collapseTree": _("Collapse tree"),
                "expandTree": _("Expand tree"),
            },
            "expandTreeByDefault": self.expand_tree_by_default,
        }

    def _build_tree_structure(self, queryset):
        """
        Build an in-memory representation of the item tree, trying to keep
        database accesses down to a minimum. The returned dictionary looks like
        this (as json dump):

            {"6": [7, 8, 10]
             "7": [12],
             ...
             }

        Leaves are not included in the dictionary.
        """
        all_nodes = {}

        mptt_opts = self.model._mptt_meta
        items = queryset.values_list(
            "pk",
            "%s_id" % mptt_opts.parent_attr,
        )
        for p_id, parent_id in items:
            all_nodes.setdefault(
                str(parent_id) if parent_id else 0,
                [],
            ).append(p_id)
        return all_nodes


class TreeRelatedFieldListFilter(RelatedFieldListFilter):
    """
    Admin filter class which filters models related to parent model with all
    its descendants.

     Usage:

    from mptt.admin import TreeRelatedFieldListFilter

    @admin.register(models.MyModel)
    class MyModelAdmin(admin.ModelAdmin):
        model = models.MyModel
        list_filter =
        (
            ('my_related_model', TreeRelatedFieldListFilter),
        )
    """

    template = "admin/mptt_filter.html"
    mptt_level_indent = 10

    def __init__(self, field, request, params, model, model_admin, field_path):
        self.other_model = get_model_from_relation(field)
        if field.remote_field is not None and hasattr(
            field.remote_field, "get_related_field"
        ):
            self.rel_name = field.remote_field.get_related_field().name
        else:
            self.rel_name = self.other_model._meta.pk.name
        self.changed_lookup_kwarg = f"{field_path}__{self.rel_name}__inhierarchy"
        super().__init__(field, request, params, model, model_admin, field_path)
        self.lookup_val = request.GET.get(self.changed_lookup_kwarg)

    def expected_parameters(self):
        return [self.changed_lookup_kwarg, self.lookup_kwarg_isnull]

    # Ripped from contrib.admin.filters,FieldListFilter Django 1.8 to deal with
    # lookup name 'inhierarchy'
    def queryset(self, request, queryset):
        try:
            # #### MPTT ADDITION START
            if self.lookup_val:
                other_model = self.other_model.objects.get(pk=self.lookup_val)
                other_models = other_model.get_descendants(True)
                del self.used_parameters[self.changed_lookup_kwarg]
                self.used_parameters.update(
                    {f"{self.field_path}__{self.rel_name}__in": other_models}
                )
            # #### MPTT ADDITION END
            return queryset.filter(**self.used_parameters)
        except ValidationError as e:
            raise IncorrectLookupParameters(e)

    # Adding padding_style to each choice tuple
    def field_choices(self, field, request, model_admin):
        mptt_level_indent = getattr(
            model_admin, "mptt_level_indent", self.mptt_level_indent
        )
        language_bidi = get_language_bidi()
        initial_choices = field.get_choices(include_blank=False)
        pks = [pk for pk, val in initial_choices]
        models = field.related_model._default_manager.filter(pk__in=pks)
        levels_dict = {
            model.pk: getattr(model, model._mptt_meta.level_attr) for model in models
        }
        choices = []
        for pk, val in initial_choices:
            padding_style = ' style="padding-{}:{}px"'.format(
                "right" if language_bidi else "left",
                mptt_level_indent * levels_dict[pk],
            )
            choices.append((pk, val, mark_safe(padding_style)))
        return choices

    # Ripped from contrib.admin.filters,RelatedFieldListFilter Django 1.8 to
    # yield padding_style
    def choices(self, cl):
        # #### MPTT ADDITION START
        EMPTY_CHANGELIST_VALUE = self.empty_value_display
        # #### MPTT ADDITION END
        yield {
            "selected": self.lookup_val is None and not self.lookup_val_isnull,
            "query_string": cl.get_query_string(
                {}, [self.changed_lookup_kwarg, self.lookup_kwarg_isnull]
            ),
            "display": _("All"),
        }
        for pk_val, val, padding_style in self.lookup_choices:
            yield {
                "selected": self.lookup_val == smart_str(pk_val),
                "query_string": cl.get_query_string(
                    {
                        self.changed_lookup_kwarg: pk_val,
                    },
                    [self.lookup_kwarg_isnull],
                ),
                "display": val,
                # #### MPTT ADDITION START
                "padding_style": padding_style,
                # #### MPTT ADDITION END
            }
        if (
            isinstance(self.field, ForeignObjectRel)
            and (self.field.field.null or isinstance(self.field.field, ManyToManyField))
        ) or (
            self.field.remote_field is not None
            and (self.field.null or isinstance(self.field, ManyToManyField))
        ):
            yield {
                "selected": bool(self.lookup_val_isnull),
                "query_string": cl.get_query_string(
                    {
                        self.lookup_kwarg_isnull: "True",
                    },
                    [self.changed_lookup_kwarg],
                ),
                "display": EMPTY_CHANGELIST_VALUE,
            }
