import datetime
from decimal import Decimal

from django import forms
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from club.models import Club, ClubMembership, ClubRole, FeePayment, OnboardingRequirement, Sponsor
from club.services.access import groups_manageable_by, is_club_admin, teams_managed_by
from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
from events.services.rbihf_import import RBIHFImportError, extract_team_id
from members.models import Family, FamilyMembership, Group, Member
from members.services.family import find_member_by_email
from news.models import News
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
from teams.services import eligible_roster_members

from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule

User = get_user_model()


class MemberForm(forms.ModelForm):
    class Meta:
        model = Member
        fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"]
        widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})}


class TeamForm(forms.ModelForm):
    class Meta:
        model = Team
        fields = ["name", "short_name", "referee_management"]


class TeamPhotoForm(forms.ModelForm):
    """One photo per (team, season) -- bound to the existing TeamPhoto (if
    any) by the view's get_form_kwargs, same "create or update the one row
    for this scope" pattern as controlpanel.forms.HomeLocationForm."""

    class Meta:
        model = TeamPhoto
        fields = ["image"]


class GroupForm(forms.ModelForm):
    """A generic named collection of members -- nothing team- or
    referee-specific here; see MemberRefereeEligibilityForm for that."""

    class Meta:
        model = Group
        fields = ["name"]


class RefereeLevelForm(forms.ModelForm):
    class Meta:
        model = RefereeLevel
        fields = ["name", "teams", "inherits_from"]
        widgets = {"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")})}

    def __init__(self, *args, club=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["teams"].queryset = Team.objects.filter(club=club)
        # Excludes self from the dropdown -- self.instance always has a pk (UUIDModel
        # gets its default at construction, not at save()), so this also works for a
        # brand-new, unsaved level. clean() still catches an indirect loop (A -> B -> A).
        self.fields["inherits_from"].queryset = RefereeLevel.objects.filter(club=club).exclude(pk=self.instance.pk)
        self.fields["inherits_from"].empty_label = _("— none —")


class MemberRefereeEligibilityForm(forms.ModelForm):
    """This member's referee level and how long it's valid for -- edited from
    the member's own page (teams.RefereeProfile), get-or-created on save.
    Which teams that translates to is derived entirely from the level
    (RefereeLevel.teams), not picked here -- see RefereeProfile.eligible_teams."""

    class Meta:
        model = RefereeProfile
        fields = ["level", "valid_until"]
        widgets = {"valid_until": forms.DateInput(attrs={"type": "date"})}

    def __init__(self, *args, club=None, member=None, **kwargs):
        instance = RefereeProfile.objects.filter(member=member).first() if member is not None else None
        super().__init__(*args, instance=instance, **kwargs)
        # UUIDModel PKs get their default at construction time (not at save()),
        # so a brand-new instance already has a non-None pk -- "is this new"
        # can't be read off self.instance.pk. Setting member here (rather than
        # in save()) means it's already correct before validation runs too.
        if instance is None:
            self.instance.member = member
        self.fields["level"].queryset = RefereeLevel.objects.filter(club=club)
        self.fields["level"].required = False
        self.fields["level"].empty_label = _("— no level (not eligible) —")


class ExternalRefereeForm(forms.Form):
    """Log a non-member referee (federation-appointed, most often) by name
    only -- see events.services.referees.add_external_referee."""

    name = forms.CharField(label=_("Referee name"))


class EventRefereeFeeForm(forms.ModelForm):
    """One referee assignment's payment details -- see
    events.services.referees.set_referee_fee. `km`/`km_rate` are left blank
    (not defaulted to 0) whenever no travel was logged, so the PDF export can
    tell "no kilometers" from "zero kilometers claimed"."""

    class Meta:
        model = EventReferee
        fields = ["fee", "km", "km_rate"]
        widgets = {
            "fee": forms.NumberInput(attrs={"step": "0.01", "min": "0"}),
            "km": forms.NumberInput(attrs={"step": "0.1", "min": "0"}),
            "km_rate": forms.NumberInput(attrs={"step": "any", "min": "0"}),
        }


class TeamMembershipForm(forms.ModelForm):
    """Add/edit one roster entry -- team and season come from the view (the URL
    already identifies both), never from the form itself."""

    class Meta:
        model = TeamMembership
        fields = ["member", "position", "jersey_number", "is_captain", "is_alternate_captain"]
        widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}

    def __init__(self, *args, club=None, team=None, season=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.team = team
        self.season = season
        # eligible_roster_members already covers "active this season or the next
        # one, not lapsed/pending/cancelled or active only in some other season" --
        # eligible regardless of which season's roster is being edited (the team
        # detail page's season switcher can be pointed at an older season).
        members = eligible_roster_members(club)
        if team is not None and season is not None:
            # Already on this team's roster this season -- offering them again
            # would just fail the unique_member_per_team_per_season constraint.
            taken = TeamMembership.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
            members = members.exclude(pk__in=taken)
        self.fields["member"].queryset = members
        self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False)
        # The model field itself is optional (SignupTeamPlacementForm leaves it
        # blank on purpose -- see that form's own docstring), but adding/editing a
        # roster spot here is still expected to pick one; only the Sign-up page's
        # own placement skips it.
        self.fields["position"].required = True

    def clean(self):
        cleaned = super().clean()
        # team/season aren't form fields (the view sets them from the URL, not user
        # input), so Django's automatic validate_unique() excludes both of them --
        # and with them, the whole unique_jersey_number_per_team_per_season check.
        # Without this, a clashing jersey number reaches the database unrejected
        # and surfaces as a raw IntegrityError instead of a form error.
        jersey_number = cleaned.get("jersey_number")
        if jersey_number is not None and self.team is not None and self.season is not None:
            clash = TeamMembership.objects.filter(team=self.team, season=self.season, jersey_number=jersey_number).exclude(pk=self.instance.pk).exists()
            if clash:
                self.add_error("jersey_number", _("Another player on this team already has this jersey number this season."))
        return cleaned


