import json
from decimal import Decimal

from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.forms import inlineformset_factory
from django.utils.text import slugify

from apps.core.authentication.models import Institution, UserProfile
from apps.core.countries import (
    ALL_COUNTRY_CHOICES,
    CountryMultipleChoiceField,
    to_country_name,
)
from apps.core.money import DEFAULT_CURRENCY, ISO4217_CHOICES
from apps.core.permissions.roles import UserRole
from apps.rims.grants.constants import ACTIVE_CALL_EDITABLE_FIELDS, CALL_TYPE_RULES
from apps.rims.grants.models import (
    Application,
    AwardAgreement,
    AwardAmendment,
    AwardCancellationRequest,
    AwardTranche,
    BeneficiaryGroup,
    ChallengeApplicationProfile,
    EligibilityRule,
    FellowshipApplicationProfile,
    FundingRecord,
    GrantBudgetLine,
    GrantCall,
    GrantCallDocument,
    ImplementationPlan,
    ImplementationPlanActivity,
    ImplementationPlanIndicator,
    ImplementationPlanOutcome,
    ImplementationPlanOutput,
    InterviewOutcome,
    InterviewSchedule,
    MonitoringVisit,
    PeriodicNarrativeReport,
    PsychometricAssessment,
    RequiredDocument,
    Review,
    VerificationRecord,
)
from apps.rims.operations.models import MOU, Partner

User = get_user_model()

RUBRIC_HIDDEN_ATTRS = {"id": "id_scoring_rubric"}

# Native select enhanced with Select2 on call create/update (see call_institutions_select2_* partials).
_SCOPE_WIDGET_ATTRS = {
    "class": (
        "js-institutions-scope w-full rounded-md border border-ruforum-border bg-white px-3 py-2 text-sm "
        "text-ruforum-text shadow-sm focus:border-ruforum-primary focus:outline-none focus:ring-1 "
        "focus:ring-ruforum-primary"
    ),
}

_GRANT_CALL_CHECKBOX_ATTRS = {
    "class": (
        "h-4 w-4 rounded border-ruforum-border text-ruforum-primary "
        "focus:outline-none focus:ring-2 focus:ring-ruforum-primary focus:ring-offset-0"
    ),
}

_FUNDING_SELECT_ATTRS = {
    "class": (
        "w-full max-w-xl rounded-md border border-ruforum-border bg-white px-3 py-2 text-sm "
        "text-ruforum-text shadow-sm focus:border-ruforum-primary focus:outline-none "
        "focus:ring-1 focus:ring-ruforum-primary"
    ),
}
# Mark `<select>` widgets that list users — Select2 init binds `select[data-select2-user]` (see templates).
_USER_SELECT2_ATTRS = {**_FUNDING_SELECT_ATTRS, "data-select2-user": "1"}


