import warnings
from collections import namedtuple

from django.contrib.admin.utils import quote, unquote
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin
from django.views.generic.list import BaseListView
from django_filters.filters import (
    ChoiceFilter,
    DateFromToRangeFilter,
    ModelChoiceFilter,
    ModelMultipleChoiceFilter,
    MultipleChoiceFilter,
)

from wagtail.admin import messages
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.ui.tables import Column, Table
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.admin.widgets.button import ButtonWithDropdown
from wagtail.search.backends import get_search_backend
from wagtail.search.index import class_is_indexed
from wagtail.utils.utils import flatten_choices


class WagtailAdminTemplateMixin(TemplateResponseMixin, ContextMixin):
    """
    Mixin for views that render a template response using the standard Wagtail admin
    page furniture.
    Provides accessors for page title, subtitle and header icon.
    """

    page_title = ""
    page_subtitle = ""
    header_icon = ""
    # Breadcrumbs are opt-in until we have a design that can be consistently applied
    _show_breadcrumbs = False
    breadcrumbs_items = [{"url": reverse_lazy("wagtailadmin_home"), "label": _("Home")}]
    template_name = "wagtailadmin/generic/base.html"
    header_buttons = []
    header_more_buttons = []

    def get_page_title(self):
        return self.page_title

    def get_page_subtitle(self):
        return self.page_subtitle

    def get_header_title(self):
        title = self.get_page_title()
        subtitle = self.get_page_subtitle()
        if subtitle:
            title = f"{title}: {subtitle}"
        return title

    def get_header_icon(self):
        return self.header_icon

    def get_breadcrumbs_items(self):
        return self.breadcrumbs_items

    def get_header_buttons(self):
        buttons = sorted(self.header_buttons)

        more_buttons = self.get_header_more_buttons()
        if more_buttons:
            buttons.append(
                ButtonWithDropdown(
                    buttons=more_buttons,
                    icon_name="dots-horizontal",
                    attrs={"aria-label": _("Actions")},
                    classname="w-h-slim-header",
                )
            )
        return buttons

    def get_header_more_buttons(self):
        return sorted(self.header_more_buttons)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # These are only used for legacy header.html
        # and view templates that don't use "wagtailadmin/generic/base.html"
        context["page_title"] = self.get_page_title()
        context["page_subtitle"] = self.get_page_subtitle()
        context["header_icon"] = self.get_header_icon()

        # Once all appropriate views use "wagtailadmin/generic/base.html" and
        # the slim_header.html, _show_breadcrumbs can be removed
        context["header_title"] = self.get_header_title()
        context["breadcrumbs_items"] = None
        if self._show_breadcrumbs:
            context["breadcrumbs_items"] = self.get_breadcrumbs_items()
            context["header_buttons"] = self.get_header_buttons()
        return context

    def get_template_names(self):
        # Instead of always wrapping self.template_name in a list like
        # TemplateResponseMixin does, only do so if it's not already a list/tuple.
        # This allows us to use a list of template names in self.template_name.
        if isinstance(self.template_name, (list, tuple)):
            return self.template_name
        return super().get_template_names()


class BaseObjectMixin:
    """Mixin for views that make use of a model instance."""

    model = None
    pk_url_kwarg = "pk"

    def setup(self, request, *args, **kwargs):
        super().setup(request, *args, **kwargs)
        self.pk = self.get_pk()
        self.object = self.get_object()
        self.model_opts = self.object._meta

    def get_pk(self):
        return unquote(str(self.kwargs[self.pk_url_kwarg]))

    def get_base_object_queryset(self):
        return self.model._default_manager.all()

    def get_object(self):
        if not self.model:
            raise ImproperlyConfigured(
                "Subclasses of wagtail.admin.views.generic.base.BaseObjectMixin must provide a "
                "model attribute or a get_object method"
            )
        return get_object_or_404(self.get_base_object_queryset(), pk=self.pk)