class StaffAssignmentForm(forms.ModelForm):
    """Assign/edit one staff assignment -- team and season come from the view,
    same reasoning as TeamMembershipForm."""

    class Meta:
        model = StaffAssignment
        fields = ["member", "position"]
        widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}

    def __init__(self, *args, club=None, team=None, season=None, **kwargs):
        super().__init__(*args, **kwargs)
        # See TeamMembershipForm for why current-or-next-season: eligible to be
        # assigned regardless of which season's staff list is being edited.
        members = eligible_roster_members(club)
        if team is not None and season is not None:
            taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
            members = members.exclude(pk__in=taken)
        self.fields["member"].queryset = members
        self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=True)


class PositionSelect(forms.Select):
    """Marks staff positions in the DOM (``data-staff``) so a bulk-add row's
    jersey-number input can disable itself for them -- a jersey number is
    meaningless on a StaffAssignment, which has no such field."""

    def create_option(self, name, value, *args, **kwargs):
        option = super().create_option(name, value, *args, **kwargs)
        position = getattr(value, "instance", None)
        if position is not None and position.staff_position:
            option["attrs"]["data-staff"] = "1"
        return option


class GroupedPositionChoiceField(forms.ModelChoiceField):
    """Positions split into "Player positions" / "Staff positions" optgroups.

    Which group a position sits in is exactly what tells the bulk-add view
    whether a row means a ``TeamMembership`` or a ``StaffAssignment``
    (``Position.staff_position``) -- so the row needs no separate "player or
    staff?" control that could drift out of step with the position picked.
    """

    widget = PositionSelect

    def _get_choices(self):
        if hasattr(self, "_choices"):
            return self._choices

        iterator = self.iterator(self)
        player, staff = [], []
        for value, label in iterator:
            if value == "":
                continue  # the empty label is yielded separately below
            target = staff if getattr(value, "instance", None) is not None and value.instance.staff_position else player
            target.append((value, label))

        grouped = []
        if self.empty_label is not None:
            grouped.append(("", self.empty_label))
        if player:
            grouped.append((_("Player positions"), player))
        if staff:
            grouped.append((_("Staff positions"), staff))
        return grouped

    choices = property(_get_choices, forms.ChoiceField.choices.fset)


def bulk_add_member_label(member, *, rostered_ids, staffed_ids):
    """"Peter Player — on roster" for the bulk-add member picker.

    Already-assigned members stay selectable rather than being filtered out:
    someone already on the roster as a player can still legitimately be added
    as staff (a playing coach), so what's "taken" depends on the position the
    row ends up picking. The label is the hint; the row's own clean() is what
    actually refuses a true duplicate.
    """
    marks = []
    if member.pk in rostered_ids:
        marks.append(_("on roster"))
    if member.pk in staffed_ids:
        marks.append(_("on staff"))
    if not marks:
        return str(member)
    # Not itself a translatable message -- just a separator between the member's
    # name and the already-translated markers.
    return f"{member} — {', '.join(str(mark) for mark in marks)}"


