from collections import OrderedDict
from datetime import datetime, timedelta
from decimal import Decimal

from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Q, Sum
from django.db.models.functions import TruncMonth
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import CreateView, DeleteView, DetailView, ListView, TemplateView, UpdateView

from apps.core.lists import CsvExportMixin, SortableListMixin, paginate_qs
from apps.core.permissions.mixins import (
    AuditorReadOnlyMixin,
    DisbursementCreateMixin,
    FinanceApprovalMixin,
    ProgramCoordinatorMixin,
    RimsAccessMixin,
)
from apps.core.permissions.roles import UserRole
from apps.rims.grants import rims_perms
from apps.rims.finance.forms import (
    BudgetForm,
    BudgetLineFormSet,
    BudgetYearAllocationFormSet,
    DisbursementExecutionForm,
    DisbursementReconciliationForm,
    DisbursementRequestForm,
    ExchangeRateForm,
    ExpenditureForm,
    PaymentBatchForm,
)
from apps.rims.finance.models import (
    BankStatement,
    BankStatementLine,
    Budget,
    BudgetApprovalWorkflow,
    BudgetLine,
    DisbursementExecution,
    DisbursementRequest,
    ExchangeRate,
    Expenditure,
    FinanceApproval,
    FundingSource,
    GLEntry,
    PaymentBatch,
    PaymentRetry,
    Reforecast,
)
from apps.rims.finance.services_reforecast import generate_reforecast, reforecast_vs_actual
from apps.rims.finance.services import (
    approve_budget_line,
    approve_budget_total,
    approve_disbursement_request,
    budget_ceiling,
    cancel_payment_batch,
    configure_budget_workflow,
    consolidate_multi_currency_budget,
    create_payment_batch,
    execute_payment_batch,
    finance_health_snapshot,
    list_approval_history,
    lock_payment_batch,
    record_expenditure,
    reject_disbursement_request,
    reconcile_disbursement,
    remaining_budget,
    resubmit_disbursement_request,
    reverse_payment_batch,
    save_execution_plan,
    split_budget_to_years,
    submit_disbursement_request,
    total_disbursed,
    total_expended,
    upsert_exchange_rate,
)


def _fx_refreshed_at():
    """Most-recent ExchangeRate effective_date — proxy for "FX rates as of"
    on §9 finance-confidence footers. Returns ``None`` when no rates are on
    file so the footer skips that line."""
    return (
        ExchangeRate.objects.order_by("-effective_date")
        .values_list("effective_date", flat=True)
        .first()
    )


def _award_label(award) -> str:
    """Human-readable award reference for CSV exports — mirrors the _award_ref.html
    partial used in the web table. Format: "Call title — Recipient (AWD-pk)"."""
    if award is None:
        return ""
    app = award.application
    applicant = app.applicant
    recipient = applicant.get_full_name() or applicant.email or f"user-{applicant.pk}"
    return f"{app.call.title} — {recipient} (AWD-{award.pk})"


class FinanceStaffMixin(RimsAccessMixin):
    allowed_roles = (UserRole.ADMIN, UserRole.FINANCE_OFFICER, UserRole.GRANTS_MANAGER)
    rims_permissions = (rims_perms.MANAGE_FINANCE, rims_perms.MANAGE_CALLS)


class BudgetCreateView(FinanceStaffMixin, CreateView):
    model = Budget
    form_class = BudgetForm
    template_name = "finance/budget_form.html"
    object = None

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if "line_formset" not in ctx:
            if self.request.POST:
                ctx["line_formset"] = BudgetLineFormSet(self.request.POST)
            else:
                ctx["line_formset"] = BudgetLineFormSet()
        return ctx

    def post(self, request, *args, **kwargs):
        form = self.get_form_class()(request.POST)
        if not form.is_valid():
            return self.render_to_response(self.get_context_data(form=form, line_formset=BudgetLineFormSet(request.POST)))

        line_formset = None
        lines_ok = False
        with transaction.atomic():
            self.object = form.save()
            line_formset = BudgetLineFormSet(request.POST, instance=self.object)
            lines_ok = line_formset.is_valid()
            if lines_ok:
                line_formset.save()
            else:
                transaction.set_rollback(True)

        if lines_ok:
            messages.success(request, "Budget created.")
            return redirect("rims_finance:budget_detail", pk=self.object.pk)

        self.object = None
        form = self.get_form_class()(request.POST)
        return self.render_to_response(self.get_context_data(form=form, line_formset=line_formset))