class BaseOperationView(BaseObjectMixin, View):
    """Base view to perform an operation on a model instance using a POST request."""

    success_message = None
    success_message_extra_tags = ""
    success_url_name = None

    def setup(self, request, *args, **kwargs):
        super().setup(request, *args, **kwargs)
        self.next_url = get_valid_next_url_from_request(request)

    def perform_operation(self):
        raise NotImplementedError

    def get_success_message(self):
        return self.success_message

    def add_success_message(self):
        success_message = self.get_success_message()
        if success_message:
            messages.success(
                self.request,
                success_message,
                extra_tags=self.success_message_extra_tags,
            )

    def get_success_url(self):
        if not self.success_url_name:
            raise ImproperlyConfigured(
                "Subclasses of wagtail.admin.views.generic.base.BaseOperationView must provide a "
                "success_url_name attribute or a get_success_url method"
            )
        if self.next_url:
            return self.next_url
        return reverse(self.success_url_name, args=[quote(self.object.pk)])

    def post(self, request, *args, **kwargs):
        self.perform_operation()
        self.add_success_message()
        return redirect(self.get_success_url())


# Represents a django-filters filter that is currently in force on a listing queryset
ActiveFilter = namedtuple(
    "ActiveFilter", ["auto_id", "field_label", "value", "removed_filter_url"]
)


