from datetime import timedelta

from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Avg, Count, F, Prefetch, Q, Subquery, OuterRef
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView

from apps.core.lists import CsvExportMixin, SortableListMixin, paginate_qs
from apps.core.permissions.mixins import RimsAccessMixin
from apps.core.permissions.roles import UserRole
from apps.rims.grants.review_conflicts import recommendation_divergence
from apps.rims.grants import rims_perms
from apps.rims.grants.forms import (
    ApplicationForm,
    ApplyWizardBasicForm,
    ApplyWizardDocumentsForm,
    AssignReviewerForm,
    AwardAgreementForm,
    AwardAmendmentForm,
    AwardCancellationForm,
    AwardCloseoutForm,
    AwardForm,
    AwardTrancheForm,
    ChallengeProfileForm,
    CompleteCallForm,
    EligibilityRuleFormSet,
    FellowshipPlacementForm,
    GrantCallActiveEditForm,
    GrantCallDocumentFormSet,
    GrantCallForm,
    ImplementationPlanActivityForm,
    ImplementationPlanForm,
    ImplementationPlanIndicatorForm,
    ImplementationPlanOutcomeForm,
    ImplementationPlanOutputForm,
    InterviewOutcomeForm,
    InterviewScheduleForm,
    MonitoringVisitFindingsForm,
    MonitoringVisitForm,
    PeriodicNarrativeReportForm,
    PsychometricAssessmentForm,
    RequiredDocumentForm,
    ReviewerCoiDeclarationForm,
    ReviewForm,
    VerificationRecordForm,
)
from apps.rims.grants.closeout_helpers import (
    award_financial_snapshot,
    closeout_preconditions,
    get_or_create_closeout_record,
    record_closeout_residual,
)
from apps.rims.grants.models import (
    HomeValidationRecord,
    Application,
    ApplicationDocument,
    ApplicationPartner,
    AssetVerification,
    Award,
    AwardAgreement,
    AwardAmendment,
    AwardCancellationRequest,
    AwardTranche,
    ChallengeApplicationProfile,
    ConflictOfInterest,
    EligibilityRule,
    FellowshipApplicationProfile,
    FinalFinancialReport,
    FinalNarrativeReport,
    FundingRecord,
    FundingRecordCloseout,
    GrantCall,
    GrantCallDocument,
    ImplementationPlan,
    ImplementationPlanActivity,
    ImplementationPlanIndicator,
    ImplementationPlanOutcome,
    ImplementationPlanOutput,
    InterviewSchedule,
    MonitoringVisit,
    PeriodicNarrativeReport,
    PsychometricAssessment,
    RequiredDocument,
    Review,
    VerificationRecord,
)
from apps.rims.grants.services import (
    aggregate_average_score,
    aggregate_weighted_score,
    approve_implementation_plan,
    assign_reviewer,
    auto_assign_reviewers,
    award_portfolio_snapshot,
    award_application,
    create_award_amendment,
    create_award_cancellation,
    create_or_update_interview,
    close_call,
    close_grant_call,
    evaluate_eligibility_for_user,
    lock_monitoring_visit,
    program_officer_advance,
    ProgramOfficerDecision,
    record_activity_progress,
    record_interview_outcome,
    record_monitoring_visit_findings,
    record_verification,
    refresh_call_rankings,
    publish_call,
    reject_application,
    shortlist_application,
    submit_application,
    submit_review,
    validate_grant_upload,
)
from apps.rims.grants.funding import uncommitted_balance
from apps.rims.grants.profile_gate import ProfileCompleteRequiredMixin


_GRANT_CALL_WIZARD_STEPS = [
    {"n": 1, "title": "Basics"},
    {"n": 2, "title": "Timeline"},
    {"n": 3, "title": "Partnership & review"},
    {"n": 4, "title": "Scope & rubric"},
    {"n": 5, "title": "Eligibility & docs"},
]


def _funding_balances_json(call=None):
    """Return JSON map {funding_record_pk: "uncommitted balance"} for approved records.

    Used on the call create/update form so the parent funding-record dropdown
    can show the available headroom client-side (PRD CS001/A4, line 1614).
    """
    import json

    exclude_id = getattr(call, "pk", None) if call is not None else None
    balances = {}
    for fr in FundingRecord.objects.filter(status=FundingRecord.Status.APPROVED):
        balances[str(fr.pk)] = {
            "amount": str(uncommitted_balance(fr, exclude_call_id=exclude_id)),
            "currency": fr.currency,
        }
    return json.dumps(balances)


class PublishedCallListView(SortableListMixin, ListView):
    model = GrantCall
    template_name = "grants/call_list.html"
    context_object_name = "calls"
    paginate_by = 12

    sortable_fields = {
        "title": "title",
        "opens": "opens_at",
        "closes": "closes_at",
    }
    default_sort = "-opens_at"

    def get_queryset(self):
        qs = (
            GrantCall.objects.filter(
                status=GrantCall.Status.PUBLISHED,
                closes_at__gte=timezone.now(),
            )
            .prefetch_related("institutions_scope")
        )
        q = self.request.GET.get("q", "").strip()
        if q:
            qs = qs.filter(title__icontains=q)
        return self.apply_sort(qs)

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["grants/partials/call_list_results.html"]
        return super().get_template_names()


class CallDetailView(DetailView):
    model = GrantCall
    template_name = "grants/call_detail.html"
    context_object_name = "call"
    slug_url_kwarg = "slug"

    def get_queryset(self):
        # §8 — the public path is RUFORUM's marketing surface; anonymous
        # visitors should see only published / closed calls, never drafts,
        # withdrawn, or in-flight awarding states. Authenticated users keep
        # the broader visibility for staff inspection.
        qs = GrantCall.objects.exclude(status=GrantCall.Status.DRAFT).prefetch_related(
            "required_documents",
            "eligibility_rules",
            "documents",
            "institutions_scope",
        )
        if not self.request.user.is_authenticated:
            qs = qs.filter(status=GrantCall.Status.PUBLISHED)
        return qs

    def get_template_names(self):
        # §8 — public visitors get the recruiting-poster layout
        # (`call_detail_public.html`). Authenticated users keep the full
        # call_detail.html which carries apply/manager flows.
        if not self.request.user.is_authenticated:
            return ["grants/call_detail_public.html"]
        return [self.template_name]

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["is_open"] = (
            self.object.status == GrantCall.Status.PUBLISHED
            and self.object.closes_at >= timezone.now()
        )
        return ctx


class GrantsManagerMixin(RimsAccessMixin):
    allowed_roles = (UserRole.ADMIN, UserRole.GRANTS_MANAGER)
    rims_permissions = (rims_perms.MANAGE_CALLS,)


class ManagerCallListView(GrantsManagerMixin, SortableListMixin, CsvExportMixin, ListView):
    model = GrantCall
    template_name = "grants/manager_call_list.html"
    context_object_name = "calls"
    paginate_by = 25

    sortable_fields = {
        "title": "title",
        "status": "status",
        "opens": "opens_at",
        "closes": "closes_at",
    }
    default_sort = "-created_at"

    csv_columns = (
        ("Title", "title"),
        ("Slug", "slug"),
        ("Call type", "get_call_type_display"),
        ("Status", "get_status_display"),
        ("Opens at", lambda c: c.opens_at.isoformat() if c.opens_at else ""),
        ("Closes at", lambda c: c.closes_at.isoformat() if c.closes_at else ""),
    )
    csv_filename = "grant_calls"

    def get_queryset(self):
        qs = GrantCall.objects.all()
        status = self.request.GET.get("status", "").strip()
        if status:
            qs = qs.filter(status=status)
        q = self.request.GET.get("q", "").strip()
        if q:
            qs = qs.filter(title__icontains=q)
        return self.apply_sort(qs)

    def get_template_names(self):
        # Live-search swaps only #call-rows; without this an HTMX request
        # would return the full page and nest it inside the table body.
        if getattr(self.request, "htmx", False):
            return ["grants/partials/manager_call_list_results.html"]
        return super().get_template_names()

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["status_choices"] = GrantCall.Status.choices
        ctx["current_status"] = self.request.GET.get("status", "")
        return ctx


class GrantCallCreateView(GrantsManagerMixin, CreateView):
    model = GrantCall
    form_class = GrantCallForm
    template_name = "grants/call_create.html"

    # Fields not required when saving as draft — can be filled before publishing
    _DRAFT_OPTIONAL = [
        "reviewers_per_application",
        "tie_break_policy",
        "reviewer_workload_mode",
        "review_deadline_days",
        "verification_type",
        "verification_score_weight",
    ]

    def get_form(self, form_class=None):
        form = super().get_form(form_class)
        for fname in self._DRAFT_OPTIONAL:
            if fname in form.fields:
                form.fields[fname].required = False
        return form

    def _inherited_funding(self):
        """PRD §5.1 FRFA-CS003 — resolve ?funding=<pk> to an APPROVED FundingRecord
        with uncommitted balance, or return None. Cached on the view instance.
        """
        cached = getattr(self, "_inherited_funding_cache", "__missing__")
        if cached != "__missing__":
            return cached
        raw = self.request.GET.get("funding", "").strip()
        fr = None
        if raw.isdigit():
            fr = (
                FundingRecord.objects.filter(
                    pk=int(raw),
                    status=FundingRecord.Status.APPROVED,
                )
                .select_related("partner", "donor")
                .first()
            )
        self._inherited_funding_cache = fr
        return fr

    def get_initial(self):
        initial = super().get_initial()
        fr = self._inherited_funding()
        if fr is not None:
            initial["funding_record"] = fr.pk
            if fr.partner_id:
                initial["partner"] = fr.partner_id
        # Pre-fill optional review fields with sensible defaults so the user
        # only needs Title, Type, Opens, Closes to save a draft
        initial.setdefault("reviewers_per_application", 2)
        initial.setdefault("review_deadline_days", 14)
        initial.setdefault("verification_score_weight", "0.00")
        return initial

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if "eligibility_formset" not in kwargs:
            kwargs["eligibility_formset"] = (
                EligibilityRuleFormSet(self.request.POST, instance=self.object)
                if self.request.method == "POST"
                else EligibilityRuleFormSet(instance=self.object)
            )
        if "document_formset" not in kwargs:
            kwargs["document_formset"] = (
                GrantCallDocumentFormSet(
                    self.request.POST,
                    self.request.FILES,
                    instance=self.object,
                )
                if self.request.method == "POST"
                else GrantCallDocumentFormSet(instance=self.object)
            )
        ctx["eligibility_formset"] = kwargs["eligibility_formset"]
        ctx["document_formset"] = kwargs["document_formset"]
        ctx["funding_balances_json"] = _funding_balances_json(self.object)
        ctx["wizard_steps"] = _GRANT_CALL_WIZARD_STEPS
        ctx["cancel_url"] = reverse("rims_grants:call_list")
        # PRD §5.1 FRFA-CS003 — inherited-from banner.
        fr = self._inherited_funding()
        if fr is not None:
            ctx["inherited_funding"] = fr
            ctx["inherited_funding_balance"] = uncommitted_balance(fr)
        # PRD §5.1 FRFA-CS004 — empty state when no APPROVED funding has uncommitted balance.
        approved_qs = FundingRecord.objects.filter(status=FundingRecord.Status.APPROVED)
        approved_with_balance = sum(1 for f in approved_qs if uncommitted_balance(f) > 0)
        ctx["no_approved_funding_available"] = approved_with_balance == 0
        # Distinguish "no approved record exists yet" from "approved records exist
        # but are fully committed" so the banner can word it accurately.
        ctx["approved_funding_exists"] = approved_qs.exists()
        return ctx

    def form_invalid(self, form):
        # Build a human-readable list of all field errors for the top banner
        error_labels = []
        for fname, errors in form.errors.items():
            if fname == "__all__":
                continue
            field_label = form.fields[fname].label or fname.replace("_", " ").title()
            error_labels.append(f"{field_label}: {errors[0]}")
        messages.error(
            self.request,
            "Please fix the following before saving: " + " · ".join(error_labels) if error_labels
            else "Please correct the errors below before saving.",
        )
        return self.render_to_response(
            self.get_context_data(
                form=form,
                eligibility_formset=EligibilityRuleFormSet(self.request.POST, instance=self.object),
                document_formset=GrantCallDocumentFormSet(
                    self.request.POST,
                    self.request.FILES,
                    instance=self.object,
                ),
            )
        )

    def form_valid(self, form):
        with transaction.atomic():
            self.object = form.save(commit=False)
            self.object.created_by = self.request.user
            self.object.save()
            form.save_m2m()
            elig_fs = EligibilityRuleFormSet(self.request.POST, instance=self.object)
            doc_fs = GrantCallDocumentFormSet(self.request.POST, self.request.FILES, instance=self.object)
            if elig_fs.is_valid() and doc_fs.is_valid():
                elig_fs.save()
                doc_fs.save()
            else:
                transaction.set_rollback(True)
                return self.render_to_response(
                    self.get_context_data(
                        form=form,
                        eligibility_formset=elig_fs,
                        document_formset=doc_fs,
                    )
                )
        messages.success(self.request, "Call saved as draft. Publish when ready.")
        return redirect("rims_grants:call_detail_manage", slug=self.object.slug)


class GrantCallDetailManageView(GrantsManagerMixin, DetailView):
    queryset = (
        GrantCall.objects.select_related(
            "partner", "mou", "mou__partner", "mou__institution", "funding_record"
        )
        .prefetch_related(
            Prefetch(
                "required_documents",
                queryset=RequiredDocument.objects.order_by("sort_order", "pk"),
            ),
            Prefetch(
                "eligibility_rules",
                queryset=EligibilityRule.objects.order_by("pk"),
            ),
            Prefetch(
                "documents",
                queryset=GrantCallDocument.objects.order_by("sort_order", "pk"),
            ),
            "institutions_scope",
        )
    )
    template_name = "grants/call_edit.html"
    context_object_name = "call"
    slug_url_kwarg = "slug"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        call = self.object
        ctx["recent_applications"] = list(
            call.applications.select_related("applicant", "institution").order_by(
                "-submitted_at", "-created_at"
            )[:15]
        )
        ctx["applications_total_count"] = call.applications.count()
        ctx["required_doc_form"] = RequiredDocumentForm()
        ctx["preset_labels"] = [
            "CV / Résumé",
            "Research proposal",
            "Reference letter",
            "Academic transcripts",
            "Budget plan",
            "Institutional endorsement",
            "Project report",
            "Cover letter",
        ]
        return ctx


class GrantCallUpdateView(GrantsManagerMixin, UpdateView):
    model = GrantCall
    form_class = GrantCallForm
    template_name = "grants/call_update.html"
    slug_url_kwarg = "slug"

    # Fields not rendered in call_update.html — keep them optional so the
    # existing instance values are preserved without the user having to re-enter them.
    _FORM_OPTIONAL = [
        "reviewers_per_application",
        "tie_break_policy",
        "reviewer_workload_mode",
        "review_deadline_days",
        "verification_type",
        "verification_score_weight",
    ]

    def get_form(self, form_class=None):
        form = super().get_form(form_class)
        for fname in self._FORM_OPTIONAL:
            if fname in form.fields:
                form.fields[fname].required = False
        return form

    def get_form_class(self):
        # PRD §5.1 FRFA-CS028 — restrict to non-critical fields on PUBLISHED calls.
        if self.object and self.object.status == GrantCall.Status.PUBLISHED:
            return GrantCallActiveEditForm
        return GrantCallForm

    def get_template_names(self):
        # PRD §5.1 FRFA-CS028 — PUBLISHED calls use a simple two-field surface
        # instead of the full create-wizard, which references fields the active
        # edit form deliberately omits.
        if self.object and self.object.status == GrantCall.Status.PUBLISHED:
            return ["grants/call_active_edit.html"]
        return [self.template_name]

    def get_queryset(self):
        return GrantCall.objects.filter(
            status__in=[GrantCall.Status.DRAFT, GrantCall.Status.PUBLISHED]
        ).select_related("funding_record")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if "eligibility_formset" not in kwargs:
            kwargs["eligibility_formset"] = (
                EligibilityRuleFormSet(self.request.POST, instance=self.object)
                if self.request.method == "POST"
                else EligibilityRuleFormSet(instance=self.object)
            )
        if "document_formset" not in kwargs:
            kwargs["document_formset"] = (
                GrantCallDocumentFormSet(
                    self.request.POST,
                    self.request.FILES,
                    instance=self.object,
                )
                if self.request.method == "POST"
                else GrantCallDocumentFormSet(instance=self.object)
            )
        ctx["eligibility_formset"] = kwargs["eligibility_formset"]
        ctx["document_formset"] = kwargs["document_formset"]
        ctx["funding_balances_json"] = _funding_balances_json(self.object)
        ctx["wizard_steps"] = _GRANT_CALL_WIZARD_STEPS
        if self.object:
            ctx["cancel_url"] = reverse(
                "rims_grants:call_detail_manage", kwargs={"slug": self.object.slug}
            )
        return ctx

    def form_invalid(self, form):
        return self.render_to_response(
            self.get_context_data(
                form=form,
                eligibility_formset=EligibilityRuleFormSet(self.request.POST, instance=self.object),
                document_formset=GrantCallDocumentFormSet(
                    self.request.POST,
                    self.request.FILES,
                    instance=self.object,
                ),
            )
        )

    def form_valid(self, form):
        # PRD §5.1 FRFA-CS028 — for PUBLISHED calls the active edit form covers
        # only non-critical fields. Skip formsets + snapshot a version row + notify
        # in-progress applicants when applicant-visible content changed.
        if isinstance(form, GrantCallActiveEditForm):
            with transaction.atomic():
                changed_fields = set(form.changed_data)
                self.object = form.save()
                from apps.rims.grants.audit_helper import action_update, audit_rims
                from apps.rims.grants.services import snapshot_call_version

                audit_rims(
                    actor=self.request.user,
                    action=action_update(),
                    target_model="GrantCall",
                    object_id=self.object.pk,
                    object_repr=str(self.object),
                    changes={f: "updated" for f in changed_fields},
                    request=self.request,
                )
                snapshot_call_version(
                    self.object,
                    actor=self.request.user,
                    change_summary=f"Active-call edit: {', '.join(sorted(changed_fields))}" if changed_fields else "Active-call save (no field changes)",
                )

                applicant_visible = changed_fields & {"description", "guidelines"}
                if applicant_visible:
                    from apps.core.notifications.emailing import rims_email_context
                    from apps.core.notifications.models import Notification
                    from apps.core.notifications.services import bulk_notify

                    drafts = list(
                        Application.objects.filter(
                            call=self.object, status=Application.Status.DRAFT
                        ).select_related("applicant")
                    )
                    if drafts:
                        ctx = rims_email_context(
                            subject=f"Call updated: {self.object.title}",
                            headline="The call you're applying to was updated",
                            action_path=reverse("rims_grants:call_apply", kwargs={"slug": self.object.slug}),
                            action_label="Review the changes",
                            preheader="Description or guidelines were revised — review before submitting.",
                        )
                        bulk_notify(
                            [d.applicant for d in drafts],
                            f'"{self.object.title}" was updated by the administrator. Review the revised content before submitting.',
                            verb=Notification.Verb.APPLICATION_STATUS,
                            email_context=ctx,
                        )
            messages.success(self.request, "Call updated. Applicants with drafts have been notified.")
            return redirect("rims_grants:call_detail_manage", slug=self.object.slug)

        with transaction.atomic():
            self.object = form.save()
            elig_fs = EligibilityRuleFormSet(self.request.POST, instance=self.object)
            doc_fs = GrantCallDocumentFormSet(self.request.POST, self.request.FILES, instance=self.object)
            if elig_fs.is_valid() and doc_fs.is_valid():
                elig_fs.save()
                doc_fs.save()
            else:
                transaction.set_rollback(True)
                return self.render_to_response(
                    self.get_context_data(
                        form=form,
                        eligibility_formset=elig_fs,
                        document_formset=doc_fs,
                    )
                )
        messages.success(self.request, "Call updated.")
        return redirect("rims_grants:call_detail_manage", slug=self.object.slug)


class CallExtendDeadlineView(GrantsManagerMixin, View):
    """PRD §5.1 FRFA-CS029 — extend the submission deadline on a PUBLISHED call."""

    template_name = "grants/call_extend_deadline.html"

    def get(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug, status=GrantCall.Status.PUBLISHED)
        return render(request, self.template_name, {"call": call})

    def post(self, request, slug):
        from apps.rims.grants.services import extend_call_deadline

        call = get_object_or_404(GrantCall, slug=slug, status=GrantCall.Status.PUBLISHED)
        raw = request.POST.get("new_deadline", "").strip()
        new_deadline = None
        if raw:
            try:
                new_deadline = timezone.datetime.fromisoformat(raw)
                if timezone.is_naive(new_deadline):
                    new_deadline = timezone.make_aware(new_deadline)
            except (ValueError, TypeError):
                new_deadline = None
        try:
            extend_call_deadline(call, new_deadline, actor=request.user, request=request)
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_grants:call_extend_deadline", slug=call.slug)
        messages.success(request, "Deadline extended. Applicants and reviewers notified.")
        return redirect("rims_grants:call_detail_manage", slug=call.slug)