class GrantCallForm(forms.ModelForm):
    """`scoring_rubric` is a CharField (JSON in POST) so HiddenInput + Alpine work reliably."""

    scoring_rubric = forms.CharField(
        required=False,
        widget=forms.HiddenInput(attrs=RUBRIC_HIDDEN_ATTRS),
    )

    # PRD §5.1 FRFA-CS023 — per-call notification configuration. Stored on
    # ``CallNotificationConfig`` (OneToOne) after the parent call is saved.
    notify_publication = forms.BooleanField(
        required=False,
        initial=True,
        help_text="Dispatch publication notifications to potential applicants.",
        widget=forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
    )
    notify_acknowledgement = forms.BooleanField(
        required=False,
        initial=True,
        help_text="Send an acknowledgement notification to the applicant on submit.",
        widget=forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
    )
    notify_reminder_intervals = forms.CharField(
        required=False,
        initial="7,1",
        help_text="Days-before-deadline at which to remind in-progress applicants. Comma-separated, e.g. 7,1.",
        widget=forms.TextInput(
            attrs={
                "class": (
                    "w-full max-w-xs rounded-md border border-ruforum-border bg-white px-3 py-2 text-sm "
                    "text-ruforum-text shadow-sm focus:border-ruforum-primary focus:outline-none "
                    "focus:ring-1 focus:ring-ruforum-primary"
                ),
                "placeholder": "7,1",
            }
        ),
    )

    class Meta:
        model = GrantCall
        fields = [
            "title",
            "call_type",
            "description",
            "guidelines",
            "opens_at",
            "closes_at",
            "review_starts_at",
            "review_ends_at",
            "max_awards",
            "reviewers_per_application",
            "review_score_threshold",
            "tie_break_policy",
            "reviewer_workload_mode",
            "review_deadline_days",
            "interview_required",
            "verification_required",
            "verification_type",
            "verification_score_weight",
            "budget_ceiling",
            "blind_review",
            "funding_record",
            "partner",
            "mou",
            "institutions_scope",
            "reviewer_pool",
            # PRD §5.1 — applicant + scholarship + decision-email controls
            "applicant_budget_required",
            "scholarship_university_required",
            "scholarship_course_required",
            "scholarship_cohort_year",
            # PRD §5.1 FRFA-CS005 — fellowship-specific call configuration.
            "fellowship_host_institution_required",
            "fellowship_term_months",
            # PRD §5.1 FRFA-CS005 — challenge-specific call configuration.
            "challenge_innovation_stage",
            "challenge_sector_focus",
            # PRD §5.1 FRFA-CS020 — per-call submission settings (applicant uploads).
            "applicant_attachment_max_size_mb",
            "applicant_attachment_formats",
            "include_reviewer_comments_in_decision_email",
        ]
        widgets = {
            "opens_at": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "closes_at": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "review_starts_at": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "review_ends_at": forms.DateTimeInput(attrs={"type": "datetime-local"}),
            "description": forms.Textarea(attrs={"rows": 4}),
            "guidelines": forms.Textarea(attrs={"rows": 4}),
            "institutions_scope": forms.SelectMultiple(attrs=_SCOPE_WIDGET_ATTRS),
            "reviewer_pool": forms.SelectMultiple(attrs=_SCOPE_WIDGET_ATTRS),
            "blind_review": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "interview_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "verification_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "applicant_budget_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "scholarship_university_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "scholarship_course_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "fellowship_host_institution_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "include_reviewer_comments_in_decision_email": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "scholarship_cohort_year": forms.NumberInput(attrs={"min": 2020, "max": 2100, "placeholder": "2026"}),
            "fellowship_term_months": forms.NumberInput(attrs={"min": 1, "max": 60, "placeholder": "12"}),
            "challenge_sector_focus": forms.TextInput(attrs={"placeholder": "e.g. agritech, fintech"}),
            "applicant_attachment_max_size_mb": forms.NumberInput(attrs={"min": 1, "max": 200}),
            "applicant_attachment_formats": forms.TextInput(attrs={"placeholder": "pdf,docx"}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["partner"].queryset = Partner.objects.order_by("name")
        self.fields["partner"].required = False
        self.fields["partner"].widget.attrs.update({
            "data-s2": "1",
            "data-s2-placeholder": "Search partner / donor…",
        })
        self.fields["mou"].queryset = MOU.objects.select_related("partner", "institution").order_by(
            "-created_at"
        )
        self.fields["mou"].required = False
        self.fields["mou"].widget.attrs.update({
            "data-s2": "1",
            "data-s2-placeholder": "Search MOU…",
        })
        self.fields["institutions_scope"].queryset = Institution.objects.filter(is_active=True).order_by(
            "name"
        )
        self.fields["reviewer_pool"].queryset = (
            User.objects.filter(role__in=[UserRole.REVIEWER, UserRole.ADMIN, UserRole.GRANTS_MANAGER])
            .order_by("email")
            .distinct()
        )
        self.fields["reviewer_pool"].required = False
        self.fields["institutions_scope"].required = False
        self.fields["institutions_scope"].help_text = "Empty = all institutions eligible."
        self.fields["funding_record"].queryset = FundingRecord.objects.filter(
            status=FundingRecord.Status.APPROVED
        ).order_by("-created_at")
        self.fields["funding_record"].required = False
        self.fields["funding_record"].widget.attrs.update({
            "data-s2": "1",
            "data-s2-placeholder": "Search funding record…",
        })
        # Show each approved record's remaining (uncommitted) balance in the
        # option label so a fully-committed record reads as such, instead of
        # silently offering capacity that publish-validation will reject.
        from apps.rims.grants.funding import uncommitted_balance

        def _funding_label(fr):
            room = uncommitted_balance(fr)
            if room > 0:
                return f"{fr} · {room:,.2f} {fr.currency} uncommitted"
            return f"{fr} · fully committed"

        self.fields["funding_record"].label_from_instance = _funding_label
        self.fields["funding_record"].help_text = (
            "Optional: link to an approved funding record to enforce allocation against its uncommitted balance."
        )
        self.fields["max_awards"].required = False
        self.fields["review_starts_at"].required = False
        self.fields["review_ends_at"].required = False
        if self.instance.pk:
            self.fields["scoring_rubric"].initial = json.dumps(self.instance.scoring_rubric or [])
        elif not self.data:
            self.fields["scoring_rubric"].initial = "[]"
        # PRD §5.1 FRFA-CS023 — seed initial values from existing config row.
        if self.instance.pk:
            cfg = getattr(self.instance, "notification_config", None)
            if cfg is not None:
                self.fields["notify_publication"].initial = cfg.publication_notify_enabled
                self.fields["notify_acknowledgement"].initial = cfg.acknowledgement_enabled
                intervals = cfg.reminder_intervals_days or []
                if intervals:
                    self.fields["notify_reminder_intervals"].initial = ",".join(
                        str(i) for i in intervals
                    )

    def clean_notify_reminder_intervals(self):
        raw = (self.cleaned_data.get("notify_reminder_intervals") or "").strip()
        if not raw:
            return []
        out: list[int] = []
        for piece in raw.split(","):
            piece = piece.strip()
            if not piece:
                continue
            try:
                v = int(piece)
            except ValueError as e:
                raise ValidationError(f"'{piece}' is not a valid day count.") from e
            if v <= 0:
                raise ValidationError(f"Reminder days must be positive integers; got {v}.")
            out.append(v)
        # Sort descending so longest lead time fires first.
        return sorted(set(out), reverse=True)

    def clean_scoring_rubric(self):
        raw = self.cleaned_data.get("scoring_rubric")
        if raw in (None, "", []):
            return []
        if isinstance(raw, str):
            raw = raw.strip()
            if not raw:
                return []
            try:
                raw = json.loads(raw)
            except json.JSONDecodeError as e:
                raise ValidationError("Invalid JSON for scoring rubric.") from e
        if not isinstance(raw, list):
            raise ValidationError("Scoring rubric must be a JSON array.")
        cleaned = []
        for i, row in enumerate(raw):
            if not isinstance(row, dict):
                raise ValidationError(f"Rubric row {i + 1} must be an object.")
            crit = str(row.get("criterion", "")).strip()
            if not crit:
                raise ValidationError(f"Rubric row {i + 1} needs a non-empty criterion.")
            try:
                max_p = row.get("max_points", 0)
                max_p = Decimal(str(max_p))
            except Exception as e:
                raise ValidationError(f"Rubric row {i + 1}: invalid max_points.") from e
            if max_p < 0:
                raise ValidationError(f"Rubric row {i + 1}: max_points cannot be negative.")
            cleaned.append({"criterion": crit, "max_points": float(max_p)})
        return cleaned

    def clean(self):
        cleaned = super().clean()
        if self.instance.pk and self.instance.status == GrantCall.Status.PUBLISHED:
            prev_fr = self.instance.funding_record_id
            new_fr = cleaned.get("funding_record")
            new_fr_id = getattr(new_fr, "pk", None) if new_fr else None
            if prev_fr != new_fr_id:
                raise ValidationError("Cannot change linked funding record after the call is published.")
        # PRD §5.1 FRFA-CS005 — zero out per-type fields that don't apply to the
        # chosen call_type so accidentally-checked scholarship boxes on a
        # Fellowship call don't poison the saved row.
        call_type = cleaned.get("call_type") or GrantCall.CallType.GRANT
        rules = CALL_TYPE_RULES.get(call_type, {})
        visible = rules.get("visible_fields", set())
        per_type_defaults = {
            "applicant_budget_required": False,
            "scholarship_university_required": False,
            "scholarship_course_required": False,
            "scholarship_cohort_year": None,
            "fellowship_host_institution_required": False,
            "fellowship_term_months": None,
            "challenge_innovation_stage": "",
            "challenge_sector_focus": "",
        }
        for field, default in per_type_defaults.items():
            if field in self.fields and field not in visible:
                cleaned[field] = default
        return cleaned

    def save(self, commit=True):
        obj = super().save(commit=False)
        obj.scoring_rubric = self.cleaned_data.get("scoring_rubric") or []
        base = slugify(obj.title)[:100] or "call"
        slug = base
        n = 0
        qs = GrantCall.objects.all()
        if obj.pk:
            qs = qs.exclude(pk=obj.pk)
        while qs.filter(slug=slug).exists():
            n += 1
            slug = f"{base}-{n}"
        obj.slug = slug
        if commit:
            obj.save()
            self.save_m2m()
            self._sync_notification_config(obj)
        return obj

    def _sync_notification_config(self, call):
        """PRD §5.1 FRFA-CS023 — upsert the per-call notification config row
        from the form's notify_* fields."""
        from apps.rims.grants.models import CallNotificationConfig

        intervals = self.cleaned_data.get("notify_reminder_intervals") or []
        publication = bool(self.cleaned_data.get("notify_publication"))
        ack = bool(self.cleaned_data.get("notify_acknowledgement"))
        CallNotificationConfig.objects.update_or_create(
            call=call,
            defaults={
                "publication_notify_enabled": publication,
                "acknowledgement_enabled": ack,
                "reminder_intervals_days": intervals,
            },
        )


class GrantCallActiveEditForm(forms.ModelForm):
    """PRD §5.1 FRFA-CS028 — restricted edit form for PUBLISHED calls.

    Only the non-critical fields listed in
    ``constants.ACTIVE_CALL_EDITABLE_FIELDS`` are editable mid-flight.
    Critical fields (call_type, dates, funding linkage, budget ceiling,
    eligibility rules) are immutable on an active call — withdraw + recreate
    to change them.
    """

    class Meta:
        model = GrantCall
        fields = tuple(ACTIVE_CALL_EDITABLE_FIELDS)
        widgets = {
            "description": forms.Textarea(attrs={"rows": 4}),
            "guidelines": forms.Textarea(attrs={"rows": 4}),
        }


class ApplicationForm(forms.ModelForm):
    class Meta:
        model = Application
        fields = [
            "institution",
            "proposal",
            # PRD §5.1 FRFA-CS013 — surfaced only for SCHOLARSHIP calls (see __init__ below).
            "scholarship_university_choice",
            "scholarship_course_choice",
        ]
        widgets = {
            "institution": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "proposal": forms.Textarea(
                attrs={
                    "rows": 8,
                    "class": (
                        "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm "
                        "text-ruforum-text shadow-sm focus:border-ruforum-primary "
                        "focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
                    ),
                }
            ),
            "scholarship_university_choice": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "scholarship_course_choice": forms.TextInput(
                attrs={
                    "class": (
                        "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm "
                        "text-ruforum-text shadow-sm focus:border-ruforum-primary "
                        "focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
                    ),
                    "placeholder": "e.g. MSc Agroecology, PhD Plant Pathology",
                }
            ),
        }

    # PRD §5.1 FRFA-AM005 — submit-time minimum length for proposals. Draft
    # autosave bypasses the form entirely (see ApplicationDraftAutoSaveView),
    # so this only fires on full submit.
    PROPOSAL_MIN_LENGTH = 200

    def __init__(self, *args, call=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["institution"].queryset = Institution.objects.filter(is_active=True).order_by("name")
        self.fields["institution"].required = False
        self.fields["proposal"].required = False
        # Scholarship inputs are always optional at form-level — submit_application
        # enforces the per-call required flags. Limit the university choice list
        # to active institutions for the dropdown.
        self.fields["scholarship_university_choice"].queryset = Institution.objects.filter(
            is_active=True
        ).order_by("name")
        self.fields["scholarship_university_choice"].required = False
        self.fields["scholarship_university_choice"].widget.attrs.update({
            "data-s2": "1",
            "data-s2-placeholder": "Search university…",
        })
        self.fields["scholarship_course_choice"].required = False
        # When the form knows its call, drop the scholarship fields entirely for
        # non-scholarship calls so they are not posted.
        self.call = call
        if call is not None and getattr(call, "call_type", None) != "scholarship":
            self.fields.pop("scholarship_university_choice", None)
            self.fields.pop("scholarship_course_choice", None)

    def clean_proposal(self):
        """PRD §5.1 FRFA-AM005 — specific reason on a short / empty proposal."""
        value = (self.cleaned_data.get("proposal") or "").strip()
        if not value:
            raise forms.ValidationError(
                "A proposal is required to submit. Use the text area to describe your "
                "project — what problem you are tackling, your approach, and the outcomes."
            )
        if len(value) < self.PROPOSAL_MIN_LENGTH:
            raise forms.ValidationError(
                f"Your proposal is too short to evaluate ({len(value)} characters; "
                f"minimum is {self.PROPOSAL_MIN_LENGTH}). Expand on your method, your "
                "expected outcomes, and how funding will be used."
            )
        return value

    def clean_institution(self):
        """PRD §5.1 FRFA-AM005 — explain *why* the institution must be set."""
        value = self.cleaned_data.get("institution")
        if value is None:
            raise forms.ValidationError(
                "Select your home institution. The grants team uses this to verify "
                "your affiliation and apply institution-based eligibility rules."
            )
        return value

    def clean_scholarship_university_choice(self):
        """PRD §5.1 FRFA-CS013 — required only when the scholarship call asks for it."""
        value = self.cleaned_data.get("scholarship_university_choice")
        call = self.call
        if (
            call is not None
            and getattr(call, "call_type", None) == "scholarship"
            and getattr(call, "scholarship_university_required", False)
            and value is None
        ):
            raise forms.ValidationError(
                "This scholarship call requires a target university — pick the institution "
                "where you plan to enrol. The grants team uses this to verify the placement."
            )
        return value

    def clean_scholarship_course_choice(self):
        """PRD §5.1 FRFA-CS013 — required only when the scholarship call asks for it."""
        value = (self.cleaned_data.get("scholarship_course_choice") or "").strip()
        call = self.call
        if (
            call is not None
            and getattr(call, "call_type", None) == "scholarship"
            and getattr(call, "scholarship_course_required", False)
            and not value
        ):
            raise forms.ValidationError(
                "This scholarship call requires the target programme name. Use the exact "
                "title from the university prospectus (e.g. \"MSc Agroecology\")."
            )
        return value


class RequiredDocumentForm(forms.ModelForm):
    class Meta:
        model = RequiredDocument
        fields = ["label", "description", "is_required"]
        widgets = {
            "label": forms.TextInput(attrs={"id": "doc-label-input", "placeholder": "e.g. CV / Résumé"}),
            "description": forms.TextInput(attrs={"placeholder": "Brief guidance shown to applicants"}),
        }


class ReviewForm(forms.Form):
    recommendation_code = forms.ChoiceField(
        required=False,
        choices=[("", "— Select recommendation —")] + list(Review.RecommendationCode.choices),
    )
    recommendation = forms.CharField(widget=forms.Textarea(attrs={"rows": 4}), required=False)

    def __init__(self, *args, rubric=None, **kwargs):
        super().__init__(*args, **kwargs)
        rubric = rubric if rubric else [{"criterion": "Overall", "max_points": 10}]
        self._rubric = rubric
        for i, row in enumerate(rubric):
            label = str(row.get("criterion", f"Criterion {i + 1}"))
            max_p = Decimal(str(row.get("max_points", 10)))
            definition = str(row.get("definition") or row.get("description") or "").strip()
            hint = f"Maximum {max_p.normalize():f} pts"
            if definition:
                hint = f"{definition} · {hint}"
            self.fields[f"score_{i}"] = forms.DecimalField(
                label=label,
                help_text=hint,
                max_digits=8,
                decimal_places=2,
                min_value=Decimal("0"),
                max_value=max_p,
                required=True,
            )

    def scores_dict(self) -> dict:
        out = {}
        for i, row in enumerate(self._rubric):
            key = str(row.get("criterion", f"criterion_{i}"))
            out[key] = self.cleaned_data[f"score_{i}"]
        return out


class AwardForm(forms.Form):
    amount = forms.DecimalField(max_digits=14, decimal_places=2, min_value=Decimal("0.01"))
    currency = forms.ChoiceField(
        label="Currency",
        choices=ISO4217_CHOICES,
        initial=DEFAULT_CURRENCY,
    )
    project_end_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
    narrative = forms.CharField(widget=forms.Textarea(attrs={"rows": 4}), required=False)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["currency"].widget.attrs.update(_FUNDING_SELECT_ATTRS)
        self.fields["amount"].widget.attrs.update(
            {"class": "w-full max-w-xs rounded-md border border-ruforum-border px-3 py-2 text-sm tabular-nums"}
        )
        self.fields["project_end_date"].widget.attrs.update(
            {"type": "date", "class": "rounded-md border border-ruforum-border px-3 py-2 text-sm"}
        )


class AwardCloseoutForm(forms.Form):
    narrative = forms.CharField(widget=forms.Textarea(attrs={"rows": 6}), required=False)


class AssignReviewerForm(forms.Form):
    reviewer = forms.ModelChoiceField(
        queryset=User.objects.none(),
        required=True,
    )
    declare_conflict = forms.BooleanField(required=False, initial=False)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["reviewer"].queryset = (
            User.objects.filter(role__in=[UserRole.REVIEWER, UserRole.ADMIN, UserRole.GRANTS_MANAGER])
            .order_by("email")
            .distinct()
        )
        self.fields["reviewer"].widget.attrs.update(_USER_SELECT2_ATTRS)


class ReviewerCoiDeclarationForm(forms.Form):
    """PRD §5.1 FRFA-AM029 — reviewer self-declares a conflict of interest.

    Declaring is blocking: the assignment is paused, the application leaves the
    reviewer's queue, and the programme officer is notified for reassignment.
    """

    NOTES_MIN_LENGTH = 20

    notes = forms.CharField(
        label="Describe the conflict",
        widget=forms.Textarea(
            attrs={
                "rows": 4,
                "class": (
                    "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm "
                    "text-ruforum-text focus:border-ruforum-primary focus:outline-none "
                    "focus:ring-1 focus:ring-ruforum-primary"
                ),
                "placeholder": (
                    "What is the conflict? Examples: prior collaboration, "
                    "family or financial relationship, current dispute."
                ),
            }
        ),
        max_length=1000,
        help_text=(
            "Programme officers see only your explanation, never the applicant's identity. "
            "Be brief but specific so they can reassign quickly."
        ),
    )
    confirm = forms.BooleanField(
        label="I confirm this conflict of interest in good faith.",
        required=True,
    )

    def clean_notes(self):
        value = (self.cleaned_data.get("notes") or "").strip()
        if len(value) < self.NOTES_MIN_LENGTH:
            raise forms.ValidationError(
                f"Please write at least {self.NOTES_MIN_LENGTH} characters so the "
                "programme officer can act on this declaration."
            )
        return value


class ShortlistDecisionForm(forms.Form):
    override_shortlist = forms.BooleanField(required=False)
    override_reason = forms.CharField(widget=forms.Textarea(attrs={"rows": 3}), required=False)

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("override_shortlist") and not (cleaned.get("override_reason") or "").strip():
            raise forms.ValidationError("Provide a reason when overriding the shortlist recommendation.")
        return cleaned


class InterviewScheduleForm(forms.ModelForm):
    class Meta:
        model = InterviewSchedule
        fields = ["scheduled_for", "format", "venue", "notes", "status"]
        widgets = {
            "scheduled_for": forms.DateTimeInput(
                attrs={"type": "datetime-local", "class": _FUNDING_SELECT_ATTRS["class"]}
            ),
            "format": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "venue": forms.TextInput(
                attrs={**_FUNDING_SELECT_ATTRS, "placeholder": "e.g. Zoom, Secretariat board room"}
            ),
            "notes": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Add coordination notes, joining instructions, or panel context.",
                }
            ),
            "status": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
        }
        labels = {
            "scheduled_for": "Interview date and time",
            "format": "Interview format",
            "venue": "Venue or meeting link",
            "notes": "Interview notes",
            "status": "Workflow status",
        }


class InterviewOutcomeForm(forms.ModelForm):
    """PRD §5.1 FRFA-AM030 — Program Officers record interview outcomes via this form.

    Outcome (attendance, score, recommendation, summary) is linked to the
    applicant's :class:`InterviewSchedule` and feeds shortlist scoring through
    :func:`record_interview_outcome`.
    """

    class Meta:
        model = InterviewOutcome
        fields = ["attendance", "score", "recommendation_code", "summary"]
        widgets = {
            "attendance": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "score": forms.NumberInput(
                attrs={
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "step": "0.01",
                    "min": "0",
                    "placeholder": "Optional panel score",
                }
            ),
            "recommendation_code": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "summary": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Summarize attendance, panel concerns, and next action.",
                }
            ),
        }
        labels = {
            "attendance": "Attendance outcome",
            "score": "Interview score",
            "recommendation_code": "Panel recommendation",
            "summary": "Outcome summary",
        }