class TeamBulkAddRowForm(forms.Form):
    """One row of the team bulk-add page: who, in what position, and (players
    only) their jersey number and captaincy.

    Rows replace the old "every eligible member gets a table row" layout, which
    doesn't survive a club with a hundred-plus members. `member` is a searchable
    select (static ``choices``, not the field's own lazy queryset iterator, so a
    twenty-row formset doesn't re-query the member list twenty times over).
    """

    member = forms.ModelChoiceField(
        queryset=Member.objects.none(),
        label=_("Member"),
        widget=forms.Select(attrs={"class": "select select-bordered w-full", "data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
    )
    position = GroupedPositionChoiceField(
        queryset=Position.objects.none(),
        label=_("Position"),
        widget=PositionSelect(attrs={"class": "select select-bordered w-full position-select"}),
    )
    jersey_number = forms.IntegerField(
        required=False,
        min_value=0,
        label=_("Jersey #"),
        widget=forms.NumberInput(attrs={"class": "input input-bordered w-full player-only", "min": "0"}),
    )
    # Player-only, same as jersey_number -- "player-only" marks them for the
    # row script, which greys them out when a staff position is picked.
    is_captain = forms.BooleanField(required=False, label=_("Captain"), widget=forms.CheckboxInput(attrs={"class": "checkbox player-only"}))
    is_alternate_captain = forms.BooleanField(required=False, label=_("Alternate"), widget=forms.CheckboxInput(attrs={"class": "checkbox player-only"}))

    def __init__(self, *args, club=None, team=None, season=None, member_choices=None, position_queryset=None, rostered_ids=frozenset(), staffed_ids=frozenset(), **kwargs):
        super().__init__(*args, **kwargs)
        self.team = team
        self.season = season
        self.rostered_ids = rostered_ids
        self.staffed_ids = staffed_ids

        self.fields["member"].queryset = eligible_roster_members(club)
        if member_choices is not None:
            self.fields["member"].choices = member_choices

        self.fields["position"].queryset = position_queryset if position_queryset is not None else Position.objects.filter(club=club)

    def clean(self):
        cleaned = super().clean()
        member, position = cleaned.get("member"), cleaned.get("position")
        if member is None or position is None:
            return cleaned

        if position.staff_position:
            # A staff row becomes a StaffAssignment, which has no jersey number
            # and no captaincy -- both belong to TeamMembership only.
            if member.pk in self.staffed_ids:
                self.add_error("member", _("%(member)s is already on this team's staff for this season.") % {"member": member})
            if cleaned.get("jersey_number") is not None:
                self.add_error("jersey_number", _("A jersey number doesn't apply to a staff position."))
            if cleaned.get("is_captain"):
                self.add_error("is_captain", _("Captaincy doesn't apply to a staff position."))
            if cleaned.get("is_alternate_captain"):
                self.add_error("is_alternate_captain", _("Captaincy doesn't apply to a staff position."))
            return cleaned

        if member.pk in self.rostered_ids:
            self.add_error("member", _("%(member)s is already on this team's roster for this season.") % {"member": member})

        # Contradictory rather than a club policy: an alternate captain is by
        # definition not the captain. How *many* captains a team may have isn't
        # constrained anywhere (not by the model, not by the single-add form),
        # so this row deliberately doesn't invent that rule either.
        if cleaned.get("is_captain") and cleaned.get("is_alternate_captain"):
            self.add_error("is_alternate_captain", _("Someone can be captain or alternate captain, not both."))

        jersey_number = cleaned.get("jersey_number")
        if jersey_number is not None and self.team is not None and self.season is not None:
            if TeamMembership.objects.filter(team=self.team, season=self.season, jersey_number=jersey_number).exists():
                self.add_error("jersey_number", _("Jersey #%(number)s is already taken on this team this season.") % {"number": jersey_number})
        return cleaned


class BaseTeamBulkAddFormSet(forms.BaseFormSet):
    """Cross-row checks the individual rows can't see: the same person twice,
    or two rows claiming one jersey number. Per-row checks (already assigned,
    jersey already taken by an *existing* roster entry) live on the row form."""

    def clean(self):
        super().clean()
        if any(self.errors):
            return

        seen_assignments, seen_jerseys = set(), set()
        for form in self.forms:
            member, position = form.cleaned_data.get("member"), form.cleaned_data.get("position")
            if member is None or position is None:
                continue

            assignment = (member.pk, bool(position.staff_position))
            if assignment in seen_assignments:
                form.add_error("member", _("%(member)s is listed twice for the same kind of position.") % {"member": member})
            seen_assignments.add(assignment)

            jersey_number = form.cleaned_data.get("jersey_number")
            if jersey_number is None or position.staff_position:
                continue
            if jersey_number in seen_jerseys:
                form.add_error("jersey_number", _("Jersey #%(number)s is claimed by more than one row.") % {"number": jersey_number})
            seen_jerseys.add(jersey_number)


TeamBulkAddFormSet = forms.formset_factory(TeamBulkAddRowForm, formset=BaseTeamBulkAddFormSet, extra=3)


class GroupBulkAddRowForm(forms.Form):
    """One row of the group bulk-add page -- just who. Group membership carries
    no per-member attributes, so there's nothing else to fill in."""

    member = forms.ModelChoiceField(
        queryset=Member.objects.none(),
        label=_("Member"),
        widget=forms.Select(attrs={"class": "select select-bordered w-full", "data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
    )

    def __init__(self, *args, member_queryset=None, member_choices=None, existing_ids=frozenset(), **kwargs):
        super().__init__(*args, **kwargs)
        self.existing_ids = existing_ids
        self.fields["member"].queryset = member_queryset if member_queryset is not None else Member.objects.none()
        if member_choices is not None:
            self.fields["member"].choices = member_choices

    def clean_member(self):
        member = self.cleaned_data["member"]
        if member.pk in self.existing_ids:
            raise forms.ValidationError(_("%(member)s is already in this group.") % {"member": member})
        return member


class BaseGroupBulkAddFormSet(forms.BaseFormSet):
    def clean(self):
        super().clean()
        if any(self.errors):
            return

        seen = set()
        for form in self.forms:
            member = form.cleaned_data.get("member")
            if member is None:
                continue
            if member.pk in seen:
                form.add_error("member", _("%(member)s is listed twice.") % {"member": member})
            seen.add(member.pk)


GroupBulkAddFormSet = forms.formset_factory(GroupBulkAddRowForm, formset=BaseGroupBulkAddFormSet, extra=3)


class PositionForm(forms.ModelForm):
    class Meta:
        model = Position
        fields = ["name", "short_name", "ordering", "staff_position", "management_position"]

    def clean(self):
        cleaned = super().clean()
        # Mirrors Position's management_position_implies_staff_position check
        # constraint -- caught here so it reads as a form error, not a 500.
        if cleaned.get("management_position") and not cleaned.get("staff_position"):
            self.add_error("management_position", _("A management position must also be a staff position."))
        return cleaned


class LocationForm(forms.ModelForm):
    class Meta:
        model = Location
        fields = ["name", "address", "city", "zip_code", "country"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # CountryField's own widget (a lazily-translated Select) must stay the widget
        # class -- only the searchable-select JS hooks are added on top of it, the
        # same progressive enhancement TeamMembershipForm uses for its member field.
        self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")})


class OpponentForm(forms.ModelForm):
    class Meta:
        model = Opponent
        fields = ["name", "logo"]


class SponsorForm(forms.ModelForm):
    class Meta:
        model = Sponsor
        fields = ["name", "logo", "url", "start_date", "end_date"]
        widgets = {
            "start_date": forms.DateInput(attrs={"type": "date"}),
            "end_date": forms.DateInput(attrs={"type": "date"}),
        }

    def clean(self):
        cleaned = super().clean()
        # Mirrors Sponsor.clean() -- caught here too so it reads as a form
        # error tied to the end_date field, not a raw ValidationError.
        start_date, end_date = cleaned.get("start_date"), cleaned.get("end_date")
        if start_date and end_date and end_date < start_date:
            self.add_error("end_date", _("End date can't be before the start date."))
        return cleaned


def _location_label(location) -> str:
    """"Name — City" for the location picker, plus the country when it isn't
    Belgium (the club's home country and, in practice, nearly every location
    a club will ever add) -- lets an admin tell two same-named venues apart,
    or spot an away trip abroad, straight from the dropdown."""
    label = f"{location.name} — {location.city}"
    if location.country.code != "BE":
        label += f", {location.country.name}"
    return label


class EventAudienceFormMixin:
    """Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
    teams restricted to the ones the requester manages (all of them for an
    admin), groups restricted to the ones the requester belongs to (all of them
    for an admin -- Group has no manager/owner concept the way Team does, so
    membership is the only claim there is), and a non-admin must pick at least
    one team or group -- a club-wide event (e.g. an AGM) has no such claim to
    anchor it to, so `club_wide` stays admin-only (the field doesn't even exist
    on the form otherwise)."""

    def scope_audience_fields(self, club, user):
        self.club = club
        self.user = user
        self.fields["teams"].queryset = teams_managed_by(user, club)
        self.fields["groups"].queryset = groups_manageable_by(user, club)
        self.fields["location"].queryset = Location.objects.filter(club=club)
        self.fields["location"].label_from_instance = _location_label
        self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
        members = Member.objects.filter(member_of__club=club).distinct()
        self.fields["invited_members"].queryset = members
        self.fields["excluded_members"].queryset = members
        if not is_club_admin(user, club):
            del self.fields["club_wide"]

    def clean_audience_requires_a_claim_for_non_admins(self, cleaned):
        if is_club_admin(self.user, self.club):
            return
        teams = cleaned.get("teams")
        groups = cleaned.get("groups")
        if not (teams is not None and teams.exists()) and not (groups is not None and groups.exists()):
            self.add_error(None, _("Select at least one of your teams or groups, or ask an admin to create a club-wide event."))

    def clean_club_wide_excludes_teams_and_groups(self, cleaned):
        if not cleaned.get("club_wide"):
            return
        teams = cleaned.get("teams")
        groups = cleaned.get("groups")
        if (teams is not None and teams.exists()) or (groups is not None and groups.exists()):
            self.add_error("club_wide", _("A whole-club event can't also list specific teams or groups -- clear them, or turn this off."))


_AUDIENCE_WIDGETS = {
    "teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}),
    "groups": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a group to search...")}),
    "invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
    "excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
    "location": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name or city to search...")}),
}


class EventForm(EventAudienceFormMixin, forms.ModelForm):
    class Meta:
        model = Event
        fields = [
            "title",
            "kind",
            "teams",
            "groups",
            "club_wide",
            "invited_members",
            "excluded_members",
            "location",
            "opponent",
            "start",
            "end",
            "gathering",
            "deadline",
            "competition",
            "external_game_id",
            "score_for",
            "score_against",
            "is_live",
            "max_referees",
        ]
        widgets = {
            "start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "end": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "gathering": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "score_for": forms.NumberInput(attrs={"min": 0}),
            "score_against": forms.NumberInput(attrs={"min": 0}),
            "max_referees": forms.NumberInput(attrs={"min": 1}),
            **_AUDIENCE_WIDGETS,
        }

    def __init__(self, *args, club=None, user=None, editing=False, **kwargs):
        super().__init__(*args, **kwargs)
        self.scope_audience_fields(club, user)
        # Unlike the Django-admin form (events.admin.EventAdminForm), this dropdown
        # isn't filtered by which competitions have their feature flag on for the
        # club -- a manager should be able to pick any competition when scheduling a
        # game; it's fetch_game_info (events.services.competitions) that gates the
        # actual per-club fetch once a competition is set.
        self.fields["competition"] = forms.ChoiceField(
            choices=[("", "---------"), *[(competition.name, competition.name) for competition in Competition.objects.all()]],
            required=False,
            label=self.fields["competition"].label,
            help_text=self.fields["competition"].help_text,
        )
        if not editing:
            # Score/live status don't exist yet for a game that's only just being
            # scheduled -- offering them on the add form is just noise. Editing an
            # existing game is the only time there's anything to record here.
            del self.fields["score_for"]
            del self.fields["score_against"]
            del self.fields["is_live"]

    def clean(self):
        cleaned = super().clean()
        self.clean_audience_requires_a_claim_for_non_admins(cleaned)
        self.clean_club_wide_excludes_teams_and_groups(cleaned)
        return cleaned


class RBIHFImportForm(forms.Form):
    """Step 1 of importing a team's fixtures from RBIHF's own website -- see
    events.services.rbihf_import. Only the URL and which of the club's own
    teams it applies to; everything else is scraped."""

    url = forms.CharField(
        label=_("RBIHF team page URL"),
        help_text=_("E.g. https://www.rbihf.be/league/team/4460"),
        widget=forms.URLInput(attrs={"placeholder": "https://www.rbihf.be/league/team/4460"}),
    )
    team = forms.ModelChoiceField(queryset=Team.objects.none(), label=_("Team"), help_text=_("Which of your teams this fixture list is for."))

    def __init__(self, *args, club=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["team"].queryset = Team.objects.filter(club=club)

    def clean_url(self):
        url = self.cleaned_data["url"].strip()
        try:
            extract_team_id(url)
        except RBIHFImportError as error:
            raise forms.ValidationError(str(error)) from error
        return url


class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
    """A friendly weekly/monthly recurrence picker on top of EventSeries' raw
    RRULE (see management.recurrence_ui) -- covers the common case (every N
    weeks/months) with an advanced raw-RRULE field as an escape hatch for
    anything else."""

    frequency = forms.ChoiceField(choices=FREQUENCY_CHOICES, initial="weekly", label=_("Repeats"))
    interval = forms.IntegerField(min_value=1, initial=1, label=_("Every"), help_text=_("E.g. 2 for every other week/month."))
    # A plain multi-select, not CheckboxSelectMultiple: its widget_type ("checkboxselectmultiple")
    # isn't one form_field's ui.py recognises, which would silently render a broken
    # plain text input instead -- see the "lazyselect" fix for the same class of bug.
    weekdays = forms.MultipleChoiceField(choices=WEEKDAY_CHOICES, required=False, label=_("On"), widget=forms.SelectMultiple(attrs={"data-searchable": "true"}))
    duration_hours = forms.IntegerField(min_value=0, required=False, label=_("Duration (hours)"))
    duration_minutes = forms.IntegerField(min_value=0, max_value=59, required=False, label=_("Duration (minutes)"))
    gathering_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Gather (minutes before)"))
    deadline_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Registration deadline (minutes before)"))
    advanced_rrule = forms.CharField(required=False, label=_("Advanced: raw recurrence rule"), help_text=_("Overrides the fields above if filled in. RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE."))

    class Meta:
        model = EventSeries
        fields = ["title", "kind", "dtstart", "until", "teams", "groups", "club_wide", "invited_members", "excluded_members", "location", "opponent"]
        widgets = {
            "dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "until": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            **_AUDIENCE_WIDGETS,
        }

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

        if self.instance.pk:
            parsed = parse_rrule(self.instance.rrule)
            if parsed is not None:
                self.fields["frequency"].initial = parsed["frequency"]
                self.fields["interval"].initial = parsed["interval"]
                self.fields["weekdays"].initial = parsed["weekdays"]
            else:
                # Not one of our two shapes (hand-written/imported) -- show it
                # verbatim in the advanced field rather than guessing at it.
                self.fields["advanced_rrule"].initial = self.instance.rrule

            if self.instance.duration is not None:
                total_minutes = int(self.instance.duration.total_seconds() // 60)
                self.fields["duration_hours"].initial = total_minutes // 60
                self.fields["duration_minutes"].initial = total_minutes % 60
            if self.instance.gathering_offset is not None:
                self.fields["gathering_minutes_before"].initial = int(self.instance.gathering_offset.total_seconds() // 60)
            if self.instance.deadline_offset is not None:
                self.fields["deadline_minutes_before"].initial = int(self.instance.deadline_offset.total_seconds() // 60)

    def clean(self):
        cleaned = super().clean()
        self.clean_audience_requires_a_claim_for_non_admins(cleaned)
        self.clean_club_wide_excludes_teams_and_groups(cleaned)

        if cleaned.get("advanced_rrule"):
            return cleaned  # the advanced field wins outright; nothing else to check

        if cleaned.get("frequency") == "weekly" and not cleaned.get("weekdays"):
            self.add_error("weekdays", _("Pick at least one day of the week."))
        return cleaned

    def save(self, commit=True):
        instance = super().save(commit=False)
        cleaned = self.cleaned_data

        advanced = (cleaned.get("advanced_rrule") or "").strip()
        instance.rrule = advanced or build_rrule(cleaned["frequency"], cleaned["interval"], cleaned.get("weekdays"))

        hours, minutes = cleaned.get("duration_hours") or 0, cleaned.get("duration_minutes") or 0
        instance.duration = datetime.timedelta(hours=hours, minutes=minutes) if (hours or minutes) else None

        gathering_minutes = cleaned.get("gathering_minutes_before")
        instance.gathering_offset = datetime.timedelta(minutes=gathering_minutes) if gathering_minutes else None

        deadline_minutes = cleaned.get("deadline_minutes_before")
        instance.deadline_offset = datetime.timedelta(minutes=deadline_minutes) if deadline_minutes else None

        if commit:
            instance.save()
            self.save_m2m()
        return instance


class ClubRoleAssignForm(forms.ModelForm):
    """Grant a club-wide role to a member already affiliated with this club."""

    class Meta:
        model = ClubRole
        fields = ["member", "role"]
        widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}

    def __init__(self, *args, club=None, **kwargs):
        super().__init__(*args, **kwargs)
        # Never list members of other clubs -- this isn't a platform-wide picker.
        self.fields["member"].queryset = Member.objects.filter(member_of__club=club).distinct()


class FamilyCreateForm(forms.Form):
    """One new family in one go: a parent (who gets a login) and a child (who
    doesn't). See members.services.family.register_family."""

    parent_first_name = forms.CharField(label=_("Parent first name"))
    parent_last_name = forms.CharField(label=_("Parent last name"))
    parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))

    parent_is_member = forms.BooleanField(
        label=_("Parent is also a member"),
        required=False,
        help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
    )

    child_first_name = forms.CharField(label=_("Child first name"))
    child_last_name = forms.CharField(label=_("Child last name"))
    child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))


class AddChildForm(forms.Form):
    """A family that needs one more child registered -- see
    members.services.family.add_child_to_family."""

    first_name = forms.CharField(label=_("First name"))
    last_name = forms.CharField(label=_("Last name"))
    date_of_birth = forms.DateField(label=_("Date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))


class AddParentForm(forms.Form):
    """A family that needs one more parent/guardian registered -- see
    members.services.family.add_parent_to_family."""

    email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
    first_name = forms.CharField(label=_("First name"), required=False)
    last_name = forms.CharField(label=_("Last name"), required=False)
    parent_is_member = forms.BooleanField(
        label=_("Also a member"),
        required=False,
        help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
    )

    def clean(self):
        cleaned = super().clean()
        email = cleaned.get("email")

        # Only a brand-new person needs a name; an existing member already has one.
        if email and find_member_by_email(email) is None:
            for field in ("first_name", "last_name"):
                if not cleaned.get(field):
                    self.add_error(field, _("Required: this email has no account yet."))

        return cleaned


class AttachToFamilyForm(forms.Form):
    """Link a standalone member into a family -- a new one, or an existing one they
    turn out to belong to. See members.services.family.attach_to_family."""

    role = forms.ChoiceField(label=_("Role"), choices=FamilyMembership.FamilyRole.choices)
    family = forms.ModelChoiceField(label=_("Family"), queryset=Family.objects.none(), required=False, empty_label=_("— start a new family —"))

    def __init__(self, *args, club=None, member=None, **kwargs):
        super().__init__(*args, **kwargs)
        # Same scoping query as management.views.families_of_club -- inlined rather
        # than imported, since that function lives in views.py, which imports this
        # module (a module-level import back here would be circular).
        queryset = Family.objects.filter(memberships__member__member_of__club=club).distinct()
        if member is not None:
            # Already a member of it -- offering it again would be a no-op re-add.
            queryset = queryset.exclude(memberships__member=member)
        self.fields["family"].queryset = queryset


class MemberImportUploadForm(forms.Form):
    """The mass-upload entry point -- one .xlsx file, built from the downloadable
    template. See management.bulk_import.read_member_import_workbook."""

    file = forms.FileField(label=_("Excel file"), help_text=_("Use the downloaded template — one row per member."))


class GrantLoginForm(forms.Form):
    """A login-less family member (a child, typically) getting their own account --
    see members.services.family.grant_login. Pre-filled from the member's contact
    email where one is already on file; still editable, and required either way."""

    email = forms.EmailField(label=_("Email"), help_text=_("They'll set a password via the reset link the first time they sign in."))

    def clean_email(self):
        email = self.cleaned_data["email"]
        if User.objects.filter(email__iexact=email).exists():
            raise forms.ValidationError(_("This email is already in use."))
        return email


class ClubMembershipForm(forms.ModelForm):
    """This season's standing -- shown and edited right on the member's own page,
    since a Member has no club of its own without one. fee_amount is the only
    money field here -- amount_paid is exclusively written by club.services.fees,
    never hand-edited."""

    class Meta:
        model = ClubMembership
        fields = ["kind", "license", "status", "fee_status", "fee_amount"]


class NewsForm(forms.ModelForm):
    """Title/teams/visibility/body only -- status and published_at are never
    directly editable, only through the publish/unpublish actions."""

    class Meta:
        model = News
        fields = ["title", "title_en", "teams", "visibility", "body", "body_en"]
        widgets = {
            "teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type to filter teams...")}),
            "body": forms.Textarea(attrs={"rows": 8}),
            "body_en": forms.Textarea(attrs={"rows": 8}),
        }

    def __init__(self, *args, club=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["teams"].queryset = Team.objects.filter(club=club)


class MultipleFileInput(forms.ClearableFileInput):
    allow_multiple_selected = True


class MultipleFileField(forms.FileField):
    """Django's own documented recipe for a multi-file upload field: the plain
    FileField only ever picks up one of several selected files, so clean() has
    to iterate the list itself instead."""

    def __init__(self, *args, **kwargs):
        kwargs.setdefault("widget", MultipleFileInput(attrs={"multiple": True}))
        super().__init__(*args, **kwargs)

    def clean(self, data, initial=None):
        single_file_clean = super().clean
        if isinstance(data, (list, tuple)):
            return [single_file_clean(item, initial) for item in data]
        return [single_file_clean(data, initial)] if data else []


class NewsPhotoUploadForm(forms.Form):
    images = MultipleFileField(label=_("Photos"))


class NewsPublishForm(forms.Form):
    published_at = forms.DateTimeField(
        label=_("Publish date"),
        initial=timezone.now,
        widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
        help_text=_("Leave as now to publish immediately, or pick a future date/time to schedule it."),
    )
    notify_members = forms.BooleanField(
        label=_("Notify linked members"),
        required=False,
        initial=False,
        help_text=_("Emails everyone this item reaches (its teams' current rosters, or every active member if it's club-wide) -- and, always, their parent/guardian. Sent when this actually goes live, not necessarily right now."),
    )


class RecordFeePaymentForm(forms.Form):
    """Money received against one membership's fee -- see club.services.fees.record_payment.
    Reusable for any amount, partial or the exact remaining balance; "Mark fully
    paid" (management.views.MembershipMarkFullyPaidView) skips this form entirely
    and settles the balance directly in one click."""

    amount = forms.DecimalField(label=_("Amount"), max_digits=10, decimal_places=2, min_value=Decimal("0.01"))
    method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
    reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
    note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))


class SendDuesInvoicesForm(forms.Form):
    """The one shared setting for a batch of invoices sent from the Dues & billing
    page -- see club.services.invoicing.create_or_resend_invoice. Applies to every
    membership selected in the same submit, not chosen per row.

    The widget's own `form` attr (rendered as-is by templatetags/field.html's attrs
    passthrough) is what lets this field live inside the confirm dialog while still
    posting through the row-checkboxes' #membership-form -- see membership_list.html's
    send_invoices_modal."""

    due_in_days = forms.IntegerField(label=_("Due in"), min_value=1, max_value=365, initial=14, help_text=_("Days from today."), widget=forms.NumberInput(attrs={"form": "membership-form"}))


class ClubSettingsForm(forms.ModelForm):
    """A club's own self-service identity/branding editor (management:club_settings) --
    the club-facing equivalent of controlpanel's ClubForm, minus everything only
    platform staff should touch: `slug` (the club's subdomain -- changing it breaks
    every link the club has ever shared) and the season fields (governed by
    club.services.seasons, not something to edit casually from a settings form)."""

    class Meta:
        model = Club
        fields = ["name", "legal_name", "legal_address", "legal_zip_code", "legal_city", "contact_email", "website", "logo", "event_background", "primary_color", "secondary_color"]
        widgets = {
            "primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
            "secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
            "logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
            "event_background": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp"}),
        }


class OnboardingRequirementForm(forms.ModelForm):
    #: Not a model field lookup -- blocked_event_kinds is a plain JSONField (see
    #: OnboardingRequirement's own docstring for why: importing Event.EventKind at
    #: the model layer would create a club<->events import cycle). This is the one
    #: place that actually validates against real event kinds.
    blocked_event_kinds = forms.MultipleChoiceField(
        label=_("Blocks selection for"),
        choices=Event.EventKind.choices,
        required=False,
        widget=forms.CheckboxSelectMultiple,
        help_text=_("Event kinds a member can't be invited to or selected for while this is open. Leave unchecked for a purely informational requirement."),
    )

    class Meta:
        model = OnboardingRequirement
        fields = ["name", "description", "requires_document", "blocked_event_kinds", "is_active"]


class RequirementCompletionForm(forms.Form):
    """Marks one OnboardingRequirement complete for one membership -- see
    club.services.onboarding.mark_complete. `document` is only meaningful when the
    requirement itself has `requires_document=True`; the view doesn't reject an
    upload against a requirement that doesn't ask for one, it's just unused."""

    document = forms.FileField(label=_("Document"), required=False)
    note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))


