from functools import partial
from itertools import groupby
from operator import methodcaller

from django import forms
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from django.utils.safestring import mark_safe

from opentech.apply.categories.models import MetaTerm
from opentech.apply.users.models import User

from .models import AssignedReviewers, ApplicationSubmission, ReviewerRole
from .utils import render_icon
from .widgets import Select2MultiCheckboxesWidget, MetaTermSelect2Widget
from .workflow import get_action_mapping


class ApplicationSubmissionModelForm(forms.ModelForm):
    """
    Application Submission model's save method performs several operations
    which are not required in forms which update fields like status, partners etc.
    It also has a side effect of creating a new file uploads every time with long filenames (#1572).
    """

    def save(self, commit=True):
        """
        Save this form's self.instance object if commit=True. Otherwise, add
        a save_m2m() method to the form which can be called after the instance
        is saved manually at a later time. Return the model instance.
        https://github.com/django/django/blob/5d9cf79baf07fc4aed7ad1b06990532a65378155/django/forms/models.py#L444
        """
        if self.errors:
            raise ValueError(
                "The %s could not be %s because the data didn't validate." % (
                    self.instance._meta.object_name,
                    'created' if self.instance._state.adding else 'changed',
                )
            )
        if commit:
            # If committing, save the instance and the m2m data immediately.
            self.instance.save(skip_custom=True)
            self._save_m2m()
        else:
            # If not committing, add a method to the form to allow deferred
            # saving of m2m data.
            self.save_m2m = self._save_m2m
        return self.instance


class ProgressSubmissionForm(ApplicationSubmissionModelForm):
    action = forms.ChoiceField(label='Take action')

    class Meta:
        model = ApplicationSubmission
        fields: list = []

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user')
        super().__init__(*args, **kwargs)
        choices = list(self.instance.get_actions_for_user(self.user))
        # Sort the transitions by the order they are listed.
        sort_by = list(self.instance.phase.transitions.keys())
        choices.sort(key=lambda k: sort_by.index(k[0]))
        action_field = self.fields['action']
        action_field.choices = choices
        self.should_show = bool(choices)


class BatchProgressSubmissionForm(forms.Form):
    action = forms.ChoiceField(label='Take action')
    submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'}))

    def __init__(self, *args, round=None, **kwargs):
        self.user = kwargs.pop('user')
        super().__init__(*args, **kwargs)
        workflow = round and round.workflow
        self.action_mapping = get_action_mapping(workflow)
        choices = [(action, detail['display']) for action, detail in self.action_mapping.items()]
        self.fields['action'].choices = choices

    def clean_submissions(self):
        value = self.cleaned_data['submissions']
        submission_ids = [int(submission) for submission in value.split(',')]
        return ApplicationSubmission.objects.filter(id__in=submission_ids)

    def clean_action(self):
        value = self.cleaned_data['action']
        action = self.action_mapping[value]['transitions']
        return action


class ScreeningSubmissionForm(ApplicationSubmissionModelForm):

    class Meta:
        model = ApplicationSubmission
        fields = ('screening_status',)

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user')
        super().__init__(*args, **kwargs)
        self.should_show = False
        if self.user.is_apply_staff:
            self.should_show = True


class UpdateSubmissionLeadForm(ApplicationSubmissionModelForm):

    class Meta:
        model = ApplicationSubmission
        fields = ('lead',)

    def __init__(self, *args, **kwargs):
        kwargs.pop('user')
        super().__init__(*args, **kwargs)
        lead_field = self.fields['lead']
        lead_field.label = f'Update lead from { self.instance.lead } to'
        lead_field.queryset = lead_field.queryset.exclude(id=self.instance.lead.id)


class BatchUpdateSubmissionLeadForm(forms.Form):
    lead = forms.ChoiceField(label='Lead')
    submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'}))

    def __init__(self, *args, round=None, **kwargs):
        self.user = kwargs.pop('user')
        super().__init__(*args, **kwargs)
        self.fields['lead'].choices = [(staff.id, staff) for staff in User.objects.staff()]

    def clean_lead(self):
        value = self.cleaned_data['lead']
        return User.objects.get(id=value)

    def clean_submissions(self):
        value = self.cleaned_data['submissions']
        submission_ids = [int(submission) for submission in value.split(',')]
        return ApplicationSubmission.objects.filter(id__in=submission_ids)

    def save(self):
        new_lead = self.cleaned_data['lead']
        submissions = self.cleaned_data['submissions']

        for submission in submissions:
            # Onle save if the lead has changed.
            if submission.lead != new_lead:
                submission.lead = new_lead
                submission.save()

        return None