class VerificationRecordForm(forms.ModelForm):
    findings_json = forms.CharField(
        required=False,
        widget=forms.Textarea(
            attrs={
                "rows": 4,
                "class": "w-full rounded-md border border-ruforum-border px-3 py-2 font-mono text-xs",
                "placeholder": '{\n  "institution_confirmed": true,\n  "notes": "Verifier remarks"\n}',
            }
        ),
    )

    class Meta:
        model = VerificationRecord
        fields = ["verification_type", "status", "score", "summary"]
        widgets = {
            "verification_type": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "status": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "score": forms.NumberInput(
                attrs={
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "step": "0.01",
                    "min": "0",
                    "placeholder": "Optional verification score",
                }
            ),
            "summary": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Record the verification conclusion and any material concerns.",
                }
            ),
        }
        labels = {
            "verification_type": "Verification type",
            "status": "Verification result",
            "score": "Verification score",
            "summary": "Verification summary",
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.initial["findings_json"] = json.dumps(self.instance.findings or {}, indent=2) if self.instance.pk else "{}"

    def clean_findings_json(self):
        raw = (self.cleaned_data.get("findings_json") or "").strip()
        if not raw:
            return {}
        try:
            obj = json.loads(raw)
        except json.JSONDecodeError as e:
            raise forms.ValidationError("Findings must be valid JSON.") from e
        if not isinstance(obj, dict):
            raise forms.ValidationError("Findings must be a JSON object.")
        return obj

    def save(self, commit=True):
        obj = super().save(commit=False)
        obj.findings = self.cleaned_data.get("findings_json") or {}
        if commit:
            obj.save()
        return obj


class AwardAgreementForm(forms.ModelForm):
    class Meta:
        model = AwardAgreement
        fields = ["version_number", "title", "document", "accepted"]
        widgets = {
            "version_number": forms.NumberInput(
                attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "1", "step": "1", "placeholder": "1"}
            ),
            "title": forms.TextInput(
                attrs={
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "e.g. Standard grant agreement, Addendum v2",
                }
            ),
            "accepted": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
        }
        labels = {
            "version_number": "Agreement version",
            "title": "Agreement title",
            "document": "Agreement file",
            "accepted": "Already accepted",
        }
        help_texts = {
            "version_number": "Increment the version when terms or attachments change.",
            "title": "Use a clear title that finance and grants staff can recognize later.",
            "document": "Upload the signed or draft agreement document for this award.",
            "accepted": "Tick only if the recipient has formally accepted this version.",
        }