class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
    template_name = "wagtailadmin/generic/listing.html"
    results_template_name = "wagtailadmin/generic/listing_results.html"
    results_only = False  # If true, just render the results as an HTML fragment
    table_class = Table
    table_classname = None
    columns = [Column("__str__", label=_("Title"))]
    index_url_name = None
    index_results_url_name = None
    page_kwarg = "p"
    is_searchable = None  # Subclasses must explicitly set this to True to enable search
    search_kwarg = "q"
    search_fields = None
    search_backend_name = "default"
    default_ordering = None
    filterset_class = None
    verbose_name_plural = None
    _show_breadcrumbs = True

    def get_template_names(self):
        if self.results_only:
            if isinstance(self.results_template_name, (list, tuple)):
                return self.results_template_name
            return [self.results_template_name]
        else:
            return super().get_template_names()

    def get_breadcrumbs_items(self):
        return self.breadcrumbs_items + [
            {
                "url": "",
                "label": self.get_page_title(),
                "sublabel": self.get_page_subtitle(),
            },
        ]

    def get_search_form(self):
        if not self.is_searchable:
            return None

        if self.search_kwarg in self.request.GET:
            return SearchForm(self.request.GET)

        return SearchForm()

    @cached_property
    def search_form(self):
        return self.get_search_form()

    @cached_property
    def search_query(self):
        if self.search_form and self.search_form.is_valid():
            return self.search_form.cleaned_data[self.search_kwarg]
        return ""

    @cached_property
    def is_searching(self):
        return bool(self.search_query)

    def search_queryset(self, queryset):
        if not self.is_searching:
            return queryset

        # Use Wagtail Search if the model is indexed and a search backend is defined.
        # Django ORM can still be used on an indexed model by unsetting
        # search_backend_name and defining search_fields on the view.
        if class_is_indexed(queryset.model) and self.search_backend_name:
            search_backend = get_search_backend(self.search_backend_name)
            if queryset.model.get_autocomplete_search_fields():
                return search_backend.autocomplete(
                    self.search_query,
                    queryset,
                    fields=self.search_fields,
                    order_by_relevance=(not self.is_explicitly_ordered),
                )
            else:
                # fall back on non-autocompleting search
                warnings.warn(
                    f"{queryset.model} is defined as Indexable but does not specify "
                    "any AutocompleteFields. Searches within the admin will only "
                    "respond to complete words.",
                    category=RuntimeWarning,
                )
                return search_backend.search(
                    self.search_query,
                    queryset,
                    fields=self.search_fields,
                    order_by_relevance=(not self.is_explicitly_ordered),
                )

        query = Q()
        for field in self.search_fields or []:
            query |= Q(**{field + "__icontains": self.search_query})
        return queryset.filter(query)

    @cached_property
    def filters(self):
        if self.filterset_class:
            filterset = self.filterset_class(**self.get_filterset_kwargs())
            # Don't use the filterset if it has no fields
            if filterset.form.fields:
                return filterset

    @cached_property
    def is_filtering(self):
        # we are filtering if the filter form has changed from its default state
        return (
            self.filters and self.filters.is_valid() and self.filters.form.has_changed()
        )

    def get_filterset_kwargs(self):
        return {
            "data": self.request.GET,
            "request": self.request,
        }

    def filter_queryset(self, queryset):
        if self.filters and self.filters.is_valid():
            queryset = self.filters.filter_queryset(queryset)
        return queryset

    def get_url_without_filter_param(self, param):
        """
        Return the index URL with the given filter parameter removed from the query string
        """
        base_url = self.index_results_url.split("?")[0]
        query_dict = self.request.GET.copy()
        query_dict.pop(self.page_kwarg, None)  # reset pagination to first page
        if isinstance(param, (list, tuple)):
            for p in param:
                query_dict.pop(p, None)
        else:
            query_dict.pop(param, None)
        query_dict["_w_filter_fragment"] = 1
        return base_url + "?" + query_dict.urlencode()

    def get_url_without_filter_param_value(self, param, value):
        """
        Return the index URL where the filter parameter with the given value has been removed
        from the query string, preserving all other values for that parameter
        """
        base_url = self.index_results_url.split("?")[0]
        query_dict = self.request.GET.copy()
        query_dict.pop(self.page_kwarg, None)  # reset pagination to first page
        query_dict.setlist(
            param, [v for v in query_dict.getlist(param) if v != str(value)]
        )
        query_dict["_w_filter_fragment"] = 1
        return base_url + "?" + query_dict.urlencode()

    @cached_property
    def active_filters(self):
        filters = []

        if not self.filters:
            return filters

        for field_name in self.filters.form.changed_data:
            filter_def = self.filters.filters[field_name]
            bound_field = self.filters.form[field_name]
            try:
                value = self.filters.form.cleaned_data[field_name]
            except KeyError:
                continue  # invalid filter value

            if value == bound_field.initial:
                continue  # filter value is the same as the default

            if isinstance(filter_def, ModelMultipleChoiceFilter):
                field = filter_def.field
                for item in value:
                    filters.append(
                        ActiveFilter(
                            bound_field.auto_id,
                            filter_def.label,
                            field.label_from_instance(item),
                            self.get_url_without_filter_param_value(
                                field_name, item.pk
                            ),
                        )
                    )
            elif isinstance(filter_def, MultipleChoiceFilter):
                choices = flatten_choices(filter_def.field.choices)
                for item in value:
                    filters.append(
                        ActiveFilter(
                            bound_field.auto_id,
                            filter_def.label,
                            choices.get(str(item), str(item)),
                            self.get_url_without_filter_param_value(field_name, item),
                        )
                    )
            elif isinstance(filter_def, ModelChoiceFilter):
                field = filter_def.field
                filters.append(
                    ActiveFilter(
                        bound_field.auto_id,
                        filter_def.label,
                        field.label_from_instance(value),
                        self.get_url_without_filter_param(field_name),
                    )
                )
            elif isinstance(filter_def, DateFromToRangeFilter):
                start_date_display = date_format(value.start) if value.start else ""
                end_date_display = date_format(value.stop) if value.stop else ""
                widget = filter_def.field.widget
                filters.append(
                    ActiveFilter(
                        bound_field.auto_id,
                        filter_def.label,
                        f"{start_date_display} - {end_date_display}",
                        self.get_url_without_filter_param(
                            [
                                widget.suffixed(field_name, suffix)
                                for suffix in widget.suffixes
                            ]
                        ),
                    )
                )
            elif isinstance(filter_def, ChoiceFilter):
                choices = flatten_choices(filter_def.field.choices)
                filters.append(
                    ActiveFilter(
                        bound_field.auto_id,
                        filter_def.label,
                        choices.get(str(value), str(value)),
                        self.get_url_without_filter_param(field_name),
                    )
                )
            else:
                filters.append(
                    ActiveFilter(
                        bound_field.auto_id,
                        filter_def.label,
                        str(value),
                        self.get_url_without_filter_param(field_name),
                    )
                )

        return filters

    def get_valid_orderings(self):
        orderings = []
        for col in self.columns:
            if col.sort_key:
                orderings.append(col.sort_key)
                orderings.append("-%s" % col.sort_key)
        return orderings

    @cached_property
    def is_explicitly_ordered(self):
        return "ordering" in self.request.GET

    def get_ordering(self):
        ordering = self.request.GET.get("ordering", self.default_ordering)
        if ordering not in self.get_valid_orderings():
            ordering = self.default_ordering
        return ordering

    @cached_property
    def ordering(self):
        return self.get_ordering()

    def order_queryset(self, queryset):
        if not self.ordering:
            return queryset

        ordering = self.ordering
        if not isinstance(ordering, (list, tuple)):
            ordering = (ordering,)
        return queryset.order_by(*ordering)

    def get_base_queryset(self):
        if self.queryset is not None:
            queryset = self.queryset
            if isinstance(queryset, models.QuerySet):
                queryset = queryset.all()
        elif self.model is not None:
            queryset = self.model._default_manager.all()
        else:
            raise ImproperlyConfigured(
                "%(cls)s is missing a QuerySet. Define "
                "%(cls)s.model, %(cls)s.queryset, or override "
                "%(cls)s.get_queryset()." % {"cls": self.__class__.__name__}
            )
        return queryset

    def get_queryset(self):
        # Instead of calling super().get_queryset(), we copy the initial logic from Django's
        # MultipleObjectMixin into get_base_queryset(). This allows us to perform additional steps
        # before the ordering step (such as annotations), and funnel the call to get_ordering()
        # through the cached property self.ordering so that we don't have to worry about calling
        # get_ordering() multiple times.
        # https://github.com/django/django/blob/stable/4.1.x/django/views/generic/list.py#L22-L47

        queryset = self.get_base_queryset()
        queryset = self.order_queryset(queryset)
        queryset = self.filter_queryset(queryset)
        queryset = self.search_queryset(queryset)
        return queryset

    def paginate_queryset(self, queryset, page_size):
        paginator = self.get_paginator(
            queryset,
            page_size,
            orphans=self.get_paginate_orphans(),
            allow_empty_first_page=self.get_allow_empty(),
        )

        page_number = self.request.GET.get(self.page_kwarg)
        page = paginator.get_page(page_number)
        return (paginator, page, page.object_list, page.has_other_pages())

    def get_table_kwargs(self):
        return {
            "ordering": self.ordering,
            "classname": self.table_classname,
            "base_url": self.index_url,
        }

    def get_table(self, object_list):
        return self.table_class(
            self.columns,
            object_list,
            **self.get_table_kwargs(),
        )

    @cached_property
    def index_url(self):
        return self.get_index_url()

    def get_index_url(self):
        if self.index_url_name:
            return reverse(self.index_url_name)

    @cached_property
    def index_results_url(self):
        return self.get_index_results_url()

    def get_index_results_url(self):
        if self.index_results_url_name:
            return reverse(self.index_results_url_name)

    @cached_property
    def no_results_message(self):
        if not self.verbose_name_plural:
            return _("There are no results.")

        if self.is_searching or self.is_filtering:
            return _("No %(model_name)s match your query.") % {
                "model_name": self.verbose_name_plural
            }

        return _("There are no %(model_name)s to display.") % {
            "model_name": self.verbose_name_plural
        }

    def get_context_data(self, *args, **kwargs):
        context = super().get_context_data(*args, **kwargs)

        table = self.get_table(context["object_list"])

        context["index_url"] = self.index_url
        context["index_results_url"] = self.index_results_url
        context["verbose_name_plural"] = self.verbose_name_plural
        context["no_results_message"] = self.no_results_message
        context["ordering"] = self.ordering
        context["table"] = table
        context["media"] = table.media
        # On Django's BaseListView, a listing where pagination is applied, but the results
        # only run to a single page, is considered is_paginated=False. Override this to
        # always consider a listing to be paginated if pagination is applied. This ensures
        # that we output "Page 1 of 1" as is standard in Wagtail.
        context["is_paginated"] = context["page_obj"] is not None

        if context["is_paginated"]:
            context["items_count"] = context["paginator"].count
        else:
            context["items_count"] = len(context["object_list"])

        if self.filters:
            context["filters"] = self.filters
            context["is_filtering"] = self.is_filtering
            context["media"] += self.filters.form.media

        if self.search_form:
            context["search_form"] = self.search_form
            context["is_searching"] = self.is_searching
            context["query_string"] = self.search_query
            context["media"] += self.search_form.media

        # If we're rendering the results as an HTML fragment, the caller can pass a _w_filter_fragment=1
        # URL parameter to indicate that the filters should be rendered as a <template> block so that
        # we can replace the existing filters.
        context["render_filters_fragment"] = (
            self.request.GET.get("_w_filter_fragment")
            and self.filters
            and self.results_only
        )
        # Ensure that the header buttons get re-rendered for the results-only view,
        # in case they make use of the search/filter state
        context["render_buttons_fragment"] = (
            context.get("header_buttons") and self.results_only
        )

        return context