class UpdateReviewersForm(ApplicationSubmissionModelForm):
    reviewer_reviewers = forms.ModelMultipleChoiceField(
        queryset=User.objects.reviewers().only('pk', 'full_name'),
        widget=Select2MultiCheckboxesWidget(attrs={'data-placeholder': 'Reviewers'}),
        label='Reviewers',
        required=False,
    )

    class Meta:
        model = ApplicationSubmission
        fields: list = []

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user')
        super().__init__(*args, **kwargs)

        assigned_roles = {
            assigned.role: assigned.reviewer
            for assigned in self.instance.assigned.filter(
                role__isnull=False
            )
        }

        self.role_fields = {}
        field_data = make_role_reviewer_fields()

        for data in field_data:
            field_name = data['field_name']
            self.fields[field_name] = data['field']
            self.role_fields[field_name] = data['role']
            self.fields[field_name].initial = assigned_roles.get(data['role'])

        submitted_reviewers = User.objects.filter(
            id__in=self.instance.assigned.reviewed().values('reviewer'),
        )

        if self.can_alter_external_reviewers(self.instance, self.user):

            reviewers = self.instance.reviewers.all().only('pk')
            self.prepare_field(
                'reviewer_reviewers',
                initial=reviewers,
                excluded=submitted_reviewers
            )

            # Move the non-role reviewers field to the end of the field list
            self.fields.move_to_end('reviewer_reviewers')
        else:
            self.fields.pop('reviewer_reviewers')

    def prepare_field(self, field_name, initial, excluded):
        field = self.fields[field_name]
        field.queryset = field.queryset.exclude(id__in=excluded)
        field.initial = initial

    def can_alter_external_reviewers(self, instance, user):
        return instance.stage.has_external_review and (user == instance.lead or user.is_superuser)

    def clean(self):
        cleaned_data = super().clean()
        role_reviewers = [
            user
            for field, user in self.cleaned_data.items()
            if field in self.role_fields
        ]

        # If any of the users match and are set to multiple roles, throw an error
        if len(role_reviewers) != len(set(role_reviewers)) and any(role_reviewers):
            self.add_error(None, _('Users cannot be assigned to multiple roles.'))

        return cleaned_data

    def save(self, *args, **kwargs):
        instance = super().save(*args, **kwargs)
        """
        1. Update role reviewers
        2. Update non-role reviewers
            2a. Remove those not on form
            2b. Add in any new non-role reviewers selected
        """

        # 1. Update role reviewers
        assigned_roles = {
            role: self.cleaned_data[field]
            for field, role in self.role_fields.items()
        }
        for role, reviewer in assigned_roles.items():
            if reviewer:
                AssignedReviewers.objects.update_role(role, reviewer, instance)
            else:
                AssignedReviewers.objects.filter(role=role, submission=instance).delete()

        # 2. Update non-role reviewers
        # 2a. Remove those not on form
        if self.can_alter_external_reviewers(self.instance, self.user):
            reviewers = self.cleaned_data.get('reviewer_reviewers')
            assigned_reviewers = instance.assigned.without_roles()
            assigned_reviewers.never_tried_to_review().exclude(
                reviewer__in=reviewers
            ).delete()

            remaining_reviewers = assigned_reviewers.values_list('reviewer_id', flat=True)

            # 2b. Add in any new non-role reviewers selected
            AssignedReviewers.objects.bulk_create_reviewers(
                [reviewer for reviewer in reviewers if reviewer.id not in remaining_reviewers],
                instance,
            )

        return instance