class AwardTrancheForm(forms.ModelForm):
    class Meta:
        model = AwardTranche
        fields = ["label", "amount", "due_on", "milestone", "status", "payment_reference", "sort_order"]
        widgets = {
            "label": forms.TextInput(
                attrs={
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "e.g. Inception tranche, Milestone 2 release",
                }
            ),
            "amount": forms.NumberInput(
                attrs={"class": _FUNDING_SELECT_ATTRS["class"], "step": "0.01", "min": "0", "placeholder": "0.00"}
            ),
            "due_on": forms.DateInput(attrs={"type": "date", "class": _FUNDING_SELECT_ATTRS["class"]}),
            "milestone": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "status": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "payment_reference": forms.TextInput(
                attrs={"class": _FUNDING_SELECT_ATTRS["class"], "placeholder": "Optional finance or bank reference"}
            ),
            "sort_order": forms.NumberInput(
                attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0", "step": "1", "placeholder": "0"}
            ),
        }
        labels = {
            "label": "Tranche label",
            "amount": "Planned amount",
            "due_on": "Planned release date",
            "milestone": "Linked milestone",
            "status": "Tranche status",
            "payment_reference": "Payment reference",
            "sort_order": "Display order",
        }
        help_texts = {
            "label": "Name the release so it is understandable in reports and dashboards.",
            "amount": "Enter the amount intended for this tranche.",
            "due_on": "Use the expected release date, even if payment is not yet scheduled.",
            "milestone": "Optional: link the tranche to the milestone that unlocks it.",
            "status": "Planned is the normal starting state for a new tranche.",
            "payment_reference": "Add a finance reference once one exists.",
            "sort_order": "Lower numbers appear earlier in the tranche sequence.",
        }


class PeriodicNarrativeReportForm(forms.ModelForm):
    class Meta:
        model = PeriodicNarrativeReport
        fields = [
            "period_label",
            "male_participants",
            "female_participants",
            "direct_beneficiaries",
            "indirect_beneficiaries",
            "beneficiary_groups",
            "field_days_organized",
            "summary_progress",
            "linkages_established",
            "problems_and_challenges",
            "no_cost_extension_required",
            "no_cost_extension_explanation",
            "on_schedule",
            "not_on_schedule_explanation",
        ]
        widgets = {
            "period_label": forms.TextInput(
                attrs={"class": _FUNDING_SELECT_ATTRS["class"], "placeholder": "e.g. Month 12, Year 1"}
            ),
            "male_participants": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
            "female_participants": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
            "direct_beneficiaries": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
            "indirect_beneficiaries": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
            "beneficiary_groups": forms.SelectMultiple(attrs=_FUNDING_SELECT_ATTRS),
            "field_days_organized": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
            "summary_progress": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 3}),
            "linkages_established": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 3}),
            "problems_and_challenges": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 3}),
            "no_cost_extension_required": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "no_cost_extension_explanation": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
            "on_schedule": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
            "not_on_schedule_explanation": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
        }
        labels = {
            "period_label": "Reporting period",
            "field_days_organized": "Field days organized",
        }
        help_texts = {
            "period_label": "A short label identifying this reporting period.",
            "no_cost_extension_required": "Tick if the project needs a no-cost extension beyond its current end date.",
            "on_schedule": "Untick if the project has fallen behind its planned timeline.",
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["beneficiary_groups"].queryset = BeneficiaryGroup.objects.all()
        self.fields["beneficiary_groups"].required = False


# ── Implementation Plan / Results Framework / Monitoring Visit (SRS §4.1.7) ──

class ImplementationPlanForm(forms.ModelForm):
    class Meta:
        model = ImplementationPlan
        fields = ["objective", "assumptions"]
        widgets = {
            "objective": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 3}),
            "assumptions": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 3}),
        }


class ImplementationPlanOutcomeForm(forms.ModelForm):
    class Meta:
        model = ImplementationPlanOutcome
        fields = ["title", "description", "order"]
        widgets = {
            "title": forms.TextInput(attrs=_FUNDING_SELECT_ATTRS),
            "description": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
            "order": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
        }


class ImplementationPlanOutputForm(forms.ModelForm):
    class Meta:
        model = ImplementationPlanOutput
        fields = ["title", "description", "order"]
        widgets = {
            "title": forms.TextInput(attrs=_FUNDING_SELECT_ATTRS),
            "description": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
            "order": forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
        }


class ImplementationPlanIndicatorForm(forms.Form):
    """Plain Form, not a ModelForm: ImplementationPlanIndicator.clean()
    requires exactly one of outcome/output to be set, but neither is a form
    field here — the view sets whichever one applies *after* validation. A
    ModelForm would run the model's full_clean() (via _post_clean()) before
    that assignment happens and always fail.
    """

    name = forms.CharField(max_length=255, widget=forms.TextInput(attrs=_FUNDING_SELECT_ATTRS))
    baseline_value = forms.DecimalField(
        required=False, widget=forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "step": "0.01"})
    )
    target_value = forms.DecimalField(
        required=False, widget=forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "step": "0.01"})
    )
    means_of_verification = forms.ChoiceField(
        choices=ImplementationPlanIndicator.MeansOfVerification.choices, widget=forms.Select(attrs=_FUNDING_SELECT_ATTRS)
    )
    reporting_frequency = forms.ChoiceField(
        choices=ImplementationPlanIndicator.ReportingFrequency.choices, widget=forms.Select(attrs=_FUNDING_SELECT_ATTRS)
    )


class ImplementationPlanActivityForm(forms.ModelForm):
    class Meta:
        model = ImplementationPlanActivity
        fields = ["title", "planned_start", "planned_end", "risks", "mitigation_measures"]
        widgets = {
            "title": forms.TextInput(attrs=_FUNDING_SELECT_ATTRS),
            "planned_start": forms.DateInput(attrs={"type": "date", "class": _FUNDING_SELECT_ATTRS["class"]}),
            "planned_end": forms.DateInput(attrs={"type": "date", "class": _FUNDING_SELECT_ATTRS["class"]}),
            "risks": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
            "mitigation_measures": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
        }


class MonitoringVisitForm(forms.ModelForm):
    class Meta:
        model = MonitoringVisit
        fields = ["visit_type", "scheduled_for", "participants", "agenda"]
        widgets = {
            "visit_type": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "scheduled_for": forms.DateTimeInput(attrs={"type": "datetime-local", "class": _FUNDING_SELECT_ATTRS["class"]}),
            "participants": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
            "agenda": forms.Textarea(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "rows": 2}),
        }


class MonitoringVisitFindingsForm(forms.Form):
    _cls = _FUNDING_SELECT_ATTRS["class"]
    findings = forms.CharField(required=False, widget=forms.Textarea(attrs={"class": _cls, "rows": 3}))
    strengths = forms.CharField(required=False, widget=forms.Textarea(attrs={"class": _cls, "rows": 2}))
    gaps = forms.CharField(required=False, widget=forms.Textarea(attrs={"class": _cls, "rows": 2}))
    risks_identified = forms.CharField(required=False, widget=forms.Textarea(attrs={"class": _cls, "rows": 2}))
    recommendations = forms.CharField(required=False, widget=forms.Textarea(attrs={"class": _cls, "rows": 2}))
    latitude = forms.DecimalField(required=False, widget=forms.NumberInput(attrs={"class": _cls, "step": "0.000001"}))
    longitude = forms.DecimalField(required=False, widget=forms.NumberInput(attrs={"class": _cls, "step": "0.000001"}))


