from itertools import groupby

from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Min
from django.template.loader import render_to_string
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy

from wagtail.admin.forms.formsets import BaseFormSetMixin
from wagtail.models import (
    Collection,
    CollectionViewRestriction,
    GroupCollectionPermission,
)

from .view_restrictions import BaseViewRestrictionForm


class CollectionViewRestrictionForm(BaseViewRestrictionForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if not getattr(settings, "WAGTAILDOCS_PRIVATE_COLLECTION_OPTIONS", {}).get(
            "SHARED_PASSWORD",
            True,
        ):
            self.fields["restriction_type"].choices = [
                choice
                for choice in CollectionViewRestriction.RESTRICTION_CHOICES
                if choice[0] != CollectionViewRestriction.PASSWORD
            ]
            del self.fields["password"]

    class Meta:
        model = CollectionViewRestriction
        fields = ("restriction_type", "password", "groups")


class SelectWithDisabledOptions(forms.Select):
    """
    Subclass of Django's select widget that allows disabling options.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.disabled_values = ()

    def create_option(self, name, value, *args, **kwargs):
        option_dict = super().create_option(name, value, *args, **kwargs)
        if value in self.disabled_values:
            option_dict["attrs"]["disabled"] = "disabled"
        return option_dict


class CollectionChoiceField(forms.ModelChoiceField):
    widget = SelectWithDisabledOptions

    def __init__(self, *args, disabled_queryset=None, **kwargs):
        super().__init__(*args, **kwargs)
        self._indentation_start_depth = 2
        self.disabled_queryset = disabled_queryset

    def _get_disabled_queryset(self):
        return self._disabled_queryset

    def _set_disabled_queryset(self, queryset):
        self._disabled_queryset = queryset
        if queryset is None:
            self.widget.disabled_values = ()
        else:
            self.widget.disabled_values = queryset.values_list(
                self.to_field_name or "pk", flat=True
            )

    disabled_queryset = property(_get_disabled_queryset, _set_disabled_queryset)

    def _set_queryset(self, queryset):
        min_depth = self.queryset.aggregate(Min("depth"))["depth__min"]
        if min_depth is None:
            self._indentation_start_depth = 2
        else:
            self._indentation_start_depth = min_depth + 1

    def label_from_instance(self, obj):
        return obj.get_indented_name(self._indentation_start_depth, html=True)


class CollectionForm(forms.ModelForm):
    parent = CollectionChoiceField(
        label=gettext_lazy("Parent"),
        queryset=Collection.objects.all(),
        required=True,
        help_text=gettext_lazy(
            "Select hierarchical position. Note: a collection cannot become a child of itself or one of its "
            "descendants."
        ),
    )

    class Meta:
        model = Collection
        fields = ("name",)

    def clean_parent(self):
        """
        Our rules about where a user may add or move a collection are as follows:
            1. The user must have 'add' permission on the parent collection (or its ancestors)
            2. We are not moving a collection used to assign permissions for this user
            3. We are not trying to move a collection to be parented by one of their descendants

        The first 2 items are taken care in the Create and Edit views by deleting the 'parent' field
        from the edit form if the user cannot move the collection. This causes Django's form
        machinery to ignore the parent field for parent regardless of what the user submits.
        This methods enforces rule #3 when we are editing an existing collection.
        """
        parent = self.cleaned_data["parent"]
        if not self.instance._state.adding and not parent.pk == self.initial.get(
            "parent"
        ):
            old_descendants = list(
                self.instance.get_descendants(inclusive=True).values_list(
                    "pk", flat=True
                )
            )
            if parent.pk in old_descendants:
                raise ValidationError(gettext_lazy("Please select another parent."))
        return parent


class BaseCollectionMemberForm(forms.ModelForm):
    """
    Abstract form handler for editing models that belong to a collection,
    such as documents and images. These forms are (optionally) instantiated
    with a 'user' kwarg, and take care of populating the 'collection' field's
    choices with the collections the user has permission for, as well as
    hiding the field when only one collection is available.

    Subclasses must define a 'permission_policy' attribute.
    """

    def __init__(self, *args, **kwargs):
        user = kwargs.pop("user", None)

        super().__init__(*args, **kwargs)

        if user is None:
            self.collections = Collection.objects.all()
        else:
            self.collections = (
                self.permission_policy.collections_user_has_permission_for(user, "add")
            )

        if self.instance.pk:
            # editing an existing document; ensure that the list of available collections
            # includes its current collection
            self.collections = self.collections | Collection.objects.filter(
                id=self.instance.collection_id
            )

        if len(self.collections) == 0:
            raise Exception(
                "Cannot construct %s for a user with no collection permissions"
                % type(self)
            )
        elif len(self.collections) == 1:
            # don't show collection field if only one collection is available
            del self.fields["collection"]
        else:
            self.fields["collection"].queryset = self.collections

    def save(self, commit=True):
        if len(self.collections) == 1:
            # populate the instance's collection field with the one available collection
            self.instance.collection = self.collections[0]

        return super().save(commit=commit)


class BaseGroupCollectionMemberPermissionFormSet(BaseFormSetMixin, forms.BaseFormSet):
    """
    A base formset class for managing GroupCollectionPermissions for a
    model with CollectionMember behaviour. Subclasses should provide attributes:
    permission_types - a list of (codename, short_label, long_label) tuples for the permissions
        being managed here
    permission_queryset - a queryset of Permission objects for the above permissions
    default_prefix - prefix to use on form fields if one is not specified in __init__
    template = template filename
    """

    def __init__(self, data=None, files=None, instance=None, prefix=None):
        if prefix is None:
            prefix = self.default_prefix

        if instance is None:
            instance = Group()

        if instance.pk is None:
            full_collection_permissions = []
        else:
            full_collection_permissions = (
                instance.collection_permissions.filter(
                    permission__in=self.permission_queryset
                )
                .select_related("permission__content_type", "collection")
                .order_by("collection")
            )

        self.instance = instance

        initial_data = []

        for collection, collection_permissions in groupby(
            full_collection_permissions,
            lambda cp: cp.collection,
        ):
            initial_data.append(
                {
                    "collection": collection,
                    "permissions": [cp.permission for cp in collection_permissions],
                }
            )

        super().__init__(data, files, initial=initial_data, prefix=prefix)

    def clean(self):
        """Checks that no two forms refer to the same collection object"""
        if any(self.errors):
            # Don't bother validating the formset unless each form is valid on its own
            return

        collections = [
            form.cleaned_data["collection"]
            for form in self.forms
            # need to check for presence of 'collection' in cleaned_data,
            # because a completely blank form passes validation
            if form not in self.deleted_forms and "collection" in form.cleaned_data
        ]
        if len(set(collections)) != len(collections):
            # collections list contains duplicates
            raise forms.ValidationError(
                _(
                    "You cannot have multiple permission records for the same collection."
                )
            )

    @transaction.atomic
    def save(self):
        if self.instance.pk is None:
            raise Exception(
                "Cannot save a GroupCollectionMemberPermissionFormSet "
                "for an unsaved group instance"
            )

        # get a set of (collection, permission) tuples for all ticked permissions
        forms_to_save = [
            form
            for form in self.forms
            if form not in self.deleted_forms and "collection" in form.cleaned_data
        ]

        final_permission_records = set()
        for form in forms_to_save:
            for permission in form.cleaned_data["permissions"]:
                final_permission_records.add(
                    (form.cleaned_data["collection"], permission)
                )

        # fetch the group's existing collection permission records for this model,
        # and from that, build a list of records to be created / deleted
        permission_ids_to_delete = []
        permission_records_to_keep = set()

        for cp in self.instance.collection_permissions.filter(
            permission__in=self.permission_queryset,
        ):
            if (cp.collection, cp.permission) in final_permission_records:
                permission_records_to_keep.add((cp.collection, cp.permission))
            else:
                permission_ids_to_delete.append(cp.id)

        self.instance.collection_permissions.filter(
            id__in=permission_ids_to_delete
        ).delete()

        permissions_to_add = final_permission_records - permission_records_to_keep
        GroupCollectionPermission.objects.bulk_create(
            [
                GroupCollectionPermission(
                    group=self.instance, collection=collection, permission=permission
                )
                for (collection, permission) in permissions_to_add
            ]
        )

    def as_admin_panel(self):
        return render_to_string(
            self.template,
            {"formset": self},
        )


def collection_member_permission_formset_factory(
    model, permission_types, template, default_prefix=None
):
    permission_queryset = Permission.objects.filter(
        content_type__app_label=model._meta.app_label,
        codename__in=[
            codename for codename, short_label, long_label in permission_types
        ],
    ).select_related("content_type")

    if default_prefix is None:
        default_prefix = "%s_permissions" % model._meta.model_name

    class PermissionMultipleChoiceField(forms.ModelMultipleChoiceField):
        """
        Allows the custom labels from ``permission_types`` to be applied to
        permission checkboxes for the ``CollectionMemberPermissionsForm`` below
        """

        def label_from_instance(self, obj):
            for codename, short_label, long_label in permission_types:
                if codename == obj.codename:
                    return long_label
            return str(obj)

    class CollectionMemberPermissionsForm(forms.Form):
        """
        For a given model with CollectionMember behaviour,
        defines the permissions that are assigned to an entity
        (such as a group or user) for a specific collection
        """

        collection = CollectionChoiceField(
            label=_("Collection"),
            queryset=Collection.objects.all().prefetch_related("group_permissions"),
            empty_label=None,
        )
        permissions = PermissionMultipleChoiceField(
            queryset=permission_queryset,
            required=False,
            widget=forms.CheckboxSelectMultiple,
        )

    GroupCollectionMemberPermissionFormSet = type(
        "GroupCollectionMemberPermissionFormSet",
        (BaseGroupCollectionMemberPermissionFormSet,),
        {
            "permission_types": permission_types,
            "permission_queryset": permission_queryset,
            "default_prefix": default_prefix,
            "template": template,
        },
    )

    return forms.formset_factory(
        CollectionMemberPermissionsForm,
        formset=GroupCollectionMemberPermissionFormSet,
        extra=0,
        can_delete=True,
    )


GroupCollectionManagementPermissionFormSet = (
    collection_member_permission_formset_factory(
        Collection,
        [
            ("add_collection", _("Add"), _("Add collections")),
            ("change_collection", _("Edit"), _("Edit collections")),
            ("delete_collection", _("Delete"), _("Delete collections")),
        ],
        "wagtailadmin/permissions/includes/collection_management_permissions_form.html",
    )
)