class AwardDetailView(GrantsManagerMixin, DetailView):
    model = Award
    template_name = "grants/award_detail.html"
    context_object_name = "award"

    def get_queryset(self):
        return Award.objects.select_related(
            "application__call", "application__applicant", "application__institution"
        ).prefetch_related("agreements", "tranches", "amendments", "cancellation_requests")

    def get_context_data(self, **kwargs):
        from apps.rims.grants.ai_helpers import award_risk_summary

        ctx = super().get_context_data(**kwargs)
        ctx["portfolio"] = award_portfolio_snapshot(self.object)
        ctx["agreement_form"] = AwardAgreementForm()
        ctx["tranche_form"] = AwardTrancheForm()
        ctx["amendment_form"] = AwardAmendmentForm()
        ctx["cancellation_form"] = AwardCancellationForm()
        funding_record = getattr(self.object.application.call, "funding_record", None)
        ctx["reporting_calendar"] = (
            list(funding_record.reporting_calendar.all()) if funding_record else []
        )
        ctx["ai_award_risk"] = award_risk_summary(self.object)
        ctx["periodic_narrative_report_form"] = PeriodicNarrativeReportForm()
        ctx["periodic_narrative_reports"] = (
            self.object.periodic_narrative_reports
            .prefetch_related("pictures", "linkages", "comments", "beneficiary_groups")
            .all()
        )

        # SRS §4.1.7 — Implementation Plan / Results Framework / Monitoring Visits.
        plan = getattr(self.object, "implementation_plan", None)
        ctx["implementation_plan"] = plan
        ctx["implementation_plan_form"] = ImplementationPlanForm()
        ctx["outcome_form"] = ImplementationPlanOutcomeForm()
        ctx["output_form"] = ImplementationPlanOutputForm()
        ctx["indicator_form"] = ImplementationPlanIndicatorForm()
        ctx["activity_form"] = ImplementationPlanActivityForm()
        ctx["monitoring_visit_form"] = MonitoringVisitForm()
        ctx["monitoring_visit_findings_form"] = MonitoringVisitFindingsForm()
        ctx["monitoring_visits"] = self.object.monitoring_visits.prefetch_related("evidence").all()

        if plan is not None:
            from apps.mel.indicators.services import calculate_progress

            outcomes = plan.outcomes.prefetch_related(
                "indicators__mel_indicator", "outputs__indicators__mel_indicator", "outputs__activities",
            ).all()
            for outcome in outcomes:
                for plan_indicator in list(outcome.indicators.all()) + [
                    i for output in outcome.outputs.all() for i in output.indicators.all()
                ]:
                    # Attach progress directly on the instance (rather than a
                    # separate pk-keyed dict) so the template can read
                    # ``indicator.rag_progress.rag`` without a custom filter.
                    plan_indicator.rag_progress = None
                    if plan_indicator.mel_indicator_id:
                        target = plan_indicator.mel_indicator.targets.first()
                        period_label = target.period_label if target else "Baseline"
                        plan_indicator.rag_progress = calculate_progress(
                            plan_indicator.mel_indicator, period_label
                        )
            ctx["plan_outcomes"] = outcomes
        return ctx