class AwardAmendmentForm(forms.ModelForm):
    previous_terms_json = forms.CharField(
        required=False,
        label="Current terms snapshot",
        help_text="Capture the terms before the amendment so the record preserves a clear before/after view.",
        widget=forms.Textarea(
            attrs={
                "rows": 4,
                "class": "w-full rounded-md border border-ruforum-border px-3 py-2 font-mono text-xs",
                "placeholder": '{\n  "amount": "12000.00",\n  "end_date": "2027-03-31"\n}',
            }
        ),
    )
    new_terms_json = forms.CharField(
        required=False,
        label="Proposed terms snapshot",
        help_text="Describe the amended terms as structured JSON for auditability.",
        widget=forms.Textarea(
            attrs={
                "rows": 4,
                "class": "w-full rounded-md border border-ruforum-border px-3 py-2 font-mono text-xs",
                "placeholder": '{\n  "amount": "13500.00",\n  "end_date": "2027-06-30"\n}',
            }
        ),
    )

    class Meta:
        model = AwardAmendment
        fields = ["change_summary", "justification", "status", "rejection_reason"]
        widgets = {
            "change_summary": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Summarize the requested amendment in plain language.",
                }
            ),
            "justification": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Explain why the amendment is needed and what changed operationally.",
                }
            ),
            "status": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "rejection_reason": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Only complete this if the amendment request is rejected.",
                }
            ),
        }
        labels = {
            "change_summary": "Requested change",
            "justification": "Business justification",
            "status": "Decision status",
            "rejection_reason": "Rejection reason",
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.initial["previous_terms_json"] = json.dumps(self.instance.previous_terms or {}, indent=2) if self.instance.pk else "{}"
        self.initial["new_terms_json"] = json.dumps(self.instance.new_terms or {}, indent=2) if self.instance.pk else "{}"

    def clean_previous_terms_json(self):
        raw = (self.cleaned_data.get("previous_terms_json") or "").strip()
        return json.loads(raw or "{}")

    def clean_new_terms_json(self):
        raw = (self.cleaned_data.get("new_terms_json") or "").strip()
        return json.loads(raw or "{}")

    def save(self, commit=True):
        obj = super().save(commit=False)
        obj.previous_terms = self.cleaned_data.get("previous_terms_json") or {}
        obj.new_terms = self.cleaned_data.get("new_terms_json") or {}
        if commit:
            obj.save()
        return obj


class AwardCancellationForm(forms.ModelForm):
    class Meta:
        model = AwardCancellationRequest
        fields = ["reason", "status", "decision_note"]
        widgets = {
            "reason": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Explain the cancellation risk or why termination is being proposed.",
                }
            ),
            "status": forms.Select(attrs=_FUNDING_SELECT_ATTRS),
            "decision_note": forms.Textarea(
                attrs={
                    "rows": 3,
                    "class": _FUNDING_SELECT_ATTRS["class"],
                    "placeholder": "Add a decision note after review, if applicable.",
                }
            ),
        }
        labels = {
            "reason": "Cancellation rationale",
            "status": "Request status",
            "decision_note": "Decision note",
        }
        help_texts = {
            "reason": "Record the issue clearly enough for audit, finance, and programme review.",
            "status": "Leave as requested for a new cancellation submission.",
            "decision_note": "Optional until a decision has been made.",
        }


class CohortAddendumForm(forms.Form):
    """PRD §5.1 FRFA-AA023 — cohort addendum request form."""

    label = forms.CharField(
        max_length=128,
        label="Cohort label",
        widget=forms.TextInput(
            attrs={
                "class": _FUNDING_SELECT_ATTRS["class"],
                "placeholder": "e.g. 2027 fellowship cohort",
            }
        ),
    )
    target_participants = forms.IntegerField(
        min_value=0,
        label="Target participants",
        widget=forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"]}),
    )
    budget_allocation = forms.DecimalField(
        max_digits=14,
        decimal_places=2,
        min_value=0,
        label="Budget allocation",
        widget=forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "step": "0.01"}),
    )
    start_date = forms.DateField(
        required=False,
        label="Start date",
        widget=forms.DateInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "type": "date"}),
    )
    end_date = forms.DateField(
        required=False,
        label="End date",
        widget=forms.DateInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "type": "date"}),
    )
    reason = forms.CharField(
        label="Reason for the addendum",
        widget=forms.Textarea(
            attrs={
                "rows": 3,
                "class": _FUNDING_SELECT_ATTRS["class"],
                "placeholder": "Explain why a new cohort needs to be added at this stage.",
            }
        ),
    )


class CompleteCallForm(forms.Form):
    confirm = forms.BooleanField(required=True, label="I confirm this call cycle is complete")


_MULTI_SELECT_CLASS = (
    "js-eligibility-multi w-full rounded-md border border-ruforum-border bg-white px-3 py-2 "
    "text-sm text-ruforum-text shadow-sm focus:border-ruforum-primary focus:outline-none "
    "focus:ring-1 focus:ring-ruforum-primary"
)