class BatchUpdateReviewersForm(forms.Form):
    submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'}))

    def __init__(self, *args, user=None, round=None, **kwargs):
        super().__init__(*args, **kwargs)

        self.role_fields = {}
        field_data = make_role_reviewer_fields()

        for data in field_data:
            field_name = data['field_name']
            self.fields[field_name] = data['field']
            self.role_fields[field_name] = data['role']

    def clean_submissions(self):
        value = self.cleaned_data['submissions']
        submission_ids = [int(submission) for submission in value.split(',')]
        return ApplicationSubmission.objects.filter(id__in=submission_ids)

    def clean(self):
        cleaned_data = super().clean()
        role_reviewers = [
            user
            for field, user in self.cleaned_data.items()
            if field in self.role_fields
        ]

        # If any of the users match and are set to multiple roles, throw an error
        if len(role_reviewers) != len(set(role_reviewers)) and any(role_reviewers):
            self.add_error(None, _('Users cannot be assigned to multiple roles.'))

        return cleaned_data

    def save(self):
        submissions = self.cleaned_data['submissions']
        assigned_roles = {
            role: self.cleaned_data[field]
            for field, role in self.role_fields.items()
        }
        for role, reviewer in assigned_roles.items():
            if reviewer:
                AssignedReviewers.objects.update_role(role, reviewer, *submissions)
            else:
                for submission in submissions:
                    AssignedReviewers.objects.filter(role=role, submission=submission).delete()

        return None


def make_role_reviewer_fields():
    role_fields = []
    staff_reviewers = User.objects.staff().only('full_name', 'pk')

    for role in ReviewerRole.objects.all().order_by('order'):
        field_name = 'role_reviewer_' + slugify(str(role))
        field = forms.ModelChoiceField(
            queryset=staff_reviewers,
            required=False,
            label=mark_safe(render_icon(role.icon) + f'{role.name} Reviewer'),
        )
        role_fields.append({
            'role': role,
            'field': field,
            'field_name': field_name,
        })

    return role_fields


class UpdatePartnersForm(ApplicationSubmissionModelForm):
    partner_reviewers = forms.ModelMultipleChoiceField(
        queryset=User.objects.partners(),
        widget=Select2MultiCheckboxesWidget(attrs={'data-placeholder': 'Partners'}),
        label='Partners',
        required=False,
    )

    class Meta:
        model = ApplicationSubmission
        fields: list = []

    def __init__(self, *args, **kwargs):
        kwargs.pop('user')
        super().__init__(*args, **kwargs)
        partners = self.instance.partners.all()
        self.submitted_partners = User.objects.partners().filter(id__in=self.instance.reviews.values('author'))

        partner_field = self.fields['partner_reviewers']
        partner_field.queryset = partner_field.queryset.exclude(id__in=self.submitted_partners)
        partner_field.initial = partners

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

        instance.partners.set(
            self.cleaned_data['partner_reviewers'] |
            self.submitted_partners
        )
        return instance


class GroupedModelChoiceIterator(forms.models.ModelChoiceIterator):
    def __init__(self, field, groupby):
        self.groupby = groupby
        super().__init__(field)

    def __iter__(self):
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        queryset = self.queryset
        # Can't use iterator() when queryset uses prefetch_related()
        if not queryset._prefetch_related_lookups:
            queryset = queryset.iterator()
        for group, objs in groupby(queryset, self.groupby):
            yield (group, [self.choice(obj) for obj in objs])


class GroupedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
    def __init__(self, *args, choices_groupby, **kwargs):
        if isinstance(choices_groupby, str):
            choices_groupby = methodcaller(choices_groupby)
        elif not callable(choices_groupby):
            raise TypeError('choices_groupby must either be a str or a callable accepting a single argument')
        self.iterator = partial(GroupedModelChoiceIterator, groupby=choices_groupby)
        super().__init__(*args, **kwargs)

    def label_from_instance(self, obj):
        return {'label': super().label_from_instance(obj), 'disabled': not obj.is_leaf()}


class UpdateMetaTermsForm(ApplicationSubmissionModelForm):
    meta_terms = GroupedModelMultipleChoiceField(
        queryset=None,  # updated in init method
        widget=MetaTermSelect2Widget(attrs={'data-placeholder': 'Meta terms'}),
        label='Meta terms',
        choices_groupby='get_parent',
        required=False,
        help_text='Meta terms are hierarchical in nature.',
    )

    class Meta:
        model = ApplicationSubmission
        fields: list = []

    def __init__(self, *args, **kwargs):
        kwargs.pop('user')
        super().__init__(*args, **kwargs)
        self.fields['meta_terms'].queryset = MetaTerm.get_root_descendants().exclude(depth=2)