class SignupTeamPlacementForm(forms.ModelForm):
    """Places one member (fixed by the view, not a form field) onto a team from
    the Sign-up page -- team only, picked from big buttons rather than a form.
    Position is left blank entirely (TeamMembership.position is nullable) rather
    than guessed at: which position/number they end up with is the team
    manager's own call to make correctly later (team_roster_update), and a
    blank position is also what groups every just-placed, not-yet-sorted member
    together (e.g. via the API) until then.

    Deliberately does NOT filter through eligible_roster_members like
    TeamMembershipForm's own member queryset does -- the whole point here is
    placing a still-PENDING member so a team's coach can already see them and the
    roster count is accurate, before their status/documents are actually clean.
    See club.services.onboarding.blocked_member_ids_for_event for what still
    gates their event invitations in the meantime."""

    class Meta:
        model = TeamMembership
        fields = ["team"]

    def __init__(self, *args, club=None, season=None, member=None, **kwargs):
        # Pre-seed the instance (not just self.season/self.member) -- same reasoning
        # as TeamRosterAddView's own form_kwargs comment: TeamMembership.clean()'s
        # validate_club_scope needs season/member set on the instance *before*
        # is_valid() runs, since ModelForm._post_clean() calls instance.clean()
        # straight after copying the form's own fields (team, the only real one
        # here) onto it -- season/member never being form fields, they'd
        # otherwise still be unset at that point. position is left at its default
        # (None) -- see the class docstring.
        kwargs.setdefault("instance", TeamMembership(season=season, member=member))
        super().__init__(*args, **kwargs)
        self.fields["team"].queryset = Team.objects.filter(club=club)


class RequirementBypassForm(forms.Form):
    """Marks one OnboardingRequirement as not needed for one member -- see
    club.services.onboarding.mark_bypassed. A note is required here (unlike
    RequirementCompletionForm's optional one): "why" is the whole point of a
    bypass in a way it isn't for an ordinary completion."""

    note = forms.CharField(label=_("Why isn't this needed?"), widget=forms.Textarea(attrs={"rows": 2}))