class EligibilityRuleForm(forms.ModelForm):
    """Manager-facing form for one eligibility rule row.

    The model stores `params` as a JSONField, but we never expose JSON to the user. Three
    typed fields drive the UI — one per rule type — and `clean()` serializes the active
    one into `params` for the model. Only the field matching the selected `rule_type` is
    visible in the template (see `_call_eligibility.html` data-rule-fields).
    """

    nationality_countries = CountryMultipleChoiceField(
        choices=ALL_COUNTRY_CHOICES,
        required=False,
        widget=forms.SelectMultiple(
            attrs={"class": _MULTI_SELECT_CLASS, "data-eligibility-multi": "country"}
        ),
        label="Eligible countries",
        help_text="Leave empty to allow applicants from any country.",
    )
    degree_levels = forms.MultipleChoiceField(
        choices=UserProfile.DegreeLevel.choices,
        required=False,
        widget=forms.CheckboxSelectMultiple(
            attrs={"class": "h-4 w-4 rounded border-ruforum-border text-ruforum-primary"}
        ),
        label="Eligible degree levels",
        help_text="Leave all unchecked to allow any degree level.",
    )
    institution_ids = forms.ModelMultipleChoiceField(
        queryset=Institution.objects.filter(is_active=True).order_by("name"),
        required=False,
        widget=forms.SelectMultiple(
            attrs={"class": _MULTI_SELECT_CLASS, "data-eligibility-multi": "institution"}
        ),
        label="Eligible institutions",
        help_text="Leave empty to allow applicants from any institution.",
    )
    # FRFA-CS012 — base criteria.
    sector_tags = forms.CharField(
        required=False,
        widget=forms.TextInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "placeholder": "e.g. agritech, climate"}),
        label="Eligible sectors / thematic areas",
        help_text="Comma-separated tags, matched against the applicant's profile interests. Leave empty to allow any.",
    )
    age_min = forms.IntegerField(
        required=False, min_value=0,
        widget=forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
        label="Minimum age",
    )
    age_max = forms.IntegerField(
        required=False, min_value=0,
        widget=forms.NumberInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "min": "0"}),
        label="Maximum age",
    )
    documentation_labels = forms.CharField(
        required=False,
        widget=forms.TextInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "placeholder": "e.g. CV, Reference letter"}),
        label="Required document labels",
        help_text="Comma-separated document labels the applicant must have uploaded by submission.",
    )
    # FRFA-CS013 — type-specific criteria.
    scholarship_university_ids = forms.ModelMultipleChoiceField(
        queryset=Institution.objects.filter(is_active=True).order_by("name"),
        required=False,
        widget=forms.SelectMultiple(attrs={"class": _MULTI_SELECT_CLASS}),
        label="Eligible target universities (scholarship)",
        help_text="Leave empty to allow any target university.",
    )
    scholarship_course_keywords = forms.CharField(
        required=False,
        widget=forms.TextInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "placeholder": "e.g. agriculture, economics"}),
        label="Eligible target course keywords (scholarship)",
    )
    fellowship_host_institutions = forms.CharField(
        required=False,
        widget=forms.TextInput(attrs={"class": _FUNDING_SELECT_ATTRS["class"], "placeholder": "e.g. Makerere University"}),
        label="Eligible host institutions (fellowship)",
        help_text="Comma-separated host institution names (partial match).",
    )
    fellowship_types = forms.MultipleChoiceField(
        choices=FellowshipApplicationProfile.FormOfService.choices,
        required=False,
        widget=forms.CheckboxSelectMultiple(attrs={"class": "h-4 w-4 rounded border-ruforum-border text-ruforum-primary"}),
        label="Eligible fellowship types",
    )
    challenge_innovation_stages = forms.MultipleChoiceField(
        choices=GrantCall.InnovationStage.choices,
        required=False,
        widget=forms.CheckboxSelectMultiple(attrs={"class": "h-4 w-4 rounded border-ruforum-border text-ruforum-primary"}),
        label="Eligible innovation stages (challenge)",
    )

    class Meta:
        model = EligibilityRule
        fields = ("rule_type", "is_blocking")
        widgets = {
            "rule_type": forms.Select(
                attrs={
                    "class": (
                        "w-full rounded-md border border-ruforum-border bg-white px-3 py-2 "
                        "text-sm text-ruforum-text shadow-sm focus:border-ruforum-primary "
                        "focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
                    )
                }
            ),
            "is_blocking": forms.CheckboxInput(
                attrs={"class": "h-4 w-4 rounded border-ruforum-border text-ruforum-primary"}
            ),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["rule_type"].required = False
        self.fields["rule_type"].choices = [("", "— Select type —")] + [
            (c[0], c[1]) for c in EligibilityRule.RuleType.choices
        ]
        if self.instance.pk:
            params = self.instance.params or {}
            if self.instance.rule_type == "nationality":
                self.fields["nationality_countries"].initial = [
                    to_country_name(c) for c in params.get("countries", []) if c
                ]
            elif self.instance.rule_type == "degree":
                self.fields["degree_levels"].initial = [
                    str(d).lower() for d in params.get("levels", []) if d
                ]
            elif self.instance.rule_type == "institution":
                self.fields["institution_ids"].initial = list(params.get("institution_ids", []))
            elif self.instance.rule_type == "sector":
                self.fields["sector_tags"].initial = ", ".join(params.get("tags", []))
            elif self.instance.rule_type == "age":
                self.fields["age_min"].initial = params.get("min_age")
                self.fields["age_max"].initial = params.get("max_age")
            elif self.instance.rule_type == "documentation":
                self.fields["documentation_labels"].initial = ", ".join(params.get("required_labels", []))
            elif self.instance.rule_type == "scholarship_university":
                self.fields["scholarship_university_ids"].initial = list(params.get("institution_ids", []))
            elif self.instance.rule_type == "scholarship_course":
                self.fields["scholarship_course_keywords"].initial = ", ".join(params.get("keywords", []))
            elif self.instance.rule_type == "fellowship_host_institution":
                self.fields["fellowship_host_institutions"].initial = ", ".join(params.get("institutions", []))
            elif self.instance.rule_type == "fellowship_type":
                self.fields["fellowship_types"].initial = list(params.get("types", []))
            elif self.instance.rule_type == "challenge_innovation_stage":
                self.fields["challenge_innovation_stages"].initial = list(params.get("stages", []))

    @staticmethod
    def _split_csv(value: str) -> list[str]:
        return [v.strip() for v in (value or "").split(",") if v.strip()]

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("DELETE"):
            return cleaned
        rt = (cleaned.get("rule_type") or "").strip()
        if not rt and self.instance.pk:
            raise ValidationError({"rule_type": "Rule type is required."})
        if rt == "nationality":
            self.instance.params = {"countries": list(cleaned.get("nationality_countries") or [])}
        elif rt == "degree":
            self.instance.params = {"levels": list(cleaned.get("degree_levels") or [])}
        elif rt == "institution":
            self.instance.params = {
                "institution_ids": [i.pk for i in (cleaned.get("institution_ids") or [])]
            }
        elif rt == "sector":
            self.instance.params = {"tags": self._split_csv(cleaned.get("sector_tags"))}
        elif rt == "age":
            self.instance.params = {
                "min_age": cleaned.get("age_min"),
                "max_age": cleaned.get("age_max"),
            }
        elif rt == "documentation":
            self.instance.params = {"required_labels": self._split_csv(cleaned.get("documentation_labels"))}
        elif rt == "scholarship_university":
            self.instance.params = {
                "institution_ids": [i.pk for i in (cleaned.get("scholarship_university_ids") or [])]
            }
        elif rt == "scholarship_course":
            self.instance.params = {"keywords": self._split_csv(cleaned.get("scholarship_course_keywords"))}
        elif rt == "fellowship_host_institution":
            self.instance.params = {"institutions": self._split_csv(cleaned.get("fellowship_host_institutions"))}
        elif rt == "fellowship_type":
            self.instance.params = {"types": list(cleaned.get("fellowship_types") or [])}
        elif rt == "challenge_innovation_stage":
            self.instance.params = {"stages": list(cleaned.get("challenge_innovation_stages") or [])}
        elif rt:
            self.instance.params = {}
        return cleaned

    def _post_clean(self):
        if not self.cleaned_data:
            return
        rt = (self.cleaned_data.get("rule_type") or "").strip()
        if not rt and not self.instance.pk:
            self.cleaned_data = {}
            return
        super()._post_clean()


class GrantCallDocumentForm(forms.ModelForm):
    class Meta:
        model = GrantCallDocument
        fields = ("label", "doc_kind", "file", "is_guideline", "sort_order")
        widgets = {
            "doc_kind": forms.Select(
                attrs={
                    "class": (
                        "w-full rounded-md border border-ruforum-border bg-white px-3 py-2 "
                        "text-sm text-ruforum-text shadow-sm focus:border-ruforum-primary "
                        "focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
                    )
                }
            ),
            "label": forms.TextInput(
                attrs={
                    "class": (
                        "w-full rounded-md border border-ruforum-border bg-white px-3 py-2 "
                        "text-sm text-ruforum-text shadow-sm focus:border-ruforum-primary "
                        "focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
                    )
                }
            ),
            "file": forms.ClearableFileInput(
                attrs={
                    "class": (
                        "block w-full text-sm text-ruforum-text file:mr-3 file:rounded-md "
                        "file:border file:border-ruforum-border file:bg-ruforum-surface "
                        "file:px-3 file:py-1.5 file:text-sm file:font-medium"
                    )
                }
            ),
            "sort_order": forms.NumberInput(
                attrs={
                    "class": (
                        "w-24 rounded-md border border-ruforum-border bg-white px-3 py-2 "
                        "text-sm text-ruforum-text shadow-sm focus:border-ruforum-primary "
                        "focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
                    ),
                    "min": 0,
                }
            ),
            "is_guideline": forms.CheckboxInput(attrs=_GRANT_CALL_CHECKBOX_ATTRS),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["file"].required = False

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("DELETE"):
            return cleaned
        label = (cleaned.get("label") or "").strip()
        f = cleaned.get("file")
        if self.instance.pk:
            if label and not f and not self.instance.file:
                raise ValidationError("Upload a file or keep the existing file.")
            return cleaned
        if not label and not f:
            return cleaned
        if label and not f:
            raise ValidationError("Choose a file for each labeled document row.")
        return cleaned

    def _post_clean(self):
        if not self.cleaned_data:
            return
        label = (self.cleaned_data.get("label") or "").strip()
        f = self.cleaned_data.get("file")
        if not self.instance.pk and not label and not f:
            self.cleaned_data = {}
            return
        super()._post_clean()


class GrantBudgetLineForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if "DELETE" in self.fields:
            self.fields["DELETE"].widget.attrs.update(_GRANT_CALL_CHECKBOX_ATTRS)

    class Meta:
        model = GrantBudgetLine
        fields = ("category", "description", "unit_of_measure", "quantity", "unit_cost", "sort_order")
        widgets = {
            "category": forms.TextInput(attrs={"class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm"}),
            "description": forms.Textarea(
                attrs={
                    "rows": 2,
                    "class": (
                        "w-full min-h-[2.75rem] rounded-md border border-ruforum-border px-3 py-2 text-sm "
                        "text-ruforum-text shadow-sm focus:border-ruforum-primary focus:outline-none "
                        "focus:ring-1 focus:ring-ruforum-primary"
                    ),
                }
            ),
            "unit_of_measure": forms.TextInput(attrs={"class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm"}),
            "quantity": forms.NumberInput(
                attrs={
                    "class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm tabular-nums",
                    "step": "0.0001",
                }
            ),
            "unit_cost": forms.NumberInput(
                attrs={
                    "class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm tabular-nums",
                    "step": "0.01",
                }
            ),
            "sort_order": forms.NumberInput(
                attrs={"class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm", "min": 0}
            ),
        }


GrantBudgetLineFormSet = inlineformset_factory(
    FundingRecord,
    GrantBudgetLine,
    form=GrantBudgetLineForm,
    extra=1,
    can_delete=True,
    min_num=0,
)


class FundingRecordForm(forms.ModelForm):
    class Meta:
        model = FundingRecord
        fields = [
            "title",
            "partner",
            "amount",
            "currency",
            "start_date",
            "end_date",
            "strategic_objectives",
            "reporting_financial_frequency",
            "reporting_narrative_frequency",
            "grant_manager",
            "finance_officer",
            "program_lead",
        ]
        widgets = {
            "title": forms.TextInput(attrs={"class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm"}),
            "strategic_objectives": forms.Textarea(attrs={"rows": 3, "class": "w-full rounded-md border border-ruforum-border px-3 py-2 text-sm"}),
            "start_date": forms.DateInput(attrs={"type": "date", "class": "rounded-md border border-ruforum-border px-3 py-2 text-sm"}),
            "end_date": forms.DateInput(attrs={"type": "date", "class": "rounded-md border border-ruforum-border px-3 py-2 text-sm"}),
            "amount": forms.NumberInput(attrs={"class": "w-40 rounded-md border border-ruforum-border px-3 py-2 text-sm", "step": "0.01"}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["partner"].queryset = Partner.objects.order_by("name")
        self.fields["partner"].required = False
        for name in ("partner", "currency", "reporting_financial_frequency", "reporting_narrative_frequency"):
            self.fields[name].widget.attrs.update(_FUNDING_SELECT_ATTRS)
        gm = User.objects.filter(
            role__in=[UserRole.GRANTS_MANAGER, UserRole.ADMIN],
        ).order_by("email")
        self.fields["grant_manager"].queryset = gm
        self.fields["finance_officer"].queryset = User.objects.filter(
            role__in=[UserRole.FINANCE_OFFICER, UserRole.ADMIN],
        ).order_by("email")
        self.fields["program_lead"].queryset = gm
        self.fields["grant_manager"].required = False
        self.fields["finance_officer"].required = False
        self.fields["program_lead"].required = False
        for name in ("grant_manager", "finance_officer", "program_lead"):
            self.fields[name].widget.attrs.update(_USER_SELECT2_ATTRS)


EligibilityRuleFormSet = inlineformset_factory(
    GrantCall,
    EligibilityRule,
    form=EligibilityRuleForm,
    extra=1,
    can_delete=True,
    min_num=0,
)

GrantCallDocumentFormSet = inlineformset_factory(
    GrantCall,
    GrantCallDocument,
    form=GrantCallDocumentForm,
    extra=0,
    can_delete=True,
    min_num=0,
)


# ═══════════════════════════════════════════════════════════════════════════
# SCHOLARSHIP MULTI-STEP APPLICATION FORMS
# ═══════════════════════════════════════════════════════════════════════════

_S2_ATTRS = {"data-s2": "1"}
_TXT = {"class": "w-full rounded-lg border border-ruforum-border bg-white px-3 py-2 text-sm text-ruforum-text focus:border-ruforum-primary focus:outline-none focus:ring-1 focus:ring-ruforum-primary"}
_NUM = {**_TXT, "type": "number", "min": "0"}
_DATE = {**_TXT, "type": "date"}
_BOOL_CHOICES = [("", "— Select —"), ("true", "Yes"), ("false", "No")]


def _bool_field(label, required=False, help_text=""):
    return forms.ChoiceField(
        label=label,
        choices=_BOOL_CHOICES,
        required=required,
        help_text=help_text,
        widget=forms.Select(attrs=_TXT),
    )


def _clean_bool(value):
    if value == "true":
        return True
    if value == "false":
        return False
    return None


class ScholarshipStep1Form(forms.Form):
    """Step 1 — Target university & course."""

    institution = forms.ModelChoiceField(
        queryset=Institution.objects.none(),
        label="Your current institution",
        required=False,
        widget=forms.Select(attrs={**_TXT, "data-s2": "1", "data-s2-placeholder": "Search institution…"}),
    )
    scholarship_university_choice = forms.ModelChoiceField(
        queryset=Institution.objects.none(),
        label="Target university",
        required=False,
        widget=forms.Select(attrs={**_TXT, "data-s2": "1", "data-s2-placeholder": "Search university…"}),
        help_text="The university where you intend to study if awarded.",
    )
    scholarship_course_choice = forms.CharField(
        label="Programme / course",
        max_length=255,
        required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "e.g. MSc Agricultural Economics"}),
        help_text="The degree programme you are applying for.",
    )
    scholarship_call_source = forms.CharField(
        label="How did you hear about this scholarship?",
        max_length=64,
        required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "e.g. University notice board, social media"}),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        qs = Institution.objects.filter(is_active=True).order_by("name")
        self.fields["institution"].queryset = qs
        self.fields["scholarship_university_choice"].queryset = qs


class ScholarshipStep2Form(forms.Form):
    """Step 2 — Personal & academic profile."""

    date_of_birth = forms.DateField(
        label="Date of birth", required=False,
        widget=forms.DateInput(attrs=_DATE),
    )
    gender = forms.ChoiceField(
        label="Gender", required=False,
        choices=[("", "— Select —"), ("male", "Male"), ("female", "Female"), ("other", "Other / Prefer not to say")],
        widget=forms.Select(attrs=_TXT),
    )
    country_of_birth = forms.ChoiceField(
        label="Country of birth", required=False,
        widget=forms.Select(attrs={**_TXT, "data-s2": "1"}),
    )
    country_of_residence = forms.ChoiceField(
        label="Country of residence", required=False,
        widget=forms.Select(attrs={**_TXT, "data-s2": "1"}),
    )
    country_of_origin = forms.ChoiceField(
        label="Country of origin / nationality", required=False,
        widget=forms.Select(attrs={**_TXT, "data-s2": "1"}),
    )
    is_refugee = _bool_field("Are you a refugee or asylum seeker?")
    passport_no = forms.CharField(
        label="Passport / national ID number", max_length=50, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )
    degree_programme = forms.CharField(
        label="Current / most recent degree programme", max_length=100, required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "e.g. BSc Agriculture"}),
    )
    gpa = forms.FloatField(
        label="GPA / grade average", required=False,
        widget=forms.NumberInput(attrs={**_TXT, "step": "0.01", "min": "0"}),
    )
    english_in_high_school = _bool_field("Was English a medium of instruction in your high school?")
    postgraduate_student = _bool_field("Are you currently a postgraduate student?")
    applied_to_university = _bool_field("Have you already applied to your target university?")
    have_physical_disability = _bool_field("Do you have a physical disability?")
    physical_disability = forms.CharField(
        label="If yes, describe your disability", max_length=200, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )
    have_history_of_chronic_illness = _bool_field("Do you have a history of chronic illness?")
    history_of_chronic_illness = forms.CharField(
        label="If yes, describe", max_length=200, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        try:
            import pycountry
            country_choices = [("", "— Select country —")] + sorted(
                [(c.alpha_3, c.name) for c in pycountry.countries],
                key=lambda x: x[1],
            )
        except ImportError:
            country_choices = [("", "— Select country —")]
        for f in ("country_of_birth", "country_of_residence", "country_of_origin"):
            self.fields[f].choices = country_choices

    def clean_is_refugee(self):
        return _clean_bool(self.cleaned_data.get("is_refugee"))

    def clean_english_in_high_school(self):
        return _clean_bool(self.cleaned_data.get("english_in_high_school"))

    def clean_postgraduate_student(self):
        return _clean_bool(self.cleaned_data.get("postgraduate_student"))

    def clean_applied_to_university(self):
        return _clean_bool(self.cleaned_data.get("applied_to_university"))

    def clean_have_physical_disability(self):
        return _clean_bool(self.cleaned_data.get("have_physical_disability"))

    def clean_have_history_of_chronic_illness(self):
        return _clean_bool(self.cleaned_data.get("have_history_of_chronic_illness"))


class ScholarshipStep3Form(forms.Form):
    """Step 3 — Household & socioeconomic background."""

    name_of_guardian_or_spouse = forms.CharField(
        label="Guardian / spouse name", max_length=150, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )
    guardian_relationship = forms.CharField(
        label="Relationship to guardian", max_length=100, required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "e.g. Parent, Spouse"}),
    )
    guardian_occupation = forms.CharField(
        label="Guardian's occupation", max_length=100, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )
    guardian_or_spouse_phone = forms.CharField(
        label="Guardian / spouse phone", max_length=32, required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "+256 ..."}),
    )
    number_of_siblings = forms.IntegerField(
        label="Number of siblings", required=False, min_value=0,
        widget=forms.NumberInput(attrs=_NUM),
    )
    people_in_house = forms.IntegerField(
        label="Number of people in household", required=False, min_value=0,
        widget=forms.NumberInput(attrs=_NUM),
    )
    rooms_in_house = forms.IntegerField(
        label="Number of rooms in house", required=False, min_value=0,
        widget=forms.NumberInput(attrs=_NUM),
    )
    electricity = _bool_field("Does your home have electricity?")
    solar_energy = _bool_field("Does your home use solar energy?")
    water_source = forms.CharField(
        label="Main water source", max_length=64, required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "e.g. Piped water, Borehole, River"}),
    )
    village_of_residence = forms.CharField(
        label="Village of residence", max_length=100, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )
    district_of_residence = forms.CharField(
        label="District / region of residence", max_length=100, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )
    income_source_1 = forms.CharField(
        label="Primary household income source", max_length=150, required=False,
        widget=forms.TextInput(attrs={**_TXT, "placeholder": "e.g. Farming, Business"}),
    )
    income_source_2 = forms.CharField(label="Income source 2", max_length=150, required=False, widget=forms.TextInput(attrs=_TXT))
    income_source_3 = forms.CharField(label="Income source 3", max_length=150, required=False, widget=forms.TextInput(attrs=_TXT))
    income_source_4 = forms.CharField(label="Income source 4", max_length=150, required=False, widget=forms.TextInput(attrs=_TXT))
    own_livestock = _bool_field("Does your family own livestock?")
    number_of_cattle = forms.IntegerField(label="Cattle", required=False, min_value=0, widget=forms.NumberInput(attrs=_NUM))
    number_of_goats = forms.IntegerField(label="Goats", required=False, min_value=0, widget=forms.NumberInput(attrs=_NUM))
    number_of_sheep = forms.IntegerField(label="Sheep", required=False, min_value=0, widget=forms.NumberInput(attrs=_NUM))
    number_of_chickens = forms.IntegerField(label="Chickens", required=False, min_value=0, widget=forms.NumberInput(attrs=_NUM))
    number_of_camels = forms.IntegerField(label="Camels", required=False, min_value=0, widget=forms.NumberInput(attrs=_NUM))
    number_of_donkeys = forms.IntegerField(label="Donkeys", required=False, min_value=0, widget=forms.NumberInput(attrs=_NUM))

    def clean_electricity(self):
        return _clean_bool(self.cleaned_data.get("electricity"))

    def clean_solar_energy(self):
        return _clean_bool(self.cleaned_data.get("solar_energy"))

    def clean_own_livestock(self):
        return _clean_bool(self.cleaned_data.get("own_livestock"))