class BudgetOverviewView(FinanceStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Budget
    template_name = "finance/budget_overview.html"
    context_object_name = "budgets"
    paginate_by = 25

    sortable_fields = {
        "name": "name",
        "created": "created_at",
    }
    default_sort = "name"

    csv_columns = (
        ("Name", "name"),
        ("Currency", "currency"),
        ("Award", lambda b: _award_label(b.award) if b.award_id else ""),
        ("Created", lambda b: b.created_at.isoformat() if b.created_at else ""),
    )
    csv_filename = "budgets"

    def get_queryset(self):
        qs = super().get_queryset().select_related(
            "award",
            "award__application",
            "award__application__call",
            "award__application__applicant",
            "award__application__institution",
        )
        return self.apply_sort(qs)

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

        ctx = super().get_context_data(**kwargs)
        ctx["ai_burn_rate"] = burn_rate_forecast(ctx.get("budgets", []))
        ctx["as_of"] = timezone.now()
        ctx["fx_refreshed"] = _fx_refreshed_at()
        return ctx


class BudgetDetailView(FinanceStaffMixin, DetailView):
    model = Budget
    template_name = "finance/budget_detail.html"
    context_object_name = "budget"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        b = self.object
        ceiling = budget_ceiling(b) or 0
        disbursed = total_disbursed(b) or 0
        ctx["budget_ceiling_val"] = ceiling
        ctx["total_disbursed_val"] = disbursed
        ctx["remaining_budget_val"] = remaining_budget(b)
        ctx["total_expended_val"] = total_expended(b)
        ctx["finance_snapshot"] = finance_health_snapshot(b)
        if ceiling > 0:
            burn_pct = float(disbursed) / float(ceiling) * 100
        else:
            burn_pct = 0.0
        ctx["burn_rate_pct"] = round(burn_pct, 1)
        ctx["high_burn_flag"] = burn_pct >= 80 and not ctx["finance_snapshot"].get("overspend_flag")
        ctx["pending_disbursements"] = paginate_qs(
            self.request,
            b.disbursement_requests.filter(
                status=DisbursementRequest.Status.SUBMITTED,
            ).select_related("requested_by", "milestone", "execution", "reconciliation"),
            per_page=20,
            param="pending_page",
        )
        ctx["other_disbursements"] = b.disbursement_requests.exclude(
            status=DisbursementRequest.Status.SUBMITTED,
        ).select_related("milestone", "execution", "reconciliation").order_by("-created_at")[:20]
        ctx["recent_expenditures"] = (
            Expenditure.objects.filter(budget_line__budget=b)
            .select_related("budget_line", "created_by")
            .order_by("-incurred_on", "-pk")[:10]
        )
        # PRD §5.4 FRFM001 / FRFM004 — multi-year allocations strip.
        ctx["year_allocations"] = list(b.year_allocations.all())
        # PRD §5.4 FRFM036/037/038 — approval-history panel.
        ctx["approval_history"] = list(list_approval_history(b))
        # PRD §5.4 FRFM027 — latest Reforecast snapshot (panel on detail).
        ctx["latest_reforecast"] = b.reforecasts.order_by("-generated_at").first()
        # PRD §5.4 FRFM008 / FRFM009 / NFRFM002 — multi-currency consolidation tab.
        target = (self.request.GET.get("target") or "").upper().strip()
        view_mode = (self.request.GET.get("view") or "").lower().strip()
        ctx["multi_currency_view"] = view_mode == "multi_currency" and target and target != b.currency
        if ctx["multi_currency_view"]:
            ctx["multi_currency_rows"] = consolidate_multi_currency_budget(b, target_currency=target)
            ctx["multi_currency_target"] = target
        return ctx


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM001 — year-allocation management on a multi-year budget.
# ---------------------------------------------------------------------------
class BudgetYearAllocationsView(FinanceStaffMixin, View):
    """GET renders the allocation formset; POST persists via split_budget_to_years."""

    template_name = "finance/budget_year_allocations.html"

    def _budget(self):
        return get_object_or_404(Budget, pk=self.kwargs["pk"])

    def get(self, request, pk):
        budget = self._budget()
        formset = BudgetYearAllocationFormSet(instance=budget)
        return self._render(request, budget, formset)

    def post(self, request, pk):
        budget = self._budget()
        formset = BudgetYearAllocationFormSet(request.POST, instance=budget)
        if not formset.is_valid():
            messages.error(request, "Fix the highlighted rows and try again.")
            return self._render(request, budget, formset)
        allocations = []
        for form in formset.forms:
            if not form.cleaned_data or form.cleaned_data.get("DELETE"):
                continue
            cleaned = form.cleaned_data
            if cleaned.get("fiscal_year") is None or cleaned.get("amount") is None:
                continue
            allocations.append(
                {
                    "fiscal_year": cleaned["fiscal_year"],
                    "amount": cleaned["amount"],
                    "currency": cleaned.get("currency") or budget.currency,
                    "notes": cleaned.get("notes") or "",
                }
            )
        if not allocations:
            messages.error(request, "Add at least one fiscal-year row before saving.")
            return self._render(request, budget, formset)
        try:
            split_budget_to_years(budget, allocations, actor=request.user)
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return self._render(request, budget, formset)
        messages.success(request, "Year allocations saved.")
        return redirect("rims_finance:budget_detail", pk=budget.pk)

    def _render(self, request, budget, formset):
        from django.shortcuts import render

        return render(
            request,
            self.template_name,
            {
                "budget": budget,
                "year_allocation_formset": formset,
            },
        )


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM008 — ExchangeRate finance-admin surface.
# ---------------------------------------------------------------------------
class ExchangeRateListView(FinanceStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = ExchangeRate
    template_name = "finance/exchange_rate_list.html"
    context_object_name = "rates"
    paginate_by = 25
    sortable_fields = {
        "effective": "effective_date",
        "pair": "from_currency",
    }
    default_sort = "-effective_date"
    csv_columns = (
        ("From", "from_currency"),
        ("To", "to_currency"),
        ("Rate", "rate"),
        ("Effective", lambda r: r.effective_date.isoformat() if r.effective_date else ""),
        ("Source", "source"),
    )
    csv_filename = "exchange_rates"

    def get_queryset(self):
        return self.apply_sort(super().get_queryset())


class ExchangeRateCreateView(FinanceApprovalMixin, View):
    template_name = "finance/exchange_rate_form.html"

    def get(self, request):
        form = ExchangeRateForm()
        return self._render(request, form)

    def post(self, request):
        form = ExchangeRateForm(request.POST)
        if not form.is_valid():
            return self._render(request, form)
        cleaned = form.cleaned_data
        try:
            upsert_exchange_rate(
                actor=request.user,
                from_currency=cleaned["from_currency"],
                to_currency=cleaned["to_currency"],
                rate=cleaned["rate"],
                effective_date=cleaned["effective_date"],
                source=cleaned.get("source", ""),
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return self._render(request, form)
        messages.success(request, "Exchange rate recorded.")
        return redirect("rims_finance:exchange_rate_list")

    def _render(self, request, form):
        from django.shortcuts import render

        return render(request, self.template_name, {"form": form, "rate": None})


class ExchangeRateUpdateView(FinanceApprovalMixin, View):
    template_name = "finance/exchange_rate_form.html"

    def _rate(self):
        return get_object_or_404(ExchangeRate, pk=self.kwargs["pk"])

    def get(self, request, pk):
        rate = self._rate()
        form = ExchangeRateForm(instance=rate)
        return self._render(request, form, rate)

    def post(self, request, pk):
        rate = self._rate()
        form = ExchangeRateForm(request.POST, instance=rate)
        if not form.is_valid():
            return self._render(request, form, rate)
        cleaned = form.cleaned_data
        try:
            upsert_exchange_rate(
                actor=request.user,
                from_currency=cleaned["from_currency"],
                to_currency=cleaned["to_currency"],
                rate=cleaned["rate"],
                effective_date=cleaned["effective_date"],
                source=cleaned.get("source", ""),
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return self._render(request, form, rate)
        messages.success(request, "Exchange rate updated.")
        return redirect("rims_finance:exchange_rate_list")

    def _render(self, request, form, rate):
        from django.shortcuts import render

        return render(request, self.template_name, {"form": form, "rate": rate})


class DisbursementCreateView(DisbursementCreateMixin, CreateView):
    model = DisbursementRequest
    form_class = DisbursementRequestForm
    template_name = "finance/disbursement_form.html"

    def dispatch(self, request, *args, **kwargs):
        self.budget = get_object_or_404(Budget, pk=kwargs["budget_pk"])
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["budget"] = self.budget
        return ctx

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["budget"] = self.budget
        return kwargs

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.budget = self.budget
        self.object.requested_by = self.request.user
        self.object.status = DisbursementRequest.Status.DRAFT
        self.object.save()
        try:
            submit_disbursement_request(self.object)
        except ValidationError as exc:
            messages.error(self.request, " ".join(getattr(exc, "messages", [str(exc)])))
            self.object.delete()
            return self.form_invalid(form)
        messages.success(self.request, "Disbursement request submitted for approval.")
        return redirect("rims_finance:budget_detail", pk=self.budget.pk)


class ApproveDisbursementView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        req = get_object_or_404(DisbursementRequest, pk=pk)
        try:
            approve_disbursement_request(req, actor=request.user)
            messages.success(request, "Disbursement approved.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_finance:budget_detail", pk=req.budget_id)


class RejectDisbursementView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        req = get_object_or_404(DisbursementRequest, pk=pk)
        try:
            reject_disbursement_request(req, reason=(request.POST.get("reason") or "").strip())
            messages.success(request, "Disbursement rejected.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_finance:budget_detail", pk=req.budget_id)


class DisbursementResubmitView(DisbursementCreateMixin, View):
    """PRD §5.4 FRFM035 — revise + resubmit a rejected disbursement request.

    POSTs the same form fields as the create view so the requester can edit
    amount / justification / supporting document inline. Service flips the
    request back to ``DRAFT``; the requester explicitly re-submits next.
    """

    def post(self, request, pk):
        req = get_object_or_404(DisbursementRequest, pk=pk)
        if req.status != DisbursementRequest.Status.REJECTED:
            messages.error(request, "Only rejected requests can be resubmitted.")
            return redirect("rims_finance:disbursement_detail", pk=req.pk)
        form = DisbursementRequestForm(
            request.POST, request.FILES, instance=req, budget=req.budget
        )
        if not form.is_valid():
            messages.error(request, "Please correct the issues before resubmitting.")
            return redirect("rims_finance:disbursement_detail", pk=req.pk)
        try:
            resubmit_disbursement_request(
                req,
                actor=request.user,
                amount=form.cleaned_data.get("amount"),
                justification=form.cleaned_data.get("justification"),
                payment_method=form.cleaned_data.get("payment_method"),
                scheduled_for=form.cleaned_data.get("scheduled_for"),
                account_reference=form.cleaned_data.get("account_reference"),
                supporting_document=form.cleaned_data.get("supporting_document"),
            )
            try:
                submit_disbursement_request(req, actor=request.user)
                messages.success(request, "Revisions submitted for approval.")
            except ValidationError as exc:
                messages.warning(
                    request,
                    "Revisions saved as draft. Submit when ready. "
                    + " ".join(getattr(exc, "messages", [str(exc)])),
                )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_finance:disbursement_detail", pk=req.pk)


class TrancheDisbursementReconciliationView(FinanceStaffMixin, TemplateView):
    """Read-only reconciliation between grants.AwardTranche (the grants
    team's milestone/payment plan) and finance.DisbursementRequest (what
    finance actually paid).

    There is no direct FK linking the two — a Budget FKs to an Award, and an
    AwardTranche links to Budget lines only via TrancheBudgetAllocation, so
    an award's planned tranches and its paid disbursements can silently
    drift apart. This view surfaces that gap by award without committing to
    a schema change; whether deeper integration is warranted is a product
    decision for the grants/finance teams, not something to infer from code.
    """

    template_name = "finance/tranche_reconciliation.html"

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

        ctx = super().get_context_data(**kwargs)
        awards = (
            Award.objects.filter(Q(tranches__isnull=False) | Q(budgets__disbursement_requests__isnull=False))
            .distinct()
            .select_related("application__call", "application__applicant")
        )
        rows = []
        for award in awards:
            tranche_total = award.tranches.aggregate(t=Sum("amount"))["t"] or Decimal("0")
            paid_total = DisbursementRequest.objects.filter(
                budget__award=award, status=DisbursementRequest.Status.PAID
            ).aggregate(t=Sum("amount"))["t"] or Decimal("0")
            rows.append({
                "award": award,
                "award_label": _award_label(award),
                "tranche_count": award.tranches.count(),
                "tranche_total": tranche_total,
                "paid_disbursement_total": paid_total,
                "difference": tranche_total - paid_total,
                "flagged": tranche_total != paid_total,
            })
        rows.sort(key=lambda r: (not r["flagged"], r["award_label"]))
        ctx["rows"] = rows
        ctx["flagged_count"] = sum(1 for r in rows if r["flagged"])
        return ctx


class DisbursementListView(FinanceStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = DisbursementRequest
    template_name = "finance/disbursement_list.html"
    context_object_name = "disbursements"
    paginate_by = 30

    sortable_fields = {
        "created": "created_at",
        "amount": "amount",
        "status": "status",
    }
    default_sort = "-created_at"

    csv_columns = (
        ("Created", lambda d: d.created_at.isoformat() if d.created_at else ""),
        ("Award", lambda d: str(d.budget.award) if d.budget and d.budget.award_id else ""),
        ("Budget line", lambda d: d.budget.line_name if d.budget else ""),
        ("Amount", "amount"),
        ("Status", "get_status_display"),
        ("Requested by", lambda d: d.requested_by.email if d.requested_by_id else ""),
    )
    csv_filename = "disbursements"

    def get_queryset(self):
        qs = (
            DisbursementRequest.objects.select_related(
                "budget", "requested_by", "milestone", "execution", "reconciliation"
            )
        )
        # PRD §5.4 FRFM045 — surface executed payments that still lack a
        # proof-of-payment file so finance staff can chase them.
        receipt = (self.request.GET.get("receipt") or "").strip().lower()
        if receipt == "missing":
            qs = qs.filter(
                status=DisbursementRequest.Status.PAID,
            ).filter(
                Q(execution__proof_of_payment__isnull=True)
                | Q(execution__proof_of_payment="")
            )
        elif receipt == "with":
            qs = qs.filter(
                status=DisbursementRequest.Status.PAID,
                execution__proof_of_payment__isnull=False,
            ).exclude(execution__proof_of_payment="")
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        # FinanceStaffMixin already limits to finance-capable roles; do not require is_staff.
        ctx["can_process_disbursements"] = True
        ctx["receipt_filter"] = (self.request.GET.get("receipt") or "").strip().lower()
        # Quick counts to power the filter chips.
        paid_qs = DisbursementRequest.objects.filter(status=DisbursementRequest.Status.PAID)
        ctx["receipt_missing_count"] = paid_qs.filter(
            Q(execution__proof_of_payment__isnull=True) | Q(execution__proof_of_payment="")
        ).count()
        ctx["receipt_present_count"] = paid_qs.exclude(
            Q(execution__proof_of_payment__isnull=True) | Q(execution__proof_of_payment="")
        ).count()
        ctx["as_of"] = timezone.now()
        ctx["fx_refreshed"] = _fx_refreshed_at()
        return ctx


class DisbursementApprovalQueueView(FinanceStaffMixin, SortableListMixin, ListView):
    """PRD §5.4 — pending disbursement requests awaiting Finance Officer approval."""

    model = DisbursementRequest
    template_name = "finance/disbursement_approval_queue.html"
    context_object_name = "disbursements"
    paginate_by = 25

    sortable_fields = {
        "created": "created_at",
        "amount": "amount",
        "award": "budget__award__id",
    }
    default_sort = "-created_at"

    def get_queryset(self):
        qs = (
            DisbursementRequest.objects.filter(status=DisbursementRequest.Status.SUBMITTED)
            .select_related("budget", "budget__award", "requested_by", "milestone")
        )
        return self.apply_sort(qs)

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

        ctx = super().get_context_data(**kwargs)
        submitted = DisbursementRequest.objects.filter(
            status=DisbursementRequest.Status.SUBMITTED
        )
        total_value = sum(
            (d.amount or Decimal("0") for d in submitted), Decimal("0")
        )
        approved_today = DisbursementRequest.objects.filter(
            status=DisbursementRequest.Status.APPROVED,
            approved_at__date=timezone.now().date(),
        ).count()
        ctx["queue_stats"] = {
            "pending_count": submitted.count(),
            "pending_value": total_value,
            "approved_today": approved_today,
        }
        return ctx


class BudgetUpdateView(ProgramCoordinatorMixin, UpdateView):
    model = Budget
    form_class = BudgetForm
    template_name = "finance/budget_edit.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if "line_formset" not in ctx:
            if self.request.POST:
                ctx["line_formset"] = BudgetLineFormSet(self.request.POST, instance=self.object)
            else:
                ctx["line_formset"] = BudgetLineFormSet(instance=self.object)
        return ctx

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        form = self.get_form()
        line_formset = BudgetLineFormSet(request.POST, instance=self.object)
        if form.is_valid() and line_formset.is_valid():
            with transaction.atomic():
                form.save()
                line_formset.save()
            messages.success(request, "Budget updated.")
            return redirect("rims_finance:budget_detail", pk=self.object.pk)
        return self.render_to_response(self.get_context_data(form=form, line_formset=line_formset))


class DisbursementDetailView(FinanceStaffMixin, DetailView):
    model = DisbursementRequest
    template_name = "finance/disbursement_detail.html"
    context_object_name = "disbursement"

    def get_queryset(self):
        return DisbursementRequest.objects.select_related(
            "budget__award",
            "requested_by",
            "milestone",
            "execution",
            "reconciliation",
        )

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

        ctx = super().get_context_data(**kwargs)
        ctx["execution_form"] = kwargs.get("execution_form") or DisbursementExecutionForm(
            instance=getattr(self.object, "execution", None)
        )
        ctx["reconciliation_form"] = kwargs.get("reconciliation_form") or DisbursementReconciliationForm(
            instance=getattr(self.object, "reconciliation", None)
        )
        ctx["ai_reconciliation_hint"] = reconciliation_hint(self.object)
        # PRD §5.4 FRFM036/037/038 — approval-history panel on this disbursement.
        ctx["approval_history"] = list(list_approval_history(self.object))
        # PRD §5.4 FRFM042/043 — payment-retry rows linked to this execution.
        execution = getattr(self.object, "execution", None)
        if execution:
            ctx["payment_retries"] = list(execution.retries.all().order_by("-created_at"))
            # PRD §5.4 FRFM046 — GL entries written for this execution. Paginated
            # under ?gl_page= since this page hosts several sibling tables.
            ctx["gl_entries"] = paginate_qs(
                self.request, execution.gl_entries.all(), per_page=25, param="gl_page"
            )
            ctx["failure_categories"] = DisbursementExecution.FailureCategory.choices
        else:
            ctx["payment_retries"] = []
            ctx["gl_entries"] = []
            ctx["failure_categories"] = []
        return ctx


class DisbursementUpdateView(FinanceStaffMixin, UpdateView):
    model = DisbursementRequest
    form_class = DisbursementRequestForm
    template_name = "finance/disbursement_edit.html"

    def get_queryset(self):
        # REJECTED is editable so the requester can revise in response to the
        # rejection_reason; the resubmit modal in disbursement_detail.html
        # drives this view directly.
        return DisbursementRequest.objects.filter(
            status__in=[
                DisbursementRequest.Status.DRAFT,
                DisbursementRequest.Status.SUBMITTED,
                DisbursementRequest.Status.REJECTED,
            ]
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["budget"] = self.object.budget
        return ctx

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["budget"] = self.object.budget
        return kwargs

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, "Disbursement request updated.")
        return redirect("rims_finance:disbursement_detail", pk=self.object.pk)


class DisbursementExecutionUpdateView(FinanceStaffMixin, View):
    def post(self, request, pk):
        req = get_object_or_404(
            DisbursementRequest.objects.select_related("execution"),
            pk=pk,
        )
        form = DisbursementExecutionForm(
            request.POST, request.FILES, instance=getattr(req, "execution", None)
        )
        if not form.is_valid():
            messages.error(request, "Provide a valid payment execution update.")
            detail_view = DisbursementDetailView()
            detail_view.request = request
            detail_view.object = req
            return detail_view.render_to_response(
                detail_view.get_context_data(disbursement=req, execution_form=form)
            )
        try:
            save_execution_plan(
                req,
                status=form.cleaned_data["status"],
                payment_method=form.cleaned_data["payment_method"],
                scheduled_for=form.cleaned_data.get("scheduled_for"),
                execution_reference=form.cleaned_data.get("execution_reference") or "",
                destination_account=form.cleaned_data.get("destination_account") or "",
                notes=form.cleaned_data.get("notes") or "",
                proof_of_payment=form.cleaned_data.get("proof_of_payment") or None,
                proof_channel=form.cleaned_data.get("proof_channel") or "",
                actor=request.user,
            )
            messages.success(request, "Payment execution updated.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_finance:disbursement_detail", pk=req.pk)


class DisbursementReconciliationUpdateView(FinanceStaffMixin, View):
    def post(self, request, pk):
        req = get_object_or_404(
            DisbursementRequest.objects.select_related("reconciliation"),
            pk=pk,
        )
        form = DisbursementReconciliationForm(
            request.POST, request.FILES, instance=getattr(req, "reconciliation", None)
        )
        if not form.is_valid():
            messages.error(request, "Provide a valid reconciliation update.")
            detail_view = DisbursementDetailView()
            detail_view.request = request
            detail_view.object = req
            return detail_view.render_to_response(
                detail_view.get_context_data(disbursement=req, reconciliation_form=form)
            )
        try:
            reconcile_disbursement(
                req,
                status=form.cleaned_data["status"],
                notes=form.cleaned_data.get("notes") or "",
                statement_excerpt=form.cleaned_data.get("statement_excerpt") or None,
                statement_source=form.cleaned_data.get("statement_source") or "",
                statement_period_start=form.cleaned_data.get("statement_period_start"),
                statement_period_end=form.cleaned_data.get("statement_period_end"),
                discrepancy_amount=form.cleaned_data.get("discrepancy_amount"),
                actor=request.user,
            )
            messages.success(request, "Disbursement reconciliation saved.")
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return redirect("rims_finance:disbursement_detail", pk=req.pk)


# ---------------------------------------------------------------------------
# PRD §5.4 — Expenditure tracking UI.
# Recorded as immutable single-step entries; the creator may amend within
# 24 hours, finance staff may delete to correct mistakes.
# ---------------------------------------------------------------------------
EXPENDITURE_EDIT_GRACE = timedelta(hours=24)


class ExpenditureListView(FinanceStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Expenditure
    template_name = "finance/expenditure_list.html"
    context_object_name = "expenditures"
    paginate_by = 30

    sortable_fields = {
        "incurred": "incurred_on",
        "amount": "amount",
        "line": "budget_line__label",
    }
    default_sort = "-incurred_on"

    csv_columns = (
        ("Incurred on", lambda e: e.incurred_on.isoformat() if e.incurred_on else ""),
        ("Budget line", lambda e: e.budget_line.label if e.budget_line_id else ""),
        ("Award", lambda e: str(e.budget_line.budget.award) if e.budget_line_id and e.budget_line.budget.award_id else ""),
        ("Amount", "amount"),
        ("Description", "description"),
        ("Created by", lambda e: e.created_by.email if e.created_by_id else ""),
    )
    csv_filename = "expenditures"

    def get_queryset(self):
        qs = (
            Expenditure.objects.select_related(
                "budget_line__budget__award",
                "created_by",
            )
        )
        params = self.request.GET
        if params.get("budget"):
            qs = qs.filter(budget_line__budget_id=params["budget"])
        if params.get("budget_line"):
            qs = qs.filter(budget_line_id=params["budget_line"])
        if params.get("date_from"):
            qs = qs.filter(incurred_on__gte=params["date_from"])
        if params.get("date_to"):
            qs = qs.filter(incurred_on__lte=params["date_to"])
        q = (params.get("q") or "").strip()
        if q:
            qs = qs.filter(
                Q(description__icontains=q)
                | Q(budget_line__label__icontains=q)
                | Q(budget_line__budget__name__icontains=q)
            )
        return self.apply_sort(qs)

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

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["budgets"] = Budget.objects.order_by("-created_at")[:200]
        ctx["filter_values"] = {
            k: self.request.GET.get(k, "")
            for k in ("budget", "budget_line", "date_from", "date_to", "q")
        }
        return ctx


class ExpenditureCreateView(FinanceStaffMixin, CreateView):
    model = Expenditure
    form_class = ExpenditureForm
    template_name = "finance/expenditure_form.html"

    def dispatch(self, request, *args, **kwargs):
        self.budget = get_object_or_404(Budget, pk=kwargs["budget_pk"])
        return super().dispatch(request, *args, **kwargs)

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["budget"] = self.budget
        return kwargs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["budget"] = self.budget
        ctx["mode"] = "create"
        return ctx

    def form_valid(self, form):
        try:
            self.object = record_expenditure(
                form.cleaned_data["budget_line"],
                amount=form.cleaned_data["amount"],
                incurred_on=form.cleaned_data["incurred_on"],
                description=form.cleaned_data.get("description") or "",
                receipt=form.cleaned_data.get("receipt"),
                actor=self.request.user,
            )
        except ValidationError as exc:
            messages.error(self.request, " ".join(getattr(exc, "messages", [str(exc)])))
            return self.form_invalid(form)
        messages.success(self.request, "Expenditure recorded.")
        return redirect("rims_finance:budget_detail", pk=self.budget.pk)


class _ExpenditureMutationMixin:
    """Allow the creator to mutate within ``EXPENDITURE_EDIT_GRACE`` of creation;
    otherwise the entry is immutable. Superusers and Finance Officers may always
    delete, but only edits are time-boxed."""

    def _can_mutate(self, expenditure: Expenditure) -> bool:
        u = self.request.user
        if u.is_superuser:
            return True
        if expenditure.created_by_id != u.id:
            return False
        return timezone.now() - expenditure.created_at <= EXPENDITURE_EDIT_GRACE


class ExpenditureUpdateView(_ExpenditureMutationMixin, FinanceStaffMixin, UpdateView):
    model = Expenditure
    form_class = ExpenditureForm
    template_name = "finance/expenditure_form.html"

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["budget"] = self.object.budget_line.budget
        return kwargs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["budget"] = self.object.budget_line.budget
        ctx["mode"] = "edit"
        return ctx

    def dispatch(self, request, *args, **kwargs):
        self.object = self.get_object()
        if not self._can_mutate(self.object):
            raise PermissionDenied("Expenditures can only be edited by their author within 24 hours.")
        return super().dispatch(request, *args, **kwargs)

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, "Expenditure updated.")
        return redirect("rims_finance:budget_detail", pk=self.object.budget_line.budget_id)


class ExpenditureDeleteView(FinanceStaffMixin, View):
    """POST-only delete. Returns an HTMX-friendly empty `<tr>` with hx-swap-oob
    when called via HTMX, otherwise redirects back to the budget detail."""

    def post(self, request, pk):
        exp = get_object_or_404(
            Expenditure.objects.select_related("budget_line__budget"),
            pk=pk,
        )
        budget_pk = exp.budget_line.budget_id
        exp.delete()
        if getattr(request, "htmx", False):
            from django.http import HttpResponse

            # 200 with empty body is enough — the row's `hx-target` swaps it out.
            return HttpResponse("")
        messages.success(request, "Expenditure deleted.")
        return redirect("rims_finance:budget_detail", pk=budget_pk)


class CashflowChartDataView(FinanceStaffMixin, View):
    """Returns JSON time-series for the cashflow chart partial.

    Without ``budget_pk`` route arg → organisation-wide aggregate. With one →
    per-budget. Buckets are months in the local timezone, on incurred_on for
    expenditures and approved_at for disbursements (paid date is more relevant
    than scheduled_for for a realised cashflow chart)."""

    def get(self, request, *args, **kwargs):
        budget_pk = kwargs.get("pk")
        exp_qs = Expenditure.objects.all()
        disb_qs = DisbursementRequest.objects.filter(
            status__in=[DisbursementRequest.Status.APPROVED, DisbursementRequest.Status.PAID],
        )
        scope_label = "Organisation-wide"
        if budget_pk is not None:
            budget = get_object_or_404(Budget, pk=budget_pk)
            exp_qs = exp_qs.filter(budget_line__budget=budget)
            disb_qs = disb_qs.filter(budget=budget)
            scope_label = budget.name

        exp_buckets = (
            exp_qs.annotate(month=TruncMonth("incurred_on"))
            .values("month")
            .annotate(total=Sum("amount"))
            .order_by("month")
        )
        disb_buckets = (
            disb_qs.annotate(month=TruncMonth("approved_at"))
            .values("month")
            .annotate(total=Sum("amount"))
            .order_by("month")
        )

        bucket_map: "OrderedDict[str, dict]" = OrderedDict()
        for row in exp_buckets:
            if not row["month"]:
                continue
            key = row["month"].strftime("%Y-%m")
            bucket_map.setdefault(key, {"label": key, "expended": 0, "disbursed": 0})
            bucket_map[key]["expended"] = float(row["total"] or 0)
        for row in disb_buckets:
            if not row["month"]:
                continue
            key = row["month"].strftime("%Y-%m")
            bucket_map.setdefault(key, {"label": key, "expended": 0, "disbursed": 0})
            bucket_map[key]["disbursed"] = float(row["total"] or 0)
        series = sorted(bucket_map.values(), key=lambda r: r["label"])
        return JsonResponse({
            "scope": scope_label,
            "buckets": series,
        })


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM040 — payment batching views.
# ---------------------------------------------------------------------------
class PaymentBatchListView(FinanceStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    """All payment batches across statuses, newest first."""

    model = PaymentBatch
    template_name = "finance/payment_batch_list.html"
    context_object_name = "batches"
    paginate_by = 25

    sortable_fields = {
        "created": "created_at",
        "status": "status",
        "label": "label",
    }
    default_sort = "-created_at"

    csv_columns = (
        ("Created", lambda b: b.created_at.isoformat() if b.created_at else ""),
        ("Label", "label"),
        ("Status", "get_status_display"),
        ("Total amount", "total_amount"),
        ("Item count", lambda b: b.items.count()),
        ("Created by", lambda b: b.created_by.email if b.created_by_id else ""),
    )
    csv_filename = "payment_batches"

    def get_queryset(self):
        qs = PaymentBatch.objects.prefetch_related("items").select_related(
            "created_by", "locked_by", "executed_by"
        )
        status = (self.request.GET.get("status") or "").strip()
        allowed = {s.value for s in PaymentBatch.Status}
        if status and status in allowed:
            qs = qs.filter(status=status)
        return self.apply_sort(qs)

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

        ctx = super().get_context_data(**kwargs)
        ctx["status_filter"] = (self.request.GET.get("status") or "").strip()
        ctx["status_choices"] = list(PaymentBatch.Status)
        # KPI roll-up across all batches (not just the filtered page).
        all_batches = PaymentBatch.objects.prefetch_related("items").all()
        total_value = Decimal("0")
        executed_value = Decimal("0")
        draft_count = locked_count = executed_count = 0
        for b in all_batches:
            if b.status == PaymentBatch.Status.DRAFT:
                draft_count += 1
            elif b.status == PaymentBatch.Status.LOCKED:
                locked_count += 1
            elif b.status == PaymentBatch.Status.EXECUTED:
                executed_count += 1
                executed_value += b.total_amount
            total_value += b.total_amount
        ctx["kpi_total_value"] = total_value
        ctx["kpi_executed_value"] = executed_value
        ctx["kpi_draft_count"] = draft_count
        ctx["kpi_locked_count"] = locked_count
        ctx["kpi_executed_count"] = executed_count
        ctx["kpi_total_count"] = all_batches.count()
        # PaymentBatch currency is a per-batch field; for the KPI tile we pick
        # the most common currency across batches so the headline number reads
        # naturally. Falls back to the project default when nothing is on file.
        from collections import Counter

        ccy_counter = Counter(b.currency for b in all_batches if b.currency)
        ctx["kpi_currency"] = ccy_counter.most_common(1)[0][0] if ccy_counter else ""
        ctx["as_of"] = timezone.now()
        ctx["fx_refreshed"] = _fx_refreshed_at()
        return ctx


class PaymentBatchCreateView(FinanceStaffMixin, View):
    """Two-step create — render the picker GET, build the batch on POST."""

    template_name = "finance/payment_batch_create.html"

    def _candidate_requests(self):
        return (
            DisbursementRequest.objects.filter(status=DisbursementRequest.Status.APPROVED)
            .filter(batch_item__isnull=True)
            .select_related("budget", "budget__award", "budget__award__application__applicant")
            .order_by("scheduled_for", "pk")
        )

    def get(self, request):
        from django.shortcuts import render

        return render(
            request,
            self.template_name,
            {
                "form": PaymentBatchForm(),
                "candidates": self._candidate_requests(),
            },
        )

    def post(self, request):
        form = PaymentBatchForm(request.POST)
        request_ids = request.POST.getlist("request_ids")
        from django.shortcuts import render

        if not form.is_valid() or not request_ids:
            if not request_ids:
                form.add_error(None, "Pick at least one approved disbursement request.")
            return render(
                request,
                self.template_name,
                {"form": form, "candidates": self._candidate_requests()},
            )
        candidates = list(
            DisbursementRequest.objects.filter(pk__in=request_ids).select_related("budget")
        )
        try:
            batch = create_payment_batch(
                candidates,
                batch_reference=form.cleaned_data["batch_reference"],
                scheduled_for=form.cleaned_data.get("scheduled_for"),
                payment_method=form.cleaned_data.get("payment_method"),
                notes=form.cleaned_data.get("notes") or "",
                actor=request.user,
            )
        except ValidationError as exc:
            form.add_error(None, exc.messages if hasattr(exc, "messages") else str(exc))
            return render(
                request,
                self.template_name,
                {"form": form, "candidates": self._candidate_requests()},
            )
        messages.success(request, f"Batch {batch.batch_reference} created with {batch.items.count()} item(s).")
        return redirect("rims_finance:payment_batch_detail", pk=batch.pk)


class PaymentBatchDetailView(FinanceStaffMixin, DetailView):
    model = PaymentBatch
    template_name = "finance/payment_batch_detail.html"
    context_object_name = "batch"

    def get_queryset(self):
        return PaymentBatch.objects.prefetch_related(
            "items__request__budget__award__application__applicant"
        ).select_related("created_by", "locked_by", "executed_by")


class _BatchActionView(FinanceStaffMixin, View):
    action: str = ""

    def post(self, request, pk):
        batch = get_object_or_404(PaymentBatch, pk=pk)
        try:
            if self.action == "lock":
                lock_payment_batch(batch, actor=request.user)
                messages.success(request, f"Batch {batch.batch_reference} locked.")
            elif self.action == "cancel":
                cancel_payment_batch(batch, actor=request.user)
                messages.warning(request, f"Batch {batch.batch_reference} cancelled.")
            elif self.action == "execute":
                # Optional bank references keyed off PaymentBatchItem.pk
                refs = {}
                for key, value in request.POST.items():
                    if key.startswith("ref_"):
                        try:
                            refs[int(key.removeprefix("ref_"))] = (value or "").strip()
                        except ValueError:
                            continue
                # PRD §5.4 FRFM045 — optional per-item proof file uploads.
                # File field key: proof_<item_pk>; channel key: channel_<item_pk>.
                proofs = {}
                for key, file_obj in request.FILES.items():
                    if key.startswith("proof_"):
                        try:
                            item_id = int(key.removeprefix("proof_"))
                        except ValueError:
                            continue
                        channel = (request.POST.get(f"channel_{item_id}") or "").strip()
                        proofs[item_id] = (file_obj, channel)
                execute_payment_batch(
                    batch,
                    item_references=refs,
                    item_proofs=proofs,
                    actor=request.user,
                )
                messages.success(
                    request,
                    f"Batch {batch.batch_reference} executed — {batch.items.count()} payment(s) processed.",
                )
            else:
                raise ValidationError(f"Unknown action {self.action!r}.")
        except ValidationError as exc:
            messages.error(request, exc.messages[0] if hasattr(exc, "messages") and exc.messages else str(exc))
        return redirect("rims_finance:payment_batch_detail", pk=batch.pk)


class PaymentBatchLockView(_BatchActionView):
    action = "lock"


class PaymentBatchCancelView(_BatchActionView):
    action = "cancel"


class PaymentBatchExecuteView(_BatchActionView):
    action = "execute"


class PaymentBatchReverseView(FinanceApprovalMixin, View):
    """PRD §5.4 FRFM044 — reverse an executed payment batch.

    P0 (assessment §3 #8) — gated to Finance Manager / Director (two-eyes
    on a destructive financial action). Requires a written reason and the
    bank chargeback reference when available.
    """

    def post(self, request, pk):
        batch = get_object_or_404(PaymentBatch, pk=pk)
        reason = (request.POST.get("reason") or "").strip()
        reference = (request.POST.get("reference") or "").strip()
        try:
            reverse_payment_batch(
                batch,
                reason=reason,
                reference=reference,
                actor=request.user,
            )
            messages.success(
                request,
                f"Batch {batch.batch_reference} reversed — {batch.items.count()} payment(s) walked back to APPROVED.",
            )
        except ValidationError as exc:
            messages.error(request, exc.messages[0] if hasattr(exc, "messages") and exc.messages else str(exc))
        return redirect("rims_finance:payment_batch_detail", pk=batch.pk)


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM014/018-025 — variance dashboard
# PRD §5.4 FRFM031-039 — donor reporting
# ---------------------------------------------------------------------------
class FinanceAnalyticsDashboardView(FinanceStaffMixin, View):
    """Variance + cost analytics roll-up across active-award budgets."""

    template_name = "finance/analytics_dashboard.html"

    def get(self, request):
        from django.shortcuts import render

        from apps.rims.grants.models import Award
        from apps.rims.finance.services_analytics import (
            aggregate_variance_snapshot,
            cost_breakdown_by_category,
            spend_curve,
        )

        threshold_param = (request.GET.get("threshold") or "").strip()
        try:
            threshold_pct = float(threshold_param) if threshold_param else 10.0
        except ValueError:
            threshold_pct = 10.0
        threshold_pct = max(min(threshold_pct, 100.0), 0.0)
        variances = aggregate_variance_snapshot(threshold_ratio=threshold_pct / 100.0)
        over_count = sum(1 for v in variances if v.direction == "over")
        under_count = sum(1 for v in variances if v.direction == "under")

        focus_budget_pk = (request.GET.get("budget") or "").strip()
        focus_budget = None
        cost_slices = []
        spend_series = []
        if focus_budget_pk.isdigit():
            focus_budget = Budget.objects.filter(pk=int(focus_budget_pk)).first()
            if focus_budget is not None:
                cost_slices = cost_breakdown_by_category(focus_budget)
                spend_series = spend_curve(focus_budget, bucket="month")

        active_award_count = Award.objects.filter(
            status__in=["active", "ready_for_closeout"]
        ).count()
        active_budget_count = Budget.objects.filter(
            award__status__in=["active", "ready_for_closeout"]
        ).count()

        # PRD §5.4 FRFM027 — count of budgets that have at least one Reforecast snapshot.
        budgets_with_reforecast = Reforecast.objects.values("budget_id").distinct().count()
        # PRD §5.4 FRFM042/043 — failed payments still awaiting retry resolution.
        failed_retries_count = PaymentRetry.objects.filter(
            status__in=[PaymentRetry.Status.QUEUED, PaymentRetry.Status.RETRYING]
        ).count()
        # PRD §5.4 FRFM046/049 — GL posting health: count of flagged entries.
        gl_flagged_count = GLEntry.objects.filter(
            validation_status=GLEntry.ValidationStatus.FLAGGED
        ).count()

        return render(
            request,
            self.template_name,
            {
                "variances": variances,
                "over_count": over_count,
                "under_count": under_count,
                "threshold_pct": threshold_pct,
                "focus_budget": focus_budget,
                "cost_slices": cost_slices,
                "spend_series": spend_series,
                "all_budgets": Budget.objects.select_related("award").order_by("name"),
                "active_award_count": active_award_count,
                "active_budget_count": active_budget_count,
                "budgets_with_reforecast": budgets_with_reforecast,
                "failed_retries_count": failed_retries_count,
                "gl_flagged_count": gl_flagged_count,
                "as_of": timezone.now(),
                "fx_refreshed": _fx_refreshed_at(),
            },
        )


class DonorReportListView(FinanceStaffMixin, View):
    """Per-donor finance roll-up packets across an optional reporting period."""

    template_name = "finance/donor_report_list.html"

    def _parse_period(self, request):
        from datetime import datetime

        def _parse(name):
            raw = (request.GET.get(name) or "").strip()
            if not raw:
                return None
            try:
                return datetime.strptime(raw, "%Y-%m-%d").date()
            except ValueError:
                return None

        return _parse("start"), _parse("end")

    def get(self, request):
        from decimal import Decimal

        from django.shortcuts import render

        from apps.rims.finance.services_analytics import donor_finance_summary

        start, end = self._parse_period(request)
        packets = donor_finance_summary(period_start=start, period_end=end)
        # Roll-up KPIs across all donors for the header strip.
        total_ceiling = sum((p.ceiling_total for p in packets), Decimal("0"))
        total_disbursed = sum((p.disbursed_total for p in packets), Decimal("0"))
        total_expended = sum((p.expended_total for p in packets), Decimal("0"))
        funded_donors = sum(1 for p in packets if p.budget_count > 0)
        return render(
            request,
            self.template_name,
            {
                "packets": packets,
                "period_start": start,
                "period_end": end,
                "total_ceiling": total_ceiling,
                "total_disbursed": total_disbursed,
                "total_expended": total_expended,
                "funded_donors": funded_donors,
                "donor_count": len(packets),
                "overall_utilisation": (
                    float(total_disbursed / total_ceiling) if total_ceiling else 0.0
                ),
            },
        )


class DonorReportDetailView(FinanceStaffMixin, View):
    """Detail page for one donor — every budget that draws on it + line items."""

    template_name = "finance/donor_report_detail.html"

    def get(self, request, pk):
        from datetime import datetime

        from django.shortcuts import render

        from apps.rims.finance.services_analytics import donor_finance_packet

        donor = get_object_or_404(FundingSource, pk=pk)

        def _parse(name):
            raw = (request.GET.get(name) or "").strip()
            if not raw:
                return None
            try:
                return datetime.strptime(raw, "%Y-%m-%d").date()
            except ValueError:
                return None

        start = _parse("start")
        end = _parse("end")
        packet = donor_finance_packet(donor, period_start=start, period_end=end)
        return render(
            request,
            self.template_name,
            {
                "donor": donor,
                "packet": packet,
                "period_start": start,
                "period_end": end,
            },
        )


class DonorReportExportView(FinanceStaffMixin, View):
    """CSV / XLSX export of one donor's roll-up packet (FRFM050/051).

    Supports ``?format=csv`` (default) and ``?format=xlsx``. Period filter
    (``?start=YYYY-MM-DD&end=YYYY-MM-DD``) is preserved alongside the
    format param.
    """

    def get(self, request, pk):
        from datetime import datetime

        from apps.core.utils.export import csv_response, xlsx_response
        from apps.rims.finance.services_analytics import donor_finance_packet

        donor = get_object_or_404(FundingSource, pk=pk)

        def _parse(name):
            raw = (request.GET.get(name) or "").strip()
            if not raw:
                return None
            try:
                return datetime.strptime(raw, "%Y-%m-%d").date()
            except ValueError:
                return None

        start = _parse("start")
        end = _parse("end")
        closeout_only = request.GET.get("closeout_only") in {"1", "true", "yes", "on"}
        packet = donor_finance_packet(
            donor,
            period_start=start,
            period_end=end,
            closeout_only=closeout_only,
        )

        headers = [
            "Donor",
            "Budget",
            "Awardee",
            "Currency",
            "Ceiling",
            "Disbursed",
            "Expended",
            "Period start",
            "Period end",
        ]
        if closeout_only:
            # PRD §5.1 FRFA-CO019 — surface close-out-specific columns.
            headers = headers + [
                "Closed at",
                "Archived at",
                "Residual returned",
                "Forced closure",
            ]
        rows = []
        for row in packet.rows:
            base = [
                donor.name,
                row["budget_name"],
                row["awardee"],
                row["currency"],
                str(row["ceiling"]),
                str(row["disbursed"]),
                str(row["expended"]),
                start.isoformat() if start else "",
                end.isoformat() if end else "",
            ]
            if closeout_only:
                closed_at = row.get("closed_at")
                archived_at = row.get("archived_at")
                base.extend(
                    [
                        closed_at.isoformat() if closed_at else "",
                        archived_at.isoformat() if archived_at else "",
                        str(row.get("residual_returned") or ""),
                        "yes" if row.get("forced_closure") else "",
                    ]
                )
            rows.append(base)

        slug = donor.code or donor.pk
        fmt = (request.GET.get("format") or "csv").lower()
        if fmt in {"xlsx", "excel"}:
            return xlsx_response(
                headers,
                rows,
                filename=f"rims-finance-donor-{slug}.xlsx",
                sheet_name=f"donor-{slug}"[:31],
            )
        return csv_response(
            headers,
            rows,
            filename=f"rims-finance-donor-{slug}.csv",
        )


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM012/013/015 — BudgetApprovalWorkflow CRUD.
# PRD §5.4 FRFM036/037/038 — Approval telemetry surface.
# ---------------------------------------------------------------------------
from django.shortcuts import render as _render  # noqa: E402


class BudgetApprovalWorkflowListView(FinanceApprovalMixin, SortableListMixin, ListView):
    model = BudgetApprovalWorkflow
    template_name = "finance/approval_workflow_list.html"
    context_object_name = "workflows"
    paginate_by = 25
    sortable_fields = {"created": "created_at"}
    default_sort = "-created_at"

    def get_queryset(self):
        return self.apply_sort(super().get_queryset().select_related("award", "funding_source", "created_by"))


class BudgetApprovalWorkflowCreateView(FinanceApprovalMixin, View):
    template_name = "finance/approval_workflow_form.html"

    def get(self, request):
        return _render(request, self.template_name, self._ctx())

    def post(self, request):
        award_id = request.POST.get("award") or None
        fs_id = request.POST.get("funding_source") or None
        mode = request.POST.get("approval_mode") or BudgetApprovalWorkflow.Mode.TOTAL
        threshold = request.POST.get("threshold_amount") or None
        tiers_raw = request.POST.get("required_tiers") or ""
        tiers = [t.strip() for t in tiers_raw.split(",") if t.strip()]
        award = None
        funding_source = None
        if award_id:
            from apps.rims.grants.models import Award

            award = get_object_or_404(Award, pk=award_id)
        if fs_id:
            funding_source = get_object_or_404(FundingSource, pk=fs_id)
        try:
            configure_budget_workflow(
                actor=request.user,
                award=award,
                funding_source=funding_source,
                approval_mode=mode,
                threshold_amount=Decimal(threshold) if threshold else None,
                required_tiers=tiers,
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return _render(request, self.template_name, self._ctx())
        messages.success(request, "Approval workflow configured.")
        return redirect("rims_finance:approval_workflow_list")

    def _ctx(self):
        from apps.rims.grants.models import Award

        return {
            "awards": Award.objects.select_related("application").order_by("-awarded_at")[:200],
            "funding_sources": FundingSource.objects.all()[:200],
            "modes": BudgetApprovalWorkflow.Mode.choices,
        }


class BudgetApprovalWorkflowUpdateView(FinanceApprovalMixin, View):
    template_name = "finance/approval_workflow_form.html"

    def get(self, request, pk):
        wf = get_object_or_404(BudgetApprovalWorkflow, pk=pk)
        return _render(request, self.template_name, {**self._ctx(), "workflow": wf})

    def post(self, request, pk):
        wf = get_object_or_404(BudgetApprovalWorkflow, pk=pk)
        wf.approval_mode = request.POST.get("approval_mode") or wf.approval_mode
        threshold = request.POST.get("threshold_amount") or None
        wf.threshold_amount = Decimal(threshold) if threshold else None
        tiers_raw = request.POST.get("required_tiers") or ""
        wf.required_tiers = [t.strip() for t in tiers_raw.split(",") if t.strip()]
        wf.save(update_fields=["approval_mode", "threshold_amount", "required_tiers"])
        messages.success(request, "Workflow updated.")
        return redirect("rims_finance:approval_workflow_list")

    def _ctx(self):
        from apps.rims.grants.models import Award

        return {
            "awards": Award.objects.select_related("application").order_by("-awarded_at")[:200],
            "funding_sources": FundingSource.objects.all()[:200],
            "modes": BudgetApprovalWorkflow.Mode.choices,
        }


class ApprovalTelemetryView(AuditorReadOnlyMixin, SortableListMixin, ListView):
    """PRD §5.4 FRFM036/037/038 — read-only telemetry of who approved which row."""

    model = FinanceApproval
    template_name = "finance/approval_telemetry.html"
    context_object_name = "approvals"
    paginate_by = 25
    sortable_fields = {"decided": "decided_at"}
    default_sort = "-decided_at"

    def get_queryset(self):
        qs = super().get_queryset().select_related("actor", "target_content_type")
        actor_id = self.request.GET.get("actor")
        if actor_id:
            qs = qs.filter(actor_id=actor_id)
        tier = self.request.GET.get("tier")
        if tier:
            qs = qs.filter(tier=tier)
        return self.apply_sort(qs)


class ApproveBudgetLineView(FinanceApprovalMixin, View):
    def post(self, request, pk, line_pk):
        line = get_object_or_404(BudgetLine, pk=line_pk, budget_id=pk)
        tier = (request.POST.get("tier") or "").strip() or getattr(request.user, "role", "")
        try:
            approve_budget_line(line, actor=request.user, tier=tier, comment=request.POST.get("comment", ""))
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_finance:budget_detail", pk=pk)
        messages.success(request, "Budget line approved.")
        return redirect("rims_finance:budget_detail", pk=pk)


class ApproveBudgetTotalView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        budget = get_object_or_404(Budget, pk=pk)
        tier = (request.POST.get("tier") or "").strip() or getattr(request.user, "role", "")
        try:
            approve_budget_total(budget, actor=request.user, tier=tier, comment=request.POST.get("comment", ""))
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_finance:budget_detail", pk=pk)
        messages.success(request, "Budget total approved.")
        return redirect("rims_finance:budget_detail", pk=pk)


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM027 — Reforecast views.
# ---------------------------------------------------------------------------
class ReforecastListView(FinanceStaffMixin, SortableListMixin, ListView):
    model = Reforecast
    template_name = "finance/reforecast_list.html"
    context_object_name = "reforecasts"
    paginate_by = 25
    sortable_fields = {"generated": "generated_at", "as_of": "as_of_date"}
    default_sort = "-generated_at"

    def get_queryset(self):
        qs = super().get_queryset().filter(budget_id=self.kwargs["pk"])
        return self.apply_sort(qs.select_related("budget", "generated_by"))

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["budget"] = get_object_or_404(Budget, pk=self.kwargs["pk"])
        return ctx


class ReforecastDetailView(FinanceStaffMixin, DetailView):
    model = Reforecast
    template_name = "finance/reforecast_detail.html"
    context_object_name = "reforecast"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["chart_rows"] = reforecast_vs_actual(self.object)
        ctx["lines"] = paginate_qs(
            self.request,
            self.object.lines.select_related("budget_line").all(),
            per_page=50,
            param="line_page",
        )
        return ctx


class ReforecastGenerateView(FinanceApprovalMixin, View):
    """POST-only manual trigger from the budget-detail panel."""

    def post(self, request, pk):
        budget = get_object_or_404(Budget, pk=pk)
        methodology = request.POST.get("methodology") or Reforecast.Methodology.BLENDED
        horizon = int(request.POST.get("horizon_months") or 12)
        try:
            rf = generate_reforecast(
                budget,
                horizon_months=horizon,
                methodology=methodology,
                actor=request.user,
                notes=(request.POST.get("notes") or "").strip(),
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_finance:budget_detail", pk=pk)
        messages.success(request, f"Reforecast #{rf.pk} generated.")
        return redirect("rims_finance:reforecast_detail", pk=rf.pk)


class ReforecastChartDataView(FinanceStaffMixin, View):
    def get(self, request, pk):
        rf = get_object_or_404(Reforecast, pk=pk)
        rows = reforecast_vs_actual(rf)
        return JsonResponse(
            {
                "labels": [r["bucket"] for r in rows],
                "forecast": [str(r["forecast"]) for r in rows],
                "actual": [str(r["actual"]) for r in rows],
            }
        )


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM042/043 — payment failure + retry queue views.
# ---------------------------------------------------------------------------
class PaymentRetryQueueView(FinanceStaffMixin, SortableListMixin, ListView):
    model = PaymentRetry
    template_name = "finance/payment_retry_queue.html"
    context_object_name = "retries"
    paginate_by = 25
    sortable_fields = {"created": "created_at", "status": "status"}
    default_sort = "-created_at"

    def get_queryset(self):
        qs = super().get_queryset().select_related(
            "execution", "execution__request", "execution__request__budget"
        )
        status = self.request.GET.get("status")
        if status:
            qs = qs.filter(status=status)
        return self.apply_sort(qs)


class PaymentRetryDetailView(FinanceStaffMixin, DetailView):
    model = PaymentRetry
    template_name = "finance/payment_retry_detail.html"
    context_object_name = "retry"


class MarkExecutionFailedView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        from apps.rims.finance.services import mark_execution_failed

        execution = get_object_or_404(DisbursementExecution, pk=pk)
        category = (request.POST.get("failure_category") or "other").strip()
        reason = (request.POST.get("failure_reason") or "").strip()
        try:
            mark_execution_failed(
                execution,
                actor=request.user,
                failure_category=category,
                failure_reason=reason,
            )
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_finance:disbursement_detail", pk=execution.request_id)
        messages.success(request, "Execution marked failed; retry queued.")
        return redirect("rims_finance:payment_retry_queue")


class RetryPaymentView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        from apps.rims.finance.services import attempt_payment_retry

        retry = get_object_or_404(PaymentRetry, pk=pk)
        try:
            attempt_payment_retry(retry, actor=request.user)
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_finance:payment_retry_detail", pk=pk)
        messages.success(request, "Retry recorded.")
        return redirect("rims_finance:payment_retry_detail", pk=pk)


class AbandonPaymentRetryView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        from apps.rims.finance.services import abandon_payment_retry

        retry = get_object_or_404(PaymentRetry, pk=pk)
        reason = (request.POST.get("reason") or "").strip()
        if not reason:
            messages.error(request, "A reason is required to abandon a retry.")
            return redirect("rims_finance:payment_retry_detail", pk=pk)
        abandon_payment_retry(retry, actor=request.user, reason=reason)
        messages.success(request, "Retry abandoned.")
        return redirect("rims_finance:payment_retry_queue")


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM046 / FRFM048 / FRFM049 — GL register + bank-statement views.
# ---------------------------------------------------------------------------
def _gl_awardee_name(entry) -> str:
    """Best-effort awardee label for a GL row's source disbursement.

    The budget→award→application→applicant tail can be absent on legacy or
    partially-linked records, so every hop is guarded.
    """
    request = getattr(getattr(entry, "execution", None), "request", None)
    budget = getattr(request, "budget", None)
    application = getattr(getattr(budget, "award", None), "application", None)
    applicant = getattr(application, "applicant", None)
    if applicant is None:
        return ""
    return applicant.get_full_name() or applicant.email


class GLEntryRegisterView(FinanceStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = GLEntry
    template_name = "finance/gl_register.html"
    context_object_name = "entries"
    paginate_by = 25
    sortable_fields = {"posted": "posted_at"}
    default_sort = "-posted_at"
    csv_columns = (
        ("Posted", lambda e: e.posted_at.isoformat() if e.posted_at else ""),
        ("Account", "account_code"),
        ("Debit", "debit"),
        ("Credit", "credit"),
        ("Currency", "currency"),
        ("Status", "validation_status"),
        ("Disbursement", lambda e: f"#{e.execution.request_id}"),
        ("Budget", lambda e: e.execution.request.budget.name),
        ("Awardee", lambda e: _gl_awardee_name(e)),
        ("Amount", lambda e: e.execution.request.amount),
    )
    csv_filename = "gl_register"

    # Deepen the eager-load so each row can show budget · awardee · amount
    # without an N+1 per row (PRD: GL register should be self-explanatory).
    _DISBURSEMENT_CHAIN = (
        "execution",
        "execution__request",
        "execution__request__budget",
        "execution__request__budget__award__application__applicant",
        "execution__request__budget__award__application__institution",
        "execution__request__milestone",
        "posted_by",
    )

    def get_queryset(self):
        qs = super().get_queryset().select_related(*self._DISBURSEMENT_CHAIN)
        status = self.request.GET.get("status")
        if status:
            qs = qs.filter(validation_status=status)
        return self.apply_sort(qs)


class GLEntryDetailView(FinanceStaffMixin, DetailView):
    model = GLEntry
    template_name = "finance/gl_entry_detail.html"
    context_object_name = "entry"

    def get_queryset(self):
        return GLEntry.objects.select_related(
            "execution",
            "execution__request",
            "execution__request__budget",
            "execution__request__budget__award__application__applicant",
            "execution__request__budget__award__application__institution",
            "execution__request__milestone",
            "posted_by",
        )


class VoidGLEntryView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        from apps.rims.finance.services_gl import void_gl_entry

        entry = get_object_or_404(GLEntry, pk=pk)
        reason = (request.POST.get("reason") or "").strip()
        if not reason:
            messages.error(request, "A reason is required to void a GL entry.")
            return redirect("rims_finance:gl_entry_detail", pk=pk)
        void_gl_entry(entry, actor=request.user, reason=reason)
        messages.success(request, "GL entry voided.")
        return redirect("rims_finance:gl_register")


class BankStatementListView(FinanceStaffMixin, SortableListMixin, ListView):
    model = BankStatement
    template_name = "finance/bank_statement_list.html"
    context_object_name = "statements"
    paginate_by = 25
    sortable_fields = {"uploaded": "uploaded_at", "period": "period_end"}
    default_sort = "-uploaded_at"

    def get_queryset(self):
        return self.apply_sort(super().get_queryset().select_related("uploaded_by"))


class BankStatementDetailView(FinanceStaffMixin, DetailView):
    model = BankStatement
    template_name = "finance/bank_statement_detail.html"
    context_object_name = "statement"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        lines = self.object.lines.select_related(
            "matched_execution", "matched_gl_entry"
        ).all()
        ctx["lines"] = paginate_qs(self.request, lines, per_page=50)
        return ctx


class BankStatementUploadView(FinanceApprovalMixin, View):
    template_name = "finance/bank_statement_upload.html"

    def get(self, request):
        from django.shortcuts import render

        return render(request, self.template_name, {})

    def post(self, request):
        from apps.rims.finance.services_reconciliation import (
            auto_match_bank_lines,
            import_bank_statement,
            parse_bank_statement_lines,
        )

        file_obj = request.FILES.get("file")
        name = (request.POST.get("name") or "Bank statement").strip()
        period_start = request.POST.get("period_start") or ""
        period_end = request.POST.get("period_end") or ""
        currency = (request.POST.get("currency") or "USD").upper()
        account_ref = (request.POST.get("account_reference") or "").strip()
        if not file_obj or not period_start or not period_end:
            messages.error(request, "File, period start, and period end are required.")
            return redirect("rims_finance:bank_statement_upload")
        try:
            from datetime import date as _date

            statement = import_bank_statement(
                actor=request.user,
                file=file_obj,
                name=name,
                period_start=_date.fromisoformat(period_start),
                period_end=_date.fromisoformat(period_end),
                account_reference=account_ref,
                currency=currency,
            )
            parse_bank_statement_lines(statement)
            counts = auto_match_bank_lines(statement)
        except ValidationError as exc:
            messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
            return redirect("rims_finance:bank_statement_upload")
        messages.success(
            request,
            f"Statement uploaded — matched {counts['matched']} of {counts['matched'] + counts['unmatched']} lines.",
        )
        return redirect("rims_finance:bank_statement_detail", pk=statement.pk)


class ConfirmBankMatchView(FinanceApprovalMixin, View):
    def post(self, request, pk):
        from apps.rims.finance.services_reconciliation import confirm_bank_match

        line = get_object_or_404(BankStatementLine, pk=pk)
        execution_id = request.POST.get("execution_id")
        if not execution_id:
            messages.error(request, "Choose an execution to match against.")
            return redirect("rims_finance:bank_statement_detail", pk=line.statement_id)
        execution = get_object_or_404(DisbursementExecution, pk=execution_id)
        confirm_bank_match(line, execution=execution, actor=request.user)
        messages.success(request, "Bank line matched.")
        return redirect("rims_finance:bank_statement_detail", pk=line.statement_id)