class PublishCallView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        # PRD §5.1 FRFA-CS022 — admin must acknowledge auto-close before publish.
        acknowledged = request.POST.get("auto_close_acknowledge") in ("on", "true", "1", "yes")
        try:
            publish_call(
                call,
                actor=request.user,
                request=request,
                auto_close_acknowledged=acknowledged,
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_grants:call_detail_manage", slug=call.slug)
        messages.success(request, "Call published. Notifications queued.")
        return redirect("rims_grants:call_detail_manage", slug=call.slug)


class ApplyCallView(LoginRequiredMixin, ProfileCompleteRequiredMixin, View):
    template_name = "grants/application_create.html"

    def _ctx(self, call, form, eligibility=None):
        return {
            "call": call,
            "form": form,
            "required_docs": call.required_documents.all(),
            "eligibility": eligibility,
        }

    def _check_eligibility(self, request, call):
        return evaluate_eligibility_for_user(
            request.user,
            call,
            institution_id=request.user.institution_id,
        )

    def get(self, request, slug):
        call = get_object_or_404(
            GrantCall.objects.prefetch_related("eligibility_rules"),
            slug=slug,
            status=GrantCall.Status.PUBLISHED,
        )
        # Route scholarship/fellowship/challenge calls to their multi-step wizards
        if call.call_type == "scholarship":
            return redirect("rims_grants:scholarship_apply_step1", slug=slug)
        if call.call_type == "fellowship":
            return redirect("rims_grants:fellowship_apply_step1", slug=slug)
        if call.call_type == "challenge":
            return redirect("rims_grants:challenge_apply_step1", slug=slug)
        if timezone.now() > call.closes_at:
            messages.error(request, "Applications are closed for this call.")
            return redirect("rims_grants:call_list")
        existing = Application.objects.filter(call=call, applicant=request.user).first()
        # A non-draft application is already in the pipeline — send the user to
        # its record. A DRAFT (typically created by auto-save) means the form is
        # still in progress, so re-open it pre-filled rather than stranding the
        # applicant on a detail page with no way to finish submitting.
        if existing and existing.status != Application.Status.DRAFT:
            return redirect("rims_grants:application_detail", pk=existing.pk)

        # Eligibility is advisory, not a gate. Applicants may submit regardless
        # of whether they meet the requirements (e.g. degree level lives in an
        # uploaded document, not a profile field). The result is still computed
        # here for an upfront warning and recorded at submission for managers.
        eligibility = self._check_eligibility(request, call)

        form = ApplicationForm(
            initial={
                "institution": (
                    existing.institution_id if existing else request.user.institution_id
                ),
                "proposal": existing.proposal if existing else "",
            },
            call=call,
        )
        return render(request, self.template_name, self._ctx(call, form, eligibility))

    def post(self, request, slug):
        call = get_object_or_404(
            GrantCall.objects.prefetch_related("eligibility_rules"),
            slug=slug,
            status=GrantCall.Status.PUBLISHED,
        )
        if timezone.now() > call.closes_at:
            messages.error(request, "Applications are closed for this call.")
            return redirect("rims_grants:call_list")

        # Eligibility is advisory, not a gate — re-compute for context only.
        # Submission proceeds regardless; auto_screen_eligibility records the
        # outcome so managers can screen ineligible applications on their end.
        eligibility = self._check_eligibility(request, call)
        form = ApplicationForm(request.POST, request.FILES, call=call)
        required_docs = call.required_documents.all()

        # Validate required document uploads
        missing = [d.label for d in required_docs if d.is_required and not request.FILES.get(f"doc_{d.pk}")]
        if missing:
            for label in missing:
                messages.error(request, f"Required document missing: {label}")
            return render(request, self.template_name, self._ctx(call, form, eligibility), status=400)

        if not form.is_valid():
            return render(request, self.template_name, self._ctx(call, form, eligibility), status=400)

        try:
            for doc in required_docs:
                f = request.FILES.get(f"doc_{doc.pk}")
                if f:
                    validate_grant_upload(f, call=call)
            idx = 0
            while True:
                f = request.FILES.get(f"extra_file_{idx}")
                if not f:
                    break
                validate_grant_upload(f, call=call)
                idx += 1
        except ValidationError as exc:
            errs = getattr(exc, "messages", None) or getattr(exc, "error_list", None) or [str(exc)]
            for msg in errs:
                messages.error(request, msg)
            return render(request, self.template_name, self._ctx(call, form, eligibility), status=400)

        # Reuse the draft an auto-save may already have created for this
        # (call, applicant) pair — otherwise the unique constraint
        # ``uniq_application_per_call_user`` raises an IntegrityError (500).
        app, _created = Application.objects.get_or_create(
            call=call,
            applicant=request.user,
            defaults={"institution": form.cleaned_data.get("institution")},
        )
        if app.status != Application.Status.DRAFT:
            messages.info(request, "You have already applied to this call.")
            return redirect("rims_grants:application_detail", pk=app.pk)
        app.institution = form.cleaned_data.get("institution")
        app.proposal = form.cleaned_data.get("proposal", "")
        app.scholarship_university_choice = form.cleaned_data.get("scholarship_university_choice")
        app.scholarship_course_choice = form.cleaned_data.get("scholarship_course_choice", "")
        app.save()

        # Save required document uploads
        for doc in required_docs:
            file = request.FILES.get(f"doc_{doc.pk}")
            if file:
                ApplicationDocument.objects.create(application=app, label=doc.label, file=file)

        # Save any extra free-form uploads
        idx = 0
        while True:
            file = request.FILES.get(f"extra_file_{idx}")
            label = request.POST.get(f"extra_label_{idx}", "").strip()
            if not file:
                break
            ApplicationDocument.objects.create(application=app, label=label or "Supporting document", file=file)
            idx += 1

        try:
            submit_application(app)
            messages.success(request, "Application submitted.")
        except ValidationError as exc:
            app.delete()
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return render(request, self.template_name, self._ctx(call, form, eligibility), status=400)
        except Exception as exc:
            app.delete()
            messages.error(request, str(exc))
            return render(request, self.template_name, self._ctx(call, form, eligibility), status=400)
        return redirect("rims_grants:application_detail", pk=app.pk)


class RequiredDocumentCreateView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        form = RequiredDocumentForm(request.POST)
        if form.is_valid():
            doc = form.save(commit=False)
            doc.call = call
            doc.save()
            messages.success(request, "Document requirement added.")
        else:
            messages.error(request, "Invalid document requirement.")
        return redirect("rims_grants:call_detail_manage", slug=slug)


class RequiredDocumentDeleteView(GrantsManagerMixin, View):
    def post(self, request, slug, pk):
        call = get_object_or_404(GrantCall, slug=slug)
        RequiredDocument.objects.filter(pk=pk, call=call).delete()
        messages.success(request, "Document requirement removed.")
        return redirect("rims_grants:call_detail_manage", slug=slug)


class MyApplicationListView(LoginRequiredMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Application
    template_name = "grants/application_list.html"
    context_object_name = "applications"
    paginate_by = 20

    sortable_fields = {
        "call": "call__title",
        "submitted": "submitted_at",
        "status": "status",
        "updated": "updated_at",
    }
    default_sort = "-created_at"

    csv_columns = (
        ("Call", "call.title"),
        ("Call slug", "call.slug"),
        ("Submitted", lambda a: a.submitted_at.isoformat() if a.submitted_at else ""),
        ("Status", "get_status_display"),
        ("Last update", lambda a: a.updated_at.isoformat() if a.updated_at else ""),
    )
    csv_filename = "my_applications"

    # Facets surfaced in the toolbar — keep the 6 statuses an applicant cares about.
    APPLICANT_FACET_STATUSES = (
        ("draft", "Draft"),
        ("submitted", "Submitted"),
        ("under_review", "Under review"),
        ("shortlisted", "Shortlisted"),
        ("awarded", "Awarded"),
        ("rejected", "Rejected"),
    )

    def get_queryset(self):
        qs = Application.objects.filter(applicant=self.request.user).select_related("call")
        self._status_filter = (self.request.GET.get("status") or "").strip()
        if self._status_filter:
            qs = qs.filter(status=self._status_filter)
        return self.apply_sort(qs)

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["grants/partials/application_list_results.html"]
        return super().get_template_names()

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        base_qs = Application.objects.filter(applicant=self.request.user)
        counts = {row["status"]: row["n"] for row in base_qs.values("status").annotate(n=Count("id"))}
        active = getattr(self, "_status_filter", "") or ""
        facets = [{
            "label": "All",
            "href": self.request.path,
            "count": base_qs.count(),
            "active": not active,
        }]
        for value, label in self.APPLICANT_FACET_STATUSES:
            facets.append({
                "label": label,
                "href": f"{self.request.path}?status={value}",
                "count": counts.get(value, 0),
                "active": active == value,
            })
        ctx["facets"] = facets
        ctx["active_status"] = active
        return ctx


class ApplicationWithdrawView(LoginRequiredMixin, View):
    """PRD §5.1 FRFA-AM010 — applicant self-withdrawal from a live application."""

    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk, applicant=request.user)
        try:
            app.withdraw()
            app.save(update_fields=["status", "updated_at"])
        except Exception as exc:  # noqa: BLE001
            messages.error(
                request,
                "This application cannot be withdrawn at its current stage.",
            )
            return redirect("rims_grants:application_detail", pk=pk)
        messages.success(request, "Your application has been withdrawn.")
        return redirect("rims_grants:application_detail", pk=pk)


class AwardAcknowledgeView(LoginRequiredMixin, View):
    """PRD §5.1 FRFA-AA005 — awardee acknowledges receipt of the award letter."""

    def post(self, request, pk):
        award = get_object_or_404(
            Award.objects.select_related("application"), pk=pk, application__applicant=request.user
        )
        from apps.rims.grants.services import acknowledge_award

        acknowledge_award(award, actor=request.user)
        messages.success(request, "Thank you — your award has been acknowledged.")
        return redirect("rims_grants:application_detail", pk=award.application_id)


class ApplicationDetailView(LoginRequiredMixin, DetailView):
    model = Application
    template_name = "grants/application_detail.html"
    context_object_name = "application"

    def get_queryset(self):
        return (
            Application.objects.filter(applicant=self.request.user)
            .select_related("call", "award")
            .prefetch_related("documents", "interviews")
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["award"] = getattr(self.object, "award", None)
        ctx["latest_interview"] = self.object.interviews.first()
        ctx["documents"] = paginate_qs(
            self.request, self.object.documents.all(), per_page=15, param="doc_page"
        )
        # PRD §5.1 FRFA-CO006 — surface final reports (with manager feedback)
        # to the awardee so they can see review_comment / queries and resubmit.
        ctx["narrative_reports"] = []
        ctx["financial_reports"] = []
        if ctx["award"] is not None:
            closeout = getattr(ctx["award"], "closeout_record", None)
            if closeout is not None:
                ctx["narrative_reports"] = list(
                    closeout.narrative_reports.order_by("-submitted_at")
                )
                ctx["financial_reports"] = list(
                    closeout.financial_reports.order_by("-submitted_at")
                )
        standard = [
            ("submitted", "Submitted"),
            ("under_review", "Under review"),
            ("shortlisted", "Shortlisted"),
            ("approved", "Approved"),
            ("awarded", "Awarded"),
        ]
        order = [s[0] for s in standard]
        current = self.object.status

        if current == Application.Status.DRAFT:
            ctx["steps"] = [("draft", "Draft")]
            ctx["completed_steps"] = set()
        elif current == Application.Status.INELIGIBLE:
            # PRD 6.1 — screened out before review queue; show honest pipeline.
            ctx["steps"] = [
                ("submitted", "Submitted"),
                ("ineligible", "Not eligible"),
            ]
            ctx["completed_steps"] = {"submitted"}
        elif current == Application.Status.REJECTED:
            ctx["steps"] = [
                ("submitted", "Submitted"),
                ("rejected", "Not selected"),
            ]
            ctx["completed_steps"] = {"submitted"}
        elif current == Application.Status.AWARDED:
            ctx["steps"] = standard
            ctx["completed_steps"] = set(order[: order.index(Application.Status.AWARDED)])
        elif current in order:
            ctx["steps"] = standard
            ctx["completed_steps"] = set(order[: order.index(current)])
        else:
            ctx["steps"] = standard
            ctx["completed_steps"] = set()

        return ctx


class ReviewerAccessMixin(RimsAccessMixin):
    allowed_roles = (UserRole.REVIEWER, UserRole.ADMIN, UserRole.GRANTS_MANAGER)
    rims_permissions = (rims_perms.REVIEW_APPLICATIONS, rims_perms.MANAGE_CALLS)


class ReviewerQueueView(ReviewerAccessMixin, SortableListMixin, ListView):
    model = Review
    template_name = "grants/review_queue.html"
    context_object_name = "reviews"
    paginate_by = 30

    sortable_fields = {
        "due": "due_at",
        "submitted": "submitted_at",
        "call": "application__call__title",
    }
    default_sort = "application__call__closes_at"

    FILTER_PENDING = "pending"
    FILTER_SCORED = "scored"
    FILTER_OVERDUE = "overdue"
    FILTER_ALL = "all"

    def _active_filter(self):
        f = (self.request.GET.get("status") or self.FILTER_PENDING).lower()
        return f if f in {self.FILTER_PENDING, self.FILTER_SCORED, self.FILTER_OVERDUE, self.FILTER_ALL} else self.FILTER_PENDING

    def _coi_blocked_application_ids(self):
        """PRD §5.1 FRFA-AM029 — applications this reviewer has self-declared a COI on."""
        return list(
            ConflictOfInterest.objects.filter(
                reviewer=self.request.user,
                has_conflict=True,
                declared_by=self.request.user,
            ).values_list("application_id", flat=True)
        )

    def get_queryset(self):
        base = (
            Review.objects.filter(reviewer=self.request.user)
            .exclude(application_id__in=self._coi_blocked_application_ids())
            .select_related("application__call")
        )
        active = self._active_filter()
        now = timezone.now()
        if active == self.FILTER_PENDING:
            qs = base.filter(submitted_at__isnull=True)
        elif active == self.FILTER_SCORED:
            qs = base.filter(submitted_at__isnull=False)
        elif active == self.FILTER_OVERDUE:
            qs = base.filter(submitted_at__isnull=True, due_at__lt=now)
        else:
            qs = base
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        now = timezone.now()
        blocked_ids = self._coi_blocked_application_ids()
        base = Review.objects.filter(reviewer=self.request.user).exclude(
            application_id__in=blocked_ids
        )
        pending = base.filter(submitted_at__isnull=True)
        ctx["stats"] = {
            "total": base.count(),
            "pending": pending.count(),
            "scored": base.filter(submitted_at__isnull=False).count(),
            "overdue": pending.filter(due_at__lt=now).count(),
        }
        # PRD §5.1 FRFA-AM029 — surface the count of COI-blocked applications so
        # the empty-state explains why the queue is empty.
        ctx["coi_blocked_count"] = len(blocked_ids)
        active = self._active_filter()
        # Each facet links to ?status=<key>; the chip shows its current count
        # so the user can compare workloads without clicking through.
        ctx["facets"] = [
            {
                "label": "Pending",
                "href": f"?status={self.FILTER_PENDING}",
                "count": ctx["stats"]["pending"],
                "active": active == self.FILTER_PENDING,
            },
            {
                "label": "Overdue",
                "href": f"?status={self.FILTER_OVERDUE}",
                "count": ctx["stats"]["overdue"],
                "active": active == self.FILTER_OVERDUE,
            },
            {
                "label": "Scored",
                "href": f"?status={self.FILTER_SCORED}",
                "count": ctx["stats"]["scored"],
                "active": active == self.FILTER_SCORED,
            },
            {
                "label": "All",
                "href": f"?status={self.FILTER_ALL}",
                "count": ctx["stats"]["total"],
                "active": active == self.FILTER_ALL,
            },
        ]
        ctx["active_filter"] = active
        return ctx


class ReviewerApplicationView(ReviewerAccessMixin, DetailView):
    """Read-only application content for reviewers.

    PRD §5.1 (Application Management) — reviewers must be able to read the
    submitted proposal, attached documents, and any call-specific responses
    before scoring. When ``call.blind_review`` is True, applicant identity
    (name, email, institution) is suppressed and only the anonymised
    reference is shown (PRD: "Add Anonymisation Features to Review Process").

    Access is gated by review assignment: the URL is keyed on a ``Review``
    pk, and only the assigned reviewer (or admins / grants managers via
    ``ReviewerAccessMixin``) may load it.
    """

    model = Review
    template_name = "grants/review_application.html"
    context_object_name = "review"

    def get_queryset(self):
        qs = Review.objects.select_related(
            "application__call",
            "application__applicant",
            "application__institution",
            "application__scholarship_university_choice",
        ).prefetch_related("application__documents", "application__institutions")
        if self.request.user.is_superuser:
            return qs
        if self.request.user.role in (UserRole.ADMIN, UserRole.GRANTS_MANAGER):
            return qs
        # PRD §5.1 FRFA-AM029 — hide applications the reviewer self-declared COI on.
        blocked = ConflictOfInterest.objects.filter(
            reviewer=self.request.user,
            has_conflict=True,
            declared_by=self.request.user,
        ).values_list("application_id", flat=True)
        return qs.filter(reviewer=self.request.user).exclude(application_id__in=blocked)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        review = self.object
        application = review.application
        call = application.call
        ctx["application"] = application
        ctx["call"] = call
        ctx["is_blind"] = bool(call.blind_review)
        ctx["display_ref"] = (
            application.anonymized_code or "ANON"
            if call.blind_review
            else (application.applicant.email if application.applicant_id else "—")
        )
        # §7 reviewer surface — anchor the page with "Reviewing N of M".
        # Position is computed against the reviewer's open queue ordered by
        # the call's closing date (same ordering as ReviewerQueueView default
        # sort), so the count moves the way the reviewer expects as they
        # progress through assignments. We only compute this when the review
        # is still open; submitted reviews don't need a progress anchor.
        if review.submitted_at is None and not self.request.user.is_superuser:
            open_queue = list(
                Review.objects.filter(
                    reviewer=self.request.user, submitted_at__isnull=True
                )
                .order_by("application__call__closes_at", "pk")
                .values_list("pk", flat=True)
            )
            if review.pk in open_queue:
                ctx["queue_position"] = open_queue.index(review.pk) + 1
                ctx["queue_total"] = len(open_queue)
        return ctx


class ReviewerDeclareCoiView(ReviewerAccessMixin, FormView):
    """PRD §5.1 FRFA-AM029 — reviewer self-declares COI on an assigned review.

    Blocking semantics: the application disappears from the reviewer's queue,
    the COI row is upserted with declared_by/at, the programme officer is
    notified to reassign, and an audit row is written.
    """

    template_name = "grants/review_declare_coi.html"
    form_class = ReviewerCoiDeclarationForm

    def _load_review(self):
        # Reviewers can only touch their own assignments. Managers / admins
        # are intentionally barred here — the manager flow is the existing
        # AssignReviewerForm.declare_conflict checkbox, which records a
        # PO-attributed COI (declared_by IS NULL).
        self.review = get_object_or_404(
            Review.objects.select_related("application__call", "application__applicant"),
            pk=self.kwargs["pk"],
            reviewer=self.request.user,
        )

    def get(self, request, *args, **kwargs):
        self._load_review()
        if self.review.submitted_at:
            messages.error(
                request,
                "This review has already been submitted; conflicts must be raised before scoring.",
            )
            return redirect("rims_grants:review_queue")
        return super().get(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        self._load_review()
        if self.review.submitted_at:
            messages.error(
                request,
                "This review has already been submitted; conflicts must be raised before scoring.",
            )
            return redirect("rims_grants:review_queue")
        return super().post(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        review = self.review
        application = review.application
        call = application.call
        ctx["review"] = review
        ctx["application"] = application
        ctx["call"] = call
        ctx["display_ref"] = (
            application.anonymized_code or f"application #{application.pk}"
            if call.blind_review
            else (application.applicant.email if application.applicant_id else "—")
        )
        return ctx

    def form_valid(self, form):
        from apps.rims.grants.audit_helper import action_update, audit_rims

        review = self.review
        application = review.application
        call = application.call
        notes = form.cleaned_data["notes"]

        coi, _ = ConflictOfInterest.objects.update_or_create(
            application=application,
            reviewer=self.request.user,
            defaults={
                "has_conflict": True,
                "notes": notes,
                "declared_by": self.request.user,
                "declared_at": timezone.now(),
            },
        )

        audit_rims(
            actor=self.request.user,
            action=action_update(),
            target_model="ConflictOfInterest",
            object_id=coi.pk,
            object_repr=str(coi),
            changes={"has_conflict": True, "declared_by_reviewer": True},
            request=self.request,
        )

        # PRD §5.1 FRFA-AM029 — alert the grants team to reassign.
        from django.urls import reverse

        from apps.core.notifications.emailing import rims_email_context
        from apps.core.notifications.models import Notification
        from apps.core.notifications.services import send_notification

        manager = getattr(call, "created_by", None)
        ref = application.anonymized_code or f"application #{application.pk}"
        action_path = reverse(
            "rims_grants:application_assign_reviewer", kwargs={"pk": application.pk}
        )
        ctx = rims_email_context(
            subject=f"Reviewer COI on {call.title}",
            headline="A reviewer has declared a conflict",
            action_path=action_path,
            action_label="Reassign reviewers",
            preheader="Reassign the reviewer pool.",
        )
        ctx.update(
            {
                "call_title": call.title,
                "reviewer_name": self.request.user.get_full_name() or self.request.user.email,
                "application_reference": ref,
                "coi_notes": notes,
            }
        )
        body = (
            f"{ctx['reviewer_name']} declared a conflict of interest on "
            f'"{call.title}" (reference {ref}). Their assignment has been blocked.'
        )
        recipients = []
        if manager is not None:
            recipients.append(manager)
        # When the call has no created_by, fall back to any user with
        # GRANTS_MANAGER role so the alert is never silently dropped.
        if not recipients:
            from django.contrib.auth import get_user_model

            recipients = list(
                get_user_model().objects.filter(role=UserRole.GRANTS_MANAGER).distinct()
            )
        for recipient in recipients:
            send_notification(
                recipient,
                body,
                verb=Notification.Verb.REVIEWER_COI_DECLARED,
                email_context=ctx,
                action_url=action_path,
            )

        messages.success(
            self.request,
            "Conflict declared. The grants team has been notified to reassign this review.",
        )
        return redirect("rims_grants:review_queue")


class ReviewSubmitView(ReviewerAccessMixin, FormView):
    template_name = "grants/review_submit.html"
    form_class = ReviewForm

    def _load_review(self):
        self.review = get_object_or_404(
            Review.objects.select_related("application__call"),
            pk=self.kwargs["pk"],
            reviewer=self.request.user,
        )

    def get(self, request, *args, **kwargs):
        self._load_review()
        if self.review.submitted_at:
            return HttpResponseForbidden("This review is already submitted.")
        return super().get(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        self._load_review()
        if self.review.submitted_at:
            return HttpResponseForbidden("This review is already submitted.")
        return super().post(request, *args, **kwargs)

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        rubric = self.review.application.call.scoring_rubric
        kwargs["rubric"] = rubric if rubric else None
        return kwargs

    def form_valid(self, form):
        self.review.recommendation_code = form.cleaned_data.get("recommendation_code") or ""
        submit_review(self.review, form.scores_dict(), form.cleaned_data.get("recommendation", ""))
        messages.success(self.request, "Review submitted.")
        return redirect("rims_grants:review_queue")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        app = self.review.application
        call = app.call
        ctx["review"] = self.review
        ctx["application"] = app
        ctx["call"] = call
        ctx["display_ref"] = app.anonymized_code if call.blind_review else app.applicant.email
        ctx["review_due_at"] = self.review.due_at
        # §7 — anonymized score distribution across other submitted reviews
        # on the same call. The reviewer compares their pending submission
        # against the peer distribution before clicking submit, surfacing
        # bias (consistently 2pts above the cohort, e.g.) before the score
        # is locked in.
        ctx.update(self._peer_score_distribution(call))
        return ctx

    REVIEW_DISTRIBUTION_BUCKETS = 5

    def _peer_score_distribution(self, call):
        from decimal import Decimal

        rubric = call.scoring_rubric or []
        max_total = sum(
            (Decimal(str(item.get("max_points", 0))) for item in rubric),
            Decimal("0"),
        )
        if not max_total:
            return {"peer_distribution": None}
        peer_scores = list(
            Review.objects.filter(
                application__call=call,
                submitted_at__isnull=False,
            )
            .exclude(reviewer=self.request.user)
            .values_list("total_score", flat=True)
        )
        peer_count = len(peer_scores)
        bucket_size = max_total / self.REVIEW_DISTRIBUTION_BUCKETS
        buckets = []
        for index in range(self.REVIEW_DISTRIBUTION_BUCKETS):
            low = bucket_size * index
            # The top bucket is closed on the right so a perfect score lands inside it.
            high = bucket_size * (index + 1)
            is_top = index == self.REVIEW_DISTRIBUTION_BUCKETS - 1
            count = sum(
                1
                for s in peer_scores
                if (s >= low and s < high) or (is_top and s == high)
            )
            buckets.append(
                {
                    "label": f"{low.quantize(Decimal('0.1'))}–{high.quantize(Decimal('0.1'))}",
                    "count": count,
                    "low": low,
                    "high": high,
                }
            )
        return {
            "peer_distribution": {
                "buckets": buckets,
                "peer_count": peer_count,
                "max_total": max_total,
            }
        }


class ManagerApplicationListView(GrantsManagerMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Application
    template_name = "grants/manager_application_list.html"
    context_object_name = "applications"
    paginate_by = 25

    sortable_fields = {
        "applicant": "applicant__email",
        "status": "status",
        "submitted": "submitted_at",
        "score": "avg_review_score",
    }
    default_sort = "-created_at"

    csv_columns = (
        ("Applicant", lambda a: a.applicant.email if a.applicant_id else ""),
        ("Institution", lambda a: a.institution.name if a.institution_id else ""),
        ("Status", "get_status_display"),
        ("Submitted", lambda a: a.submitted_at.isoformat() if a.submitted_at else ""),
        ("Reviews submitted", "submitted_review_count"),
        ("Reviews assigned", "assigned_review_count"),
        ("Avg review score", "avg_review_score"),
    )
    csv_filename = "manager_applications"

    def dispatch(self, request, *args, **kwargs):
        self.call = get_object_or_404(GrantCall, slug=kwargs["slug"])
        return super().dispatch(request, *args, **kwargs)

    def get_queryset(self):
        qs = (
            Application.objects.filter(call=self.call)
            .select_related("applicant", "institution", "call")
            .annotate(
                avg_review_score=Avg("reviews__total_score", filter=Q(reviews__submitted_at__isnull=False)),
                submitted_review_count=Count("reviews", filter=Q(reviews__submitted_at__isnull=False)),
                assigned_review_count=Count("reviews"),
            )
        )
        if self.request.GET.get("screening") == "pending":
            qs = qs.filter(status=Application.Status.SUBMITTED)
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["call"] = self.call
        ctx["screening_filter"] = self.request.GET.get("screening", "")
        ctx["pending_screening_count"] = Application.objects.filter(
            call=self.call, status=Application.Status.SUBMITTED
        ).count()
        return ctx


class ManagerAllApplicationsListView(GrantsManagerMixin, SortableListMixin, CsvExportMixin, ListView):
    """Every non-draft application across all calls (grants manager)."""

    model = Application
    template_name = "grants/manager_all_applications.html"
    context_object_name = "applications"
    paginate_by = 25

    sortable_fields = {
        "call": "call__title",
        "applicant": "applicant__email",
        "status": "status",
        "submitted": "submitted_at",
        "score": "avg_review_score",
    }
    default_sort = "-submitted_at"

    csv_columns = (
        ("Call", "call.title"),
        ("Applicant", lambda a: a.applicant.email if a.applicant_id else ""),
        ("Institution", lambda a: a.institution.name if a.institution_id else ""),
        ("Status", "get_status_display"),
        ("Submitted", lambda a: a.submitted_at.isoformat() if a.submitted_at else ""),
        ("Avg review score", "avg_review_score"),
    )
    csv_filename = "all_applications"

    def get_queryset(self):
        qs = (
            Application.objects.exclude(status=Application.Status.DRAFT)
            .select_related("call", "applicant", "institution")
            .annotate(
                avg_review_score=Avg("reviews__total_score", filter=Q(reviews__submitted_at__isnull=False)),
                submitted_review_count=Count("reviews", filter=Q(reviews__submitted_at__isnull=False)),
                assigned_review_count=Count("reviews"),
            )
        )
        st = self.request.GET.get("status", "").strip()
        allowed = {s.value for s in Application.Status if s != Application.Status.DRAFT}
        if st and st in allowed:
            qs = qs.filter(status=st)
        institution_id = (self.request.GET.get("institution") or "").strip()
        if institution_id.isdigit():
            qs = qs.filter(institution_id=int(institution_id))
        q = self.request.GET.get("q", "").strip()
        if q:
            qs = qs.filter(
                Q(applicant__email__icontains=q)
                | Q(applicant__first_name__icontains=q)
                | Q(applicant__last_name__icontains=q)
                | Q(call__title__icontains=q)
                | Q(call__slug__icontains=q)
            )
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["status_filter"] = self.request.GET.get("status", "").strip()
        ctx["status_choices"] = [s for s in Application.Status if s != Application.Status.DRAFT]
        return ctx


# PRD §5.1 FRFA-AM040 — archived view of rejected / ineligible / withdrawn
# applications. Audit trail (FRFA-AM041) lives on AuditLog; this view exposes
# the filtered list to authorised staff so reviewers can revisit dispositions
# without giving applicants delete-style "lost work" semantics.
_ARCHIVE_STATUSES = (
    Application.Status.REJECTED,
    Application.Status.REJECTED_INELIGIBLE,
    Application.Status.INELIGIBLE,
    Application.Status.WITHDRAWN,
    Application.Status.WITHDRAWN_NO_RESPONSE,
    Application.Status.EXPIRED_DRAFT,
)


class ApplicationArchiveListView(GrantsManagerMixin, SortableListMixin, CsvExportMixin, ListView):
    """PRD §5.1 FRFA-AM040 — archived (rejected/ineligible/withdrawn) applications.

    Read-only register accessible to grants management staff. The `?export=csv`
    query string streams the same rows as a CSV download for archival /
    compliance review (via apps.core.lists.CsvExportMixin).
    """

    model = Application
    template_name = "grants/application_archive_list.html"
    context_object_name = "applications"
    paginate_by = 25

    sortable_fields = {
        "call": "call__title",
        "applicant": "applicant__email",
        "status": "status",
        "submitted": "submitted_at",
    }
    default_sort = "-submitted_at"

    csv_columns = (
        ("Application ID", "pk"),
        ("Call slug", "call.slug"),
        ("Call title", "call.title"),
        ("Applicant email", lambda a: a.applicant.email if a.applicant_id else ""),
        ("Applicant name", lambda a: a.applicant.get_full_name() if a.applicant_id else ""),
        ("Institution", lambda a: a.institution.name if a.institution_id else ""),
        ("Submitted at", lambda a: a.submitted_at.isoformat() if a.submitted_at else ""),
        ("Created at", lambda a: a.created_at.isoformat() if a.created_at else ""),
        ("Status", "get_status_display"),
    )
    csv_filename = "rims-archived-applications"

    def get_queryset(self):
        qs = (
            Application.objects.filter(status__in=[s.value for s in _ARCHIVE_STATUSES])
            .select_related("call", "applicant", "institution")
        )
        st = (self.request.GET.get("status") or "").strip()
        allowed = {s.value for s in _ARCHIVE_STATUSES}
        if st and st in allowed:
            qs = qs.filter(status=st)
        year = (self.request.GET.get("year") or "").strip()
        if year.isdigit():
            qs = qs.filter(created_at__year=int(year))
        q = (self.request.GET.get("q") or "").strip()
        if q:
            qs = qs.filter(
                Q(applicant__email__icontains=q)
                | Q(applicant__first_name__icontains=q)
                | Q(applicant__last_name__icontains=q)
                | Q(call__title__icontains=q)
                | Q(call__slug__icontains=q)
            )
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["status_filter"] = (self.request.GET.get("status") or "").strip()
        ctx["status_choices"] = list(_ARCHIVE_STATUSES)
        ctx["year_filter"] = (self.request.GET.get("year") or "").strip()
        # Derive the available archive years from the underlying data (capped
        # at 12 to keep the dropdown short).
        year_dates = (
            Application.objects.filter(status__in=[s.value for s in _ARCHIVE_STATUSES])
            .dates("created_at", "year", order="DESC")
        )
        ctx["available_years"] = [d.year for d in year_dates[:12]]
        ctx["q"] = (self.request.GET.get("q") or "").strip()
        # Per-status counts so the chips can show badges. Pre-populate every
        # archive status with 0 so the template can safely add them — Django's
        # variable resolver raises on missing dict keys instead of falling back
        # via |default.
        archive_qs = Application.objects.filter(status__in=[s.value for s in _ARCHIVE_STATUSES])
        ctx["archive_total"] = archive_qs.count()
        status_counts = {s.value: 0 for s in _ARCHIVE_STATUSES}
        for status, n in archive_qs.values_list("status").annotate(n=Count("pk")).values_list(
            "status", "n"
        ):
            status_counts[status] = n
        ctx["archive_status_counts"] = status_counts
        return ctx


class ManagerApplicationDetailView(GrantsManagerMixin, DetailView):
    model = Application
    template_name = "grants/manager_application_detail.html"
    context_object_name = "application"

    def get_queryset(self):
        return (
            Application.objects.select_related("call", "applicant", "institution")
            .prefetch_related(
                Prefetch(
                    "reviews",
                    queryset=Review.objects.select_related("reviewer").order_by("pk"),
                ),
                "documents",
                "interviews__outcome",
                "verification_records",
            )
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        refresh_call_rankings(self.object.call)
        refreshed_application = self.get_queryset().get(pk=self.object.pk)
        self.object = refreshed_application
        ctx["application"] = refreshed_application
        ctx["documents"] = paginate_qs(
            self.request, refreshed_application.documents.all(), per_page=15, param="doc_page"
        )
        ctx["avg_score"] = aggregate_average_score(refreshed_application)
        # PRD §5.1 FRFA-AM026 — weighted aggregate + per-criterion breakdown.
        ctx["score_summary"] = aggregate_weighted_score(refreshed_application)
        # PRD §5.1 FRFA-AM029 — reviewer-declared conflicts for the decision rail.
        ctx["declared_cois"] = list(
            refreshed_application.conflicts.filter(has_conflict=True, declared_by__isnull=False)
            .select_related("reviewer", "declared_by")
            .order_by("-declared_at")
        )
        ctx["assign_form"] = AssignReviewerForm()
        ctx["interview_form"] = InterviewScheduleForm()
        ctx["interview_outcome_form"] = InterviewOutcomeForm()
        ctx["verification_form"] = VerificationRecordForm()
        divergent, _reviews = recommendation_divergence(refreshed_application)
        ctx["reviewer_recommendation_conflict"] = divergent
        from apps.rims.grants.review_conflicts import resolve_conflict

        ctx["reviewer_conflict_outcome"] = resolve_conflict(refreshed_application)
        ctx["ranking_score"] = refreshed_application.ranking_score
        ctx["ranking_position"] = refreshed_application.ranking_position
        ctx["shortlist_recommended"] = refreshed_application.shortlist_recommended
        ctx["latest_interview"] = refreshed_application.interviews.order_by("-scheduled_for").first()
        ctx["latest_verification"] = refreshed_application.verification_records.order_by("-created_at").first()

        # PRD §5.1 FRFA-AM018 — expose candidate reviewers sorted by current
        # open-assignment count so the call manager can see workload balance
        # at a glance when assigning. Each item is (user, open_count).
        from apps.rims.grants.services import (
            recommended_reviewer_queryset,
            reviewer_open_assignment_count,
        )

        candidates = list(recommended_reviewer_queryset(refreshed_application.call))
        already_assigned = set(
            refreshed_application.reviews.values_list("reviewer_id", flat=True)
        )
        candidates = [u for u in candidates if u.pk not in already_assigned]
        ctx["reviewer_workload"] = sorted(
            ((u, reviewer_open_assignment_count(u)) for u in candidates),
            key=lambda item: (item[1], item[0].email),
        )
        from apps.rims.grants.ai_helpers import reviewer_consensus

        ctx["ai_reviewer_summary"] = reviewer_consensus(refreshed_application)
        return ctx


class ApplicantPipelineView(GrantsManagerMixin, View):
    """Full shortlisting dashboard: summary cards, demographic filters, bulk actions."""

    template_name = "grants/applicant_pipeline.html"

    def dispatch(self, request, *args, **kwargs):
        self.call = get_object_or_404(GrantCall, slug=kwargs["slug"])
        return super().dispatch(request, *args, **kwargs)

    def _base_qs(self):
        latest_interview_pk = (
            InterviewSchedule.objects.filter(application=OuterRef("pk"))
            .order_by("-scheduled_for")
            .values("pk")[:1]
        )
        interview_score = (
            InterviewSchedule.objects.filter(application=OuterRef("pk"))
            .order_by("-scheduled_for")
            .values("outcome__score")[:1]
        )
        return (
            Application.objects.filter(call=self.call)
            .exclude(status__in=[Application.Status.DRAFT, Application.Status.EXPIRED_DRAFT])
            .select_related("applicant", "institution", "scholarship_profile")
            .annotate(
                avg_review_score=Avg(
                    "reviews__total_score", filter=Q(reviews__submitted_at__isnull=False)
                ),
                submitted_review_count=Count(
                    "reviews", filter=Q(reviews__submitted_at__isnull=False)
                ),
                has_interview=Count("interviews"),
                interview_completed_count=Count(
                    "interviews", filter=Q(interviews__status="completed")
                ),
                latest_interview_pk=Subquery(latest_interview_pk),
                latest_interview_score=Subquery(interview_score),
            )
        )

    def _apply_filters(self, qs, params):
        status = params.get("status")
        if status:
            qs = qs.filter(status=status)
        gender = params.get("gender")
        if gender:
            qs = qs.filter(scholarship_profile__gender=gender)
        nationality = params.get("nationality")
        if nationality:
            qs = qs.filter(scholarship_profile__country_of_origin=nationality)
        disability = params.get("disability")
        if disability == "yes":
            qs = qs.filter(scholarship_profile__have_physical_disability=True)
        elif disability == "no":
            qs = qs.filter(scholarship_profile__have_physical_disability=False)
        score_min = params.get("score_min")
        if score_min:
            try:
                qs = qs.filter(ranking_score__gte=float(score_min))
            except (ValueError, TypeError):
                pass
        score_max = params.get("score_max")
        if score_max:
            try:
                qs = qs.filter(ranking_score__lte=float(score_max))
            except (ValueError, TypeError):
                pass
        interview = params.get("interview")
        if interview == "scheduled":
            qs = qs.filter(has_interview__gt=0)
        elif interview == "none":
            qs = qs.filter(has_interview=0)
        elif interview == "completed":
            qs = qs.filter(interview_completed_count__gt=0)
        # Sorting
        sort = params.get("sort", "")
        if sort == "score_asc":
            qs = qs.order_by(F("ranking_score").asc(nulls_last=True))
        elif sort == "score_desc" or not sort:
            qs = qs.order_by(F("ranking_score").desc(nulls_last=True), "-submitted_at")
        elif sort == "name":
            qs = qs.order_by("applicant__first_name", "applicant__last_name")
        elif sort == "submitted":
            qs = qs.order_by("-submitted_at")
        return qs

    def _build_summary(self, base_qs):
        from django.db.models import Count as DjCount
        from apps.core.countries import to_country_name

        totals = {}
        for status in [
            Application.Status.SUBMITTED,
            Application.Status.UNDER_REVIEW,
            Application.Status.SHORTLISTED,
            Application.Status.REJECTED,
            Application.Status.INELIGIBLE,
        ]:
            totals[status] = base_qs.filter(status=status).count()
        totals["total"] = base_qs.count()

        gender_counts = (
            base_qs.values("scholarship_profile__gender")
            .annotate(n=DjCount("id"))
            .order_by("-n")
        )
        gender_data = [
            {"label": g["scholarship_profile__gender"] or "Unknown", "count": g["n"]}
            for g in gender_counts
        ]

        nationality_counts = (
            base_qs.values("scholarship_profile__country_of_origin")
            .annotate(n=DjCount("id"))
            .order_by("-n")[:8]
        )
        nationality_data = [
            {
                "label": to_country_name(c["scholarship_profile__country_of_origin"]) or c["scholarship_profile__country_of_origin"] or "Unknown",
                "count": c["n"],
            }
            for c in nationality_counts
        ]

        disability_yes = base_qs.filter(scholarship_profile__have_physical_disability=True).count()
        disability_no = base_qs.filter(scholarship_profile__have_physical_disability=False).count()

        return {
            "totals": totals,
            "gender_data": gender_data,
            "nationality_data": nationality_data,
            "disability_yes": disability_yes,
            "disability_no": disability_no,
        }

    def get(self, request, slug):
        qs = self._base_qs()
        summary = self._build_summary(qs)
        filtered_qs = self._apply_filters(qs, request.GET)

        # Available filter options derived from actual data
        from apps.core.countries import to_country_name
        nationalities = (
            Application.objects.filter(call=self.call)
            .exclude(scholarship_profile__country_of_origin="")
            .exclude(scholarship_profile__isnull=True)
            .values_list("scholarship_profile__country_of_origin", flat=True)
            .distinct()
            .order_by("scholarship_profile__country_of_origin")
        )
        nationality_choices = [
            (c, to_country_name(c) or c) for c in nationalities if c
        ]

        from django.contrib.auth import get_user_model
        User = get_user_model()
        all_reviewers = User.objects.filter(
            role__in=[UserRole.REVIEWER, UserRole.ADMIN, UserRole.GRANTS_MANAGER]
        ).order_by("first_name", "last_name", "email").distinct()
        pool = self.call.reviewer_pool.all()
        pool_ids = set(pool.values_list("pk", flat=True))
        available_to_add = all_reviewers.exclude(pk__in=pool_ids)

        from apps.core.money import ISO4217_CHOICES, DEFAULT_CURRENCY
        from django.db.models import Max as DjMax
        max_score = qs.aggregate(m=DjMax("ranking_score"))["m"] or 100
        scored_count = qs.exclude(ranking_score__isnull=True).count()
        return render(request, self.template_name, {
            "call": self.call,
            "applications": filtered_qs,
            "summary": summary,
            "status_choices": Application.Status.choices,
            "nationality_choices": nationality_choices,
            "active_filters": {k: v for k, v in request.GET.items() if v},
            "interview_form": InterviewScheduleForm(),
            "reviewers": pool if pool_ids else all_reviewers,
            "reviewer_pool": pool,
            "available_reviewers": available_to_add,
            "pool_is_set": pool_ids,
            "currency_choices": ISO4217_CHOICES,
            "default_currency": DEFAULT_CURRENCY,
            "max_ranking_score": float(max_score),
            "scored_count": scored_count,
            "max_awards": self.call.max_awards or 10,
            "active_sort": request.GET.get("sort", "score_desc"),
        })


class BulkShortlistView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        success = failed = 0
        for app in Application.objects.filter(call=call, pk__in=pks):
            try:
                shortlist_application(app)
                success += 1
            except (ValidationError, Exception):
                failed += 1
        if success:
            messages.success(request, f"{success} application(s) shortlisted.")
        if failed:
            messages.warning(request, f"{failed} application(s) could not be shortlisted (wrong status).")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class BulkRejectView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        success = failed = 0
        for app in Application.objects.filter(call=call, pk__in=pks):
            try:
                reject_application(app)
                success += 1
            except (ValidationError, Exception):
                failed += 1
        if success:
            messages.success(request, f"{success} application(s) rejected.")
        if failed:
            messages.warning(request, f"{failed} application(s) could not be rejected.")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class ProgramDirectorDashboardView(RimsAccessMixin, View):
    """Strategic dashboard for Program Director role."""

    template_name = "grants/director_dashboard.html"
    allowed_roles = (
        UserRole.PROGRAM_DIRECTOR,
        UserRole.ADMIN,
        UserRole.SYSTEM_ADMIN,
    )

    def get(self, request):
        from django.db.models import Count, Sum, Q
        from django.utils import timezone
        from apps.core.countries import to_country_name
        from apps.rims.grants.models import Award, AwardCancellationRequest, AwardTranche
        from apps.mel.indicators.models import Indicator, IndicatorTarget, DataPoint, LogFrame

        today = timezone.now().date()
        current_year = today.year
        period_label = f"FY{current_year}"

        # ── MEL indicator scorecards ──────────────────────────────────────
        # Build grouped indicator panels from the RUFORUM strategic log frame
        logframe = LogFrame.objects.filter(slug="ruforum-iilmp-strategic-plan-2024-2028").first()

        def _indicator_card(code):
            """Return target, actual, pct, rag for a given indicator code."""
            ind = Indicator.objects.filter(code=code).first()
            if not ind:
                return None
            target_obj = IndicatorTarget.objects.filter(
                indicator=ind, period_label=period_label
            ).first()
            dp = DataPoint.objects.filter(
                indicator=ind, period_label=period_label, source_module="rims"
            ).order_by("-reported_at").first()
            target_val = float(target_obj.target_value) if target_obj else None
            actual_val = float(dp.value) if dp else None
            if target_val and actual_val is not None:
                pct = round(actual_val / target_val * 100)
                if ind.unit == "%":
                    # For % indicators gap from target matters not ratio
                    gap = target_val - actual_val
                    rag = "green" if gap <= 2 else ("amber" if gap <= 10 else "red")
                else:
                    rag = "green" if pct >= 80 else ("amber" if pct >= 50 else "red")
            else:
                pct = 0
                rag = "amber" if actual_val is not None else "red"
            return {
                "code": code,
                "name": ind.name,
                "unit": ind.unit,
                "target": target_val,
                "actual": actual_val,
                "pct": min(pct, 150),   # cap bar at 150%
                "rag": rag,
                "indicator_url": f"/mel/indicators/{ind.code}/",
            }

        scholarship_indicators = [_indicator_card(c) for c in ["SCH-001","SCH-002","SCH-003","SCH-004"] if _indicator_card(c)]
        grant_indicators = [_indicator_card(c) for c in ["RG-001","RG-002","RG-003"] if _indicator_card(c)]
        fellowship_indicators = [_indicator_card(c) for c in ["FEL-001","FEL-002"] if _indicator_card(c)]
        crosscutting_indicators = [_indicator_card(c) for c in ["CC-001","CC-002","CC-003","CC-004"] if _indicator_card(c)]

        # Deduplicate (each _indicator_card call was being called twice above)
        scholarship_indicators = [c for c in [_indicator_card(x) for x in ["SCH-001","SCH-002","SCH-003","SCH-004"]] if c]
        grant_indicators = [c for c in [_indicator_card(x) for x in ["RG-001","RG-002","RG-003"]] if c]
        fellowship_indicators = [c for c in [_indicator_card(x) for x in ["FEL-001","FEL-002"]] if c]
        crosscutting_indicators = [c for c in [_indicator_card(x) for x in ["CC-001","CC-002","CC-003","CC-004"]] if c]

        # Multi-year trend: targets vs actuals across 2024-2026
        trend_data = []
        cc002 = Indicator.objects.filter(code="CC-002").first()
        if cc002:
            for year in [2024, 2025, 2026]:
                pl = f"FY{year}"
                t = IndicatorTarget.objects.filter(indicator=cc002, period_label=pl).first()
                dp = DataPoint.objects.filter(indicator=cc002, period_label=pl).order_by("-reported_at").first()
                trend_data.append({
                    "year": year,
                    "target": float(t.target_value) if t else None,
                    "actual": float(dp.value) if dp else (float(cc002.data_points.filter(period_label=pl).first().value) if year == current_year and dp else None),
                })

        # ── Pending director approvals ─────────────────────────────────────
        pending_cancellations = (
            AwardCancellationRequest.objects
            .filter(status__in=[
                AwardCancellationRequest.Status.REQUESTED,
                AwardCancellationRequest.Status.DIRECTOR_REVIEW,
            ])
            .select_related("award__application__call", "award__application__applicant", "requested_by")
            .order_by("-created_at")
        )
        force_closeout_candidates = (
            Award.objects.filter(status=Award.Status.READY_FOR_CLOSEOUT)
            .select_related("application__call", "application__applicant")
            .order_by("closeout_ready_at")
        )
        pending_approvals_count = pending_cancellations.count() + force_closeout_candidates.count()

        # ── Funder accountability ──────────────────────────────────────────
        apps_qs = Application.objects.exclude(
            status__in=[Application.Status.DRAFT, Application.Status.EXPIRED_DRAFT]
        )
        funding_records = (
            FundingRecord.objects.filter(status=FundingRecord.Status.APPROVED)
            .select_related("donor", "partner")
            .prefetch_related("calls")
            .order_by("-start_date")[:8]
        )
        funder_cards = []
        for fr in funding_records:
            fr_calls = fr.calls.all()
            fr_apps = apps_qs.filter(call__in=fr_calls)
            awarded_count = fr_apps.filter(status=Application.Status.AWARDED).count()
            disbursed = (
                AwardTranche.objects.filter(
                    award__application__call__in=fr_calls,
                    status=AwardTranche.Status.DISBURSED,
                ).aggregate(total=Sum("amount"))["total"] or 0
            )
            female_count = fr_apps.filter(
                status=Application.Status.AWARDED, scholarship_profile__gender="female"
            ).count()
            countries = (
                fr_apps.filter(status=Application.Status.AWARDED)
                .exclude(scholarship_profile__country_of_origin="")
                .values_list("scholarship_profile__country_of_origin", flat=True)
                .distinct().count()
            )
            days_remaining = (fr.end_date - today).days if fr.end_date else None
            funder_cards.append({
                "funder_name": (fr.donor.name if fr.donor_id else "") or (fr.partner.name if fr.partner_id else fr.title),
                "title": fr.title,
                "total_amount": fr.amount,
                "currency": fr.currency,
                "disbursed": disbursed,
                "utilisation_pct": round(float(disbursed) / float(fr.amount) * 100) if fr.amount else 0,
                "awarded_count": awarded_count,
                "female_pct": round(female_count / awarded_count * 100) if awarded_count else 0,
                "countries": countries,
                "days_remaining": days_remaining,
            })

        # ── Nationality reach among awardees ──────────────────────────────
        awarded_apps = apps_qs.filter(status=Application.Status.AWARDED)
        total_awarded = awarded_apps.count()
        nationality_dist = (
            awarded_apps.exclude(scholarship_profile__country_of_origin="")
            .exclude(scholarship_profile__isnull=True)
            .values("scholarship_profile__country_of_origin")
            .annotate(n=Count("id")).order_by("-n")[:8]
        )
        nationality_data = [
            {
                "label": to_country_name(r["scholarship_profile__country_of_origin"]) or r["scholarship_profile__country_of_origin"],
                "count": r["n"],
            }
            for r in nationality_dist
        ]

        calls_with_pipeline = (
            GrantCall.objects.filter(pk__in=apps_qs.values_list("call_id", flat=True))
            .exclude(status=GrantCall.Status.WITHDRAWN)
            .order_by("-created_at")[:8]
        )

        return render(request, self.template_name, {
            "logframe": logframe,
            "current_period": period_label,
            "calls_with_pipeline": calls_with_pipeline,
            "scholarship_indicators": scholarship_indicators,
            "grant_indicators": grant_indicators,
            "fellowship_indicators": fellowship_indicators,
            "crosscutting_indicators": crosscutting_indicators,
            "trend_data": trend_data,
            "pending_cancellations": pending_cancellations,
            "force_closeout_candidates": force_closeout_candidates,
            "pending_approvals_count": pending_approvals_count,
            "funder_cards": funder_cards,
            "total_awarded": total_awarded,
            "nationality_data": nationality_data,
        })


class ManageReviewerPoolView(GrantsManagerMixin, View):
    """Add or remove reviewers from a call's reviewer pool."""

    def post(self, request, slug):
        from django.contrib.auth import get_user_model
        call = get_object_or_404(GrantCall, slug=slug)
        action = request.POST.get("action")
        reviewer_ids = request.POST.getlist("reviewer_ids")
        User = get_user_model()
        if action == "add" and reviewer_ids:
            reviewers = User.objects.filter(pk__in=reviewer_ids)
            call.reviewer_pool.add(*reviewers)
            messages.success(request, f"{reviewers.count()} reviewer(s) added to the pool.")
        elif action == "remove":
            rid = request.POST.get("reviewer_id")
            if rid:
                user = get_object_or_404(User, pk=rid)
                call.reviewer_pool.remove(user)
                messages.success(request, f"{user.get_full_name() or user.email} removed from pool.")
        elif action == "clear":
            call.reviewer_pool.clear()
            messages.success(request, "Reviewer pool cleared — all reviewer-capable users are now eligible.")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class BulkMarkSelectedView(GrantsManagerMixin, View):
    """Mark selected shortlisted applications as approved (selected for award).

    This is a selection decision only — no applicant email is sent at this stage.
    Offer terms (tuition, stipend, allowances) are confirmed and communicated
    per awardee later when the formal offer letter is prepared.
    An internal notification is sent to the grants team.
    """

    def post(self, request, slug):
        from apps.rims.grants.services import approve_application
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        comment = request.POST.get("notes", "").strip()
        success = failed = skipped = 0
        selected_names = []
        for app in Application.objects.filter(call=call, pk__in=pks).select_related("applicant"):
            if app.status != Application.Status.SHORTLISTED:
                skipped += 1
                continue
            try:
                approve_application(app, comment=comment)
                selected_names.append(app.applicant.get_full_name() or app.applicant.email)
                success += 1
            except (ValidationError, Exception) as exc:
                failed += 1
        # Notify grants team internally
        if success:
            from django.contrib.auth import get_user_model
            from apps.core.notifications.services import send_notification
            from apps.core.notifications.models import Notification
            User = get_user_model()
            team = User.objects.filter(
                role__in=[UserRole.GRANTS_MANAGER, UserRole.ADMIN, UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR]
            ).distinct()
            body = (
                f"{success} applicant(s) have been marked as selected for '{call.title}' "
                f"by {request.user.get_full_name() or request.user.email}. "
                f"Offer letters and terms should now be prepared per awardee."
            )
            for member in team:
                try:
                    send_notification(
                        member, body,
                        verb=Notification.Verb.APPLICATION_STATUS,
                        action_url=reverse("rims_grants:applicant_pipeline", kwargs={"slug": slug}),
                    )
                except Exception:
                    pass
        if success:
            messages.success(request, f"{success} applicant(s) marked as selected. Grants team notified internally.")
        if skipped:
            messages.info(request, f"{skipped} skipped — only shortlisted applicants can be marked selected.")
        if failed:
            messages.warning(request, f"{failed} could not be updated.")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class BulkMoveToUnderReviewView(GrantsManagerMixin, View):
    """Move selected submitted applications to Under Review via eligibility screening."""

    def post(self, request, slug):
        from apps.rims.grants.services import auto_screen_eligibility
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        advanced = skipped = held = 0
        qs = Application.objects.filter(call=call, pk__in=pks)
        for app in qs:
            if app.status == Application.Status.UNDER_REVIEW:
                skipped += 1
                continue
            if app.status != Application.Status.SUBMITTED:
                skipped += 1
                continue
            try:
                auto_screen_eligibility(app)
                app.refresh_from_db()
                if app.status == Application.Status.UNDER_REVIEW:
                    advanced += 1
                else:
                    # Eligibility is advisory — nothing is auto-rejected. A still-
                    # SUBMITTED app is held for Programme Officer screening.
                    held += 1
            except Exception:
                skipped += 1
        if advanced:
            messages.success(request, f"{advanced} application(s) moved to Under Review.")
        if held:
            messages.info(request, f"{held} application(s) held for Programme Officer screening.")
        if skipped:
            messages.info(request, f"{skipped} application(s) already processed or in a non-submitted state.")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class BulkAutoAssignReviewersView(GrantsManagerMixin, View):
    """Auto-assign reviewers (workload-balanced) to selected Under Review applications."""

    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        success = failed = 0
        for app in Application.objects.filter(call=call, pk__in=pks, status=Application.Status.UNDER_REVIEW):
            try:
                auto_assign_reviewers(app, actor=request.user)
                success += 1
            except (ValidationError, Exception) as exc:
                failed += 1
        if success:
            messages.success(request, f"Reviewers auto-assigned to {success} application(s).")
        if failed:
            messages.warning(request, f"{failed} application(s) could not be assigned (no eligible reviewers or wrong status).")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class BulkManualAssignReviewerView(GrantsManagerMixin, View):
    """Assign a specific reviewer to all selected Under Review applications."""

    def post(self, request, slug):
        from django.contrib.auth import get_user_model
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        reviewer_id = request.POST.get("reviewer_id")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        if not reviewer_id:
            messages.error(request, "No reviewer selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        User = get_user_model()
        reviewer = get_object_or_404(User, pk=reviewer_id)
        success = failed = 0
        for app in Application.objects.filter(call=call, pk__in=pks, status=Application.Status.UNDER_REVIEW):
            try:
                assign_reviewer(app, reviewer, declare_conflict=False)
                success += 1
            except (ValidationError, Exception):
                failed += 1
        if success:
            messages.success(request, f"{reviewer.get_full_name() or reviewer.email} assigned to {success} application(s).")
        if failed:
            messages.warning(request, f"{failed} application(s) could not be assigned.")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class BulkScheduleInterviewView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        pks = request.POST.getlist("selected")
        if not pks:
            messages.error(request, "No applications selected.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        form = InterviewScheduleForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Invalid interview schedule details.")
            return redirect("rims_grants:applicant_pipeline", slug=slug)
        success = 0
        for app in Application.objects.filter(call=call, pk__in=pks):
            create_or_update_interview(
                app,
                scheduled_for=form.cleaned_data["scheduled_for"],
                format=form.cleaned_data.get("format", "virtual"),
                venue=form.cleaned_data.get("venue", ""),
                notes=form.cleaned_data.get("notes", ""),
                actor=request.user,
            )
            success += 1
        messages.success(request, f"Interview scheduled for {success} applicant(s).")
        return redirect("rims_grants:applicant_pipeline", slug=slug)


class GenerateShortlistView(GrantsManagerMixin, View):
    """PRD §5.1 FRFA-AM032 — generate + optionally apply an automated shortlist.

    GET renders a preview with four buckets (chosen, tied, below-threshold, unscored).
    POST with ``apply=1`` transitions the ``chosen`` bucket to SHORTLISTED.
    """

    template_name = "grants/shortlist_generate.html"

    def get(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        from apps.rims.grants.services import generate_shortlist

        result = generate_shortlist(call)
        return render(
            request,
            self.template_name,
            {
                "call": call,
                "result": result,
                "has_chosen": len(result.chosen) > 0,
            },
        )

    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        from apps.rims.grants.services import (
            apply_shortlist_result,
            generate_shortlist,
        )

        result = generate_shortlist(call)
        if request.POST.get("apply") != "1":
            return render(
                request,
                self.template_name,
                {"call": call, "result": result, "has_chosen": len(result.chosen) > 0},
            )
        applied = apply_shortlist_result(call, result, actor=request.user, request=request)
        messages.success(
            request,
            f"Shortlist applied — {applied} application(s) moved to SHORTLISTED. "
            f"{len(result.tied)} tied, {len(result.below_threshold)} below threshold.",
        )
        return redirect("rims_grants:manager_application_list", slug=call.slug)


class ShortlistApplicationView(GrantsManagerMixin, View):
    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk)
        try:
            shortlist_application(app)
            messages.success(request, "Application shortlisted.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:manager_application_detail", pk=pk)


class ProgramOfficerAccessMixin(RimsAccessMixin):
    """PRD §5.1 FRFA-AM013–017 — Program Officer screening gate.

    Uses the existing Grants Manager / Program Manager roster so deployments
    without a dedicated Program Officer role can still exercise the gate. The
    distinct `MANAGE_SCREENING` permission lets administrators later narrow
    the role-set without code changes.
    """

    allowed_roles = (
        UserRole.ADMIN,
        UserRole.SYSTEM_ADMIN,
        UserRole.GRANTS_MANAGER,
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.PROGRAMME_OFFICER,
    )
    rims_permissions = (rims_perms.MANAGE_SCREENING, rims_perms.MANAGE_CALLS)


class ProgramOfficerScreenView(ProgramOfficerAccessMixin, View):
    """POST-only endpoint for the Program Officer document-completeness decision.

    Body: `decision` (one of ``eligible_ready`` / ``rejected_incomplete`` /
    ``revision_requested``), `note` (required for the rejection / revision
    branches, ignored for eligible_ready).
    """

    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk)
        decision = (request.POST.get("decision") or "").strip()
        note = (request.POST.get("note") or "").strip()
        try:
            program_officer_advance(
                app, decision, note=note, actor=request.user, request=request
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_grants:manager_application_detail", pk=pk)
        labels = {
            ProgramOfficerDecision.ELIGIBLE_READY: "Application advanced to reviewer queue.",
            ProgramOfficerDecision.REJECTED_INCOMPLETE: "Application rejected as incomplete.",
            ProgramOfficerDecision.REVISION_REQUESTED: "Revision requested from applicant.",
        }
        messages.success(request, labels[decision])
        return redirect("rims_grants:manager_application_detail", pk=pk)


class RejectApplicationView(GrantsManagerMixin, View):
    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk)
        try:
            reject_application(app)
            messages.success(request, "Application rejected.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:manager_application_detail", pk=pk)


class AssignReviewerPostView(GrantsManagerMixin, View):
    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk)
        if app.status != Application.Status.UNDER_REVIEW:
            messages.error(
                request,
                "Reviewers can only be assigned while the application is under review and eligible.",
            )
            return redirect("rims_grants:manager_application_detail", pk=pk)
        form = AssignReviewerForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Invalid reviewer selection.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        try:
            assign_reviewer(
                app,
                form.cleaned_data["reviewer"],
                declare_conflict=form.cleaned_data.get("declare_conflict"),
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_grants:manager_application_detail", pk=pk)
        messages.success(request, "Reviewer assigned.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


class AutoAssignReviewersView(GrantsManagerMixin, View):
    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk)
        try:
            created = auto_assign_reviewers(app, actor=request.user)
            if created:
                messages.success(request, f"{len(created)} reviewer assignment(s) created.")
            else:
                messages.info(request, "No additional reviewer assignments were needed.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:manager_application_detail", pk=pk)


class ReviewConflictAckView(GrantsManagerMixin, View):
    """Manager acknowledges divergent reviewer recommendations (audit trail via updated timestamp)."""

    def post(self, request, pk):
        app = get_object_or_404(Application, pk=pk)
        app.review_conflict_acknowledged = True
        app.save(update_fields=["review_conflict_acknowledged", "updated_at"])
        messages.success(request, "Reviewer conflict noted as reviewed.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


class RequestApplicationRevisionView(GrantsManagerMixin, View):
    """Allow applicant to edit after submission when a revision was requested — unlocks the revise flow."""

    def post(self, request, pk):
        app = get_object_or_404(Application.objects.select_related("call"), pk=pk)
        if app.status not in (Application.Status.SUBMITTED, Application.Status.UNDER_REVIEW):
            messages.error(request, "Revision can only be requested before shortlist / award.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        note = (request.POST.get("revision_note") or "").strip()
        if not note:
            messages.error(request, "Enter instructions for the applicant.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        app.revision_note = note
        app.revision_requested_at = timezone.now()
        app.save(update_fields=["revision_note", "revision_requested_at", "updated_at"])
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=request.user,
            action=action_update(),
            target_model="Application",
            object_id=app.pk,
            object_repr=str(app),
            changes={"revision_requested_at": str(app.revision_requested_at)},
            request=request,
        )
        messages.success(request, "Applicant can now update their submission from My applications.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


class ApplicationReviseView(LoginRequiredMixin, UpdateView):
    """Applicant edits proposal when manager requested a revision."""

    model = Application
    form_class = ApplicationForm
    template_name = "grants/application_revise.html"
    pk_url_kwarg = "pk"

    def get_queryset(self):
        return Application.objects.filter(applicant=self.request.user).select_related("call")

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        if not obj.revision_requested_at:
            raise PermissionDenied
        if obj.status not in (Application.Status.SUBMITTED, Application.Status.UNDER_REVIEW):
            raise PermissionDenied
        return obj

    def form_valid(self, form):
        app = form.save(commit=False)
        app.revision_requested_at = None
        app.revision_note = ""
        app.save(update_fields=["institution", "proposal", "revision_requested_at", "revision_note", "updated_at"])
        messages.success(self.request, "Your updates were saved.")
        return redirect("rims_grants:application_detail", pk=app.pk)


class InterviewCompleteToggleView(GrantsManagerMixin, View):
    """PRD §6.1 — calls with interview_required cannot be awarded until this is set."""

    def post(self, request, pk):
        app = get_object_or_404(Application.objects.select_related("call"), pk=pk)
        if not app.call.interview_required:
            messages.error(request, "This call does not require interviews.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        if app.status not in (Application.Status.UNDER_REVIEW, Application.Status.SHORTLISTED):
            messages.error(request, "Interview tracking applies during review or shortlist stage.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        app.interview_completed = request.POST.get("interview_completed") == "1"
        app.save(update_fields=["interview_completed", "updated_at"])
        messages.success(
            request,
            "Interview marked complete." if app.interview_completed else "Interview completion cleared.",
        )
        return redirect("rims_grants:manager_application_detail", pk=pk)


class InterviewScheduleView(GrantsManagerMixin, View):
    def post(self, request, pk):
        app = get_object_or_404(Application.objects.select_related("call"), pk=pk)
        form = InterviewScheduleForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid interview schedule.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        create_or_update_interview(
            app,
            scheduled_for=form.cleaned_data["scheduled_for"],
            format=form.cleaned_data["format"],
            venue=form.cleaned_data.get("venue") or "",
            notes=form.cleaned_data.get("notes") or "",
            actor=request.user,
            status=form.cleaned_data["status"],
        )
        messages.success(request, "Interview schedule saved.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


class InterviewIcsView(LoginRequiredMixin, View):
    """Stream a single-event iCalendar (.ics) for an InterviewSchedule.

    Visible to the applicant who owns the application and to grants/program
    staff. The UID returned is stable across reschedules so calendar clients
    update the existing event rather than create a duplicate.
    """

    def get(self, request, pk):
        interview = get_object_or_404(
            InterviewSchedule.objects.select_related(
                "application__applicant", "application__call"
            ),
            pk=pk,
        )
        user = request.user
        is_staff_role = user.is_authenticated and user.role in {
            UserRole.SYSTEM_ADMIN,
            UserRole.ADMIN,
            UserRole.GRANTS_MANAGER,
            UserRole.PROGRAM_MANAGER,
            UserRole.PROGRAM_DIRECTOR,
            UserRole.PROGRAMME_OFFICER,
        }
        if interview.application.applicant_id != user.pk and not is_staff_role:
            raise PermissionDenied
        from apps.rims.grants.calendar_ics import build_interview_ics, ics_filename_for_interview

        payload = build_interview_ics(interview)
        response = HttpResponse(payload, content_type="text/calendar; charset=utf-8")
        response["Content-Disposition"] = (
            f'attachment; filename="{ics_filename_for_interview(interview)}"'
        )
        return response


class InterviewOutcomeView(GrantsManagerMixin, View):
    """PRD §5.1 FRFA-AM030 — record interview outcomes against a scheduled interview."""

    def post(self, request, pk):
        interview = get_object_or_404(InterviewSchedule.objects.select_related("application__call"), pk=pk)
        form = InterviewOutcomeForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid interview outcome.")
            return redirect("rims_grants:manager_application_detail", pk=interview.application_id)
        record_interview_outcome(
            interview,
            attendance=form.cleaned_data["attendance"],
            score=form.cleaned_data.get("score"),
            recommendation_code=form.cleaned_data.get("recommendation_code") or "",
            summary=form.cleaned_data.get("summary") or "",
            actor=request.user,
        )
        messages.success(request, "Interview outcome recorded.")
        referer = request.META.get("HTTP_REFERER", "")
        if "pipeline" in referer:
            return redirect(referer)
        return redirect("rims_grants:manager_application_detail", pk=interview.application_id)


class VerificationRecordCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        app = get_object_or_404(Application.objects.select_related("call"), pk=pk)
        form = VerificationRecordForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid verification record.")
            return redirect("rims_grants:manager_application_detail", pk=pk)
        record_verification(
            app,
            verification_type=form.cleaned_data["verification_type"],
            status=form.cleaned_data["status"],
            score=form.cleaned_data.get("score"),
            summary=form.cleaned_data.get("summary") or "",
            findings=form.cleaned_data.get("findings_json") or {},
            actor=request.user,
        )
        messages.success(request, "Verification result recorded.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


class AwardApplicationView(GrantsManagerMixin, FormView):
    template_name = "grants/award_form.html"
    form_class = AwardForm

    def dispatch(self, request, *args, **kwargs):
        self.application = get_object_or_404(
            Application.objects.select_related("call"),
            pk=kwargs["pk"],
        )
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        from decimal import Decimal as _Decimal

        from apps.rims.grants.services import check_award_preconditions

        ctx = super().get_context_data(**kwargs)
        ctx["application"] = self.application
        # PRD §5.1 FRFA-AA001 — surface precondition status on the form so the
        # awarding manager sees blockers before submitting.
        form = ctx.get("form")
        bound_amount = None
        if form and form.is_bound:
            try:
                bound_amount = _Decimal(str(form.data.get("amount") or "0") or "0")
            except Exception:
                bound_amount = _Decimal("0")
        probe_amount = bound_amount if bound_amount is not None else _Decimal("0")
        precond = check_award_preconditions(self.application, probe_amount)
        ctx["award_preconditions"] = precond
        ctx["award_preconditions_blocked"] = any(not item["ok"] for item in precond.values())
        return ctx

    def form_valid(self, form):
        try:
            award_application(
                self.application,
                form.cleaned_data["amount"],
                form.cleaned_data["project_end_date"],
                narrative=form.cleaned_data.get("narrative") or "",
                currency=form.cleaned_data["currency"],
                actor=self.request.user,
                request=self.request,
            )
            messages.success(self.request, "Award created.")
        except ValidationError as exc:
            messages.error(self.request, " ".join(getattr(exc, "messages", [str(exc)])))
            return self.form_invalid(form)
        return redirect("rims_grants:award_dashboard")


class AwardDashboardView(GrantsManagerMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Award
    template_name = "grants/award_dashboard.html"
    context_object_name = "awards"
    paginate_by = 30

    sortable_fields = {
        "awarded": "awarded_at",
        "status": "status",
        "call": "application__call__title",
    }
    default_sort = "-awarded_at"

    csv_columns = (
        ("Award ID", "pk"),
        ("Call", lambda a: a.application.call.title if a.application_id else ""),
        ("Applicant", lambda a: a.application.applicant.email if a.application_id else ""),
        ("Status", "get_status_display"),
        ("Awarded at", lambda a: a.awarded_at.isoformat() if a.awarded_at else ""),
    )
    csv_filename = "awards"

    def get_queryset(self):
        qs = Award.objects.select_related("application__call", "application__applicant").prefetch_related(
            "agreements", "tranches"
        )
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        from decimal import Decimal

        from django.db.models import Sum

        ctx = super().get_context_data(**kwargs)
        base = Award.objects.all()
        active = base.filter(status=Award.Status.ACTIVE)
        ready = base.filter(
            status__in=[
                Award.Status.READY_FOR_CLOSEOUT,
                Award.Status.CLOSEOUT_IN_PROGRESS,
            ]
        )
        closed = base.filter(status=Award.Status.CLOSED)
        disbursed_total = (
            AwardTranche.objects.filter(status=AwardTranche.Status.DISBURSED)
            .aggregate(total=Sum("amount"))
            .get("total")
        ) or Decimal("0")
        ctx["dashboard_stats"] = {
            "active": active.count(),
            "ready_for_closeout": ready.count(),
            "closed": closed.count(),
            "disbursed_total": disbursed_total,
        }
        return ctx


class AwardAgreementCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = AwardAgreementForm(request.POST, request.FILES)
        if not form.is_valid():
            messages.error(request, "Provide a valid agreement record.")
            return redirect("rims_grants:award_detail", pk=pk)
        agreement = form.save(commit=False)
        agreement.award = award
        agreement.uploaded_by = request.user
        agreement.save()
        if agreement.accepted:
            from apps.rims.grants.services import accept_award_agreement

            accept_award_agreement(award, agreement, actor=request.user)
        messages.success(request, "Award agreement saved.")
        return redirect("rims_grants:award_detail", pk=pk)


class AwardTrancheCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = AwardTrancheForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid tranche record.")
            return redirect("rims_grants:award_detail", pk=pk)
        tranche = form.save(commit=False)
        tranche.award = award
        tranche.save()
        messages.success(request, "Award tranche saved.")
        return redirect("rims_grants:award_detail", pk=pk)


class PeriodicNarrativeReportCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = PeriodicNarrativeReportForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid periodic report.")
            return redirect("rims_grants:award_detail", pk=pk)
        report = form.save(commit=False)
        report.award = award
        report.save()
        form.save_m2m()
        messages.success(request, "Periodic narrative report saved as draft.")
        return redirect("rims_grants:award_detail", pk=pk)


class PeriodicNarrativeReportSubmitView(GrantsManagerMixin, View):
    def post(self, request, pk, report_pk):
        award = get_object_or_404(Award, pk=pk)
        report = get_object_or_404(PeriodicNarrativeReport, pk=report_pk, award=award)
        from django_fsm import TransitionNotAllowed

        try:
            report.submit()
            report.submitted_by = request.user
            report.save()
            messages.success(request, "Periodic narrative report submitted.")
        except TransitionNotAllowed:
            messages.error(request, "Report cannot be submitted from its current state.")
        return redirect("rims_grants:award_detail", pk=pk)


class PeriodicNarrativeReportDecisionView(GrantsManagerMixin, View):
    """Expected POST params: ``action`` = "accept" or "revise", optional ``comment``."""

    def post(self, request, pk, report_pk):
        award = get_object_or_404(Award, pk=pk)
        report = get_object_or_404(PeriodicNarrativeReport, pk=report_pk, award=award)
        action = (request.POST.get("action") or "").strip()
        comment = (request.POST.get("comment") or "").strip()
        from django_fsm import TransitionNotAllowed

        try:
            if action == "accept":
                report.accept()
                report.accepted_by = request.user
                report.review_comment = comment
                report.save()
                messages.success(request, "Periodic narrative report accepted.")
            elif action == "revise":
                report.request_revision()
                report.review_comment = comment
                report.save()
                messages.success(request, "Revision requested from the PI.")
            else:
                messages.error(request, "Unknown decision action.")
        except TransitionNotAllowed:
            messages.error(request, "Decision cannot be recorded from the report's current state.")
        return redirect("rims_grants:award_detail", pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# SRS §4.1.7 "Implementation and Progress Monitoring" — Implementation Plan,
# Results Framework generation, activity progress, and Monitoring Visits.
# Gated to grants staff for now (GrantsManagerMixin); a future iteration
# could open plan/activity editing to the awardee directly.
# ─────────────────────────────────────────────────────────────────────────────

class ImplementationPlanCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        if hasattr(award, "implementation_plan"):
            messages.error(request, "This award already has an implementation plan.")
            return redirect("rims_grants:award_detail", pk=pk)
        form = ImplementationPlanForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid implementation plan.")
            return redirect("rims_grants:award_detail", pk=pk)
        plan = form.save(commit=False)
        plan.award = award
        plan.save()
        messages.success(request, "Implementation plan created as a draft.")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanSubmitView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        plan = get_object_or_404(ImplementationPlan, award=award)
        try:
            plan.submit()
            plan.submitted_by = request.user
            plan.save()
            messages.success(request, "Implementation plan submitted for approval.")
        except TransitionNotAllowed:
            messages.error(request, "Plan cannot be submitted from its current state.")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanDecisionView(GrantsManagerMixin, View):
    """Expected POST params: ``action`` = "approve" or "revise", optional ``comment``."""

    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        plan = get_object_or_404(ImplementationPlan, award=award)
        action = (request.POST.get("action") or "").strip()
        try:
            if action == "approve":
                approve_implementation_plan(plan, actor=request.user)
                messages.success(request, "Implementation plan approved and Results Framework generated.")
            elif action == "revise":
                plan.request_revision()
                plan.revision_comment = request.POST.get("comment", "")
                plan.save()
                messages.success(request, "Revision requested from the awardee.")
            else:
                messages.error(request, "Unknown decision action.")
        except (TransitionNotAllowed, ValidationError) as exc:
            messages.error(request, f"Could not record decision: {exc}")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanOutcomeCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        plan = get_object_or_404(ImplementationPlan, award=award)
        form = ImplementationPlanOutcomeForm(request.POST)
        if form.is_valid():
            outcome = form.save(commit=False)
            outcome.plan = plan
            outcome.save()
            messages.success(request, "Outcome added.")
        else:
            messages.error(request, "Provide a valid outcome.")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanOutputCreateView(GrantsManagerMixin, View):
    def post(self, request, pk, outcome_pk):
        award = get_object_or_404(Award, pk=pk)
        outcome = get_object_or_404(ImplementationPlanOutcome, pk=outcome_pk, plan__award=award)
        form = ImplementationPlanOutputForm(request.POST)
        if form.is_valid():
            output = form.save(commit=False)
            output.outcome = outcome
            output.save()
            messages.success(request, "Output added.")
        else:
            messages.error(request, "Provide a valid output.")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanIndicatorCreateView(GrantsManagerMixin, View):
    """Expected POST params: one of ``outcome_pk`` or ``output_pk``."""

    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = ImplementationPlanIndicatorForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid indicator.")
            return redirect("rims_grants:award_detail", pk=pk)
        indicator = ImplementationPlanIndicator(**form.cleaned_data)
        outcome_pk = request.POST.get("outcome_pk")
        output_pk = request.POST.get("output_pk")
        if outcome_pk:
            indicator.outcome = get_object_or_404(ImplementationPlanOutcome, pk=outcome_pk, plan__award=award)
        elif output_pk:
            indicator.output = get_object_or_404(ImplementationPlanOutput, pk=output_pk, outcome__plan__award=award)
        else:
            messages.error(request, "An indicator must attach to an outcome or an output.")
            return redirect("rims_grants:award_detail", pk=pk)
        try:
            indicator.full_clean()
        except ValidationError as exc:
            messages.error(request, " ".join(exc.messages))
            return redirect("rims_grants:award_detail", pk=pk)
        indicator.save()
        messages.success(request, "Indicator added.")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanActivityCreateView(GrantsManagerMixin, View):
    def post(self, request, pk, output_pk):
        award = get_object_or_404(Award, pk=pk)
        output = get_object_or_404(ImplementationPlanOutput, pk=output_pk, outcome__plan__award=award)
        form = ImplementationPlanActivityForm(request.POST)
        if form.is_valid():
            activity = form.save(commit=False)
            activity.output = output
            activity.save()
            messages.success(request, "Activity added.")
        else:
            messages.error(request, "Provide a valid activity.")
        return redirect("rims_grants:award_detail", pk=pk)


class ImplementationPlanActivityProgressView(GrantsManagerMixin, View):
    def post(self, request, pk, activity_pk):
        award = get_object_or_404(Award, pk=pk)
        activity = get_object_or_404(ImplementationPlanActivity, pk=activity_pk, output__outcome__plan__award=award)
        try:
            percent = int(request.POST.get("percent_complete", activity.percent_complete))
        except (TypeError, ValueError):
            messages.error(request, "Provide a valid percent-complete value.")
            return redirect("rims_grants:award_detail", pk=pk)
        status = request.POST.get("status") or None
        record_activity_progress(activity, percent_complete=percent, status=status, actor=request.user)
        messages.success(request, "Activity progress recorded.")
        return redirect("rims_grants:award_detail", pk=pk)


class MonitoringVisitCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = MonitoringVisitForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid monitoring visit.")
            return redirect("rims_grants:award_detail", pk=pk)
        visit = form.save(commit=False)
        visit.award = award
        visit.initiated_by = request.user
        visit.save()
        messages.success(request, "Monitoring visit scheduled.")
        return redirect("rims_grants:award_detail", pk=pk)


class MonitoringVisitFindingsView(GrantsManagerMixin, View):
    def post(self, request, pk, visit_pk):
        award = get_object_or_404(Award, pk=pk)
        visit = get_object_or_404(MonitoringVisit, pk=visit_pk, award=award)
        form = MonitoringVisitFindingsForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide valid findings.")
            return redirect("rims_grants:award_detail", pk=pk)
        try:
            record_monitoring_visit_findings(visit, **{k: v for k, v in form.cleaned_data.items() if v not in (None, "")})
            messages.success(request, "Monitoring visit findings recorded.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_detail", pk=pk)


class MonitoringVisitLockView(GrantsManagerMixin, View):
    def post(self, request, pk, visit_pk):
        award = get_object_or_404(Award, pk=pk)
        visit = get_object_or_404(MonitoringVisit, pk=visit_pk, award=award)
        lock_monitoring_visit(visit, actor=request.user)
        messages.success(request, "Monitoring visit locked.")
        return redirect("rims_grants:award_detail", pk=pk)


class FinalReportDecisionView(GrantsManagerMixin, View):
    """PRD §5.1 FRFA-CO008–010 — Grants Manager / Finance Officer decisions on
    final narrative + financial reports.

    Expected POST params:
    - ``kind``: "narrative" or "financial"
    - ``action``: "accept", "revise", or (financial only) "queries"
    - ``comment`` / ``queries``: required when requesting revision / queries
    """

    def post(self, request, pk):
        kind = (request.POST.get("kind") or "").strip()
        action = (request.POST.get("action") or "").strip()
        comment = (request.POST.get("comment") or "").strip()
        from apps.rims.grants.closeout_helpers import (
            accept_financial_report,
            accept_narrative_report,
            raise_financial_queries,
            request_financial_revision,
            request_narrative_revision,
        )

        try:
            if kind == "narrative":
                report = get_object_or_404(FinalNarrativeReport, pk=pk)
                if action == "accept":
                    accept_narrative_report(report, actor=request.user, comment=comment)
                    messages.success(request, "Narrative report accepted.")
                elif action == "revise":
                    request_narrative_revision(report, comment=comment, actor=request.user)
                    messages.success(request, "Revision requested from awardee.")
                else:
                    messages.error(request, "Unknown narrative action.")
                award_id = report.closeout.award_id
            elif kind == "financial":
                report = get_object_or_404(FinalFinancialReport, pk=pk)
                if action == "accept":
                    accept_financial_report(report, actor=request.user)
                    messages.success(request, "Financial report accepted.")
                elif action == "queries":
                    raise_financial_queries(report, queries=comment, actor=request.user)
                    messages.success(request, "Queries raised on the financial report.")
                elif action == "revise":
                    request_financial_revision(report, comment=comment, actor=request.user)
                    messages.success(request, "Revision requested from awardee.")
                else:
                    messages.error(request, "Unknown financial action.")
                award_id = report.closeout.award_id
            else:
                messages.error(request, "Unknown report kind.")
                return redirect("rims_grants:award_dashboard")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect(request.META.get("HTTP_REFERER", "rims_grants:award_dashboard"))
        return redirect("rims_grants:award_closeout", pk=award_id)


class FinalNarrativeResubmitView(LoginRequiredMixin, View):
    """PRD §5.1 FRFA-CO006 — awardee resubmits a final narrative report.

    Object-level permission: only the application's applicant may POST. We
    avoid a new UserRole gate (per the facilitator-is-relationship rule) and
    check ``report.closeout.award.application.applicant_id == request.user.id``.
    """

    def post(self, request, pk):
        from apps.rims.grants.closeout_helpers import resubmit_narrative_report

        report = get_object_or_404(FinalNarrativeReport, pk=pk)
        if report.closeout.award.application.applicant_id != request.user.id:
            return HttpResponseForbidden("Not your report.")
        try:
            resubmit_narrative_report(
                report,
                response_note=request.POST.get("response_note", ""),
                new_file=request.FILES.get("report"),
                actor=request.user,
            )
            messages.success(request, "Final narrative report resubmitted.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect(
            "rims_grants:application_detail",
            pk=report.closeout.award.application_id,
        )


class FinalFinancialResubmitView(LoginRequiredMixin, View):
    """PRD §5.1 FRFA-CO006 — awardee resubmits a final financial report."""

    def post(self, request, pk):
        from apps.rims.grants.closeout_helpers import resubmit_financial_report

        report = get_object_or_404(FinalFinancialReport, pk=pk)
        if report.closeout.award.application.applicant_id != request.user.id:
            return HttpResponseForbidden("Not your report.")
        try:
            resubmit_financial_report(
                report,
                response_note=request.POST.get("response_note", ""),
                new_file=request.FILES.get("report"),
                actor=request.user,
            )
            messages.success(request, "Final financial report resubmitted.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect(
            "rims_grants:application_detail",
            pk=report.closeout.award.application_id,
        )


class AwardAmendmentDecisionView(GrantsManagerMixin, View):
    """PRD §5.1 FRFA-AA022 — approve or reject a pending amendment request.

    Expects ``decision`` ∈ {approve, reject} in POST. Reject requires ``reason``.
    """

    def post(self, request, pk):
        amendment = get_object_or_404(AwardAmendment, pk=pk)
        from apps.rims.grants.services import (
            approve_award_amendment,
            reject_award_amendment,
        )

        decision = (request.POST.get("decision") or "").strip()
        try:
            if decision == "approve":
                approve_award_amendment(amendment, actor=request.user, request=request)
                messages.success(request, "Amendment approved and terms applied.")
            elif decision == "reject":
                reject_award_amendment(
                    amendment,
                    reason=(request.POST.get("reason") or "").strip(),
                    actor=request.user,
                    request=request,
                )
                messages.success(request, "Amendment rejected.")
            else:
                messages.error(request, "Unknown amendment decision.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_detail", pk=amendment.award_id)


class AwardAmendmentCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = AwardAmendmentForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid amendment request.")
            return redirect("rims_grants:award_detail", pk=pk)
        create_award_amendment(
            award,
            change_summary=form.cleaned_data["change_summary"],
            justification=form.cleaned_data.get("justification") or "",
            previous_terms=form.cleaned_data.get("previous_terms_json") or {},
            new_terms=form.cleaned_data.get("new_terms_json") or {},
            actor=request.user,
        )
        messages.success(request, "Amendment request recorded.")
        return redirect("rims_grants:award_detail", pk=pk)


class AwardAmendmentResubmitView(GrantsManagerMixin, View):
    """PRD §5.1 FRFA-AA022 — revise + resubmit a rejected amendment (P0).

    Reuses :class:`AwardAmendmentForm` so the requester can edit
    ``change_summary`` / ``justification`` / ``new_terms`` before the FSM
    transitions the row back to ``REQUESTED``.
    """

    def post(self, request, pk):
        amendment = get_object_or_404(AwardAmendment, pk=pk)
        from apps.rims.grants.services import resubmit_award_amendment

        form = AwardAmendmentForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid amendment request before resubmitting.")
            return redirect("rims_grants:award_detail", pk=amendment.award_id)
        try:
            resubmit_award_amendment(
                amendment,
                actor=request.user,
                change_summary=form.cleaned_data.get("change_summary"),
                justification=form.cleaned_data.get("justification") or "",
                new_terms=form.cleaned_data.get("new_terms_json") or {},
                request=request,
            )
            messages.success(request, "Amendment revisions submitted for re-review.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_detail", pk=amendment.award_id)


class AwardCancellationCreateView(GrantsManagerMixin, View):
    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        form = AwardCancellationForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Provide a valid cancellation request.")
            return redirect("rims_grants:award_detail", pk=pk)
        create_award_cancellation(award, reason=form.cleaned_data["reason"], actor=request.user)
        messages.success(request, "Cancellation request recorded.")
        return redirect("rims_grants:award_detail", pk=pk)


class ProgramDirectorMixin(RimsAccessMixin):
    """PRD §5.1 FRFA-AA017 — cancellation approvals are Programme Director-only."""

    allowed_roles = (UserRole.ADMIN, UserRole.PROGRAM_DIRECTOR)
    rims_permissions = (rims_perms.MANAGE_CALLS,)


class AwardCancellationDetailView(RimsAccessMixin, DetailView):
    """PRD §5.1 FRFA-AA016/AA017 — cancellation detail + cascade preview."""

    allowed_roles = (
        UserRole.ADMIN,
        UserRole.GRANTS_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.AUDITOR,
    )
    rims_permissions = (rims_perms.MANAGE_CALLS,)
    model = AwardCancellationRequest
    template_name = "grants/award_cancellation_detail.html"
    context_object_name = "cancellation"

    def get_context_data(self, **kwargs):
        from decimal import Decimal as _Decimal

        ctx = super().get_context_data(**kwargs)
        cancellation = self.object
        award = cancellation.award
        tranches = AwardTranche.objects.filter(award=award).select_related("milestone")
        undisbursed = [
            t for t in tranches if t.status not in (AwardTranche.Status.DISBURSED, AwardTranche.Status.CANCELLED)
        ]
        disbursed = [t for t in tranches if t.status == AwardTranche.Status.DISBURSED]
        ctx["award"] = award
        ctx["tranches_undisbursed"] = undisbursed
        ctx["tranches_disbursed"] = disbursed
        ctx["preview_undisbursed_total"] = sum(
            (t.amount for t in undisbursed), _Decimal("0")
        )
        ctx["preview_recovery_total"] = sum(
            (t.amount for t in disbursed), _Decimal("0")
        )
        ctx["recovery_requests"] = list(cancellation.recovery_requests.all())
        ctx["can_submit"] = (
            cancellation.status == AwardCancellationRequest.Status.REQUESTED
            and self.request.user.role in (UserRole.GRANTS_MANAGER, UserRole.ADMIN)
        )
        ctx["can_decide"] = (
            cancellation.status == AwardCancellationRequest.Status.DIRECTOR_REVIEW
            and self.request.user.role in (UserRole.PROGRAM_DIRECTOR, UserRole.ADMIN)
        )
        ctx["can_withdraw"] = (
            cancellation.status == AwardCancellationRequest.Status.REQUESTED
            and self.request.user.role in (UserRole.GRANTS_MANAGER, UserRole.ADMIN)
        )
        return ctx


class AwardCancellationSubmitForReviewView(GrantsManagerMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.services import submit_cancellation_for_review

        cancellation = get_object_or_404(AwardCancellationRequest, pk=pk)
        try:
            submit_cancellation_for_review(cancellation, actor=request.user)
            messages.success(request, "Cancellation sent to the Programme Director for review.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_cancellation_detail", pk=pk)


class AwardCancellationWithdrawView(GrantsManagerMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.services import withdraw_cancellation

        cancellation = get_object_or_404(AwardCancellationRequest, pk=pk)
        try:
            withdraw_cancellation(cancellation, actor=request.user)
            messages.success(request, "Cancellation withdrawn.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_detail", pk=cancellation.award_id)


class AwardCancellationApproveView(ProgramDirectorMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.services import approve_cancellation_and_cascade

        cancellation = get_object_or_404(AwardCancellationRequest, pk=pk)
        decision_note = (request.POST.get("decision_note") or "").strip()
        try:
            approve_cancellation_and_cascade(
                cancellation, decision_note=decision_note, actor=request.user
            )
            messages.success(request, "Cancellation approved and cascade applied.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_cancellation_detail", pk=pk)


class AwardCancellationRejectView(ProgramDirectorMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.services import reject_cancellation

        cancellation = get_object_or_404(AwardCancellationRequest, pk=pk)
        decision_note = (request.POST.get("decision_note") or "").strip()
        try:
            reject_cancellation(
                cancellation, decision_note=decision_note, actor=request.user
            )
            messages.success(request, "Cancellation rejected.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_cancellation_detail", pk=pk)


class CohortAddendumCreateView(GrantsManagerMixin, FormView):
    """PRD §5.1 FRFA-AA023 — Grants Manager raises a new cohort addendum."""

    template_name = "grants/cohort_addendum_form.html"

    def get_form_class(self):
        from apps.rims.grants.forms import CohortAddendumForm

        return CohortAddendumForm

    def dispatch(self, request, *args, **kwargs):
        self.award = get_object_or_404(
            Award.objects.select_related("application__call"), pk=kwargs["pk"]
        )
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        from decimal import Decimal as _Decimal

        from apps.rims.grants.funding import uncommitted_balance

        ctx = super().get_context_data(**kwargs)
        ctx["award"] = self.award
        funding = getattr(self.award.application.call, "funding_record", None)
        ctx["funding_uncommitted"] = (
            uncommitted_balance(funding) if funding else _Decimal("0")
        )
        ctx["cohorts"] = self.award.cohorts.order_by("cohort_number")
        return ctx

    def form_valid(self, form):
        from apps.rims.grants.services import request_cohort_addendum

        try:
            addendum = request_cohort_addendum(
                self.award,
                reason=form.cleaned_data["reason"],
                new_cohort_terms={
                    "label": form.cleaned_data["label"],
                    "target_participants": form.cleaned_data["target_participants"],
                    "budget_allocation": str(form.cleaned_data["budget_allocation"]),
                    "start_date": form.cleaned_data["start_date"].isoformat()
                    if form.cleaned_data.get("start_date")
                    else None,
                    "end_date": form.cleaned_data["end_date"].isoformat()
                    if form.cleaned_data.get("end_date")
                    else None,
                },
                actor=self.request.user,
            )
            messages.success(self.request, "Cohort addendum raised.")
            return redirect("rims_grants:cohort_addendum_detail", pk=addendum.pk)
        except ValidationError as exc:
            messages.error(
                self.request, " ".join(getattr(exc, "messages", [str(exc)]))
            )
            return self.form_invalid(form)


class CohortAddendumDetailView(RimsAccessMixin, DetailView):
    """PRD §5.1 FRFA-AA024 — addendum detail with decision panel."""

    allowed_roles = (
        UserRole.ADMIN,
        UserRole.GRANTS_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.AUDITOR,
    )
    rims_permissions = (rims_perms.MANAGE_CALLS,)
    template_name = "grants/cohort_addendum_detail.html"
    context_object_name = "addendum"

    def get_queryset(self):
        from apps.rims.grants.models import CohortAddendum

        return CohortAddendum.objects.select_related(
            "award__application__call", "created_by", "decided_by"
        )

    def get_context_data(self, **kwargs):
        from apps.rims.grants.models import CohortAddendum

        ctx = super().get_context_data(**kwargs)
        addendum = self.object
        ctx["award"] = addendum.award
        ctx["can_submit"] = (
            addendum.status == CohortAddendum.Status.REQUESTED
            and self.request.user.role in (UserRole.GRANTS_MANAGER, UserRole.ADMIN)
        )
        ctx["can_withdraw"] = ctx["can_submit"]
        ctx["can_decide"] = (
            addendum.status == CohortAddendum.Status.DIRECTOR_REVIEW
            and self.request.user.role in (UserRole.PROGRAM_DIRECTOR, UserRole.ADMIN)
        )
        return ctx


class CohortAddendumSubmitView(GrantsManagerMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.models import CohortAddendum
        from apps.rims.grants.services import submit_cohort_addendum_for_review

        addendum = get_object_or_404(CohortAddendum, pk=pk)
        try:
            submit_cohort_addendum_for_review(addendum, actor=request.user)
            messages.success(request, "Addendum sent to the Programme Director.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:cohort_addendum_detail", pk=pk)


class CohortAddendumWithdrawView(GrantsManagerMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.models import CohortAddendum
        from apps.rims.grants.services import withdraw_cohort_addendum

        addendum = get_object_or_404(CohortAddendum, pk=pk)
        try:
            withdraw_cohort_addendum(addendum, actor=request.user)
            messages.success(request, "Addendum withdrawn.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:award_detail", pk=addendum.award_id)


class CohortAddendumApproveView(ProgramDirectorMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.models import CohortAddendum
        from apps.rims.grants.services import approve_cohort_addendum

        addendum = get_object_or_404(CohortAddendum, pk=pk)
        notes = (request.POST.get("decision_notes") or "").strip()
        try:
            approve_cohort_addendum(addendum, decision_notes=notes, actor=request.user)
            messages.success(request, "Cohort addendum approved.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:cohort_addendum_detail", pk=pk)


class AwardTrancheReconciliationView(GrantsManagerMixin, DetailView):
    """PRD §5.1 FRFA-AA012 — tranche-to-budget reconciliation report."""

    model = Award
    template_name = "grants/award_tranche_reconciliation.html"
    context_object_name = "award"

    def get_context_data(self, **kwargs):
        from apps.rims.grants.services import reconcile_award_tranches

        ctx = super().get_context_data(**kwargs)
        ctx["reconciliation"] = reconcile_award_tranches(self.object)
        return ctx


class CohortAddendumRejectView(ProgramDirectorMixin, View):
    def post(self, request, pk):
        from apps.rims.grants.models import CohortAddendum
        from apps.rims.grants.services import reject_cohort_addendum

        addendum = get_object_or_404(CohortAddendum, pk=pk)
        notes = (request.POST.get("decision_notes") or "").strip()
        try:
            reject_cohort_addendum(addendum, decision_notes=notes, actor=request.user)
            messages.success(request, "Cohort addendum rejected.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:cohort_addendum_detail", pk=pk)


class AwardCloseoutView(GrantsManagerMixin, FormView):
    template_name = "grants/award_closeout.html"
    form_class = AwardCloseoutForm

    def dispatch(self, request, *args, **kwargs):
        self.award = get_object_or_404(
            Award.objects.select_related("application__call"),
            pk=kwargs["pk"],
        )
        self.closeout = get_or_create_closeout_record(self.award)
        return super().dispatch(request, *args, **kwargs)

    def get_initial(self):
        return {"narrative": self.award.narrative}

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["award"] = self.award
        ctx["closeout"] = self.closeout
        ctx["financial_preview"] = award_financial_snapshot(self.award)
        snap = self.closeout.financial_snapshot or {}
        ctx["overspend_flag"] = False
        if snap:
            try:
                from decimal import Decimal as D

                disb = D(snap.get("total_disbursed", "0"))
                ceil = D(snap.get("total_budget_ceiling", "0"))
                amt = D(snap.get("award_amount", "0"))
                ctx["overspend_flag"] = disb > amt or disb > ceil
            except Exception:
                ctx["overspend_flag"] = False
        # PRD §5.1 FRFA-CO005–010 — surface final narrative / financial
        # reports so the Grants Manager and Finance Officer can act on them.
        ctx["narrative_reports"] = list(
            self.closeout.narrative_reports.order_by("-submitted_at")
        )
        ctx["financial_reports"] = list(
            self.closeout.financial_reports.order_by("-submitted_at")
        )
        # PRD §5.1 FRFA-CO001 — pre-flight precondition catalogue for the GM.
        gates = closeout_preconditions(self.award)
        ctx["preconditions"] = gates
        ctx["preconditions_blocking"] = [g for g in gates if not g.passed]
        return ctx

    def form_valid(self, form):
        narrative = (form.cleaned_data.get("narrative") or "").strip()

        checklist = list(self.closeout.checklist or [])
        if not checklist:
            checklist = get_or_create_closeout_record(self.award).checklist

        for i, row in enumerate(checklist):
            key = row.get("key", "")
            done = self.request.POST.get(f"co_done__{key}") == "on"
            note = (self.request.POST.get(f"co_note__{key}") or "").strip()
            row["done"] = done
            row["evidence_note"] = note
            checklist[i] = row

        # PRD §5.1 FRFA-CO001 — evaluate the precondition catalogue against
        # the in-memory state the GM just posted (narrative + checklist) so
        # the same gates the UI surfaced are the ones enforced at submit.
        # Persist the checklist first because closeout_preconditions() re-reads
        # the closeout via get_or_create_closeout_record, which trusts the DB.
        self.closeout.checklist = checklist
        self.closeout.save(update_fields=["checklist", "updated_at"])
        self.award.narrative = narrative
        snapshot = award_financial_snapshot(self.award)

        blocking = [g for g in closeout_preconditions(self.award) if not g.passed]
        if blocking:
            self.closeout.financial_snapshot = snapshot
            self.closeout.save(update_fields=["financial_snapshot", "updated_at"])
            messages.error(
                self.request,
                "Close-out blocked: " + " ".join(g.blocker_message for g in blocking if g.blocker_message),
            )
            return self.form_invalid(form)

        # SRS §5.1 FRFA-CO005–010 — Close-out guard. Must be all-or-nothing:
        # 1. Persist reports + checklist + snapshot inside an atomic block.
        # 2. Verify every report is ACCEPTED and no asset is UNACCOUNTED.
        # 3. Only then walk ACTIVE/READY_FOR_CLOSEOUT → CLOSEOUT_IN_PROGRESS
        #    → CLOSED as two real saves so observers see the intermediate.
        # If any step raises, the transaction rolls back and the award stays
        # in its prior state — no half-closed dead-end (P0 assessment §3 #1).
        previous_status = self.award.status
        try:
            with transaction.atomic():
                self.closeout.checklist = checklist
                self.closeout.financial_snapshot = snapshot
                self.closeout.save(
                    update_fields=["checklist", "financial_snapshot", "updated_at"]
                )

                FinalNarrativeReport.objects.create(
                    closeout=self.closeout,
                    notes=narrative,
                    status=FinalNarrativeReport.Status.ACCEPTED,
                    submitted_by=self.request.user,
                    reviewed_by=self.request.user,
                    reviewed_at=timezone.now(),
                )
                FinalFinancialReport.objects.create(
                    closeout=self.closeout,
                    notes=(
                        f"Total budget ceilings: {snapshot.get('total_budget_ceiling')} | "
                        f"Total disbursed: {snapshot.get('total_disbursed')} | "
                        f"Pending approval: {snapshot.get('pending_approval')} | "
                        f"Awaiting execution: {snapshot.get('awaiting_execution')} | "
                        f"Awaiting reconciliation: {snapshot.get('awaiting_reconciliation')}"
                    ),
                    status=FinalFinancialReport.Status.ACCEPTED,
                    submitted_by=self.request.user,
                    reviewed_by=self.request.user,
                    reviewed_at=timezone.now(),
                )

                non_accepted_narrative = self.closeout.narrative_reports.exclude(
                    status=FinalNarrativeReport.Status.ACCEPTED
                ).count()
                non_accepted_financial = self.closeout.financial_reports.exclude(
                    status=FinalFinancialReport.Status.ACCEPTED
                ).count()
                if non_accepted_narrative or non_accepted_financial:
                    raise ValidationError(
                        "Close-out blocked: every final narrative and financial report "
                        "must be ACCEPTED before the award can close. Resolve the "
                        f"{non_accepted_narrative} narrative and "
                        f"{non_accepted_financial} financial report(s) first."
                    )

                unaccounted_assets = self.closeout.asset_verifications.filter(
                    status=AssetVerification.Status.UNACCOUNTED
                ).count()
                if unaccounted_assets:
                    raise ValidationError(
                        f"Close-out blocked: {unaccounted_assets} asset(s) still marked "
                        "UNACCOUNTED. Resolve asset verification before closing."
                    )

                self.award.narrative = narrative
                if self.award.status in [
                    Award.Status.ACTIVE,
                    Award.Status.READY_FOR_CLOSEOUT,
                ]:
                    self.award.status = Award.Status.CLOSEOUT_IN_PROGRESS
                    self.award.closeout_ready_at = (
                        self.award.closeout_ready_at or timezone.now()
                    )
                    self.award.save(
                        update_fields=[
                            "narrative",
                            "status",
                            "closeout_ready_at",
                        ]
                    )
                self.award.status = Award.Status.CLOSED
                self.award.closed_at = timezone.now()
                self.award.save(
                    update_fields=["narrative", "status", "closed_at"]
                )

                # PRD §5.1 FRFA-CO012/013 — record the residual back to parent
                # funding as an explicit event so it's visible in audit + donor
                # close-out report. No-op when fully disbursed.
                record_closeout_residual(self.award, actor=self.request.user)

                # PRD §5.1 FRFA-CO019 — queue donor close-out report dispatch.
                fr_id = self.award.application.call.funding_record_id
                if fr_id:
                    from apps.rims.finance.tasks import (
                        dispatch_donor_closeout_report,
                    )

                    transaction.on_commit(
                        lambda: dispatch_donor_closeout_report.delay(fr_id)
                    )
        except ValidationError as exc:
            messages.error(self.request, " ".join(getattr(exc, "messages", [str(exc)])))
            self.closeout.refresh_from_db()
            return self.form_invalid(form)

        if self.award.application.call.funding_record_id:
            funding_record = self.award.application.call.funding_record
            child_awards = Award.objects.filter(application__call__funding_record=funding_record)
            if child_awards.exists() and not child_awards.exclude(status=Award.Status.CLOSED).exists():
                funding_closeout, _ = FundingRecordCloseout.objects.get_or_create(funding_record=funding_record)
                funding_closeout.status = FundingRecordCloseout.Status.CLOSED
                funding_closeout.summary = {
                    "awards_closed": child_awards.count(),
                    "closed_at": timezone.now().isoformat(),
                }
                funding_closeout.submitted_by = self.request.user
                funding_closeout.submitted_at = timezone.now()
                funding_closeout.closed_at = timezone.now()
                funding_closeout.save()

        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=self.request.user,
            action=action_update(),
            target_model="Award",
            object_id=self.award.pk,
            object_repr=str(self.award),
            changes={"status": {"old": previous_status, "new": Award.Status.CLOSED}, "closeout": "submitted"},
            request=self.request,
        )
        success_msg = "Award closed out with checklist and financial snapshot recorded."
        # PRD §5.1 FRFA-CO019 — surface donor dispatch confirmation when applicable.
        if getattr(self.award.application.call, "funding_record_id", None):
            success_msg += " A donor close-out report has been queued for dispatch."
        messages.success(self.request, success_msg)
        return redirect("rims_grants:award_dashboard")


class CloseSubmissionsView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        if call.status != GrantCall.Status.PUBLISHED:
            messages.error(request, "Only published calls can be closed to new applications.")
            return redirect("rims_grants:call_detail_manage", slug=slug)
        try:
            close_call(call)
            messages.success(request, "Submissions closed for this call.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:call_detail_manage", slug=slug)


class CompleteCallView(GrantsManagerMixin, View):
    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        form = CompleteCallForm(request.POST)
        if not form.is_valid():
            messages.error(request, "Confirm completion to proceed.")
            return redirect("rims_grants:call_detail_manage", slug=slug)
        try:
            close_grant_call(call)
            messages.success(request, "Call cycle completed.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_grants:call_detail_manage", slug=slug)


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-CO023 — forced close-out (Programme Director only).
# ---------------------------------------------------------------------------
class ProgramDirectorMixin(RimsAccessMixin):
    """Restricted to Programme Director (or admin) — forced close-out is the
    only PRD action that requires this role specifically."""

    allowed_roles = (UserRole.PROGRAM_DIRECTOR, UserRole.ADMIN, UserRole.SYSTEM_ADMIN)
    rims_permissions = (rims_perms.APPROVE_GRANTS,)


class ForceAwardCloseoutView(ProgramDirectorMixin, View):
    template_name = "grants/award_force_closeout.html"

    def get(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        return render(request, self.template_name, {"award": award})

    def post(self, request, pk):
        award = get_object_or_404(Award, pk=pk)
        justification = (request.POST.get("justification") or "").strip()
        if not justification:
            messages.error(request, "A written justification is required.")
            return render(request, self.template_name, {"award": award, "justification_value": justification}, status=400)
        from apps.rims.grants.closeout_helpers import force_award_closeout

        try:
            force_award_closeout(award, justification=justification, approver=request.user)
        except ValidationError as exc:
            for m in getattr(exc, "messages", [str(exc)]):
                messages.error(request, m)
            return render(request, self.template_name, {"award": award, "justification_value": justification}, status=400)
        messages.success(request, "Forced close-out approved. The awardee, Grants Manager and Finance Officer have been notified.")
        return redirect("rims_grants:award_detail", pk=pk)


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-CS030 — withdraw a published call (Grants Manager).
# ---------------------------------------------------------------------------
class CallWithdrawView(GrantsManagerMixin, View):
    template_name = "grants/call_withdraw.html"

    def get(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        return render(request, self.template_name, {"call": call})

    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug)
        reason = (request.POST.get("reason") or "").strip()
        if not reason:
            messages.error(request, "A withdrawal reason is required.")
            return render(request, self.template_name, {"call": call, "reason_value": reason}, status=400)
        from apps.rims.grants.services import withdraw_call

        try:
            withdraw_call(call, reason=reason, actor=request.user)
        except ValidationError as exc:
            for m in getattr(exc, "messages", [str(exc)]):
                messages.error(request, m)
            return render(request, self.template_name, {"call": call, "reason_value": reason}, status=400)
        messages.success(request, "Call withdrawn. Affected applicants have been notified.")
        return redirect("rims_grants:manager_call_list")


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-AM004 — application form auto-save endpoint.
# Accepts JSON {"institution": <id|null>, "proposal": "..."} and persists
# the values into a DRAFT Application owned by the authenticated user. Used
# by the Alpine.js debounced auto-save on application_create.html /
# application_update.html. Returns JSON {ok, saved_at, application_id}.
# ---------------------------------------------------------------------------
import json as _json  # noqa: E402

from django.http import JsonResponse  # noqa: E402
from django.views.decorators.csrf import csrf_protect  # noqa: E402
from django.utils.decorators import method_decorator  # noqa: E402


@method_decorator(csrf_protect, name="dispatch")
class ApplicationDraftAutoSaveView(LoginRequiredMixin, View):
    """Persist partial application fields without enforcing the full
    submission validation. Only the application owner can write — non-owner
    POSTs return 403. Locked applications (after deadline / submitted /
    rejected) reject auto-save with 409."""

    raise_exception = True

    def post(self, request, slug):
        call = get_object_or_404(GrantCall, slug=slug, status=GrantCall.Status.PUBLISHED)
        if timezone.now() > call.closes_at:
            return JsonResponse({"ok": False, "error": "deadline_passed"}, status=409)
        try:
            payload = _json.loads(request.body.decode("utf-8") or "{}")
        except _json.JSONDecodeError:
            return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)

        application, _created = Application.objects.get_or_create(
            call=call,
            applicant=request.user,
            defaults={"institution": request.user.institution},
        )
        if application.applicant_id != request.user.pk:
            return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
        if application.status != Application.Status.DRAFT:
            return JsonResponse({"ok": False, "error": "locked"}, status=409)

        update_fields: list[str] = []
        if "proposal" in payload:
            application.proposal = (payload.get("proposal") or "")[:50_000]
            update_fields.append("proposal")
        if "institution" in payload:
            inst_id = payload.get("institution")
            if inst_id in (None, ""):
                application.institution = None
            else:
                from apps.core.authentication.models import Institution

                application.institution = Institution.objects.filter(pk=inst_id).first()
            update_fields.append("institution")
        if update_fields:
            update_fields.append("updated_at")
            application.save(update_fields=update_fields)
        return JsonResponse(
            {
                "ok": True,
                "application_id": application.pk,
                "saved_at": timezone.now().isoformat(),
            }
        )


# ── Applicant reach full-page map ────────────────────────────────────────


class ApplicantReachMapView(GrantsManagerMixin, View):
    """Full-page geographic distribution of applicants across all programme types."""

    template_name = "grants/applicant_reach_map.html"

    # ISO 3166-1 alpha-2 → (lat, lng, display_name)
    _CENTROIDS = {
        "UG": ( 1.37,  32.29, "Uganda"),        "KE": (-0.02,  37.91, "Kenya"),
        "CM": ( 7.37,  12.35, "Cameroon"),       "MW": (-13.25, 34.30, "Malawi"),
        "BJ": ( 9.31,   2.32, "Benin"),          "NG": ( 9.08,   8.68, "Nigeria"),
        "GH": ( 7.95,  -1.02, "Ghana"),          "ZW": (-19.02, 29.15, "Zimbabwe"),
        "ZA": (-30.56, 22.94, "South Africa"),   "SS": ( 6.88,  31.31, "South Sudan"),
        "RW": (-1.94,  29.87, "Rwanda"),         "MA": (31.79,  -7.09, "Morocco"),
        "TZ": (-6.37,  34.89, "Tanzania"),       "BI": (-3.37,  29.92, "Burundi"),
        "ET": ( 9.15,  40.49, "Ethiopia"),       "LR": ( 6.43,  -9.43, "Liberia"),
        "NA": (-22.96, 18.49, "Namibia"),        "CD": (-4.04,  21.76, "DR Congo"),
        "SO": ( 5.15,  46.20, "Somalia"),        "ZM": (-13.13, 27.85, "Zambia"),
        "LS": (-29.61, 28.23, "Lesotho"),        "SD": (12.86,  30.22, "Sudan"),
        "SL": ( 8.46, -11.78, "Sierra Leone"),   "MZ": (-18.67, 35.53, "Mozambique"),
        "GM": (13.44, -15.31, "Gambia"),         "LY": (26.34,  17.23, "Libya"),
        "TG": ( 8.62,   0.82, "Togo"),           "BW": (-22.33, 24.68, "Botswana"),
        "CI": ( 7.54,  -5.55, "Côte d'Ivoire"), "TD": (15.45,  18.73, "Chad"),
        "SN": (14.50, -14.45, "Senegal"),        "GA": (-0.80,  11.61, "Gabon"),
        "IN": (20.59,  78.96, "India"),          "MY": ( 4.21, 101.98, "Malaysia"),
        "KW": (29.31,  47.48, "Kuwait"),         "MU": (-20.35,  57.55, "Mauritius"),
        "BW": (-22.33, 24.68, "Botswana"),
    }

    # Additional full-name aliases for institution country field (stored as text)
    _NAME_ALIASES = {
        "Democratic Republic of Congo": "CD",
        "DR Congo": "CD",
        "Ivory Coast": "CI",
        "Côte d'Ivoire": "CI",
        "Mauritius": "MU",
        "Botswana": "BW",
    }

    def get(self, request):
        import json
        from django.db.models import Count, Q, Sum
        from apps.rims.grants.models import (
    HomeValidationRecord,
            Application, Award, GrantCall,
            ScholarshipApplicationProfile,
        )

        programme = request.GET.get("programme", "all")
        year_str  = request.GET.get("year", "all")

        # Available years for the filter selector
        from django.db.models import IntegerField
        from django.db.models.functions import ExtractYear
        sc_years = sorted(set(
            Application.objects.filter(call__call_type="scholarship")
            .exclude(call__closes_at__isnull=True)
            .values_list("call__closes_at__year", flat=True)
            .distinct()
        ), reverse=True)
        rg_years = sorted(set(
            Application.objects.filter(call__call_type="grant")
            .exclude(call__closes_at__isnull=True)
            .values_list("call__closes_at__year", flat=True)
            .distinct()
        ), reverse=True)
        fe_years = sorted(set(
            Application.objects.filter(call__call_type="fellowship")
            .exclude(call__closes_at__isnull=True)
            .values_list("call__closes_at__year", flat=True)
            .distinct()
        ), reverse=True)
        all_years = sorted(set(sc_years + rg_years + fe_years), reverse=True)

        year_filter = int(year_str) if year_str != "all" and year_str.isdigit() else None

        def year_qs(qs):
            if year_filter:
                return qs.filter(call__closes_at__year=year_filter)
            return qs

        def sc_year_qs(qs):
            if year_filter:
                return qs.filter(application__call__closes_at__year=year_filter)
            return qs

        # ── Scholarship applicants from profiles (richest geo data) ───────
        sc_base = ScholarshipApplicationProfile.objects.exclude(country_of_residence="")
        if year_filter:
            sc_base = sc_base.filter(application__call__closes_at__year=year_filter)
        sc_rows = sc_base.values("country_of_residence").annotate(c=Count("id")).order_by("-c")
        sc_by_country = {r["country_of_residence"]: r["c"] for r in sc_rows}

        # ── Research grant applicants by institution country ───────────────
        _name_to_iso2 = {name: code for code, (_, __, name) in self._CENTROIDS.items()}
        _name_to_iso2.update(self._NAME_ALIASES)

        rg_base = Application.objects.filter(call__call_type="grant").exclude(institution__isnull=True).exclude(institution__country="")
        if year_filter:
            rg_base = rg_base.filter(call__closes_at__year=year_filter)
        rg_rows = rg_base.values("institution__country").annotate(c=Count("id")).order_by("-c")
        rg_by_country = {}
        for r in rg_rows:
            iso2 = _name_to_iso2.get(r["institution__country"])
            if iso2:
                rg_by_country[iso2] = rg_by_country.get(iso2, 0) + r["c"]

        # ── Fellowship applicants by profile home_country ─────────────────
        from apps.rims.grants.models import FellowshipApplicationProfile
        fe_base = FellowshipApplicationProfile.objects.exclude(home_country="")
        if year_filter:
            fe_base = fe_base.filter(application__call__closes_at__year=year_filter)
        fe_rows = fe_base.values("home_country").annotate(c=Count("id")).order_by("-c")
        fe_by_country = {r["home_country"]: r["c"] for r in fe_rows}

        # ── Merge based on filter ─────────────────────────────────────────
        if programme == "scholarship":
            combined = sc_by_country
            colour = "#7c3aed"
            label = "Scholarship applicants"
        elif programme == "grant":
            combined = rg_by_country
            colour = "#6b5317"
            label = "Research grant applicants"
        elif programme == "fellowship":
            combined = fe_by_country
            colour = "#0284c7"
            label = "Fellowship applicants"
        else:
            # Merge all three
            combined = {}
            for d in (sc_by_country, rg_by_country, fe_by_country):
                for k, v in d.items():
                    combined[k] = combined.get(k, 0) + v
            colour = "#6b5317"
            label = "All programme applicants"

        # ── Build map points ──────────────────────────────────────────────
        points = []
        for code, count in sorted(combined.items(), key=lambda x: -x[1]):
            if code not in self._CENTROIDS:
                continue
            lat, lng, name = self._CENTROIDS[code]
            points.append({"lat": lat, "lng": lng,
                            "name": name, "count": count, "code": code})

        total = sum(p["count"] for p in points)
        country_count = len(points)

        # ── Summary stats for the sidebar ─────────────────────────────────
        top5 = points[:5]
        def _count(call_type):
            qs = Application.objects.filter(call__call_type=call_type)
            if year_filter:
                qs = qs.filter(call__closes_at__year=year_filter)
            return qs.count()

        sc_total = _count("scholarship")
        rg_total = _count("grant")
        fe_total = _count("fellowship")

        # Gender from scholarship profiles
        from django.db.models import Q as DQ
        gender = ScholarshipApplicationProfile.objects.aggregate(
            female=Count("id", filter=DQ(gender="female")),
            male=Count("id", filter=DQ(gender="male")),
        )

        filter_tabs = [
            ("all",         "All",          "#6b5317"),
            ("scholarship", "Scholarships", "#7c3aed"),
            ("fellowship",  "Fellowships",  "#0284c7"),
            ("grant",       "Grants",       "#d97706"),
        ]

        return render(request, self.template_name, {
            "page_title": "Applicant geographic reach",
            "points_json": json.dumps(points),
            "colour": colour,
            "label": label,
            "total": total,
            "country_count": country_count,
            "top5": top5,
            "programme": programme,
            "filter_tabs": filter_tabs,
            "sc_total": sc_total,
            "rg_total": rg_total,
            "fe_total": fe_total,
            "all_total": sc_total + rg_total + fe_total,
            "gender_female": gender["female"],
            "gender_male": gender["male"],
            "year": year_str,
            "year_filter": year_filter,
            "all_years": all_years,
        })


# ═══════════════════════════════════════════════════════════════════════════
# SCHOLARSHIP MULTI-STEP APPLICATION WIZARD
# ═══════════════════════════════════════════════════════════════════════════

SCHOLARSHIP_STEPS = [
    {"number": 1, "title": "Target university & course",      "key": "basic"},
    {"number": 2, "title": "Personal profile",                "key": "personal"},
    {"number": 3, "title": "Household & socioeconomic",       "key": "socioeconomic"},
    {"number": 4, "title": "Leadership & motivation",         "key": "motivation"},
    {"number": 5, "title": "Documents & statement",           "key": "documents"},
    {"number": 6, "title": "Psychometric assessment",         "key": "psychometric"},
    {"number": 7, "title": "Review & submit",                 "key": "review"},
]


class _ScholarshipWizardMixin(LoginRequiredMixin):
    """Shared helpers for all scholarship wizard steps."""

    def _get_call(self, slug):
        return get_object_or_404(
            GrantCall,
            slug=slug,
            call_type="scholarship",
            status=GrantCall.Status.PUBLISHED,
        )

    def _get_application(self, call, pk):
        return get_object_or_404(
            Application,
            pk=pk,
            call=call,
            applicant=self.request.user,
        )

    def _get_or_create_profile(self, application):
        from apps.rims.grants.models import ScholarshipApplicationProfile
        profile, _ = ScholarshipApplicationProfile.objects.get_or_create(
            application=application
        )
        return profile

    def _wizard_ctx(self, call, application, current_step, extra=None):
        ctx = {
            "call": call,
            "application": application,
            "steps": SCHOLARSHIP_STEPS,
            "current_step": current_step,
        }
        if extra:
            ctx.update(extra)
        return ctx


class ScholarshipApplyStep1View(_ScholarshipWizardMixin, ProfileCompleteRequiredMixin, View):
    """Step 1 — Create application draft + target university/course."""

    template_name = "grants/scholarship_apply/step1_basic.html"

    def get(self, request, slug):
        from apps.rims.grants.forms import ScholarshipStep1Form
        call = self._get_call(slug)

        # Resume existing draft
        existing = Application.objects.filter(call=call, applicant=request.user).exclude(
            status__in=[Application.Status.WITHDRAWN, Application.Status.EXPIRED_DRAFT]
        ).first()
        if existing and existing.status != Application.Status.DRAFT:
            messages.info(request, "You have already submitted an application for this call.")
            return redirect("rims_grants:application_detail", pk=existing.pk)
        if existing:
            initial = {
                "institution": existing.institution_id,
                "scholarship_university_choice": existing.scholarship_university_choice_id,
                "scholarship_course_choice": existing.scholarship_course_choice,
            }
            profile = getattr(existing, "scholarship_profile", None)
            if profile:
                initial["scholarship_call_source"] = profile.scholarship_call_source
            form = ScholarshipStep1Form(initial=initial)
            return render(request, self.template_name, self._wizard_ctx(call, existing, 1, {"form": form}))

        form = ScholarshipStep1Form(initial={"institution": request.user.institution_id})
        return render(request, self.template_name, self._wizard_ctx(call, None, 1, {"form": form}))

    def post(self, request, slug):
        from apps.rims.grants.forms import ScholarshipStep1Form
        call = self._get_call(slug)
        form = ScholarshipStep1Form(request.POST)

        # Resume draft if exists
        existing = Application.objects.filter(call=call, applicant=request.user,
            status=Application.Status.DRAFT).first()

        if not form.is_valid():
            return render(request, self.template_name,
                          self._wizard_ctx(call, existing, 1, {"form": form}))

        cd = form.cleaned_data
        if existing:
            application = existing
            application.institution = cd.get("institution")
            application.scholarship_university_choice = cd.get("scholarship_university_choice")
            application.scholarship_course_choice = cd.get("scholarship_course_choice") or ""
            application.save(update_fields=["institution", "scholarship_university_choice",
                                             "scholarship_course_choice", "updated_at"])
        else:
            application = Application.objects.create(
                call=call,
                applicant=request.user,
                institution=cd.get("institution"),
                scholarship_university_choice=cd.get("scholarship_university_choice"),
                scholarship_course_choice=cd.get("scholarship_course_choice") or "",
                status=Application.Status.DRAFT,
            )

        profile = self._get_or_create_profile(application)
        profile.scholarship_call_source = cd.get("scholarship_call_source") or ""
        profile.save(update_fields=["scholarship_call_source", "updated_at"])

        return redirect("rims_grants:scholarship_apply_step2", slug=slug, pk=application.pk)


class ScholarshipApplyStep2View(_ScholarshipWizardMixin, View):
    """Step 2 — Personal & academic profile."""

    template_name = "grants/scholarship_apply/step2_personal.html"

    def get(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep2Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        initial = {f: getattr(profile, f, None) for f in ScholarshipStep2Form().fields}
        for bool_field in ("is_refugee", "english_in_high_school", "postgraduate_student",
                           "applied_to_university", "have_physical_disability",
                           "have_history_of_chronic_illness"):
            v = getattr(profile, bool_field, None)
            initial[bool_field] = "true" if v is True else "false" if v is False else ""
        form = ScholarshipStep2Form(initial=initial)
        return render(request, self.template_name, self._wizard_ctx(call, application, 2, {"form": form}))

    def post(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep2Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ScholarshipStep2Form(request.POST)
        if not form.is_valid():
            return render(request, self.template_name,
                          self._wizard_ctx(call, application, 2, {"form": form}))
        cd = form.cleaned_data
        profile = self._get_or_create_profile(application)
        for field, value in cd.items():
            if hasattr(profile, field) and value not in ("", None) or value is False:
                setattr(profile, field, value)
        profile.save()
        return redirect("rims_grants:scholarship_apply_step3", slug=slug, pk=pk)


class ScholarshipApplyStep3View(_ScholarshipWizardMixin, View):
    """Step 3 — Household & socioeconomic."""

    template_name = "grants/scholarship_apply/step3_socioeconomic.html"

    def get(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep3Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        initial = {f: getattr(profile, f, None) for f in ScholarshipStep3Form().fields}
        for bool_field in ("electricity", "solar_energy", "own_livestock"):
            v = getattr(profile, bool_field, None)
            initial[bool_field] = "true" if v is True else "false" if v is False else ""
        form = ScholarshipStep3Form(initial=initial)
        return render(request, self.template_name, self._wizard_ctx(call, application, 3, {"form": form}))

    def post(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep3Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ScholarshipStep3Form(request.POST)
        if not form.is_valid():
            return render(request, self.template_name,
                          self._wizard_ctx(call, application, 3, {"form": form}))
        cd = form.cleaned_data
        profile = self._get_or_create_profile(application)
        for field, value in cd.items():
            if hasattr(profile, field):
                setattr(profile, field, value)
        profile.save()
        return redirect("rims_grants:scholarship_apply_step4", slug=slug, pk=pk)


class ScholarshipApplyStep4View(_ScholarshipWizardMixin, View):
    """Step 4 — Leadership & motivation."""

    template_name = "grants/scholarship_apply/step4_motivation.html"

    def get(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep4Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        initial = {f: getattr(profile, f, None) for f in ScholarshipStep4Form().fields}
        for bool_field in ("held_leadership_position", "community_service_participation",
                           "currently_volunteering", "member_of_group", "ever_been_employed",
                           "employer_support", "have_been_arrested"):
            v = getattr(profile, bool_field, None)
            initial[bool_field] = "true" if v is True else "false" if v is False else ""
        form = ScholarshipStep4Form(initial=initial)
        return render(request, self.template_name, self._wizard_ctx(call, application, 4, {"form": form}))

    def post(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep4Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ScholarshipStep4Form(request.POST)
        if not form.is_valid():
            return render(request, self.template_name,
                          self._wizard_ctx(call, application, 4, {"form": form}))
        cd = form.cleaned_data
        profile = self._get_or_create_profile(application)
        for field, value in cd.items():
            if hasattr(profile, field):
                setattr(profile, field, value)
        profile.save()
        return redirect("rims_grants:scholarship_apply_step5", slug=slug, pk=pk)


class ScholarshipApplyStep5View(_ScholarshipWizardMixin, View):
    """Step 5 — Documents & motivation letter."""

    template_name = "grants/scholarship_apply/step5_documents.html"

    def get(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep5Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ScholarshipStep5Form(initial={"proposal": application.proposal})
        existing_docs = application.documents.all()
        return render(request, self.template_name,
                      self._wizard_ctx(call, application, 5, {"form": form,
                                                               "existing_docs": existing_docs,
                                                               "required_docs": call.required_documents.all()}))

    def post(self, request, slug, pk):
        from apps.rims.grants.forms import ScholarshipStep5Form
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ScholarshipStep5Form(request.POST)
        if form.is_valid():
            application.proposal = form.cleaned_data.get("proposal") or ""
            application.save(update_fields=["proposal", "updated_at"])
            # Handle required doc uploads
            for doc in call.required_documents.all():
                f = request.FILES.get(f"doc_{doc.pk}")
                if f:
                    from apps.rims.grants.models import ApplicationDocument
                    ApplicationDocument.objects.update_or_create(
                        application=application,
                        required_document=doc,
                        defaults={"file": f, "label": doc.label},
                    )
            # Handle extra docs
            for i in range(10):
                label = request.POST.get(f"extra_label_{i}", "").strip()
                f = request.FILES.get(f"extra_file_{i}")
                if label and f:
                    from apps.rims.grants.models import ApplicationDocument
                    ApplicationDocument.objects.create(
                        application=application, label=label, file=f,
                    )
        return redirect("rims_grants:scholarship_apply_psychometric", slug=slug, pk=pk)


class ScholarshipApplyPsychometricView(_ScholarshipWizardMixin, View):
    """Step 6 — Psychometric self-report questionnaire.

    Old-system parity: legacy ``ApplicantHouseHoldSurvey``. Optional at the
    wizard level (applicants may proceed to review and submit without
    completing it — the SRS only requires it be done "before the deadline"),
    but ``auto_screen_eligibility`` flags scholarship applications missing a
    score during Programme Officer screening.
    """

    template_name = "grants/scholarship_apply/step6_psychometric.html"

    def _get_or_create_assessment(self, application):
        assessment, _ = PsychometricAssessment.objects.get_or_create(application=application)
        return assessment

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        assessment = self._get_or_create_assessment(application)
        form = PsychometricAssessmentForm(instance=assessment)
        return render(request, self.template_name, self._wizard_ctx(call, application, 6, {"form": form}))

    def post(self, request, slug, pk):
        from apps.rims.grants.services import record_psychometric_attempt

        call = self._get_call(slug)
        application = self._get_application(call, pk)
        assessment = self._get_or_create_assessment(application)
        form = PsychometricAssessmentForm(request.POST, instance=assessment)
        if not form.is_valid():
            return render(request, self.template_name, self._wizard_ctx(call, application, 6, {"form": form}))
        try:
            record_psychometric_attempt(assessment)
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_grants:scholarship_apply_review", slug=slug, pk=pk)
        assessment = form.save(commit=False)
        assessment.application = application
        assessment.submitted_at = timezone.now()
        assessment.save()
        return redirect("rims_grants:scholarship_apply_review", slug=slug, pk=pk)


class ScholarshipApplyReviewView(_ScholarshipWizardMixin, View):
    """Step 7 — Review & submit."""

    template_name = "grants/scholarship_apply/step6_review.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = getattr(application, "scholarship_profile", None)
        docs = application.documents.all()
        return render(request, self.template_name,
                      self._wizard_ctx(call, application, 7, {
                          "profile": profile,
                          "docs": docs,
                      }))

    def post(self, request, slug, pk):
        from django_fsm import TransitionNotAllowed
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        if application.status != Application.Status.DRAFT:
            messages.error(request, "This application has already been submitted.")
            return redirect("rims_grants:application_detail", pk=pk)
        try:
            from apps.rims.grants.services import submit_application
            submit_application(application, actor=request.user)
            messages.success(request,
                "Your application has been submitted successfully. You will receive a confirmation email.")
            return redirect("rims_grants:application_detail", pk=application.pk)
        except Exception as exc:
            messages.error(request, f"Could not submit: {exc}")
            return redirect("rims_grants:scholarship_apply_review", slug=slug, pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# Fellowship apply wizard — mirrors the scholarship wizard pattern so
# FellowshipApplicationProfile has a dedicated applicant-facing UI instead of
# sharing the generic ApplyCallView.
# ─────────────────────────────────────────────────────────────────────────────

FELLOWSHIP_STEPS = [
    {"number": 1, "title": "Institution", "key": "basic"},
    {"number": 2, "title": "Placement details", "key": "placement"},
    {"number": 3, "title": "Documents & statement", "key": "documents"},
    {"number": 4, "title": "Review & submit", "key": "review"},
]


class _FellowshipWizardMixin(LoginRequiredMixin):
    def _get_call(self, slug):
        return get_object_or_404(GrantCall, slug=slug, call_type="fellowship", status=GrantCall.Status.PUBLISHED)

    def _get_application(self, call, pk):
        return get_object_or_404(Application, pk=pk, call=call, applicant=self.request.user)

    def _get_or_create_profile(self, application):
        profile, _ = FellowshipApplicationProfile.objects.get_or_create(application=application)
        return profile

    def _wizard_ctx(self, call, application, current_step, extra=None):
        ctx = {"call": call, "application": application, "steps": FELLOWSHIP_STEPS, "current_step": current_step}
        if extra:
            ctx.update(extra)
        return ctx


class FellowshipApplyStep1View(_FellowshipWizardMixin, ProfileCompleteRequiredMixin, View):
    template_name = "grants/fellowship_apply/step1_basic.html"

    def get(self, request, slug):
        call = self._get_call(slug)
        existing = Application.objects.filter(call=call, applicant=request.user).exclude(
            status__in=[Application.Status.WITHDRAWN, Application.Status.EXPIRED_DRAFT]
        ).first()
        if existing and existing.status != Application.Status.DRAFT:
            messages.info(request, "You have already submitted an application for this call.")
            return redirect("rims_grants:application_detail", pk=existing.pk)
        initial = {"institution": existing.institution_id if existing else request.user.institution_id}
        form = ApplyWizardBasicForm(initial=initial)
        return render(request, self.template_name, self._wizard_ctx(call, existing, 1, {"form": form}))

    def post(self, request, slug):
        call = self._get_call(slug)
        form = ApplyWizardBasicForm(request.POST)
        existing = Application.objects.filter(
            call=call, applicant=request.user, status=Application.Status.DRAFT
        ).first()
        if not form.is_valid():
            return render(request, self.template_name, self._wizard_ctx(call, existing, 1, {"form": form}))
        institution = form.cleaned_data.get("institution")
        if existing:
            application = existing
            application.institution = institution
            application.save(update_fields=["institution", "updated_at"])
        else:
            application = Application.objects.create(
                call=call, applicant=request.user, institution=institution, status=Application.Status.DRAFT
            )
        return redirect("rims_grants:fellowship_apply_step2", slug=slug, pk=application.pk)


class FellowshipApplyStep2View(_FellowshipWizardMixin, View):
    template_name = "grants/fellowship_apply/step2_placement.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        form = FellowshipPlacementForm(instance=profile)
        return render(request, self.template_name, self._wizard_ctx(call, application, 2, {"form": form}))

    def post(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        form = FellowshipPlacementForm(request.POST, instance=profile)
        if not form.is_valid():
            return render(request, self.template_name, self._wizard_ctx(call, application, 2, {"form": form}))
        form.save()
        return redirect("rims_grants:fellowship_apply_step3", slug=slug, pk=pk)


class FellowshipApplyStep3View(_FellowshipWizardMixin, View):
    template_name = "grants/fellowship_apply/step3_documents.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ApplyWizardDocumentsForm(initial={"proposal": application.proposal})
        return render(request, self.template_name, self._wizard_ctx(call, application, 3, {
            "form": form,
            "existing_docs": application.documents.all(),
            "required_docs": call.required_documents.all(),
        }))

    def post(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ApplyWizardDocumentsForm(request.POST)
        if form.is_valid():
            application.proposal = form.cleaned_data.get("proposal") or ""
            application.save(update_fields=["proposal", "updated_at"])
            for doc in call.required_documents.all():
                f = request.FILES.get(f"doc_{doc.pk}")
                if f:
                    ApplicationDocument.objects.update_or_create(
                        application=application, required_document=doc, defaults={"file": f, "label": doc.label},
                    )
        return redirect("rims_grants:fellowship_apply_review", slug=slug, pk=pk)


class FellowshipApplyReviewView(_FellowshipWizardMixin, View):
    template_name = "grants/fellowship_apply/step4_review.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = getattr(application, "fellowship_profile", None)
        return render(request, self.template_name, self._wizard_ctx(call, application, 4, {
            "profile": profile, "docs": application.documents.all(),
        }))

    def post(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        if application.status != Application.Status.DRAFT:
            messages.error(request, "This application has already been submitted.")
            return redirect("rims_grants:application_detail", pk=pk)
        try:
            from apps.rims.grants.services import submit_application
            submit_application(application, actor=request.user)
            messages.success(request, "Your fellowship application has been submitted successfully.")
            return redirect("rims_grants:application_detail", pk=application.pk)
        except Exception as exc:
            messages.error(request, f"Could not submit: {exc}")
            return redirect("rims_grants:fellowship_apply_review", slug=slug, pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# Challenge apply wizard — mirrors the scholarship/fellowship wizard pattern
# so ChallengeApplicationProfile + ApplicationPartner have a dedicated
# applicant-facing UI instead of sharing the generic ApplyCallView.
# ─────────────────────────────────────────────────────────────────────────────

CHALLENGE_STEPS = [
    {"number": 1, "title": "Institution", "key": "basic"},
    {"number": 2, "title": "Innovation profile", "key": "profile"},
    {"number": 3, "title": "Partners & documents", "key": "documents"},
    {"number": 4, "title": "Review & submit", "key": "review"},
]


class _ChallengeWizardMixin(LoginRequiredMixin):
    def _get_call(self, slug):
        return get_object_or_404(GrantCall, slug=slug, call_type="challenge", status=GrantCall.Status.PUBLISHED)

    def _get_application(self, call, pk):
        return get_object_or_404(Application, pk=pk, call=call, applicant=self.request.user)

    def _get_or_create_profile(self, application):
        profile, _ = ChallengeApplicationProfile.objects.get_or_create(application=application)
        return profile

    def _wizard_ctx(self, call, application, current_step, extra=None):
        ctx = {"call": call, "application": application, "steps": CHALLENGE_STEPS, "current_step": current_step}
        if extra:
            ctx.update(extra)
        return ctx


class ChallengeApplyStep1View(_ChallengeWizardMixin, ProfileCompleteRequiredMixin, View):
    template_name = "grants/challenge_apply/step1_basic.html"

    def get(self, request, slug):
        call = self._get_call(slug)
        existing = Application.objects.filter(call=call, applicant=request.user).exclude(
            status__in=[Application.Status.WITHDRAWN, Application.Status.EXPIRED_DRAFT]
        ).first()
        if existing and existing.status != Application.Status.DRAFT:
            messages.info(request, "You have already submitted an application for this call.")
            return redirect("rims_grants:application_detail", pk=existing.pk)
        initial = {"institution": existing.institution_id if existing else request.user.institution_id}
        form = ApplyWizardBasicForm(initial=initial)
        return render(request, self.template_name, self._wizard_ctx(call, existing, 1, {"form": form}))

    def post(self, request, slug):
        call = self._get_call(slug)
        form = ApplyWizardBasicForm(request.POST)
        existing = Application.objects.filter(
            call=call, applicant=request.user, status=Application.Status.DRAFT
        ).first()
        if not form.is_valid():
            return render(request, self.template_name, self._wizard_ctx(call, existing, 1, {"form": form}))
        institution = form.cleaned_data.get("institution")
        if existing:
            application = existing
            application.institution = institution
            application.save(update_fields=["institution", "updated_at"])
        else:
            application = Application.objects.create(
                call=call, applicant=request.user, institution=institution, status=Application.Status.DRAFT
            )
        return redirect("rims_grants:challenge_apply_step2", slug=slug, pk=application.pk)


class ChallengeApplyStep2View(_ChallengeWizardMixin, View):
    template_name = "grants/challenge_apply/step2_profile.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        form = ChallengeProfileForm(instance=profile)
        return render(request, self.template_name, self._wizard_ctx(call, application, 2, {"form": form}))

    def post(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = self._get_or_create_profile(application)
        form = ChallengeProfileForm(request.POST, instance=profile)
        if not form.is_valid():
            return render(request, self.template_name, self._wizard_ctx(call, application, 2, {"form": form}))
        form.save()
        return redirect("rims_grants:challenge_apply_step3", slug=slug, pk=pk)


class ChallengeApplyStep3View(_ChallengeWizardMixin, View):
    """Step 3 — consortium partners + documents/proposal."""

    template_name = "grants/challenge_apply/step3_documents.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ApplyWizardDocumentsForm(initial={"proposal": application.proposal})
        return render(request, self.template_name, self._wizard_ctx(call, application, 3, {
            "form": form,
            "partners": application.partners.all(),
            "existing_docs": application.documents.all(),
            "required_docs": call.required_documents.all(),
        }))

    def post(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        form = ApplyWizardDocumentsForm(request.POST)
        if form.is_valid():
            application.proposal = form.cleaned_data.get("proposal") or ""
            application.save(update_fields=["proposal", "updated_at"])
            for doc in call.required_documents.all():
                f = request.FILES.get(f"doc_{doc.pk}")
                if f:
                    ApplicationDocument.objects.update_or_create(
                        application=application, required_document=doc, defaults={"file": f, "label": doc.label},
                    )
            # Consortium partners — same "up to N extra rows" pattern used
            # for extra documents on the scholarship wizard's step5.
            for i in range(5):
                org = request.POST.get(f"partner_org_{i}", "").strip()
                if not org:
                    continue
                ApplicationPartner.objects.create(
                    application=application,
                    organization_name=org,
                    website=request.POST.get(f"partner_website_{i}", "").strip(),
                    contact_person=request.POST.get(f"partner_contact_{i}", "").strip(),
                    email=request.POST.get(f"partner_email_{i}", "").strip(),
                    phone=request.POST.get(f"partner_phone_{i}", "").strip(),
                    address=request.POST.get(f"partner_address_{i}", "").strip(),
                    area_of_expertise=request.POST.get(f"partner_expertise_{i}", "").strip(),
                )
        return redirect("rims_grants:challenge_apply_review", slug=slug, pk=pk)


class ChallengeApplyReviewView(_ChallengeWizardMixin, View):
    template_name = "grants/challenge_apply/step4_review.html"

    def get(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        profile = getattr(application, "challenge_profile", None)
        return render(request, self.template_name, self._wizard_ctx(call, application, 4, {
            "profile": profile, "docs": application.documents.all(), "partners": application.partners.all(),
        }))

    def post(self, request, slug, pk):
        call = self._get_call(slug)
        application = self._get_application(call, pk)
        if application.status != Application.Status.DRAFT:
            messages.error(request, "This application has already been submitted.")
            return redirect("rims_grants:application_detail", pk=pk)
        try:
            from apps.rims.grants.services import submit_application
            submit_application(application, actor=request.user)
            messages.success(request, "Your challenge application has been submitted successfully.")
            return redirect("rims_grants:application_detail", pk=application.pk)
        except Exception as exc:
            messages.error(request, f"Could not submit: {exc}")
            return redirect("rims_grants:challenge_apply_review", slug=slug, pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# Reviewer acceptance gate
# ─────────────────────────────────────────────────────────────────────────────

class ReviewAcceptView(ReviewerAccessMixin, View):
    """Reviewer explicitly accepts or declines a review assignment.

    Must happen before the reviewer can access the full application content.
    """

    def get(self, request, pk):
        review = get_object_or_404(
            Review.objects.select_related("application__call"),
            pk=pk,
            reviewer=request.user,
        )
        if review.accepted is not None:
            # Already responded — go straight to application or queue
            if review.accepted:
                return redirect("rims_grants:review_application", pk=pk)
            return redirect("rims_grants:review_queue")
        return render(request, "grants/review_accept.html", {
            "review": review,
            "application": review.application,
            "call": review.application.call,
        })

    def post(self, request, pk):
        review = get_object_or_404(Review, pk=pk, reviewer=request.user)
        action = request.POST.get("action")
        if action == "accept":
            review.accepted = True
            review.accepted_at = timezone.now()
            review.save(update_fields=["accepted", "accepted_at"])
            messages.success(request, "Assignment accepted. You can now access the application.")
            return redirect("rims_grants:review_application", pk=pk)
        elif action == "decline":
            review.accepted = False
            review.accepted_at = timezone.now()
            review.save(update_fields=["accepted", "accepted_at"])
            messages.info(request, "Assignment declined. The grants manager has been notified.")
            # Notify grants manager
            from apps.core.notifications.models import Notification
            if review.assigned_by_id:
                Notification.objects.create(
                    recipient_id=review.assigned_by_id,
                    verb=Notification.Verb.SYSTEM,
                    message=(
                        f"{request.user.get_full_name() or request.user.email} declined their review "
                        f"assignment for {review.application.call.title}."
                    ),
                )
            return redirect("rims_grants:review_queue")
        messages.error(request, "Invalid action.")
        return redirect("rims_grants:review_accept", pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# Psychometric score entry (grants manager, scholarship applications only)
# ─────────────────────────────────────────────────────────────────────────────

class RecordPsychometricScoreView(GrantsManagerMixin, View):
    """Grants manager enters a psychometric test score for a scholarship applicant."""

    def get(self, request, pk):
        application = get_object_or_404(
            Application.objects.select_related("call", "applicant"),
            pk=pk,
            call__call_type=GrantCall.CallType.SCHOLARSHIP,
        )
        return render(request, "grants/psychometric_score_form.html", {
            "application": application,
            "current_score": application.psychometric_score,
        })

    def post(self, request, pk):
        application = get_object_or_404(
            Application.objects.select_related("call", "applicant"),
            pk=pk,
            call__call_type=GrantCall.CallType.SCHOLARSHIP,
        )
        score_raw = request.POST.get("psychometric_score", "").strip()
        try:
            score = float(score_raw)
        except (ValueError, TypeError):
            messages.error(request, "Please enter a valid numeric score.")
            return redirect("rims_grants:record_psychometric_score", pk=pk)

        application.psychometric_score = score
        application.psychometric_recorded_by = request.user
        application.psychometric_recorded_at = timezone.now()
        application.save(update_fields=[
            "psychometric_score", "psychometric_recorded_by", "psychometric_recorded_at"
        ])
        messages.success(request, f"Psychometric score {score} recorded for {application.applicant.get_full_name() or application.applicant.email}.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# Home validation record (grants manager + dedicated home-validator, scholarships only)
# ─────────────────────────────────────────────────────────────────────────────

class HomeValidatorMixin(RimsAccessMixin):
    """Household-visit / applicant-eligibility validation tier for scholarships."""

    allowed_roles = (
        UserRole.ADMIN,
        UserRole.GRANTS_MANAGER,
        UserRole.SCHOLARSHIP_HOME_VALIDATOR,
    )
    rims_permissions = (rims_perms.MANAGE_SCHOLARSHIPS,)


class HomeValidationView(HomeValidatorMixin, View):
    """Record or update home validation findings for a scholarship applicant."""

    def _get_application(self, pk):
        return get_object_or_404(
            Application.objects.select_related("call", "applicant"),
            pk=pk,
            call__call_type=GrantCall.CallType.SCHOLARSHIP,
        )

    def get(self, request, pk):
        application = self._get_application(pk)
        record = getattr(application, "home_validation", None)
        return render(request, "grants/home_validation_form.html", {
            "application": application,
            "record": record,
        })

    def post(self, request, pk):
        application = self._get_application(pk)
        from apps.rims.grants.models import HomeValidationRecord
        record, _ = HomeValidationRecord.objects.get_or_create(
            application=application,
            defaults={"conducted_by": request.user},
        )
        record.conducted_by = request.user
        record.visit_date = request.POST.get("visit_date") or record.visit_date
        record.household_income_estimate = request.POST.get("household_income_estimate", "")
        record.number_of_dependants = request.POST.get("number_of_dependants") or None
        record.housing_condition = request.POST.get("housing_condition", "")
        record.key_findings = request.POST.get("key_findings", "")
        record.recommendation = request.POST.get("recommendation", "")
        record.save()
        messages.success(request, "Home validation record saved.")
        return redirect("rims_grants:manager_application_detail", pk=pk)


# ─────────────────────────────────────────────────────────────────────────────
# Review criteria management (grants manager, per call)
# ─────────────────────────────────────────────────────────────────────────────

class ManageReviewCriteriaView(GrantsManagerMixin, View):
    """Add, edit, or remove review criteria for a call.

    Displays all existing criteria and a form to add new ones inline.
    Weight total is shown so the manager can see when it reaches 100%.
    """

    def _get_call(self, slug):
        return get_object_or_404(GrantCall, slug=slug)

    def get(self, request, slug):
        call = self._get_call(slug)
        criteria = call.review_criteria.all()
        total_weight = sum(c.weight for c in criteria)
        return render(request, "grants/manage_review_criteria.html", {
            "call": call,
            "criteria": criteria,
            "total_weight": total_weight,
            "weight_ok": total_weight == 100,
        })

    def post(self, request, slug):
        call = self._get_call(slug)
        action = request.POST.get("action")

        if action == "add":
            from apps.rims.grants.models import ReviewCriterion
            name = request.POST.get("name", "").strip()
            if not name:
                messages.error(request, "Criterion name is required.")
                return redirect("rims_grants:manage_review_criteria", slug=slug)
            ReviewCriterion.objects.create(
                call=call,
                name=name,
                description=request.POST.get("description", ""),
                scoring_method=request.POST.get("scoring_method", ReviewCriterion.ScoringMethod.NUMERIC),
                max_score=int(request.POST.get("max_score") or 10),
                weight=float(request.POST.get("weight") or 0),
                sort_order=int(request.POST.get("sort_order") or 0),
            )
            messages.success(request, f'Criterion "{name}" added.')

        elif action == "delete":
            from apps.rims.grants.models import ReviewCriterion
            criterion_pk = request.POST.get("criterion_pk")
            ReviewCriterion.objects.filter(pk=criterion_pk, call=call).delete()
            messages.success(request, "Criterion removed.")

        elif action == "update":
            from apps.rims.grants.models import ReviewCriterion
            criterion_pk = request.POST.get("criterion_pk")
            criterion = get_object_or_404(ReviewCriterion, pk=criterion_pk, call=call)
            criterion.name = request.POST.get("name", criterion.name).strip()
            criterion.description = request.POST.get("description", criterion.description)
            criterion.max_score = int(request.POST.get("max_score") or criterion.max_score)
            criterion.weight = float(request.POST.get("weight") or criterion.weight)
            criterion.sort_order = int(request.POST.get("sort_order") or criterion.sort_order)
            criterion.save()
            messages.success(request, f'Criterion "{criterion.name}" updated.')

        return redirect("rims_grants:manage_review_criteria", slug=slug)