class ScholarshipStep4Form(forms.Form):
    """Step 4 — Leadership, community & motivation."""

    held_leadership_position = _bool_field("Have you held a leadership position?")
    most_significant_leadership_contribution = forms.CharField(
        label="Describe your most significant leadership contribution",
        required=False,
        widget=forms.Textarea(attrs={**_TXT, "rows": 4}),
    )
    community_service_participation = _bool_field("Have you participated in community service?")
    currently_volunteering = _bool_field("Are you currently volunteering?")
    member_of_group = _bool_field("Are you a member of any community group or association?")
    ever_been_employed = _bool_field("Have you ever been employed?")
    employer_support = _bool_field("Does your employer support your studies?")
    challenge = forms.CharField(
        label="What is the greatest challenge you have overcome?",
        required=False,
        widget=forms.Textarea(attrs={**_TXT, "rows": 5,
            "placeholder": "Describe a significant challenge you have faced and how you overcame it…"}),
    )
    experience = forms.CharField(
        label="Relevant experience",
        required=False,
        widget=forms.Textarea(attrs={**_TXT, "rows": 4,
            "placeholder": "Describe any work, research, or community experience relevant to this scholarship…"}),
    )
    most_significant_contribution = forms.CharField(
        label="Most significant contribution to your community or field",
        required=False,
        widget=forms.Textarea(attrs={**_TXT, "rows": 4}),
    )
    have_been_arrested = _bool_field("Have you ever been arrested or convicted of a criminal offence?")
    cause_of_arrest = forms.CharField(
        label="If yes, provide details", max_length=200, required=False,
        widget=forms.TextInput(attrs=_TXT),
    )

    def clean_held_leadership_position(self):
        return _clean_bool(self.cleaned_data.get("held_leadership_position"))

    def clean_community_service_participation(self):
        return _clean_bool(self.cleaned_data.get("community_service_participation"))

    def clean_currently_volunteering(self):
        return _clean_bool(self.cleaned_data.get("currently_volunteering"))

    def clean_member_of_group(self):
        return _clean_bool(self.cleaned_data.get("member_of_group"))

    def clean_ever_been_employed(self):
        return _clean_bool(self.cleaned_data.get("ever_been_employed"))

    def clean_employer_support(self):
        return _clean_bool(self.cleaned_data.get("employer_support"))

    def clean_have_been_arrested(self):
        return _clean_bool(self.cleaned_data.get("have_been_arrested"))


class ScholarshipStep5Form(forms.Form):
    """Step 5 — Documents & supporting materials."""
    # File fields are handled manually in the view; this form just holds
    # the proposal / cover letter.
    proposal = forms.CharField(
        label="Motivation letter / statement of purpose",
        required=False,
        widget=forms.Textarea(attrs={**_TXT, "rows": 6,
            "placeholder": "Why do you deserve this scholarship? What will you do with it?"}),
    )


class PsychometricAssessmentForm(forms.ModelForm):
    """Step 6 — full psychometric self-report questionnaire.

    Old-system parity: legacy ``ApplicantHouseHoldSurvey``. All ~90 items are
    optional at the Django form level (a partial save is allowed as the
    applicant works through the questionnaire); ``clean()`` on the model
    still restricts this record to scholarship applications.
    """

    class Meta:
        model = PsychometricAssessment
        exclude = ["application", "submitted_at", "created_at", "updated_at"]


# ── Fellowship apply wizard ───────────────────────────────────────────────────

class ApplyWizardBasicForm(forms.Form):
    """Step 1 — shared by the Fellowship and Challenge apply wizards."""

    institution = forms.ModelChoiceField(
        queryset=Institution.objects.none(),
        label="Your current institution",
        required=False,
        widget=forms.Select(attrs={**_TXT, "data-s2": "1", "data-s2-placeholder": "Search institution…"}),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["institution"].queryset = Institution.objects.filter(is_active=True).order_by("name")


class FellowshipPlacementForm(forms.ModelForm):
    """Step 2 — home/host institution & placement details."""

    class Meta:
        model = FellowshipApplicationProfile
        fields = [
            "home_institute_name", "home_institute_department", "home_institute_faculty",
            "home_institute_place", "home_country",
            "host_institute_name", "host_institute_department", "host_institute_faculty",
            "host_institute_place",
            "duration_weeks", "proposed_start", "proposed_end",
            "subject_area", "area_of_interest", "area_of_specialization",
            "form_of_service", "job_title", "position", "telephone",
            "proposed_activities", "other_activities", "motivation",
        ]
        widgets = {
            "home_institute_name": forms.TextInput(attrs=_TXT),
            "home_institute_department": forms.TextInput(attrs=_TXT),
            "home_institute_faculty": forms.TextInput(attrs=_TXT),
            "home_institute_place": forms.TextInput(attrs=_TXT),
            "home_country": forms.TextInput(attrs=_TXT),
            "host_institute_name": forms.TextInput(attrs=_TXT),
            "host_institute_department": forms.TextInput(attrs=_TXT),
            "host_institute_faculty": forms.TextInput(attrs=_TXT),
            "host_institute_place": forms.TextInput(attrs=_TXT),
            "duration_weeks": forms.NumberInput(attrs={**_TXT, "min": "1"}),
            "proposed_start": forms.DateInput(attrs=_DATE),
            "proposed_end": forms.DateInput(attrs=_DATE),
            "subject_area": forms.TextInput(attrs=_TXT),
            "area_of_interest": forms.TextInput(attrs=_TXT),
            "area_of_specialization": forms.TextInput(attrs=_TXT),
            "form_of_service": forms.Select(attrs=_TXT),
            "job_title": forms.TextInput(attrs=_TXT),
            "position": forms.TextInput(attrs=_TXT),
            "telephone": forms.TextInput(attrs=_TXT),
            "proposed_activities": forms.Textarea(attrs={**_TXT, "rows": 4}),
            "other_activities": forms.Textarea(attrs={**_TXT, "rows": 3}),
            "motivation": forms.Textarea(attrs={**_TXT, "rows": 4}),
        }


class ApplyWizardDocumentsForm(forms.Form):
    """Shared documents/proposal step — used by both Fellowship and Challenge wizards."""

    proposal = forms.CharField(
        label="Motivation statement / proposal",
        required=False,
        widget=forms.Textarea(attrs={**_TXT, "rows": 6}),
    )


# ── Challenge apply wizard ────────────────────────────────────────────────────

class ChallengeProfileForm(forms.ModelForm):
    """Step 2 — innovation profile."""

    class Meta:
        model = ChallengeApplicationProfile
        fields = [
            "innovation_stage", "problem_statement", "proposed_solution",
            "target_beneficiaries", "team_size", "prototype_status",
        ]
        widgets = {
            "innovation_stage": forms.Select(attrs=_TXT),
            "problem_statement": forms.Textarea(attrs={**_TXT, "rows": 3}),
            "proposed_solution": forms.Textarea(attrs={**_TXT, "rows": 3}),
            "target_beneficiaries": forms.Textarea(attrs={**_TXT, "rows": 2}),
            "team_size": forms.NumberInput(attrs={**_TXT, "min": "1"}),
            "prototype_status": forms.Select(attrs=_TXT),
        }
