import heapq
import os
import re
from collections import OrderedDict
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation

import requests
from django.conf import settings
from django.core.cache import cache
from django.db.models import Count, DateField, F, Prefetch, Q, Sum
from django.db.models.functions import Coalesce, TruncDate, TruncMonth
from django.utils import timezone
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter

from apps.core.pagination import StandardResultsSetPagination
from apps.core.tenancy import TenantScopedMixin, get_active_organization

AI_DESCRIBE_SYSTEM_PROMPT = (
    "You are an assistant that writes clear, professional project descriptions for a "
    "research organization's internal knowledge hub.\n\n"
    "Rules:\n"
    "- Respond ONLY with the project description itself. No preamble, no commentary, "
    "no markdown code fences.\n"
    "- Format as clean semantic HTML for a rich-text editor: use <p>, <h2>, <ul>/<li>, "
    "<strong>. Do NOT include <html>, <head>, <body>, or <style> tags.\n"
    "- Keep it concise and scannable: a short overview paragraph, then optional sections "
    "(Objectives, Scope, Outcomes) only when they add value.\n"
    "- Match the user's requested language; default to English."
)


_HTML_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")


def _strip_html(html):
    text = _HTML_TAG_RE.sub(" ", html or "")
    return _WS_RE.sub(" ", text).strip()[:2000]


def _resolve_ai_config():
    """Resolve (api_key, base_url, model) from the DB AppConfig, falling back
    to env settings. Honors the selected provider (deepseek / minimax)."""
    from apps.core.models import AppConfig

    cfg = AppConfig.objects.first()
    provider = cfg.ai_provider if cfg else "deepseek"

    if provider == "minimax":
        key = (cfg.minimax_api_key if cfg else "") or os.environ.get("MINIMAX_API_KEY", "")
        base = (cfg.minimax_base_url if cfg else "") or os.environ.get("MINIMAX_BASE_URL", "https://api.minimax.io/v1")
        model = (cfg.minimax_model if cfg else "") or os.environ.get("MINIMAX_MODEL", "MiniMax-M3")
        return key, base, model

    key = (cfg.deepseek_api_key if cfg else "") or settings.DEEPSEEK_API_KEY
    base = (cfg.deepseek_base_url if cfg else "") or settings.DEEPSEEK_BASE_URL
    model = (cfg.deepseek_model if cfg else "") or settings.DEEPSEEK_MODEL
    return key, base, model


def _build_ai_context(name, current, project_id, organization):
    """Ground the description model on real organization + project data so it
    stays precise and does not invent donors, figures, names, or dates."""
    from apps.companies.models import Department

    lines = []

    # Organization identity + research divisions.
    op = organization
    org_name = (op.name if op else "") or "the organization"
    org_bits = [f"- Name: {org_name}"]
    if op and op.legal_name:
        org_bits.append(f"- Legal entity: {op.legal_name}")
    if op and (op.city or op.country):
        org_bits.append(f"- Location: {', '.join(p for p in [op.city, op.country] if p)}")
    if op and op.website:
        org_bits.append(f"- Website: {op.website}")
    if op and op.description:
        org_bits.append(f"- About: {op.description.strip()[:600]}")
    divisions = list(Department.objects.filter(organization=organization, parent__isnull=True).values_list("name", flat=True)[:15])
    if divisions:
        org_bits.append(f"- Research divisions: {', '.join(divisions)}")
    lines.append(
        "ORGANIZATION (this knowledge hub belongs to an independent, non-profit "
        "policy-research institute / think tank):\n" + "\n".join(org_bits)
    )

    # How projects work in this hub — gives the model the right framing.
    lines.append(
        "ECOSYSTEM:\n"
        "- Projects are research or policy initiatives funded by grants, "
        "sponsorships, donations, or co-funding from donor institutions "
        "(multilateral agencies, governments, foundations).\n"
        "- Outputs are disseminated through events (webinars, conferences, "
        "workshops, seminars) and publications.\n"
        "- Funding amounts are tracked in IDR and USD."
    )

    # Live facts about this specific project.
    facts = [f"- Name: {name or 'Untitled'}"]
    p = Project.objects.filter(id=project_id, organization=organization).first() if project_id else None
    if p:
        facts.append(f"- Status: {p.get_status_display()}")
        if p.start_date or p.end_date:
            facts.append(
                f"- Timeline: {p.start_date or '?'} to {p.end_date or '?'}"
            )
        if p.tags:
            facts.append(f"- Tags: {', '.join(p.tags)}")
        funds = list(p.funds.select_related("donor")[:15])
        if funds:
            parts = []
            for f in funds:
                donor = f.donor.name if f.donor_id else (f.source or "unnamed donor")
                try:
                    amt = f"{f.currency} {float(f.amount):,.0f}"
                except (TypeError, ValueError):
                    amt = ""
                parts.append(f"{donor} ({f.get_fund_type_display()}{', ' + amt if amt else ''})")
            facts.append("- Funding: " + "; ".join(parts))
        team = list(p.team_members.select_related("user")[:15])
        if team:
            members = [
                f"{(tm.user.get_full_name() or tm.user.email)} ({tm.role})"
                for tm in team
            ]
            facts.append("- Team: " + ", ".join(members))
        dissem = list(p.disseminations.all()[:10])
        if dissem:
            titles = [d.title for d in dissem if d.title]
            if titles:
                facts.append("- Disseminations: " + "; ".join(titles))
    if current:
        facts.append(f"- Current description (plain text): {current}")
    lines.append(
        "PROJECT FACTS (use ONLY these for figures, donors, people, and dates; "
        "do not invent any):\n" + "\n".join(facts)
    )

    return f"{AI_DESCRIBE_SYSTEM_PROMPT}\n\n" + "\n\n".join(lines)

from .models import (
    Project, ProjectDocument, ProjectTeamMember,
    ProjectFinance, ProjectDissemination, ProjectSchedule, ProjectFund,
    ProjectFundAllocation, ProjectFundAllocationRevision, CashAdvance,
    FundingOpportunity, FundingProposal, FundReport,
    ProjectFundTranche, ProjectFundCompliance, FundDocument,
    ProjectResource, ProjectLog, PaymentRequestForm, PRFAttachment,
    Deliverable, ProjectIndicator, IndicatorRecord, ProjectRisk,
)
from .serializers import (
    ProjectSerializer, ProjectDocumentSerializer, ProjectTeamMemberSerializer,
    ProjectFinanceSerializer, ProjectDisseminationSerializer, ProjectScheduleSerializer,
    ProjectFundSerializer, ProjectFundAllocationSerializer, ProjectSettingsSerializer,
    ProjectFundAllocationRevisionSerializer,
    FundingOpportunitySerializer, FundingProposalSerializer, FundReportSerializer,
    ProjectFundTrancheSerializer, ProjectFundComplianceSerializer, FundDocumentSerializer,
    ProjectResourceSerializer, ProjectLogSerializer, CashAdvanceSerializer,
    PaymentRequestFormSerializer, PRFAttachmentSerializer,
    DeliverableSerializer, ProjectIndicatorSerializer, IndicatorRecordSerializer,
    ProjectRiskSerializer,
)
from .models import ProjectSettings as ProjectSettingsModel
from .logging import log_project


def _serializable(value):
    """Decimal/None-safe conversion for storing model field values in a
    JSONField (revision proposed_data/previous_data snapshots)."""
    from decimal import Decimal
    if isinstance(value, Decimal):
        return str(value)
    if hasattr(value, "pk"):  # FK instance (e.g. budget_item)
        return str(value.pk)
    return value


def _slug(text):
    return re.sub(r"[^a-z0-9]+", "-", (text or "fund").lower()).strip("-") or "fund"


def _stamp_finance_idr(obj):
    """Fill ProjectFinance.amount_idr from amount/currency at the entry's date."""
    from .fx import fetch_idr_rate
    ref_date = obj.date or (obj.created_at.date() if obj.created_at else None)
    rate = fetch_idr_rate(obj.currency, ref_date) if ref_date else None
    new_idr = (obj.amount * rate) if rate is not None else None
    if obj.amount_idr != new_idr:
        obj.amount_idr = new_idr
        obj.save(update_fields=["amount_idr", "updated_at"])


def _xlsx_response(sheet_title, headers, rows, filename):
    """Build a single-sheet .xlsx HttpResponse with a bold header row."""
    import openpyxl
    from openpyxl.styles import Font
    from django.http import HttpResponse

    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = sheet_title[:31] or "Sheet1"
    ws.append(headers)
    for c in ws[1]:
        c.font = Font(bold=True)
    for row in rows:
        ws.append(row)
    resp = HttpResponse(
        content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )
    resp["Content-Disposition"] = f'attachment; filename="{filename}"'
    wb.save(resp)
    return resp


def _date_bound_q(lookups, start, end):
    """Q for: effective date (COALESCE of `lookups`, tried in order) in [start, end].

    Each lookup may use `__date` for datetime fields; the null-check applies to
    the underlying field. Mirrors the Python-side `field_a or field_b` fallback
    so the date bounds run in SQL instead of post-fetch.
    """
    q = Q()
    nulls_so_far = Q()
    for lk in lookups:
        cond = Q()
        if start:
            cond &= Q(**{f"{lk}__gte": start})
        if end:
            cond &= Q(**{f"{lk}__lte": end})
        q |= nulls_so_far & cond
        base = lk.removesuffix("__date")
        nulls_so_far &= Q(**{f"{base}__isnull": True})
    return q


def _collect_cash_transactions(organization, start=None, end=None, limit=None):
    """Union of actual cash movements (paid/received) across the system.

    Optional [start, end] date bounds (inclusive) filter by each row's
    effective date. Sources: ProjectFinance, procurement Payments,
    Reimbursements, Payment Requests, Payroll, received project Funds.
    Returns a list of normalized dicts sorted by date desc. When ``limit`` is
    set, each source query and the merged result are bounded to that many rows.
    """
    from apps.procurements.models import Payment
    from apps.administrations.models import Reimbursement, PaymentRequest
    from apps.hr.models import Payroll

    bounded = start or end

    def in_range(d):
        if start and d < start:
            return False
        if end and d > end:
            return False
        return True

    rows = []
    newest_rows = []

    def newest(queryset, lookups):
        if limit is None:
            return queryset
        expressions = [
            TruncDate(lookup.removesuffix("__date"))
            if lookup.endswith("__date") else F(lookup)
            for lookup in lookups
        ]
        return queryset.annotate(
            _cash_effective_date=Coalesce(*expressions, output_field=DateField()),
        ).order_by("-_cash_effective_date", "-pk")[:limit]

    def add_row(row):
        if limit is None:
            rows.append(row)
            return
        item = ((row["date"], row["id"]), row)
        if len(newest_rows) < limit:
            heapq.heappush(newest_rows, item)
        elif item[0] > newest_rows[0][0]:
            heapq.heapreplace(newest_rows, item)

    pf_qs = (ProjectFinance.objects.filter(project__organization=organization)
             .select_related("project")
             .only("id", "description", "amount", "type", "date", "created_at",
                   "project__id", "project__name"))
    if bounded:
        pf_qs = pf_qs.filter(_date_bound_q(["date", "created_at__date"], start, end))
    pf_qs = newest(pf_qs, ["date", "created_at__date"])
    for e in pf_qs.iterator():
        d = e.date or e.created_at.date()
        if not in_range(d):
            continue
        add_row({
            "id": f"pf-{e.id}",
            "source": "project_finance",
            "source_label": "Project Finance",
            "description": e.description,
            "amount": float(e.amount),
            "type": e.type,
            "date": d.isoformat(),
            "status": "recorded",
            "project_id": str(e.project_id),
            "project_name": e.project.name,
            "reference": None,
        })

    pay_qs = (Payment.objects.filter(organization=organization, status="completed")
              .select_related("invoice", "invoice__vendor"))
    if bounded:
        pay_qs = pay_qs.filter(_date_bound_q(["paid_at__date", "created_at__date"], start, end))
    pay_qs = newest(pay_qs, ["paid_at__date", "created_at__date"])
    for p in pay_qs.iterator():
        d = (p.paid_at or p.created_at).date()
        if not in_range(d):
            continue
        ref = p.reference or (p.invoice.invoice_number if p.invoice_id else None)
        add_row({
            "id": f"pay-{p.id}",
            "source": "procurement_payment",
            "source_label": "Procurement Payment",
            "description": f"Payment {ref}" if ref else "Procurement payment",
            "amount": float(p.amount),
            "type": "expense",
            "date": d.isoformat(),
            "status": "paid",
            "project_id": None,
            "project_name": None,
            "reference": ref,
        })

    reimb_qs = (Reimbursement.objects.filter(organization=organization, status="paid")
                .select_related("requester"))
    if bounded:
        reimb_qs = reimb_qs.filter(_date_bound_q(["paid_at__date", "created_at__date"], start, end))
    reimb_qs = newest(reimb_qs, ["paid_at__date", "created_at__date"])
    for r in reimb_qs.iterator():
        d = (r.paid_at or r.created_at).date()
        if not in_range(d):
            continue
        add_row({
            "id": f"reimb-{r.id}",
            "source": "reimbursement",
            "source_label": "Reimbursement",
            "description": r.title,
            "amount": float(r.amount),
            "type": "expense",
            "date": d.isoformat(),
            "status": "paid",
            "project_id": None,
            "project_name": None,
            "reference": r.requester.get_full_name() or r.requester.email,
        })

    payreq_qs = (PaymentRequest.objects.filter(organization=organization, status="paid")
                 .select_related("requester"))
    if bounded:
        payreq_qs = payreq_qs.filter(_date_bound_q(["paid_at__date", "created_at__date"], start, end))
    payreq_qs = newest(payreq_qs, ["paid_at__date", "created_at__date"])
    for pr in payreq_qs.iterator():
        d = (pr.paid_at or pr.created_at).date()
        if not in_range(d):
            continue
        add_row({
            "id": f"payreq-{pr.id}",
            "source": "payment_request",
            "source_label": "Payment Request",
            "description": pr.title,
            "amount": float(pr.amount),
            "type": "expense",
            "date": d.isoformat(),
            "status": "paid",
            "project_id": None,
            "project_name": None,
            "reference": pr.payee,
        })

    payroll_qs = (Payroll.objects.filter(employee__organization=organization, status="paid")
                  .select_related("employee", "employee__user"))
    if bounded:
        payroll_qs = payroll_qs.filter(_date_bound_q(["paid_at__date", "period_end"], start, end))
    payroll_qs = newest(payroll_qs, ["paid_at__date", "period_end"])
    for pay in payroll_qs.iterator():
        d = pay.paid_at.date() if pay.paid_at else pay.period_end
        if not in_range(d):
            continue
        add_row({
            "id": f"payroll-{pay.id}",
            "source": "payroll",
            "source_label": "Payroll",
            "description": f"Payroll {pay.employee.user.get_full_name()} ({pay.period_start.isoformat()}–{pay.period_end.isoformat()})",
            "amount": float(pay.net_salary),
            "type": "expense",
            "date": d.isoformat(),
            "status": "paid",
            "project_id": None,
            "project_name": None,
            "reference": None,
        })

    fund_qs = (ProjectFund.objects.filter(organization=organization, status__in=["received", "allocated", "closed"])
               .select_related("project"))
    if bounded:
        fund_qs = fund_qs.filter(
            _date_bound_q(["received_date", "agreement_date", "created_at__date"], start, end)
        )
    fund_qs = newest(fund_qs, ["received_date", "agreement_date", "created_at__date"])
    for f in fund_qs.iterator():
        d = f.received_date or f.agreement_date or f.created_at.date()
        if not in_range(d):
            continue
        amt = f.amount_idr if f.amount_idr is not None else f.amount
        add_row({
            "id": f"fund-{f.id}",
            "source": "project_fund",
            "source_label": "Project Fund",
            "description": f"{f.source} ({f.get_fund_type_display()})",
            "amount": float(amt),
            "type": "income",
            "date": d.isoformat(),
            "status": f.status,
            "project_id": str(f.project_id) if f.project_id else None,
            "project_name": f.project.name if f.project_id else None,
            "reference": None,
        })

    if limit is not None:
        return [item[1] for item in sorted(newest_rows, key=lambda item: item[0], reverse=True)]

    # Sort by date desc with `id` as a stable tie-breaker.
    rows.sort(key=lambda r: (r["date"], r["id"]), reverse=True)
    return rows


class ProjectViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """ViewSet for Project management."""
    rbac_domain = "project"
    # Expense approval needs the dedicated project.approve permission, not the
    # default "view" that custom actions fall back to.
    rbac_action_map = {"finance_approve": "approve", "finance_reject": "approve"}
    queryset = Project.objects.all().select_related("lead__user").prefetch_related(
        Prefetch("funds", queryset=ProjectFund.objects.select_related("donor")),
        "departments",
    )
    serializer_class = ProjectSerializer
    pagination_class = StandardResultsSetPagination

    def perform_create(self, serializer):
        project = serializer.save(organization=get_active_organization(self.request))
        log_project(project.id, self.request.user, "project", f"created project “{project.name}”")

    def perform_update(self, serializer):
        old_status = serializer.instance.status
        project = serializer.save()
        if project.status != old_status:
            log_project(
                project.id, self.request.user, "status",
                f"changed status from {old_status} to {project.status}",
            )
        else:
            log_project(project.id, self.request.user, "project", "updated project details")

    def perform_destroy(self, instance):
        # ProjectLog.project is on_delete=CASCADE, so a log tied to this
        # project's id would be deleted along with it — record the deletion
        # event on AccessAuditLog (not project-scoped) instead, so it survives.
        from apps.core.models import AccessAuditLog
        actor = self.request.user if self.request.user.is_authenticated else None
        AccessAuditLog.objects.create(
            actor=actor, event_type="project_deleted", object_type="project",
            object_id=str(instance.id), previous_value={"name": instance.name},
        )
        instance.delete()

    @extend_schema(
        parameters=[
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="lead", type=str, description="Filter by lead Employee ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

    @extend_schema(
        parameters=[OpenApiParameter(name="limit", type=int, description="Max rows (default 5)")],
        responses={200: ProjectSerializer(many=True)},
    )
    @action(detail=False, methods=["get"], url_path="mine")
    def mine(self, request):
        """Latest projects owned by or with current user as team member."""
        if not request.user.is_authenticated:
            return Response([])
        try:
            limit = int(request.query_params.get("limit", 5))
        except ValueError:
            limit = 5
        limit = max(1, min(limit, 50))
        # Build on get_queryset() so the serializer's fund/lead fields hit the
        # class-level select_related/prefetch_related instead of re-querying per row.
        qs = (
            self.get_queryset().filter(
                Q(lead__user=request.user) | Q(team_members__user=request.user)
            )
            .distinct()
            .order_by("-created_at")[:limit]
        )
        serializer = self.get_serializer(qs, many=True)
        return Response(serializer.data)

    @extend_schema(responses={200: {"type": "object"}})
    @action(detail=False, methods=["get"], url_path="finance-summary")
    def finance_summary(self, request):
        """Cross-project finance roll-up: per-project income/expense + grand totals + monthly trend."""
        org = get_active_organization(request)
        cache_key = f"projects:finance_summary:{org.id}"
        cached = cache.get(cache_key)
        if cached is not None:
            return Response(cached)
        today = timezone.now().date()
        six_months_ago = (today.replace(day=1) - timedelta(days=180)).replace(day=1)

        # Per-project aggregation: single query grouped by (project_id, type)
        per_proj_rows = (
            ProjectFinance.objects
            .filter(project__organization=org)
            .values("project_id", "type")
            .annotate(s=Sum("amount"), c=Count("id"))
        )
        agg = {}
        for r in per_proj_rows:
            bucket = agg.setdefault(r["project_id"], {"income": 0.0, "expense": 0.0, "count": 0})
            bucket["count"] += r["c"]
            if r["type"] == "income":
                bucket["income"] = float(r["s"] or 0)
            elif r["type"] == "expense":
                bucket["expense"] = float(r["s"] or 0)

        per_project = []
        all_income = 0.0
        all_expense = 0.0
        # values() over instantiating Project/Employee/User models — only the
        # 5 columns this payload needs.
        proj_rows = Project.objects.filter(organization=org).values(
            "id", "name", "status", "lead__user_id",
            "lead__user__first_name", "lead__user__last_name",
        )
        for p in proj_rows:
            b = agg.get(p["id"], {"income": 0.0, "expense": 0.0, "count": 0})
            lead = None
            if p["lead__user_id"]:
                lead = f"{p['lead__user__first_name']} {p['lead__user__last_name']}".strip()
            per_project.append({
                "id": str(p["id"]),
                "name": p["name"],
                "status": p["status"],
                "lead": lead,
                "income": b["income"],
                "expense": b["expense"],
                "balance": b["income"] - b["expense"],
                "entry_count": b["count"],
            })
            all_income += b["income"]
            all_expense += b["expense"]

        # Monthly trend last 6 months: single grouped query
        cursor = six_months_ago
        month_starts = []
        for _ in range(7):
            month_starts.append(cursor)
            cursor = (cursor + timedelta(days=32)).replace(day=1)
        target_months = month_starts[-6:]
        monthly_rows = (
            ProjectFinance.objects
            .filter(project__organization=org, date__gte=target_months[0])
            .annotate(m=TruncMonth("date"))
            .values("m", "type")
            .annotate(s=Sum("amount"))
        )
        monthly_map = {}
        for r in monthly_rows:
            m = r["m"]
            key = (m.year, m.month) if m else None
            if key is None:
                continue
            bucket = monthly_map.setdefault(key, {"income": 0.0, "expense": 0.0})
            if r["type"] == "income":
                bucket["income"] = float(r["s"] or 0)
            elif r["type"] == "expense":
                bucket["expense"] = float(r["s"] or 0)
        monthly = []
        for m_start in target_months:
            b = monthly_map.get((m_start.year, m_start.month), {"income": 0.0, "expense": 0.0})
            monthly.append({"month": m_start.strftime("%b"), **b})

        # Recent entries
        recent_qs = (
            ProjectFinance.objects
            .filter(project__organization=org)
            .select_related("project")
            .order_by("-created_at")[:15]
        )
        recent = [{
            "id": str(e.id),
            "project_id": str(e.project_id),
            "project_name": e.project.name,
            "description": e.description,
            "amount": float(e.amount),
            "type": e.type,
            "ledger": e.ledger,
            "ledger_label": e.get_ledger_display(),
            "category": e.category,
            "date": e.date.isoformat() if e.date else None,
            "created_at": e.created_at.isoformat(),
        } for e in recent_qs]

        # Per-ledger breakdown: single grouped query + total count
        ledger_rows = (
            ProjectFinance.objects
            .filter(project__organization=org)
            .values("ledger", "type")
            .annotate(s=Sum("amount"), c=Count("id"))
        )
        ledger_agg = {}
        total_entries = 0
        for r in ledger_rows:
            bucket = ledger_agg.setdefault(r["ledger"], {"income": 0.0, "expense": 0.0, "count": 0})
            bucket["count"] += r["c"]
            total_entries += r["c"]
            if r["type"] == "income":
                bucket["income"] = float(r["s"] or 0)
            elif r["type"] == "expense":
                bucket["expense"] = float(r["s"] or 0)
        ledger_data = []
        for code, label in ProjectFinance.LEDGER_CHOICES:
            b = ledger_agg.get(code, {"income": 0.0, "expense": 0.0, "count": 0})
            ledger_data.append({
                "code": code,
                "label": label,
                "income": b["income"],
                "expense": b["expense"],
                "balance": b["income"] - b["expense"],
                "entry_count": b["count"],
            })

        payload = {
            "totals": {
                "income": all_income,
                "expense": all_expense,
                "balance": all_income - all_expense,
                "project_count": len(per_project),
                "entry_count": total_entries,
            },
            "per_project": sorted(per_project, key=lambda p: -p["balance"]),
            "per_ledger": ledger_data,
            "monthly": monthly,
            "recent": recent,
        }
        cache.set(cache_key, payload, 60)  # 60s TTL — finance data not real-time-critical
        return Response(payload)

    @extend_schema(
        parameters=[
            OpenApiParameter(name="project", type=str, description="Narrow to one project ID"),
            OpenApiParameter(name="limit", type=int, description="Max rows (default 50, max 200)"),
        ],
        responses={200: {"type": "array"}},
    )
    @action(detail=False, methods=["get"], url_path="activity")
    def activity(self, request):
        """Project activity feed: derived from recent Task creates/edits, ProjectDocument uploads,
        ProjectFinance entries, ProjectFund entries. Scoped to user's owned/member projects."""
        from apps.tasks.models import Task

        try:
            limit = min(max(int(request.query_params.get("limit", 50)), 1), 200)
        except ValueError:
            limit = 50

        user = request.user
        org = get_active_organization(request)
        if user.is_staff or user.is_superuser:
            project_ids = list(Project.objects.filter(organization=org).values_list("id", flat=True))
        else:
            project_ids = list(
                Project.objects.filter(
                    Q(lead__user=user) | Q(team_members__user=user), organization=org,
                ).distinct().values_list("id", flat=True)
            )

        narrow = request.query_params.get("project")
        if narrow:
            project_ids = [pid for pid in project_ids if str(pid) == str(narrow)]

        if not project_ids:
            return Response([])

        # Map of project id -> name for labelling
        proj_names = dict(Project.objects.filter(id__in=project_ids).values_list("id", "name"))

        events = []

        # Tasks created
        for t in (
            Task.objects.filter(project_id__in=project_ids)
            .select_related("created_by", "project")
            .order_by("-created_at")[:limit]
        ):
            events.append({
                "id": f"task-{t.id}",
                "activity_type": "task",
                "title": f"created task “{t.title}”",
                "user_name": (t.created_by.get_full_name() or t.created_by.email) if t.created_by_id else None,
                "project": str(t.project_id) if t.project_id else None,
                "project_name": proj_names.get(t.project_id),
                "created_at": t.created_at.isoformat(),
            })

        # Documents uploaded
        for d in (
            ProjectDocument.objects.filter(project_id__in=project_ids)
            .select_related("uploaded_by", "project")
            .order_by("-created_at")[:limit]
        ):
            events.append({
                "id": f"doc-{d.id}",
                "activity_type": "note",
                "title": f"uploaded document “{d.name}”",
                "user_name": (d.uploaded_by.get_full_name() or d.uploaded_by.email) if d.uploaded_by_id else None,
                "project": str(d.project_id),
                "project_name": proj_names.get(d.project_id),
                "created_at": d.created_at.isoformat(),
            })

        # Finance entries
        for f in (
            ProjectFinance.objects.filter(project_id__in=project_ids)
            .select_related("project")
            .order_by("-created_at")[:limit]
        ):
            verb = "added income" if f.type == "income" else "logged expense"
            events.append({
                "id": f"fin-{f.id}",
                "activity_type": "other",
                "title": f"{verb} “{f.description}” ({float(f.amount):,.0f})",
                "user_name": None,
                "project": str(f.project_id),
                "project_name": proj_names.get(f.project_id),
                "created_at": f.created_at.isoformat(),
            })

        # Funds added
        for fd in (
            ProjectFund.objects.filter(project_id__in=project_ids)
            .select_related("project", "donor")
            .order_by("-created_at")[:limit]
        ):
            label = fd.source or (fd.donor.name if fd.donor_id else "fund")
            events.append({
                "id": f"fund-{fd.id}",
                "activity_type": "meeting",
                "title": f"added fund “{label}”",
                "user_name": None,
                "project": str(fd.project_id),
                "project_name": proj_names.get(fd.project_id),
                "created_at": fd.created_at.isoformat(),
            })

        events.sort(key=lambda e: e["created_at"], reverse=True)
        return Response(events[:limit])

    @action(detail=False, methods=["get"], url_path="finance-ledger")
    def finance_ledger(self, request):
        """All ProjectFinance entries across projects, ordered by date desc."""
        qs = (
            ProjectFinance.objects
            .select_related("project")
            .order_by("-date", "-created_at")
        )
        proj_filter = request.query_params.get("project")
        if proj_filter:
            qs = qs.filter(project_id=proj_filter)
        type_filter = request.query_params.get("type")
        if type_filter:
            qs = qs.filter(type=type_filter)
        ledger_filter = request.query_params.get("ledger")
        if ledger_filter:
            qs = qs.filter(ledger=ledger_filter)

        def serialize(rows):
            return [{
                "id": str(e.id),
                "project_id": str(e.project_id),
                "project_name": e.project.name,
                "description": e.description,
                "amount": float(e.amount),
                "type": e.type,
                "ledger": e.ledger,
                "ledger_label": e.get_ledger_display(),
                "category": e.category,
                "date": e.date.isoformat() if e.date else None,
                "created_at": e.created_at.isoformat(),
            } for e in rows]

        page = self.paginate_queryset(qs)
        if page is not None:
            return self.get_paginated_response(serialize(page))
        return Response(serialize(qs))

    # Hard ceiling on rows returned by all-transactions regardless of range —
    # this endpoint materializes 6 sources in Python (no shared table for a
    # SQL-level UNION), so an unbounded multi-year range would union 6+
    # tables' full history into one response. PERF-003: `?all=true` no
    # longer means "no limit" — it's capped here instead. The response
    # contract (a raw JSON array) is kept as-is: the only consumer
    # (frontend/app/(app)/finance/transactions) does client-side filtering,
    # search, and CSV/PDF/Excel export over the full array today, and
    # migrating it to server-side pagination is a separate, deliberate UI
    # change — not something to fold silently into this endpoint's fix.
    ALL_TRANSACTIONS_MAX_ROWS = 5000

    @extend_schema(parameters=[
        OpenApiParameter(name="start", type=str, description="Start date YYYY-MM-DD (inclusive)"),
        OpenApiParameter(name="end", type=str, description="End date YYYY-MM-DD (inclusive)"),
        OpenApiParameter(name="all", type=bool, description="Return full history bounded by ALL_TRANSACTIONS_MAX_ROWS (default: current year only)"),
    ])
    @action(detail=False, methods=["get"], url_path="all-transactions")
    def all_transactions(self, request):
        """Unified feed of actual cash movements across the system.

        Reads-only union of money that actually moved (paid/received), not
        commitments (POs, contracts, requisitions, unpaid invoices). Sources:
        ProjectFinance (manual entries), procurement Payments, Reimbursements,
        Payment Requests, Payroll, and received project Funds.

        Without explicit ?start=/?end=/?all=, defaults to the current
        calendar year. `?all=true` returns the full history but is capped at
        ALL_TRANSACTIONS_MAX_ROWS (PERF-003) — no longer truly unbounded.
        """
        start_param = request.query_params.get("start")
        end_param = request.query_params.get("end")
        all_param = request.query_params.get("all", "").lower() in ("1", "true", "yes")

        def parse_date(s):
            try:
                return datetime.strptime(s, "%Y-%m-%d").date()
            except (TypeError, ValueError):
                return None

        start = parse_date(start_param)
        end = parse_date(end_param)
        if not all_param and not start and not end:
            today = timezone.now().date()
            start = date(today.year, 1, 1)
            end = date(today.year, 12, 31)

        rows = _collect_cash_transactions(
            get_active_organization(request),
            start,
            end,
            limit=self.ALL_TRANSACTIONS_MAX_ROWS,
        )
        return Response(rows)

    @extend_schema(parameters=[OpenApiParameter(name="month", type=str, description="Month filter YYYY-MM (default: current month)")])
    @action(detail=False, methods=["get"], url_path="statement")
    def statement(self, request):
        """Non-profit Statement of Activities + Functional Expenses for a month.

        Revenue − Expenses = Change in Net Assets, grouped by cash-movement
        source. ?month=YYYY-MM selects the period (defaults to current month).
        """
        month_param = request.query_params.get("month")
        today = timezone.now().date()
        if month_param:
            try:
                year, mon = (int(x) for x in month_param.split("-"))
                start = date(year, mon, 1)
            except (ValueError, TypeError):
                start = today.replace(day=1)
        else:
            start = today.replace(day=1)
        # Last day of the month: first of next month minus one day.
        next_month = (start.replace(day=28) + timedelta(days=4)).replace(day=1)
        end = next_month - timedelta(days=1)

        rows = _collect_cash_transactions(get_active_organization(request), start=start, end=end)

        revenue, expenses = {}, {}
        for r in rows:
            bucket = revenue if r["type"] == "income" else expenses
            key = r["source"]
            if key not in bucket:
                bucket[key] = {"source": key, "label": r["source_label"], "amount": 0.0, "count": 0}
            bucket[key]["amount"] += r["amount"]
            bucket[key]["count"] += 1

        revenue_lines = sorted(revenue.values(), key=lambda x: x["amount"], reverse=True)
        expense_lines = sorted(expenses.values(), key=lambda x: x["amount"], reverse=True)
        total_revenue = sum(l["amount"] for l in revenue_lines)
        total_expense = sum(l["amount"] for l in expense_lines)

        return Response({
            "month": start.strftime("%Y-%m"),
            "period_start": start.isoformat(),
            "period_end": end.isoformat(),
            "revenue": revenue_lines,
            "expenses": expense_lines,
            "total_revenue": total_revenue,
            "total_expense": total_expense,
            "change_in_net_assets": total_revenue - total_expense,
        })

    @extend_schema(parameters=[
        OpenApiParameter(name="month", type=str, description="End month YYYY-MM (default: current month)"),
        OpenApiParameter(name="span", type=int, description="Number of trailing months to show (6 or 12, default 12)"),
    ])
    @action(detail=False, methods=["get"], url_path="statement-matrix")
    def statement_matrix(self, request):
        """DRC-style Financial Highlights: accounting lines (rows) × trailing
        months (columns). Cash-movement sources are mapped to fixed
        Statement-of-Activities lines.
        """
        # Resolve end month.
        today = timezone.now().date()
        month_param = request.query_params.get("month")
        if month_param:
            try:
                y, m = (int(x) for x in month_param.split("-"))
                end_month = date(y, m, 1)
            except (ValueError, TypeError):
                end_month = today.replace(day=1)
        else:
            end_month = today.replace(day=1)

        try:
            span = int(request.query_params.get("span", 12))
        except (ValueError, TypeError):
            span = 12
        span = 6 if span == 6 else 12

        # Build the list of month-starts, oldest → newest.
        months = []
        cur = end_month
        for _ in range(span):
            months.append(cur)
            # step back one month
            cur = (cur.replace(day=1) - timedelta(days=1)).replace(day=1)
        months.reverse()

        overall_start = months[0]
        last = months[-1]
        overall_end = (last.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)

        rows = _collect_cash_transactions(get_active_organization(request), start=overall_start, end=overall_end)

        # source → (group, line) mapping for DRC-style lines.
        SOURCE_TO_LINE = {
            "project_fund": ("income", "Restricted funds"),
            "procurement_payment": ("expense", "Program expenses"),
            "payroll": ("expense", "Personnel expenses"),
            "reimbursement": ("expense", "Administrative expenses"),
            "payment_request": ("expense", "Administrative expenses"),
            # project_finance is split by its own income/expense type below.
        }

        month_keys = [d.strftime("%Y-%m") for d in months]
        # line_key -> {label, group, values:{month_key: amount}}
        lines = OrderedDict()

        def bucket(group, label):
            key = (group, label)
            if key not in lines:
                lines[key] = {"group": group, "label": label, "values": {mk: 0.0 for mk in month_keys}}
            return lines[key]

        for r in rows:
            mk = r["date"][:7]
            if mk not in month_keys:
                continue
            if r["source"] == "project_finance":
                if r["type"] == "income":
                    group, label = "income", "Income from own activities"
                else:
                    group, label = "expense", "Program expenses"
            else:
                mapping = SOURCE_TO_LINE.get(r["source"])
                if not mapping:
                    continue
                group, label = mapping
            bucket(group, label)["values"][mk] += r["amount"]

        income_lines = [l for l in lines.values() if l["group"] == "income"]
        expense_lines = [l for l in lines.values() if l["group"] == "expense"]

        def totals_per_month(group_lines):
            return {mk: sum(l["values"][mk] for l in group_lines) for mk in month_keys}

        total_income = totals_per_month(income_lines)
        total_expense = totals_per_month(expense_lines)
        net = {mk: total_income[mk] - total_expense[mk] for mk in month_keys}

        return Response({
            "months": [
                {"key": d.strftime("%Y-%m"), "label": d.strftime("%b %Y"), "short": d.strftime("%b")}
                for d in months
            ],
            "income_lines": [{"label": l["label"], "values": [l["values"][mk] for mk in month_keys]} for l in income_lines],
            "expense_lines": [{"label": l["label"], "values": [l["values"][mk] for mk in month_keys]} for l in expense_lines],
            "total_income": [total_income[mk] for mk in month_keys],
            "total_expense": [total_expense[mk] for mk in month_keys],
            "change_in_net_assets": [net[mk] for mk in month_keys],
        })

    @action(detail=False, methods=["get"], url_path="dashboard")
    def dashboard(self, request):
        """Aggregate metrics across all projects: stats, monthly trends,
        status distribution, calendar markers, recent rows."""
        org = get_active_organization(request)
        cache_key = f"projects:dashboard:{org.id}"
        cached = cache.get(cache_key)
        if cached is not None:
            return Response(cached)
        from apps.tasks.models import Task

        today = timezone.now().date()
        now = timezone.now()
        start_of_month = today.replace(day=1)
        last_month_start = (start_of_month - timedelta(days=1)).replace(day=1)
        six_months_ago = (start_of_month - timedelta(days=180)).replace(day=1)

        # Project stats: one query with conditional counts
        ps = Project.objects.filter(organization=org).aggregate(
            total=Count("id"),
            active=Count("id", filter=Q(status="active")),
            completed=Count("id", filter=Q(status="completed")),
            on_hold=Count("id", filter=Q(status="on_hold")),
            planning=Count("id", filter=Q(status="planning")),
            archived=Count("id", filter=Q(status="archived")),
            this_month=Count("id", filter=Q(created_at__gte=start_of_month)),
            last_month=Count("id", filter=Q(created_at__gte=last_month_start, created_at__lt=start_of_month)),
        )
        change_pct = round(((ps["this_month"] - ps["last_month"]) / ps["last_month"]) * 100) if ps["last_month"] else None

        # Monthly trend — last 6 months: two grouped queries (projects + tasks)
        months = []
        cursor = six_months_ago
        for _ in range(7):
            months.append(cursor)
            cursor = (cursor + timedelta(days=32)).replace(day=1)
        target_months = months[-6:]
        target_start = timezone.make_aware(datetime.combine(target_months[0], datetime.min.time()))
        proj_monthly = (
            Project.objects
            .filter(organization=org, created_at__gte=target_start)
            .annotate(m=TruncMonth("created_at"))
            .values("m").annotate(c=Count("id"))
        )
        task_monthly = (
            Task.objects
            .filter(project__organization=org, created_at__gte=target_start)
            .annotate(m=TruncMonth("created_at"))
            .values("m").annotate(c=Count("id"))
        )
        proj_map = {(r["m"].year, r["m"].month): r["c"] for r in proj_monthly if r["m"]}
        task_map = {(r["m"].year, r["m"].month): r["c"] for r in task_monthly if r["m"]}
        monthly = [{
            "month": m.strftime("%b"),
            "projects": proj_map.get((m.year, m.month), 0),
            "tasks": task_map.get((m.year, m.month), 0),
        } for m in target_months]

        status_pie = [
            {"name": "Active", "value": ps["active"]},
            {"name": "Planning", "value": ps["planning"]},
            {"name": "On Hold", "value": ps["on_hold"]},
            {"name": "Completed", "value": ps["completed"]},
            {"name": "Archived", "value": ps["archived"]},
        ]

        # Calendar markers
        cal_start = start_of_month
        cal_end = (start_of_month + timedelta(days=32)).replace(day=1)
        marked = set()
        for s, e in Project.objects.filter(
            Q(start_date__gte=cal_start, start_date__lt=cal_end) |
            Q(end_date__gte=cal_start, end_date__lt=cal_end),
            organization=org,
        ).values_list("start_date", "end_date"):
            if s and cal_start <= s < cal_end:
                marked.add(s.isoformat())
            if e and cal_start <= e < cal_end:
                marked.add(e.isoformat())
        for due in Task.objects.filter(
            project__organization=org, due_date__gte=cal_start, due_date__lt=cal_end
        ).values_list("due_date", flat=True):
            marked.add(due.date().isoformat())

        # Recent — 8 latest projects with denormalized counts
        recent_qs = (
            Project.objects
            .filter(organization=org)
            .select_related("lead__user")
            .annotate(
                task_count=Count("tasks", distinct=True),
                team_count=Count("team_members", distinct=True),
                doc_count=Count("documents", distinct=True),
            )
            .order_by("-created_at")[:8]
        )
        recent = []
        for p in recent_qs:
            lead_user = p.lead.user if p.lead_id else None
            recent.append({
                "id": str(p.id),
                "name": p.name,
                "status": p.status,
                "lead": lead_user.get_full_name() if lead_user else None,
                "lead_email": lead_user.email if lead_user else None,
                "start_date": p.start_date.isoformat() if p.start_date else None,
                "end_date": p.end_date.isoformat() if p.end_date else None,
                "task_count": p.task_count,
                "team_count": p.team_count,
                "doc_count": p.doc_count,
                "tags": p.tags or [],
            })

        # Tasks roll-up: one query
        ts = Task.objects.filter(project__organization=org).aggregate(
            total=Count("id"),
            todo=Count("id", filter=Q(status="todo")),
            in_progress=Count("id", filter=Q(status="in_progress")),
            review=Count("id", filter=Q(status="review")),
            done=Count("id", filter=Q(status="done")),
            overdue=Count("id", filter=Q(due_date__lt=now, status__in=["todo", "in_progress", "review"])),
        )

        payload = {
            "stats": {
                "total": ps["total"],
                "active": ps["active"],
                "completed": ps["completed"],
                "this_month": ps["this_month"],
                "change_pct_vs_last_month": change_pct,
            },
            "task_stats": ts,
            "monthly": monthly,
            "status_distribution": status_pie,
            "calendar_dates": sorted(marked),
            "recent": recent,
        }
        cache.set(cache_key, payload, 30)  # 30s TTL — dashboard tolerates short staleness
        return Response(payload)

    @extend_schema(
        responses={200: [{"type": "object"}]}
    )
    @action(detail=True, methods=["get", "post"], url_path="tasks")
    def tasks(self, request, pk=None):
        """List/create tasks for a specific project."""
        from apps.tasks.models import Task

        project = self.get_object()  # 404 if project missing
        if request.method == "POST":
            # Only project lead, team members, or staff may create tasks
            is_lead = (
                project.lead_id is not None
                and getattr(project.lead, "user_id", None) == request.user.id
            )
            is_member = (
                is_lead
                or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
                or request.user.is_staff or request.user.is_superuser
            )
            if not is_member:
                return Response({"error": "not a project member"}, status=403)
            payload = {
                "organization": project.organization,
                "title": request.data.get("title", "").strip(),
                "description": request.data.get("description", ""),
                "status": request.data.get("status") or "todo",
                "priority": request.data.get("priority") or "medium",
                "due_date": request.data.get("due_date") or None,
                "project_id": pk,
                "created_by": request.user,
            }
            if not payload["title"]:
                return Response({"error": "title is required"}, status=400)
            assignee_id = request.data.get("assignee")
            if assignee_id:
                if not ProjectTeamMember.objects.filter(project_id=pk, user_id=assignee_id).exists():
                    return Response({"error": "assignee is not a team member"}, status=400)
                payload["assignee_id"] = assignee_id
            t = Task.objects.create(**payload)
            log_project(pk, request.user, "task", f"created task “{t.title}”")
            return Response({
                "id": str(t.id),
                "project_id": str(t.project_id) if t.project_id else None,
                "title": t.title,
                "description": t.description,
                "status": t.status,
                "assignee": str(t.assignee_id) if t.assignee_id else None,
                "assignee_name": (t.assignee.get_full_name() or t.assignee.email) if t.assignee_id else None,
                "due_date": str(t.due_date) if t.due_date else None,
                "created_at": t.created_at.isoformat(),
                "updated_at": t.updated_at.isoformat(),
            }, status=201)

        from django.db.models import Count
        tasks = (
            Task.objects.filter(project_id=pk)
            .select_related("assignee")
            .annotate(msg_count=Count("messages", distinct=True))
            .order_by("-created_at")
        )
        data = [{
            "id": str(t.id),
            "project_id": str(t.project_id) if t.project_id else None,
            "title": t.title,
            "description": t.description,
            "status": t.status,
            "priority": t.priority,
            "assignee": str(t.assignee_id) if t.assignee_id else None,
            "assignee_name": (t.assignee.get_full_name() or t.assignee.email) if t.assignee_id else None,
            "due_date": str(t.due_date) if t.due_date else None,
            "attachment_count": len(t.attachments) if isinstance(t.attachments, list) else 0,
            "message_count": t.msg_count,
            "created_at": t.created_at.isoformat(),
            "updated_at": t.updated_at.isoformat(),
        } for t in tasks]
        return Response(data)

    @extend_schema(
        responses={200: [{"type": "object"}]}
    )
    @action(detail=True, methods=["get"], url_path="documents")
    def documents(self, request, pk=None):
        """List documents for a project, filtered by access permissions."""
        qs = (
            ProjectDocument.objects
            .filter(project_id=pk)
            .select_related("uploaded_by", "project")
            .prefetch_related("allowed_users")
            .order_by("-created_at")
        )
        qs = ProjectDocument.visible_to(qs, request.user)
        return Response(
            ProjectDocumentSerializer(qs, many=True, context={"request": request}).data
        )

    @extend_schema(
        parameters=[
            OpenApiParameter(name="log_type", type=str, description="Filter by log type"),
            OpenApiParameter(name="limit", type=int, description="Max rows (default 200)"),
        ],
        responses={200: ProjectLogSerializer(many=True)},
    )
    @action(detail=True, methods=["get"], url_path="logs")
    def logs(self, request, pk=None):
        """Audit log of all actions on this project."""
        qs = ProjectLog.objects.filter(project_id=pk).select_related("actor").order_by("-created_at")
        log_type = request.query_params.get("log_type")
        if log_type:
            qs = qs.filter(log_type=log_type)
        try:
            limit = min(max(int(request.query_params.get("limit", 200)), 1), 500)
        except ValueError:
            limit = 200
        return Response(ProjectLogSerializer(qs[:limit], many=True).data)

    @extend_schema(
        responses={200: [{"type": "object"}]}
    )
    @action(detail=True, methods=["get", "post"], url_path="resources")
    def resources(self, request, pk=None):
        """List/create research data sources and literature for a project."""
        if request.method == "POST":
            project = self.get_object()
            is_lead = (
                project.lead_id is not None
                and getattr(project.lead, "user_id", None) == request.user.id
            )
            is_member = (
                is_lead
                or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
                or request.user.is_staff or request.user.is_superuser
            )
            if not is_member:
                return Response({"error": "not a project member"}, status=403)
            serializer = ProjectResourceSerializer(data=request.data, context={"request": request})
            serializer.is_valid(raise_exception=True)
            obj = serializer.save(project=project, created_by=request.user)
            log_project(pk, request.user, "resource", f"added {obj.kind} “{obj.title}”")
            return Response(serializer.data, status=201)

        qs = (
            ProjectResource.objects
            .filter(project_id=pk)
            .select_related("created_by")
            .order_by("-created_at")
        )
        kind = request.query_params.get("kind")
        if kind in ("data", "literature"):
            qs = qs.filter(kind=kind)
        return Response(ProjectResourceSerializer(qs, many=True).data)

    @extend_schema(
        responses={200: {"type": "object"}}
    )
    @action(detail=True, methods=["patch", "delete"], url_path=r"resources/(?P<resource_id>[^/.]+)")
    def resource_detail(self, request, pk=None, resource_id=None):
        """Update or delete a single project resource."""
        project = self.get_object()
        is_lead = (
            project.lead_id is not None
            and getattr(project.lead, "user_id", None) == request.user.id
        )
        is_member = (
            is_lead
            or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
            or request.user.is_staff or request.user.is_superuser
        )
        if not is_member:
            return Response({"error": "not a project member"}, status=403)
        try:
            resource = ProjectResource.objects.get(id=resource_id, project_id=pk)
        except ProjectResource.DoesNotExist:
            return Response({"error": "not found"}, status=404)

        if request.method == "DELETE":
            log_project(pk, request.user, "resource", f"deleted {resource.kind} “{resource.title}”")
            resource.delete()
            return Response(status=204)

        serializer = ProjectResourceSerializer(resource, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        obj = serializer.save()
        log_project(pk, request.user, "resource", f"updated {obj.kind} “{obj.title}”")
        return Response(serializer.data)

    @extend_schema(
        responses={200: [{"type": "object"}]}
    )
    @action(detail=True, methods=["get"], url_path="calendar")
    def calendar(self, request, pk=None):
        """Combined calendar feed: project tasks + activities."""
        return Response(self._calendar_items(get_active_organization(request), project_id=pk))

    @action(detail=False, methods=["get"], url_path="calendar-all")
    def calendar_all(self, request):
        """Combined calendar feed across all projects."""
        return Response(self._calendar_items(get_active_organization(request), project_id=None))

    def _calendar_items(self, organization, project_id):
        from apps.tasks.models import Task
        from apps.activities.models import Activity

        task_qs = Task.objects.filter(organization=organization, due_date__isnull=False)
        act_qs = Activity.objects.filter(organization=organization, due_date__isnull=False)
        if project_id is None:
            task_qs = task_qs.filter(project__isnull=False)
            act_qs = act_qs.filter(project__isnull=False)
        else:
            task_qs = task_qs.filter(project_id=project_id)
            act_qs = act_qs.filter(project_id=project_id)

        items = []
        for t in task_qs:
            items.append({
                "id": f"task-{t.id}",
                "title": t.title,
                "date": t.due_date.isoformat(),
                "type": "task",
                "project_id": str(t.project_id),
                "status": t.status,
            })
        for a in act_qs:
            cat = "meeting" if a.activity_type == "meeting" else "event"
            items.append({
                "id": f"activity-{a.id}",
                "title": a.title,
                "date": a.due_date.isoformat(),
                "type": cat,
                "project_id": str(a.project_id),
                "activity_type": a.activity_type,
            })
        return items

    @action(detail=True, methods=["get", "post"], url_path="finance")
    def finance(self, request, pk=None):
        # Optional ?to=<CCY> converts each entry to that currency at its date.
        to_ccy = request.query_params.get("to")
        ctx = {"request": request, "to_currency": to_ccy}
        if request.method == "POST":
            project = self.get_object()
            is_lead = (
                project.lead_id is not None
                and getattr(project.lead, "user_id", None) == request.user.id
            )
            is_member = (
                is_lead
                or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
                or request.user.is_staff or request.user.is_superuser
            )
            if not is_member:
                return Response({"error": "not a project member"}, status=403)
            # request.data is a QueryDict for multipart; spreading it flattens
            # scalar fields into single-item lists. Copy + set to preserve types.
            data = request.data.copy()
            data["project"] = pk
            serializer = ProjectFinanceSerializer(data=data, context=ctx)
            serializer.is_valid(raise_exception=True)
            obj = serializer.save(created_by=request.user if request.user.is_authenticated else None)
            _stamp_finance_idr(obj)
            verb = "added income" if obj.type == "income" else "logged expense"
            log_project(pk, request.user, "finance", f"{verb} “{obj.description}” ({float(obj.amount):,.0f})")
            return Response(serializer.data, status=201)
        qs = (
            ProjectFinance.objects.filter(project_id=pk)
            .select_related("fund", "budget_category", "budget_item", "budget_item__category", "approved_by", "created_by")
            .order_by("-date", "-created_at")
        )
        rows = list(qs)
        # Resolve every distinct (currency, date) FX pair up front. Without this
        # each row's amount_converted/amount_idr_effective would issue its own
        # upstream lookup, serially.
        from .fx import prefetch_idr_rates
        pairs = set()
        for row in rows:
            ref_date = row.date or (row.created_at.date() if row.created_at else None)
            if ref_date is None:
                continue
            pairs.add((row.currency, ref_date))
            if to_ccy:
                pairs.add((to_ccy, ref_date))
        prefetch_idr_rates(pairs)
        return Response(ProjectFinanceSerializer(rows, many=True, context=ctx).data)

    # NB: distinct from the list-level `finance_summary` above (the org-wide
    # rollup) — same concept, different scope, so both name and url_path differ.
    @action(detail=True, methods=["get"], url_path="finance-totals")
    def finance_totals(self, request, pk=None):
        """Project spend/income totals, converted to ?to=<CCY> at each entry's date.

        Exists so dashboards that only need the totals don't have to fetch and
        serialize every finance row (which costs one FX conversion per row).
        """
        from .fx import convert_on_date, prefetch_idr_rates

        to_ccy = (request.query_params.get("to") or "").upper()
        rows = list(
            ProjectFinance.objects.filter(project_id=pk)
            .values_list("type", "amount", "currency", "date", "created_at")
        )

        def ref_of(date, created_at):
            return date or (created_at.date() if created_at else None)

        if to_ccy:
            pairs = set()
            for _, _, currency, date, created_at in rows:
                ref = ref_of(date, created_at)
                if ref is not None:
                    pairs.add((currency, ref))
                    pairs.add((to_ccy, ref))
            prefetch_idr_rates(pairs)

        totals = {"expense": Decimal("0"), "income": Decimal("0")}
        unconverted = 0
        for entry_type, amount, currency, date, created_at in rows:
            if entry_type not in totals:
                continue
            value = amount or Decimal("0")
            if to_ccy and (currency or "").upper() != to_ccy:
                ref = ref_of(date, created_at)
                converted = convert_on_date(value, currency, to_ccy, ref) if ref else None
                if converted is None:
                    # Mirrors the client's expenseInFundCcy fallback: an
                    # unresolvable rate contributes its raw amount.
                    unconverted += 1
                else:
                    value = converted
            totals[entry_type] += value

        # Expenses with no fund belong to no donor, so they are absent from
        # every donor's spend view. On a co-funded project that is usually a
        # bookkeeping gap rather than intentional internal spend, so surface it
        # here instead of letting it stay silently invisible.
        unattributed = ProjectFinance.objects.filter(
            project_id=pk, type="expense", fund__isnull=True,
        ).count()

        return Response({
            "currency": to_ccy or None,
            "spent": str(totals["expense"]),
            "income": str(totals["income"]),
            "entry_count": len(rows),
            # Non-zero means some entries couldn't be converted and were summed
            # at face value, so the total mixes currencies.
            "unconverted_count": unconverted,
            # Expenses not attributed to any fund — excluded from donor spend.
            "unattributed_expense_count": unattributed,
        })

    @action(detail=True, methods=["get"], url_path="finance-income")
    def finance_income(self, request, pk=None):
        """Realized income for the project = received tranches across its funds,
        converted to IDR at each tranche's received date (fund currency → IDR)."""
        from .fx import fetch_idr_rate

        tranches = (
            ProjectFundTranche.objects
            .filter(fund__project_id=pk, status="received")
            .select_related("fund")
        )
        total_idr = Decimal("0")
        by_ccy: dict[str, Decimal] = {}
        for t in tranches:
            amt = t.amount or Decimal("0")
            ccy = (t.fund.currency or "IDR").upper()
            by_ccy[ccy] = by_ccy.get(ccy, Decimal("0")) + amt
            ref = t.received_date or t.expected_date
            rate = fetch_idr_rate(t.fund.currency, ref) if ref else None
            if rate is None:
                # Fall back to the fund's booked rate, else treat amount as IDR.
                rate = t.fund.exchange_rate or Decimal("1")
            total_idr += amt * rate
        return Response({
            "received_income_idr": str(total_idr),
            "received_income_by_currency": {k: str(v) for k, v in by_ccy.items()},
        })

    @action(detail=True, methods=["post"], url_path=r"finance/(?P<fin_id>[^/.]+)/approve")
    def finance_approve(self, request, pk=None, fin_id=None):
        return self._finance_decision(request, pk, fin_id, approve=True)

    @action(detail=True, methods=["post"], url_path=r"finance/(?P<fin_id>[^/.]+)/reject")
    def finance_reject(self, request, pk=None, fin_id=None):
        return self._finance_decision(request, pk, fin_id, approve=False)

    def _finance_decision(self, request, pk, fin_id, approve):
        """Approve/reject a finance entry. Gated by project.approve (see
        rbac_action_map). Submitters cannot approve their own entries."""
        try:
            obj = ProjectFinance.objects.select_related("created_by").get(id=fin_id, project_id=pk)
        except ProjectFinance.DoesNotExist:
            return Response({"detail": "Finance entry not found."}, status=404)
        # Segregation of duties: you cannot approve/reject what you submitted.
        if obj.created_by_id and obj.created_by_id == request.user.id:
            return Response({"detail": "You cannot approve your own expense."}, status=403)
        obj.approval_status = "approved" if approve else "rejected"
        obj.approved_by = request.user
        obj.approved_at = timezone.now()
        obj.save(update_fields=["approval_status", "approved_by", "approved_at", "updated_at"])
        verb = "approved" if approve else "rejected"
        log_project(pk, request.user, "finance", f"{verb} expense “{obj.description}”")
        if obj.created_by_id and obj.created_by_id != request.user.id:
            _notify_project(
                obj.created_by,
                title=f"Expense {verb}",
                message=f"Your expense \"{obj.description}\" was {verb}.",
                link=f"/project/{pk}?tab=finance",
                priority="medium",
                related_user=request.user,
            )
        ctx = {"request": request, "to_currency": request.query_params.get("to")}
        return Response(ProjectFinanceSerializer(obj, context=ctx).data)

    @action(detail=True, methods=["get", "post"], url_path="disseminations")
    def disseminations(self, request, pk=None):
        if request.method == "POST":
            project = self.get_object()
            is_lead = (
                project.lead_id is not None
                and getattr(project.lead, "user_id", None) == request.user.id
            )
            is_member = (
                is_lead
                or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
                or request.user.is_staff or request.user.is_superuser
            )
            if not is_member:
                return Response({"error": "not a project member"}, status=403)
            data = {**request.data, "project": pk}
            serializer = ProjectDisseminationSerializer(data=data, context={"request": request})
            serializer.is_valid(raise_exception=True)
            obj = serializer.save()
            log_project(pk, request.user, "dissemination", f"added dissemination “{obj.title}”")
            return Response(serializer.data, status=201)
        qs = ProjectDissemination.objects.filter(project_id=pk).select_related("event", "publication").order_by("-date", "-created_at")
        return Response(ProjectDisseminationSerializer(qs, many=True).data)

    @action(detail=True, methods=["get", "post"], url_path="schedule")
    def schedule(self, request, pk=None):
        if request.method == "POST":
            project = self.get_object()
            is_lead = (
                project.lead_id is not None
                and getattr(project.lead, "user_id", None) == request.user.id
            )
            is_member = (
                is_lead
                or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
                or request.user.is_staff or request.user.is_superuser
            )
            if not is_member:
                return Response({"error": "not a project member"}, status=403)
            data = {**request.data, "project": pk}
            serializer = ProjectScheduleSerializer(data=data, context={"request": request})
            serializer.is_valid(raise_exception=True)
            obj = serializer.save()
            log_project(pk, request.user, "schedule", f"added schedule “{obj.title}”")
            return Response(serializer.data, status=201)
        qs = ProjectSchedule.objects.filter(project_id=pk).order_by("start_date", "-created_at")
        return Response(ProjectScheduleSerializer(qs, many=True).data)

    @action(detail=True, methods=["patch", "delete"], url_path=r"schedule/(?P<item_id>[^/.]+)")
    def schedule_item(self, request, pk=None, item_id=None):
        """Update or remove one schedule item (milestone status, revised dates,
        donor visibility)."""
        project = self.get_object()
        is_lead = (
            project.lead_id is not None
            and getattr(project.lead, "user_id", None) == request.user.id
        )
        is_member = (
            is_lead
            or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
            or request.user.is_staff or request.user.is_superuser
        )
        if not is_member:
            return Response({"error": "not a project member"}, status=403)
        item = ProjectSchedule.objects.filter(project_id=pk, pk=item_id).first()
        if item is None:
            return Response({"error": "schedule item not found"}, status=404)
        if request.method == "DELETE":
            log_project(pk, request.user, "schedule", f"removed schedule “{item.title}”")
            item.delete()
            return Response(status=204)
        serializer = ProjectScheduleSerializer(item, data={**request.data, "project": pk}, partial=True, context={"request": request})
        serializer.is_valid(raise_exception=True)
        obj = serializer.save()
        log_project(pk, request.user, "schedule", f"updated schedule “{obj.title}”")
        return Response(serializer.data)

    @action(detail=True, methods=["get", "post"], url_path="team")
    def team(self, request, pk=None):
        """Team members for project. POST to add."""
        if request.method == "POST":
            project = self.get_object()
            is_lead = (
                project.lead_id is not None
                and getattr(project.lead, "user_id", None) == request.user.id
            )
            is_member = (
                is_lead
                or ProjectTeamMember.objects.filter(project_id=pk, user_id=request.user.id).exists()
                or request.user.is_staff or request.user.is_superuser
            )
            if not is_member:
                return Response({"error": "not a project member"}, status=403)
            data = {**request.data, "project": pk}
            serializer = ProjectTeamMemberSerializer(data=data, context={"request": request})
            serializer.is_valid(raise_exception=True)
            obj = serializer.save()
            who = (obj.user.get_full_name() or obj.user.email) if obj.user_id else "member"
            log_project(pk, request.user, "team", f"added team member {who}")
            return Response(serializer.data, status=201)
        qs = ProjectTeamMember.objects.filter(project_id=pk).select_related("user")
        return Response(ProjectTeamMemberSerializer(qs, many=True).data)

    @extend_schema(
        request={"type": "object"},
        responses={200: {"type": "object"}},
    )
    @action(detail=False, methods=["post"], url_path="ai-describe")
    def ai_describe(self, request):
        """Generate/refine a project description via DeepSeek.

        Body: {name, current_description, messages: [{role, content}, ...]}.
        Returns {reply} where reply is clean HTML for the rich-text editor.
        """
        api_key, base_url, model = _resolve_ai_config()
        if not api_key:
            return Response({"error": "AI assistant is not configured."}, status=503)

        name = (request.data.get("name") or "").strip()[:300]
        current = _strip_html(request.data.get("current_description") or "")
        raw_messages = request.data.get("messages") or []
        if not isinstance(raw_messages, list):
            return Response({"error": "messages must be a list"}, status=400)

        chat = []
        for m in raw_messages[-20:]:
            if not isinstance(m, dict):
                continue
            role = m.get("role")
            content = m.get("content")
            if role in ("user", "assistant") and isinstance(content, str) and content.strip():
                chat.append({"role": role, "content": content.strip()[:4000]})
        if not chat:
            return Response({"error": "at least one message is required"}, status=400)

        system = _build_ai_context(name, current, request.data.get("project_id"), get_active_organization(request))

        try:
            resp = requests.post(
                f"{base_url.rstrip('/')}/chat/completions",
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": model,
                    "messages": [{"role": "system", "content": system}, *chat],
                    "temperature": 0.7,
                    "stream": False,
                },
                timeout=60,
            )
        except requests.RequestException:
            return Response({"error": "AI request failed. Try again."}, status=502)

        if resp.status_code != 200:
            return Response({"error": "AI provider returned an error."}, status=502)

        try:
            reply = resp.json()["choices"][0]["message"]["content"].strip()
        except (KeyError, IndexError, TypeError, ValueError):
            return Response({"error": "AI returned no usable content."}, status=502)

        # Strip accidental markdown fences if the model wrapped its output.
        reply = re.sub(r"^```[a-zA-Z]*\n?", "", reply)
        reply = re.sub(r"\n?```$", "", reply).strip()
        return Response({"reply": reply})


class ProjectDocumentViewSet(viewsets.ModelViewSet):
    """ViewSet for ProjectDocument management. Queryset scoped by visible_to to prevent metadata leak."""
    rbac_domain = "project"
    serializer_class = ProjectDocumentSerializer
    parser_classes = [JSONParser, MultiPartParser, FormParser]

    def get_queryset(self):
        base = ProjectDocument.objects.filter(
            project__organization=get_active_organization(self.request)
        ).select_related("uploaded_by", "project").prefetch_related("allowed_users")
        # write operations need full queryset; permission check on instance below
        if self.action in ("update", "partial_update", "destroy"):
            return base
        return ProjectDocument.visible_to(base, self.request.user)

    def get_serializer_context(self):
        ctx = super().get_serializer_context()
        ctx["request"] = self.request
        return ctx

    def _can_modify(self, instance, user):
        if user.is_staff or user.is_superuser:
            return True
        return instance.uploaded_by_id == user.id

    def perform_update(self, serializer):
        instance = serializer.instance
        if not self._can_modify(instance, self.request.user):
            from rest_framework.exceptions import PermissionDenied
            raise PermissionDenied("only uploader or staff may edit")
        instance = serializer.save()
        log_project(instance.project_id, self.request.user, "document", f"updated document “{instance.name}”")
        # Handle M2M for multipart bodies (where DRF expects single getlist call)
        data = self.request.data
        if hasattr(data, "getlist"):
            allowed_raw = data.getlist("allowed_users") if "allowed_users" in data else None
            if allowed_raw is not None:
                instance.allowed_users.set(allowed_raw)

    def perform_destroy(self, instance):
        if not self._can_modify(instance, self.request.user):
            from rest_framework.exceptions import PermissionDenied
            raise PermissionDenied("only uploader or staff may delete")
        log_project(instance.project_id, self.request.user, "document", f"deleted document “{instance.name}”")
        instance.delete()

    @extend_schema(
        parameters=[
            OpenApiParameter(name="project", type=str, description="Filter by project ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        queryset = self.get_queryset()
        project = request.query_params.get("project")
        if project:
            queryset = queryset.filter(project_id=project)
        page = self.paginate_queryset(queryset)
        if page is not None:
            return self.get_paginated_response(self.get_serializer(page, many=True).data)
        return Response(self.get_serializer(queryset, many=True).data)

    def perform_create(self, serializer):
        kwargs = {}
        if self.request.user.is_authenticated:
            kwargs["uploaded_by"] = self.request.user
        instance = serializer.save(**kwargs)
        log_project(instance.project_id, self.request.user, "document", f"uploaded document “{instance.name}”")
        # M2M from request.data: allowed_users may come as repeated form keys or list
        raw = self.request.data.getlist("allowed_users") if hasattr(self.request.data, "getlist") else self.request.data.get("allowed_users", [])
        if raw:
            ids = raw if isinstance(raw, list) else [raw]
            instance.allowed_users.set(ids)


def _notify_project(user, *, title, message, link="", priority="medium", related_user=None):
    """Fire an in-app project notification; never break the request on failure."""
    if not user:
        return
    try:
        from apps.notifications.handler import NotificationHandler
        NotificationHandler.send(
            user=user, title=title, message=message,
            notification_type="project", priority=priority, icon="folder",
            link=link, related_user=related_user, broadcast=True,
        )
    except Exception:
        pass


class ProjectTeamMemberViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """ViewSet for ProjectTeamMember management."""
    rbac_domain = "project"
    queryset = ProjectTeamMember.objects.select_related("user", "project").all()
    serializer_class = ProjectTeamMemberSerializer
    organization_lookup = "project__organization"

    def perform_create(self, serializer):
        member = serializer.save()
        actor = self.request.user if self.request.user.is_authenticated else None
        who = (member.user.get_full_name() or member.user.email) if member.user_id else "member"
        log_project(member.project_id, actor, "team", f"added team member {who}")
        if member.user_id and (not actor or member.user_id != actor.id):
            actor_name = (actor.get_full_name() or actor.email) if actor else "Someone"
            _notify_project(
                member.user,
                title="Added to project",
                message=f"{actor_name} added you to “{member.project.name}” as {member.get_role_display()}.",
                link=f"/project/{member.project_id}",
                related_user=actor,
            )

    @extend_schema(
        parameters=[
            OpenApiParameter(name="project", type=str, description="Filter by project ID"),
            OpenApiParameter(name="role", type=str, description="Filter by role"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

    def perform_destroy(self, instance):
        actor = self.request.user if self.request.user.is_authenticated else None
        who = (instance.user.get_full_name() or instance.user.email) if instance.user_id else "member"
        log_project(instance.project_id, actor, "team", f"removed team member {who}")
        instance.delete()


class ProjectFundViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """ViewSet for project funding sources. Supports multiple funds per project."""
    rbac_domain = "project"
    queryset = ProjectFund.objects.select_related("project", "donor").order_by("-created_at")
    serializer_class = ProjectFundSerializer

    def perform_create(self, serializer):
        from .fx import convert_fund
        fund = serializer.save(organization=get_active_organization(self.request))
        convert_fund(fund)
        label = fund.source or (fund.donor.name if fund.donor_id else "fund")
        log_project(fund.project_id, self.request.user, "fund", f"added fund “{label}”")

    def perform_update(self, serializer):
        from .fx import convert_fund
        fund = serializer.save()
        convert_fund(fund)
        label = fund.source or (fund.donor.name if fund.donor_id else "fund")
        log_project(fund.project_id, self.request.user, "fund", f"updated fund “{label}”")

    def perform_destroy(self, instance):
        label = instance.source or (instance.donor.name if instance.donor_id else "fund")
        log_project(instance.project_id, self.request.user if self.request.user.is_authenticated else None, "fund", f"deleted fund “{label}”")
        instance.delete()

    def update(self, request, *args, **kwargs):
        if self.get_object().frozen:
            return Response({"detail": "Fund is frozen. Unfreeze it before editing."}, status=409)
        return super().update(request, *args, **kwargs)

    @action(detail=True, methods=["post"], url_path="freeze")
    def freeze(self, request, pk=None):
        """Lock a fund: blocks edits and new allocations until unfrozen."""
        fund = self.get_object()
        fund.frozen = True
        fund.save(update_fields=["frozen", "updated_at"])
        label = fund.source or (fund.donor.name if fund.donor_id else "fund")
        log_project(fund.project_id, request.user, "fund", f"froze fund “{label}”")
        return Response(self.get_serializer(fund).data)

    @action(detail=True, methods=["post"], url_path="unfreeze")
    def unfreeze(self, request, pk=None):
        fund = self.get_object()
        fund.frozen = False
        fund.save(update_fields=["frozen", "updated_at"])
        label = fund.source or (fund.donor.name if fund.donor_id else "fund")
        log_project(fund.project_id, request.user, "fund", f"unfroze fund “{label}”")
        return Response(self.get_serializer(fund).data)

    @action(detail=True, methods=["get"], url_path="allocation-export")
    def allocation_export(self, request, pk=None):
        """Download this fund's allocations as an .xlsx workbook."""
        fund = self.get_object()
        rows = (
            ProjectFundAllocation.objects
            .filter(fund=fund)
            .select_related("budget_item", "budget_item__category")
            .order_by("budget_item__code")
        )
        headers = [
            "Budget Code", "Budget Item", "Category", "Unit",
            "Quantity", "Frequency", "Unit Cost", "Time %",
            f"Amount ({fund.currency})", "Note",
        ]
        data = [[
            a.budget_item.code if a.budget_item_id else "",
            a.budget_item.name if a.budget_item_id else "",
            (a.budget_item.category.name if a.budget_item_id and a.budget_item.category_id else ""),
            a.unit, a.quantity, a.frequency, a.unit_cost,
            a.time_allocated_pct, a.amount, a.note,
        ] for a in rows]
        fname = f"allocations-{_slug(fund.source)}.xlsx"
        return _xlsx_response(f"Allocations — {fund.source}"[:31], headers, data, fname)

    @action(detail=True, methods=["get"], url_path="expense-export")
    def expense_export(self, request, pk=None):
        """Download the fund's project expense ledger as an .xlsx workbook."""
        fund = self.get_object()
        if not fund.project_id:
            return Response({"detail": "Fund has no linked project — no expense ledger."}, status=400)
        rows = (
            ProjectFinance.objects
            .filter(project_id=fund.project_id, type="expense")
            .select_related("budget_item")
            .order_by("-date", "-created_at")
        )
        headers = ["Date", "Description", "Ledger", "Category", "Budget Item", "Currency", "Amount"]
        data = [[
            f.date.isoformat() if f.date else "",
            f.description, f.get_ledger_display(), f.category,
            (f.budget_item.code if f.budget_item_id else ""),
            f.currency, f.amount,
        ] for f in rows]
        fname = f"expenses-{_slug(fund.project.name)}.xlsx"
        return _xlsx_response(f"Expenses — {fund.project.name}"[:31], headers, data, fname)

    @extend_schema(
        request={"type": "object"},
        responses={200: {"type": "object"}},
    )
    @action(detail=True, methods=["post"], url_path="ai-allocate")
    def ai_allocate(self, request, pk=None):
        """Propose a budget allocation breakdown from a natural-language prompt.

        Body: {prompt: str}. Grounds the model on this fund's amount/currency,
        its existing allocations, and the real budget-item catalog, then asks
        for a strict JSON array of proposed allocation rows. Does NOT write to
        the database — the frontend reviews/edits the proposal before saving
        each row via the normal allocation-create endpoint.
        """
        from .ai_allocate import build_allocation_prompt, parse_allocation_response

        fund = self.get_object()
        prompt = (request.data.get("prompt") or "").strip()[:2000]
        if not prompt:
            return Response({"error": "prompt is required"}, status=400)

        api_key, base_url, model = _resolve_ai_config()
        if not api_key:
            return Response({"error": "AI assistant is not configured."}, status=503)

        system, user_content = build_allocation_prompt(fund, prompt)

        try:
            resp = requests.post(
                f"{base_url.rstrip('/')}/chat/completions",
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": model,
                    "messages": [
                        {"role": "system", "content": system},
                        {"role": "user", "content": user_content},
                    ],
                    "temperature": 0.2,
                    "response_format": {"type": "json_object"},
                    "stream": False,
                },
                timeout=60,
            )
        except requests.RequestException:
            return Response({"error": "AI request failed. Try again."}, status=502)

        if resp.status_code != 200:
            return Response({"error": "AI provider returned an error."}, status=502)

        try:
            raw = resp.json()["choices"][0]["message"]["content"]
        except (KeyError, IndexError, ValueError):
            return Response({"error": "AI returned an unexpected response."}, status=502)

        try:
            allocations = parse_allocation_response(raw, fund)
        except ValueError as e:
            return Response({"error": str(e)}, status=502)

        return Response({"allocations": allocations})

    @extend_schema(
        parameters=[
            OpenApiParameter(name="project", type=str, description="Filter by project ID"),
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="fund_type", type=str, description="Filter by fund type"),
            OpenApiParameter(name="donor", type=str, description="Filter by donor (Company) ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        qs = self.get_queryset()
        proj = request.query_params.get("project")
        if proj:
            qs = qs.filter(project_id=proj)
        st = request.query_params.get("status")
        if st:
            qs = qs.filter(status=st)
        ft = request.query_params.get("fund_type")
        if ft:
            qs = qs.filter(fund_type=ft)
        donor = request.query_params.get("donor")
        if donor:
            qs = qs.filter(donor_id=donor)
        page = self.paginate_queryset(qs)
        if page is not None:
            return self.get_paginated_response(self.get_serializer(page, many=True).data)
        return Response(self.get_serializer(qs, many=True).data)


class ProjectFundAllocationViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Allocations of a fund across budget items — read-only here.

    Every change (create/update/delete) requires approval for donor-reporting
    integrity, so writes go through ProjectFundAllocationRevisionViewSet
    instead. See propose/approve/reject there.
    """
    rbac_domain = "project"
    queryset = ProjectFundAllocation.objects.select_related(
        "fund", "fund__donor", "budget_item", "budget_item__category",
    ).all()
    serializer_class = ProjectFundAllocationSerializer
    http_method_names = ["get", "head", "options"]
    pagination_class = None  # allocation sets are small; the fund detail wants them all
    organization_lookup = "fund__organization"

    @extend_schema(parameters=[
        OpenApiParameter(name="fund", type=str, description="Filter by fund ID"),
        OpenApiParameter(name="project", type=str, description="Filter by project ID (all funds of that project)"),
    ])
    def list(self, request, *args, **kwargs):
        fund = request.query_params.get("fund")
        project = request.query_params.get("project")
        if not fund and not project:
            raise ValidationError("Provide a 'fund' or 'project' filter.")
        qs = self.get_queryset()
        if fund:
            qs = qs.filter(fund_id=fund)
        if project:
            qs = qs.filter(fund__project_id=project)
        return Response(self.get_serializer(qs, many=True).data)


class ProjectFundAllocationRevisionViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Propose/approve/reject changes to fund allocations.

    Creating a revision here (action=create/update/delete) never touches
    ProjectFundAllocation directly — it only takes effect once approved
    (project.approve permission), which lets donor reporting show exactly
    who proposed and who signed off on every budget-line change.
    """
    rbac_domain = "project"
    rbac_action_map = {"approve": "approve", "reject": "approve"}
    queryset = ProjectFundAllocationRevision.objects.select_related(
        "fund", "allocation", "allocation__budget_item", "proposed_by", "decided_by",
    ).all()
    serializer_class = ProjectFundAllocationRevisionSerializer
    pagination_class = None
    http_method_names = ["get", "post", "head", "options"]  # no update/delete of a revision — reject and re-propose
    organization_lookup = "fund__organization"

    @extend_schema(parameters=[
        OpenApiParameter(name="fund", type=str, description="Filter by fund ID"),
        OpenApiParameter(name="project", type=str, description="Filter by project ID (all funds of that project)"),
        OpenApiParameter(name="status", type=str, description="Filter by status (pending/approved/rejected)"),
    ])
    def list(self, request, *args, **kwargs):
        fund = request.query_params.get("fund")
        project = request.query_params.get("project")
        if not fund and not project:
            raise ValidationError("Provide a 'fund' or 'project' filter.")
        qs = self.get_queryset()
        if fund:
            qs = qs.filter(fund_id=fund)
        if project:
            qs = qs.filter(fund__project_id=project)
        status_ = request.query_params.get("status")
        if status_:
            qs = qs.filter(status=status_)
        qs = list(qs)
        # Batch-resolve proposed_data.budget_item (a raw UUID in a JSONField,
        # not an FK) up front to avoid one BudgetItem query per 'create' row.
        from apps.finance.models import BudgetItem
        item_ids = {
            r.proposed_data.get("budget_item")
            for r in qs
            if r.action == "create" and r.proposed_data.get("budget_item")
        }
        proposed_items = {str(i.id): i for i in BudgetItem.objects.filter(id__in=item_ids)} if item_ids else {}
        serializer = self.get_serializer(qs, many=True, context={**self.get_serializer_context(), "proposed_budget_items": proposed_items})
        return Response(serializer.data)

    def create(self, request, *args, **kwargs):
        """Propose a create/update/delete. Never writes the live allocation —
        that only happens once approve() applies it."""
        fund_id = request.data.get("fund")
        allocation_id = request.data.get("allocation")
        action_ = request.data.get("action")
        if action_ not in ("create", "update", "delete"):
            return Response({"detail": "action must be create, update, or delete."}, status=400)

        org = get_active_organization(request)
        fund = ProjectFund.objects.filter(id=fund_id, organization=org).first() if fund_id else None
        allocation = ProjectFundAllocation.objects.filter(id=allocation_id, fund__organization=org).first() if allocation_id else None
        if action_ == "create" and not fund:
            return Response({"detail": "fund is required to propose a new allocation."}, status=400)
        if action_ in ("update", "delete") and not allocation:
            return Response({"detail": "allocation is required to propose an update or delete."}, status=400)
        if allocation:
            fund = allocation.fund
        if fund and fund.frozen:
            return Response({"detail": "Fund is frozen. Unfreeze it before proposing allocation changes."}, status=409)

        proposed_data = {}
        if action_ in ("create", "update"):
            raw = request.data.get("proposed_data") or {}
            base = ({f: _serializable(getattr(allocation, f)) for f in ProjectFundAllocationRevision.FIELDS} if allocation else {})
            base.update(raw)
            if not base.get("budget_item"):
                return Response({"detail": "budget_item is required."}, status=400)
            from apps.finance.models import BudgetItem
            if not BudgetItem.objects.filter(id=base["budget_item"], category__organization=org).exists():
                return Response({"detail": "budget_item is invalid."}, status=400)
            try:
                if base.get("quantity") is not None:
                    Decimal(str(base["quantity"]))
                if base.get("unit_cost") is not None:
                    Decimal(str(base["unit_cost"]))
            except InvalidOperation:
                return Response({"detail": "quantity and unit_cost must be numbers."}, status=400)
            proposed_data = base
            # A create for a budget item that already has a live allocation on
            # this fund is really an update — the DB unique constraint would
            # reject it at approval time, so catch it here instead.
            if action_ == "create" and ProjectFundAllocation.objects.filter(
                fund=fund, budget_item_id=base.get("budget_item"),
            ).exists():
                return Response(
                    {"detail": "An allocation for this budget item already exists on this fund. Propose an update instead."},
                    status=409,
                )

        previous_data = (
            {f: _serializable(getattr(allocation, f)) for f in ProjectFundAllocationRevision.FIELDS} if allocation else {}
        )

        revision = ProjectFundAllocationRevision.objects.create(
            fund=fund, allocation=allocation, action=action_,
            proposed_data=proposed_data, previous_data=previous_data,
            reason=(request.data.get("reason") or "")[:500],
            proposed_by=request.user if request.user.is_authenticated else None,
        )
        log_project(
            fund.project_id if fund else None, request.user, "fund",
            f"proposed {action_} allocation revision on fund {fund.source if fund else '—'}",
        )
        return Response(self.get_serializer(revision).data, status=201)

    @action(detail=True, methods=["post"], url_path="approve")
    def approve(self, request, pk=None):
        return self._decide(request, pk, approve=True)

    @action(detail=True, methods=["post"], url_path="reject")
    def reject(self, request, pk=None):
        return self._decide(request, pk, approve=False)

    def _decide(self, request, pk, approve):
        """Approve/reject a pending revision. Segregation of duties: you
        cannot approve your own proposal (mirrors ProjectFinance approval)."""
        from django.db import transaction

        revision = self.get_object()
        if revision.status != "pending":
            return Response({"detail": f"Revision is already {revision.status}."}, status=409)
        if revision.proposed_by_id and revision.proposed_by_id == request.user.id:
            return Response({"detail": "You cannot approve your own allocation revision."}, status=403)

        with transaction.atomic():
            if approve:
                self._apply(revision)
                revision.status = "approved"
            else:
                revision.status = "rejected"
            revision.decided_by = request.user if request.user.is_authenticated else None
            revision.decided_at = timezone.now()
            revision.decision_note = (request.data.get("decision_note") or "")[:500]
            revision.save(update_fields=["status", "decided_by", "decided_at", "decision_note", "allocation"])

        verb = "approved" if approve else "rejected"
        log_project(
            revision.fund.project_id, request.user, "fund",
            f"{verb} {revision.action} allocation revision on fund {revision.fund.source}",
        )
        return Response(self.get_serializer(revision).data)

    _DECIMAL_FIELDS = {"quantity", "frequency", "unit_cost", "time_allocated_pct"}

    def _apply(self, revision):
        """Write the proposed change to the live allocation and bump its
        version. Runs inside the approval transaction only."""
        data = revision.proposed_data
        if revision.action == "delete":
            if revision.allocation_id:
                revision.allocation.delete()
            return
        if revision.action == "create":
            allocation = ProjectFundAllocation(fund=revision.fund)
        else:
            allocation = revision.allocation
            allocation.version = (allocation.version or 1) + 1
        for field in ProjectFundAllocationRevision.FIELDS:
            if field not in data:
                continue
            value = data[field]
            if field == "budget_item":
                allocation.budget_item_id = value
            elif field in self._DECIMAL_FIELDS and value is not None:
                setattr(allocation, field, Decimal(str(value)))
            else:
                setattr(allocation, field, value)
        allocation.save()
        revision.allocation = allocation


def _next_business_key(model, field, prefix, organization):
    """Next sequential business key like 'JV-ADV-000123' for `model.field`, scoped to organization."""
    from django.db.models import Max
    last = model.objects.filter(organization=organization, **{f"{field}__startswith": prefix}).aggregate(m=Max(field))["m"]
    n = int(last.rsplit("-", 1)[-1]) + 1 if last else 1
    return f"{prefix}{n:06d}"


class CashAdvanceViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Cash advances issued to staff against a project fund.

    Accounting semantics: issuing an advance debits the "Employee / Activity
    Advance" asset account and credits Cash — it is NOT an expense, so it does
    not reduce the fund's recognized "spent" total (see accounting.FundViewSet,
    which only sums posted journal lines on Expense-type accounts). Settling
    the advance is what reclasses the spent portion into a real Expense
    account, and mirrors that portion into ProjectFinance so the existing
    project expense views/totals include it automatically.
    """
    rbac_domain = "project"
    rbac_action_map = {"settle": "approve", "cancel": "approve"}
    queryset = CashAdvance.objects.select_related(
        "project", "fund", "recipient", "budget_item", "settled_by", "created_by",
    ).all()
    serializer_class = CashAdvanceSerializer
    organization_lookup = "project__organization"

    def perform_create(self, serializer):
        serializer.save()

    def get_queryset(self):
        qs = super().get_queryset()
        fund = self.request.query_params.get("fund")
        if fund:
            qs = qs.filter(fund_id=fund)
        project = self.request.query_params.get("project")
        if project:
            qs = qs.filter(project_id=project)
        status_ = self.request.query_params.get("status")
        if status_:
            qs = qs.filter(status=status_)
        return qs

    def create(self, request, *args, **kwargs):
        return self._issue(request)

    def _issue(self, request):
        """Create the advance and immediately post the issue journal
        (Dr Advance Asset / Cr Cash) — cash has genuinely left the door, but
        this is a balance-sheet reclass, not an Expense-account debit."""
        from decimal import Decimal
        from .fx import fetch_idr_rate
        from apps.accounting.models import Account, JournalEntry, JournalLine

        data = request.data.copy()
        serializer = self.get_serializer(data=data)
        serializer.is_valid(raise_exception=True)
        fund = serializer.validated_data.get("fund")
        if fund and fund.frozen:
            return Response({"detail": "Fund is frozen. Unfreeze it before issuing an advance."}, status=409)

        advance = serializer.save(created_by=request.user if request.user.is_authenticated else None)

        ref_date = advance.date_issued
        rate = fetch_idr_rate(advance.currency, ref_date)
        advance.amount_idr = (advance.amount * rate) if rate is not None else advance.amount

        org = advance.project.organization
        advance_account = Account.objects.filter(organization=org, code="1200").first()
        cash_account = Account.objects.filter(organization=org, is_cash=True).first()
        if advance_account and cash_account:
            journal = JournalEntry.objects.create(
                organization=org,
                journal_no=_next_business_key(JournalEntry, "journal_no", "JV-ADV-", org),
                date=ref_date,
                reference=advance.purpose[:50],
                description=f"Cash advance to {advance.recipient} — {advance.purpose}",
                fund=fund.accounting_funds.first() if fund else None,
                source_project_fund=fund,
                status="Posted",
            )
            JournalLine.objects.create(
                journal=journal, account=advance_account,
                description=f"Advance — {advance.purpose}", debit_idr=advance.amount_idr,
            )
            JournalLine.objects.create(
                journal=journal, account=cash_account,
                description=f"Advance issued to {advance.recipient}", credit_idr=advance.amount_idr,
            )
            advance.issue_journal_entry = journal

        advance.save(update_fields=["amount_idr", "issue_journal_entry", "updated_at"])
        log_project(
            advance.project_id, request.user, "fund",
            f"issued cash advance of {advance.amount:,.0f} {advance.currency} to {advance.recipient} for “{advance.purpose}”",
        )
        return Response(self.get_serializer(advance).data, status=201)

    @action(detail=True, methods=["post"], url_path="settle")
    def settle(self, request, pk=None):
        """Liquidate an advance: reclass the spent portion into a real Expense
        account and return any unspent cash. Only the spent portion becomes a
        recognized expense — mirrored into ProjectFinance so it shows up in
        the fund's normal expense ledger/totals."""
        from decimal import Decimal
        from django.utils import timezone
        from apps.accounting.models import Account, JournalEntry, JournalLine

        advance = self.get_object()
        if advance.status != "issued":
            return Response({"detail": f"Advance is already {advance.get_status_display()}."}, status=409)

        try:
            spent = Decimal(str(request.data.get("spent_amount", "0")))
        except Exception:
            return Response({"detail": "spent_amount must be a number."}, status=400)
        if spent <= 0:
            return Response({"detail": "spent_amount must be greater than 0."}, status=400)
        if spent > advance.amount:
            return Response({"detail": "spent_amount cannot exceed the advance amount."}, status=400)
        returned = advance.amount - spent
        if returned > 0 and not request.FILES.get("return_receipt"):
            return Response(
                {"detail": "A return receipt (proof of unspent cash returned) is required when returned_amount > 0."},
                status=400,
            )

        org = advance.project.organization
        expense_account_id = request.data.get("expense_account")
        expense_account = (
            Account.objects.filter(id=expense_account_id, type="Expense", organization=org).first()
            if expense_account_id else None
        )
        if not expense_account:
            return Response({"detail": "A valid Expense account is required to settle."}, status=400)

        advance_account = Account.objects.filter(organization=org, code="1200").first()
        cash_account = Account.objects.filter(organization=org, is_cash=True).first()
        if not advance_account or not cash_account:
            return Response({"detail": "Chart of accounts is missing the Advance or Cash account."}, status=500)

        spent_idr = (advance.amount_idr / advance.amount) * spent if advance.amount else spent
        returned_idr = (advance.amount_idr / advance.amount) * returned if advance.amount else returned

        journal = JournalEntry.objects.create(
            organization=org,
            journal_no=_next_business_key(JournalEntry, "journal_no", "JV-SET-", org),
            date=timezone.now().date(),
            reference=advance.purpose[:50],
            description=f"Settlement of advance to {advance.recipient} — {advance.purpose}",
            fund=advance.fund.accounting_funds.first() if advance.fund_id else None,
            source_project_fund=advance.fund,
            status="Posted",
        )
        JournalLine.objects.create(
            journal=journal, account=expense_account,
            description=f"Settled advance — {advance.purpose}", debit_idr=spent_idr,
        )
        if returned > 0:
            JournalLine.objects.create(
                journal=journal, account=cash_account,
                description="Unspent advance returned", debit_idr=returned_idr,
            )
        JournalLine.objects.create(
            journal=journal, account=advance_account,
            description=f"Clear advance — {advance.recipient}", credit_idr=spent_idr + returned_idr,
        )

        finance_entry = ProjectFinance.objects.create(
            project=advance.project,
            description=f"Cash advance settlement — {advance.purpose} ({advance.recipient})",
            amount=spent,
            currency=advance.currency,
            amount_idr=spent_idr,
            type="expense",
            ledger="expense",
            # The advance already names the fund it draws down (used for the
            # journal above); carry it onto the ledger entry so donor spend
            # stays attributable on a co-funded project.
            fund=advance.fund,
            budget_item=advance.budget_item,
            date=timezone.now().date(),
            approval_status="approved",
            approved_by=request.user if request.user.is_authenticated else None,
            approved_at=timezone.now(),
            created_by=request.user if request.user.is_authenticated else None,
        )

        advance.status = "settled"
        advance.spent_amount = spent
        advance.returned_amount = returned
        advance.settlement_notes = (request.data.get("settlement_notes") or "")[:2000]
        if request.FILES.get("settlement_receipt"):
            advance.settlement_receipt = request.FILES["settlement_receipt"]
        if request.FILES.get("return_receipt"):
            advance.return_receipt = request.FILES["return_receipt"]
        advance.settled_at = timezone.now()
        advance.settled_by = request.user if request.user.is_authenticated else None
        advance.settlement_journal_entry = journal
        advance.project_finance_entry = finance_entry
        advance.save()

        log_project(
            advance.project_id, request.user, "fund",
            f"settled cash advance to {advance.recipient}: {spent:,.0f} spent, {returned:,.0f} returned",
        )
        if advance.created_by_id and advance.created_by_id != request.user.id:
            _notify_project(
                advance.created_by,
                title="Cash advance settled",
                message=f"Your cash advance to {advance.recipient} was settled ({spent:,.0f} spent, {returned:,.0f} returned).",
                link=f"/project/{advance.project_id}?tab=funds",
                priority="medium",
                related_user=request.user,
            )
        return Response(self.get_serializer(advance).data)

    @action(detail=True, methods=["post"], url_path="cancel")
    def cancel(self, request, pk=None):
        """Cancel an advance that was never actually drawn/settled. Reverses
        the issue journal by marking it Rejected (kept for audit trail)."""
        advance = self.get_object()
        if advance.status != "issued":
            return Response({"detail": f"Advance is already {advance.get_status_display()}."}, status=409)
        advance.status = "cancelled"
        advance.save(update_fields=["status", "updated_at"])
        if advance.issue_journal_entry_id:
            advance.issue_journal_entry.status = "Rejected"
            advance.issue_journal_entry.save(update_fields=["status"])
        log_project(advance.project_id, request.user, "fund", f"cancelled cash advance to {advance.recipient}")
        if advance.created_by_id and advance.created_by_id != request.user.id:
            _notify_project(
                advance.created_by,
                title="Cash advance cancelled",
                message=f"Your cash advance to {advance.recipient} was cancelled.",
                link=f"/project/{advance.project_id}?tab=funds",
                priority="medium",
                related_user=request.user,
            )
        return Response(self.get_serializer(advance).data)


# --- AI seed-template prompts for proposal / report ---
_TEMPLATE_SYSTEM = {
    "proposal": (
        "You are an assistant that drafts professional PROJECT PROPOSAL templates for a "
        "research organization. Produce a reusable seed template with placeholder guidance.\n\n"
        "Rules:\n"
        "- Respond ONLY with the template. No preamble, no markdown code fences.\n"
        "- Output clean semantic HTML for a rich-text editor: <h2>, <p>, <ul>/<li>, <strong>. "
        "No <html>/<head>/<body>/<style> tags.\n"
        "- Include the standard proposal sections (Background, Objectives, Methodology, "
        "Timeline, Budget, Expected Outcomes) with short placeholder prompts the author fills in.\n"
        "- Match the user's requested language; default to English."
    ),
    "report": (
        "You are an assistant that drafts professional PROJECT REPORT templates for a "
        "research organization. Produce a reusable seed template with placeholder guidance.\n\n"
        "Rules:\n"
        "- Respond ONLY with the template. No preamble, no markdown code fences.\n"
        "- Output clean semantic HTML for a rich-text editor: <h2>, <p>, <ul>/<li>, <strong>. "
        "No <html>/<head>/<body>/<style> tags.\n"
        "- Include the standard report sections (Executive Summary, Activities, Results & "
        "Impact, Challenges, Financial Summary, Next Steps) with short placeholder prompts.\n"
        "- Match the user's requested language; default to English."
    ),
}

# System prompt for FILLING a fixed design with content from structured inputs.
# Implements the proposal/report generation-agent guardrails: fill the given
# structure, stay factual, mark unknowns, never invent.
_FILL_SYSTEM = (
    "You are a {kind} co-pilot for a research/policy organization. You FILL an existing "
    "{kind} DESIGN with content — you do NOT redesign it.\n\n"
    "RULES:\n"
    "- Keep the provided template's structure, section order, and headings intact. Replace "
    "placeholder text with real content drawn ONLY from the user inputs and organization facts below.\n"
    "- Use formal, professional language and the organization's voice. Keep it consistent and logical: "
    "timeline must match activities, budget narrative must match activities, outcomes must align with objectives.\n"
    "- NEVER invent facts, partnerships, funding commitments, beneficiary numbers, results, or budget figures. "
    "If a required detail is missing, insert the literal marker [NEEDS INPUT] in place — do not fabricate.\n"
    "- Reference relevant organization projects/experience only when provided in the facts.\n"
    "- Output ONLY the filled document as clean semantic HTML (<h2>, <p>, <ul>/<li>, <strong>). "
    "No markdown code fences, no <html>/<head>/<body>/<style>.\n"
    "- Match the requested language; default to English."
)


def _org_brief(organization):
    """Short organization context to ground the draft (no fabrication)."""
    from apps.companies.models import Department

    op = organization
    bits = [f"- Name: {(op.name if op else '') or 'the organization'}"]
    if op and op.legal_name:
        bits.append(f"- Legal entity: {op.legal_name}")
    if op and (op.city or op.country):
        bits.append(f"- Location: {', '.join(p for p in [op.city, op.country] if p)}")
    if op and op.description:
        bits.append(f"- About: {op.description.strip()[:600]}")
    divs = list(Department.objects.filter(organization=organization, parent__isnull=True).values_list("name", flat=True)[:15])
    if divs:
        bits.append(f"- Research divisions: {', '.join(divs)}")
    return "\n".join(bits)


class ProjectSettingsViewSet(viewsets.ViewSet):
    """Singleton Projects-module settings (access rules + AI seed templates)."""
    rbac_domain = "project"

    def get_permissions(self):
        from rest_framework.permissions import IsAuthenticated, IsAdminUser
        if self.action in ("current",) and self.request.method in ("PATCH", "PUT"):
            return [IsAdminUser()]
        return [IsAuthenticated()]

    @action(detail=False, methods=["get", "patch", "put"], url_path="current")
    def current(self, request):
        obj = ProjectSettingsModel.get_solo(get_active_organization(request))
        if request.method in ("PATCH", "PUT"):
            serializer = ProjectSettingsSerializer(obj, data=request.data, partial=request.method == "PATCH")
            serializer.is_valid(raise_exception=True)
            serializer.save()
            return Response(serializer.data)
        return Response(ProjectSettingsSerializer(obj).data)

    @action(detail=False, methods=["post"], url_path="ai-generate")
    def ai_generate(self, request):
        """Generate a proposal/report seed template via the configured AI engine.

        Body: {kind: "proposal"|"report", prompt: str}. Returns {html}.
        """
        kind = request.data.get("kind")
        if kind not in _TEMPLATE_SYSTEM:
            return Response({"error": "kind must be 'proposal' or 'report'"}, status=400)
        prompt = (request.data.get("prompt") or "").strip()[:2000]

        api_key, base_url, model = _resolve_ai_config()
        if not api_key:
            return Response({"error": "AI assistant is not configured."}, status=503)

        user_msg = prompt or f"Create a generic, reusable {kind} template."
        try:
            resp = requests.post(
                f"{base_url.rstrip('/')}/chat/completions",
                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
                json={
                    "model": model,
                    "messages": [
                        {"role": "system", "content": _TEMPLATE_SYSTEM[kind]},
                        {"role": "user", "content": user_msg},
                    ],
                    "temperature": 0.7,
                    "stream": False,
                },
                timeout=60,
            )
        except requests.RequestException:
            return Response({"error": "AI request failed. Try again."}, status=502)

        if resp.status_code != 200:
            return Response({"error": "AI provider returned an error."}, status=502)
        try:
            html = resp.json()["choices"][0]["message"]["content"].strip()
        except (KeyError, IndexError, TypeError, ValueError):
            return Response({"error": "AI returned no usable content."}, status=502)

        html = re.sub(r"^```[a-zA-Z]*\n?", "", html)
        html = re.sub(r"\n?```$", "", html).strip()
        return Response({"html": html})

    @action(detail=False, methods=["post"], url_path="ai-fill")
    def ai_fill(self, request):
        """Fill a chosen design with content from structured inputs.

        Body: {kind, template_html, inputs: {label: value, ...}}. Returns {html}.
        The AI keeps the template structure and only fills content, marking any
        missing detail as [NEEDS INPUT] rather than inventing it.
        """
        kind = request.data.get("kind")
        if kind not in ("proposal", "report"):
            return Response({"error": "kind must be 'proposal' or 'report'"}, status=400)
        template_html = (request.data.get("template_html") or "").strip()
        inputs = request.data.get("inputs") or {}
        if not isinstance(inputs, dict):
            return Response({"error": "inputs must be an object"}, status=400)

        api_key, base_url, model = _resolve_ai_config()
        if not api_key:
            return Response({"error": "AI assistant is not configured."}, status=503)

        filled_inputs = "\n".join(
            f"- {label}: {str(value).strip()[:1500]}"
            for label, value in inputs.items() if str(value).strip()
        ) or "(none provided)"

        user_content = (
            "TEMPLATE DESIGN (keep this structure and headings; fill the content):\n"
            f"{template_html or '(blank — use a standard ' + kind + ' structure)'}\n\n"
            "USER INPUTS:\n"
            f"{filled_inputs}\n\n"
            "ORGANIZATION FACTS (use only these for org details; do not invent):\n"
            f"{_org_brief(get_active_organization(request))}"
        )

        try:
            resp = requests.post(
                f"{base_url.rstrip('/')}/chat/completions",
                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
                json={
                    "model": model,
                    "messages": [
                        {"role": "system", "content": _FILL_SYSTEM.format(kind=kind)},
                        {"role": "user", "content": user_content},
                    ],
                    "temperature": 0.5,
                    "stream": False,
                },
                timeout=90,
            )
        except requests.RequestException:
            return Response({"error": "AI request failed. Try again."}, status=502)

        if resp.status_code != 200:
            return Response({"error": "AI provider returned an error."}, status=502)
        try:
            html = resp.json()["choices"][0]["message"]["content"].strip()
        except (KeyError, IndexError, TypeError, ValueError):
            return Response({"error": "AI returned no usable content."}, status=502)

        html = re.sub(r"^```[a-zA-Z]*\n?", "", html)
        html = re.sub(r"\n?```$", "", html).strip()
        return Response({"html": html})


class _FundScopedViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Base for fund sub-resources: list filterable by ?fund=<id>, no pagination
    (per-fund sets are small and the fund detail wants them all)."""
    rbac_domain = "project"
    pagination_class = None
    fund_select_related = ()
    organization_lookup = "fund__organization"

    def perform_create(self, serializer):
        serializer.save()

    @extend_schema(parameters=[OpenApiParameter(name="fund", type=str, description="Filter by fund ID")])
    def list(self, request, *args, **kwargs):
        qs = self.get_queryset()
        fund = request.query_params.get("fund")
        if fund:
            qs = qs.filter(fund_id=fund)
        return Response(self.get_serializer(qs, many=True).data)


class FundingOpportunityViewSet(_FundScopedViewSet):
    """Funding opportunities (open calls / prospects) for a fund."""
    queryset = FundingOpportunity.objects.select_related("fund", "donor")
    serializer_class = FundingOpportunitySerializer


class FundingProposalViewSet(_FundScopedViewSet):
    """Proposals submitted under a funding source."""
    queryset = FundingProposal.objects.select_related("fund")
    serializer_class = FundingProposalSerializer


def _notify_fund_donor_users(fund, *, title, message, link):
    """Notify every portal login of the fund's donor company."""
    from apps.notifications.utils import notify

    if not fund.donor_id:
        return
    for donor_user in fund.donor.donor_users.filter(is_active=True):
        notify(donor_user, title=title, message=message, link=link,
               notification_type="project", icon="folder")


def _staff_submit(viewset, request, pk):
    """Shared staff (re)submission handler for reviewable items (fund reports
    and deliverables): appends a SubmissionVersion and notifies the donor."""
    item = viewset.get_object()
    file = request.FILES.get("file")
    if not file:
        return Response({"file": "A file is required."}, status=400)
    item.record_submission(file=file, note=request.data.get("note", ""), user=request.user)
    _notify_fund_donor_users(
        item.fund,
        title="New submission",
        message=f"{item.title} was submitted for your review.",
        link="/donor-portal",
    )
    return Response(viewset.get_serializer(item).data)


class FundReportViewSet(_FundScopedViewSet):
    """Donor reports owed under a fund's agreement."""
    queryset = FundReport.objects.select_related("fund")
    serializer_class = FundReportSerializer

    @action(detail=True, methods=["post"])
    def submit(self, request, pk=None):
        """Submit (or resubmit) this report to the donor with a file version."""
        return _staff_submit(self, request, pk)


class DeliverableViewSet(_FundScopedViewSet):
    """Contractual deliverables owed under a fund's agreement."""
    queryset = Deliverable.objects.select_related("fund", "fund__project", "milestone")
    serializer_class = DeliverableSerializer

    @action(detail=True, methods=["post"])
    def submit(self, request, pk=None):
        """Submit (or resubmit) this deliverable to the donor with a file version."""
        return _staff_submit(self, request, pk)


class ProjectIndicatorViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Results-framework indicators, filterable by ?project=."""
    rbac_domain = "project"
    queryset = ProjectIndicator.objects.select_related("project").prefetch_related("records")
    serializer_class = ProjectIndicatorSerializer
    pagination_class = None  # per-project indicator sets are small; detail pages want them all
    organization_lookup = "project__organization"

    def perform_create(self, serializer):
        serializer.save()

    def get_queryset(self):
        qs = super().get_queryset()
        project = self.request.query_params.get("project")
        return qs.filter(project_id=project) if project else qs


class IndicatorRecordViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Reported data points for an indicator, filterable by ?indicator=."""
    rbac_domain = "project"
    queryset = IndicatorRecord.objects.select_related("indicator")
    serializer_class = IndicatorRecordSerializer
    pagination_class = None  # bounded by reporting frequency; indicator detail wants them all
    organization_lookup = "indicator__project__organization"

    def perform_create(self, serializer):
        serializer.save(created_by=self.request.user)

    def get_queryset(self):
        qs = super().get_queryset()
        indicator = self.request.query_params.get("indicator")
        return qs.filter(indicator_id=indicator) if indicator else qs


class ProjectRiskViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Project risks and issues, filterable by ?project=."""
    rbac_domain = "project"
    queryset = ProjectRisk.objects.select_related("project", "owner")
    serializer_class = ProjectRiskSerializer
    pagination_class = None  # per-project risk registers are small
    organization_lookup = "project__organization"

    def perform_create(self, serializer):
        serializer.save()

    def get_queryset(self):
        qs = super().get_queryset()
        project = self.request.query_params.get("project")
        return qs.filter(project_id=project) if project else qs


def _sync_fund_from_tranches(fund):
    """Keep a fund's status/received_date in step with its tranches.

    Auto-manages only the pledged → partially_received → received ladder so
    manual states (allocated, closed, rejected, proposed) are never clobbered.
    """
    # Auto-managed statuses we are allowed to move between.
    AUTO = {"pledged", "partially_received", "received"}
    tranches = list(fund.tranches.all())
    received = [t for t in tranches if t.status == "received"]
    total_received = sum((t.amount for t in received), Decimal("0"))

    if fund.amount and total_received >= fund.amount:
        target = "received"
    elif total_received > 0:
        target = "partially_received"
    else:
        target = "pledged"

    changed = []
    if fund.status in AUTO and fund.status != target:
        fund.status = target
        changed.append("status")

    # received_date reflects the latest landed tranche, only once fully received.
    if target == "received":
        dates = [t.received_date for t in received if t.received_date]
        new_date = max(dates) if dates else None
        if new_date and fund.received_date != new_date:
            fund.received_date = new_date
            changed.append("received_date")
    elif fund.received_date is not None and fund.status in AUTO:
        fund.received_date = None
        changed.append("received_date")

    if changed:
        fund.save(update_fields=[*changed, "updated_at"])


def _post_tranche_receipt(tranche):
    """Post Dr Cash / Cr Deferred Grant Income (Draft) when a tranche lands.

    Mirrors CashAdvanceViewSet._issue's posting pattern, but stays Draft so
    finance reviews/posts it manually.
    """
    from apps.accounting.models import Account, Fund as AccountingFund, JournalEntry, JournalLine

    fund = tranche.fund
    org = fund.organization
    cash_account = Account.objects.filter(organization=org, code="1000").first()
    deferred_account = Account.objects.filter(organization=org, code="2100").first()
    if not cash_account or not deferred_account:
        return

    acct_fund = fund.accounting_funds.first()
    if not acct_fund:
        acct_fund = AccountingFund.objects.create(
            organization=org,
            fund_id=_next_business_key(AccountingFund, "fund_id", "FND-", org),
            donor=fund.donor,
            donor_name=fund.donor.name if fund.donor else fund.source,
            name=fund.source,
            project=fund.project,
            project_fund=fund,
            award_amount=fund.amount_idr or fund.amount,
            currency=fund.currency,
        )

    rate = fund.exchange_rate or Decimal("1")
    amount_idr = tranche.amount * rate

    journal = JournalEntry.objects.create(
        organization=org,
        journal_no=_next_business_key(JournalEntry, "journal_no", "JV-TR-", org),
        date=tranche.received_date or timezone.now().date(),
        reference=tranche.label[:50],
        description=f"Tranche received — {tranche.label} ({fund.source})",
        fund=acct_fund,
        source_project_fund=fund,
        status="Draft",
    )
    JournalLine.objects.create(
        journal=journal, account=cash_account,
        description=f"Tranche receipt — {tranche.label}", debit_idr=amount_idr,
    )
    JournalLine.objects.create(
        journal=journal, account=deferred_account,
        description=f"Deferred grant income — {tranche.label}", credit_idr=amount_idr,
    )

    tranche.journal_entry = journal
    tranche.save(update_fields=["journal_entry", "updated_at"])


class ProjectFundTrancheViewSet(_FundScopedViewSet):
    """Disbursement tranches (milestone payments) of a fund."""
    queryset = ProjectFundTranche.objects.select_related("fund")
    serializer_class = ProjectFundTrancheSerializer

    def perform_create(self, serializer):
        tranche = serializer.save()
        _sync_fund_from_tranches(tranche.fund)
        if tranche.status == "received" and not tranche.journal_entry_id:
            _post_tranche_receipt(tranche)

    def perform_update(self, serializer):
        tranche = serializer.save()
        _sync_fund_from_tranches(tranche.fund)
        if tranche.status == "received" and not tranche.journal_entry_id:
            _post_tranche_receipt(tranche)

    def perform_destroy(self, instance):
        fund = instance.fund
        instance.delete()
        _sync_fund_from_tranches(fund)


class ProjectFundComplianceViewSet(_FundScopedViewSet):
    """Compliance / audit obligations under a fund."""
    queryset = ProjectFundCompliance.objects.select_related("fund")
    serializer_class = ProjectFundComplianceSerializer


class FundDocumentViewSet(TenantScopedMixin, viewsets.ModelViewSet):
    """Documents attached to a fund, classified by category
    (proposal / agreement / reporting / compliance)."""
    rbac_domain = "project"
    queryset = FundDocument.objects.select_related("fund").all()
    serializer_class = FundDocumentSerializer
    pagination_class = None
    parser_classes = [JSONParser, MultiPartParser, FormParser]
    organization_lookup = "fund__organization"

    def perform_create(self, serializer):
        serializer.save()

    @extend_schema(parameters=[
        OpenApiParameter(name="fund", type=str, description="Filter by fund ID"),
        OpenApiParameter(name="category", type=str, description="proposal | agreement | reporting | compliance"),
    ])
    def list(self, request, *args, **kwargs):
        qs = self.get_queryset()
        fund = request.query_params.get("fund")
        if fund:
            qs = qs.filter(fund_id=fund)
        category = request.query_params.get("category")
        if category:
            qs = qs.filter(category=category)
        return Response(self.get_serializer(qs, many=True).data)


def _prf_budget_snapshot(prf):
    """Compute Budget Tersedia / Realisasi Sebelum / Sesudah / Sisa for a PRF,
    in the PRF's own currency. Mirrors ProjectBudgetOverview's allocation/
    expense join (frontend/app/(app)/project/[id]/page.tsx), but only realized
    (approved) spend counts toward "Realisasi" — pending doesn't.
    """
    from .fx import convert_on_date
    to_ccy = (prf.currency or "IDR").upper()
    today = timezone.now().date()

    alloc_qs = ProjectFundAllocation.objects.filter(fund__project_id=prf.project_id).select_related("fund")
    if prf.budget_item_id:
        alloc_qs = alloc_qs.filter(budget_item_id=prf.budget_item_id)
    available = Decimal("0")
    for a in alloc_qs:
        converted = convert_on_date(a.amount, a.fund.currency, to_ccy, today)
        if converted is not None:
            available += converted

    fin_qs = ProjectFinance.objects.filter(project_id=prf.project_id, type="expense", approval_status="approved")
    if prf.budget_item_id:
        fin_qs = fin_qs.filter(budget_item_id=prf.budget_item_id)
    realized_before = Decimal("0")
    for f in fin_qs:
        ref_date = f.date or (f.created_at.date() if f.created_at else today)
        converted = convert_on_date(f.amount, f.currency, to_ccy, ref_date)
        if converted is not None:
            realized_before += converted

    realized_after = realized_before + (prf.amount or Decimal("0"))
    return {
        "budget_available": available,
        "realized_before": realized_before,
        "realized_after": realized_after,
        "budget_remaining": available - realized_after,
    }


class PaymentRequestFormViewSet(viewsets.ModelViewSet):
    """Payment Request Form (PRF) — request release of budget to a vendor.

    Status moves forward through a fixed 4-step chain via dedicated actions
    (review, verify-budget, approve/reject); the base create/update only
    covers the draft/PIC-authored fields (A-E on the paper form).
    """
    rbac_domain = "project"
    serializer_class = PaymentRequestFormSerializer
    parser_classes = [JSONParser, MultiPartParser, FormParser]

    def get_queryset(self):
        return PaymentRequestForm.objects.filter(
            project__organization=get_active_organization(self.request)
        ).select_related(
            "project", "fund", "budget_item", "vendor", "created_by",
            "prepared_by", "reviewed_by", "budget_verified_by", "finance_approved_by",
        ).prefetch_related("attachments")

    @extend_schema(parameters=[
        OpenApiParameter(name="project", type=str, description="Filter by project ID"),
    ])
    def list(self, request, *args, **kwargs):
        qs = self.get_queryset()
        project = request.query_params.get("project")
        if project:
            qs = qs.filter(project_id=project)
        page = self.paginate_queryset(qs)
        if page is not None:
            return self.get_paginated_response(self.get_serializer(page, many=True).data)
        return Response(self.get_serializer(qs, many=True).data)

    def perform_create(self, serializer):
        user = self.request.user if self.request.user.is_authenticated else None
        instance = serializer.save(
            created_by=user, prepared_by=user, prepared_at=timezone.now(),
            status="in_review",
        )
        log_project(instance.project_id, user, "payment_request", f"created PRF for {instance.recipient_name or 'recipient'}")

    def perform_update(self, serializer):
        instance = serializer.save()
        log_project(instance.project_id, self.request.user, "payment_request", f"updated PRF {instance.number or instance.id}")

    def perform_destroy(self, instance):
        log_project(instance.project_id, self.request.user, "payment_request", f"deleted PRF {instance.number or instance.id}")
        instance.delete()

    @action(detail=True, methods=["post"], url_path="attachments", parser_classes=[MultiPartParser, FormParser])
    def upload_attachment(self, request, pk=None):
        """Upload (or replace) the file for one Lampiran slug."""
        obj = self._get(pk)
        if obj is None:
            return Response({"detail": "Not found."}, status=404)
        slug = request.data.get("slug")
        if slug not in PaymentRequestForm.ATTACHMENT_SLUGS:
            return Response({"detail": "Invalid slug."}, status=400)
        file_obj = request.data.get("file")
        if not file_obj:
            return Response({"detail": "file is required."}, status=400)

        serializer = PRFAttachmentSerializer(data={"prf": obj.id, "slug": slug, "file": file_obj}, context={"request": request})
        serializer.is_valid(raise_exception=True)

        user = request.user if request.user.is_authenticated else None
        attachment, _created = PRFAttachment.objects.update_or_create(
            prf=obj, slug=slug,
            defaults={"file": serializer.validated_data["file"], "uploaded_by": user},
        )
        log_project(obj.project_id, user, "payment_request", f"uploaded {slug} to PRF {obj.number or obj.id}")
        return Response(PRFAttachmentSerializer(attachment, context={"request": request}).data, status=201)

    @action(detail=True, methods=["delete"], url_path=r"attachments/(?P<attachment_id>[^/.]+)")
    def delete_attachment(self, request, pk=None, attachment_id=None):
        obj = self._get(pk)
        if obj is None:
            return Response({"detail": "Not found."}, status=404)
        try:
            attachment = obj.attachments.get(id=attachment_id)
        except PRFAttachment.DoesNotExist:
            return Response({"detail": "Attachment not found."}, status=404)
        slug = attachment.slug
        attachment.delete()
        log_project(obj.project_id, request.user, "payment_request", f"removed {slug} from PRF {obj.number or obj.id}")
        return Response(status=204)

    def _get(self, pk):
        try:
            return self.get_queryset().get(pk=pk)
        except PaymentRequestForm.DoesNotExist:
            return None

    @action(detail=True, methods=["post"], url_path="review")
    def review(self, request, pk=None):
        obj = self._get(pk)
        if obj is None:
            return Response({"detail": "Not found."}, status=404)
        if obj.status != "in_review":
            return Response({"detail": f"Cannot review a PRF in status '{obj.status}'."}, status=400)
        if obj.created_by_id and obj.created_by_id == request.user.id:
            return Response({"detail": "You cannot review your own PRF."}, status=403)
        decision = request.data.get("decision", "approve")
        obj.reviewed_by = request.user
        obj.reviewed_at = timezone.now()
        obj.review_comment = (request.data.get("comment") or "")[:500]
        obj.status = "budget_verification" if decision == "approve" else "rejected"
        obj.save(update_fields=["reviewed_by", "reviewed_at", "review_comment", "status", "updated_at"])
        log_project(obj.project_id, request.user, "payment_request", f"reviewed PRF {obj.number or obj.id} ({decision})")
        if obj.created_by_id and obj.created_by_id != request.user.id:
            _notify_project(
                obj.created_by,
                title="PRF reviewed" if decision == "approve" else "PRF rejected",
                message=f"Your payment request {obj.number or obj.id} was {'sent for budget verification' if decision == 'approve' else 'rejected'} at review.",
                link=f"/project/{obj.project_id}?tab=payment-requests",
                priority="medium",
                related_user=request.user,
            )
        return Response(self.get_serializer(obj).data)

    @action(detail=True, methods=["post"], url_path="verify-budget")
    def verify_budget(self, request, pk=None):
        obj = self._get(pk)
        if obj is None:
            return Response({"detail": "Not found."}, status=404)
        if obj.status != "budget_verification":
            return Response({"detail": f"Cannot verify budget on a PRF in status '{obj.status}'."}, status=400)
        if obj.created_by_id and obj.created_by_id == request.user.id:
            return Response({"detail": "You cannot verify your own PRF."}, status=403)
        decision = request.data.get("decision", "approve")
        snapshot = _prf_budget_snapshot(obj)
        obj.budget_verified_by = request.user
        obj.budget_verified_at = timezone.now()
        obj.budget_verify_comment = (request.data.get("comment") or "")[:500]
        obj.status = "pending_approval" if decision == "approve" else "rejected"
        for k, v in snapshot.items():
            setattr(obj, k, v)
        obj.save(update_fields=[
            "budget_verified_by", "budget_verified_at", "budget_verify_comment", "status",
            "budget_available", "realized_before", "realized_after", "budget_remaining", "updated_at",
        ])
        log_project(obj.project_id, request.user, "payment_request", f"verified budget for PRF {obj.number or obj.id} ({decision})")
        if obj.created_by_id and obj.created_by_id != request.user.id:
            _notify_project(
                obj.created_by,
                title="PRF budget verified" if decision == "approve" else "PRF rejected",
                message=f"Your payment request {obj.number or obj.id} was {'sent for finance approval' if decision == 'approve' else 'rejected'} at budget verification.",
                link=f"/project/{obj.project_id}?tab=payment-requests",
                priority="medium",
                related_user=request.user,
            )
        return Response(self.get_serializer(obj).data)

    @action(detail=True, methods=["post"], url_path="approve")
    def approve(self, request, pk=None):
        obj = self._get(pk)
        if obj is None:
            return Response({"detail": "Not found."}, status=404)
        if obj.status != "pending_approval":
            return Response({"detail": f"Cannot approve a PRF in status '{obj.status}'."}, status=400)
        if obj.created_by_id and obj.created_by_id == request.user.id:
            return Response({"detail": "You cannot approve your own PRF."}, status=403)
        decision = request.data.get("decision", "approve")
        obj.finance_approved_by = request.user
        obj.finance_approved_at = timezone.now()
        obj.approve_comment = (request.data.get("comment") or "")[:500]
        obj.status = "approved" if decision == "approve" else "rejected"
        obj.save(update_fields=["finance_approved_by", "finance_approved_at", "approve_comment", "status", "updated_at"])
        log_project(obj.project_id, request.user, "payment_request", f"{'approved' if decision == 'approve' else 'rejected'} PRF {obj.number or obj.id}")
        if obj.created_by_id and obj.created_by_id != request.user.id:
            _notify_project(
                obj.created_by,
                title="PRF approved" if decision == "approve" else "PRF rejected",
                message=f"Your payment request {obj.number or obj.id} was {'approved' if decision == 'approve' else 'rejected'}.",
                link=f"/project/{obj.project_id}?tab=payment-requests",
                priority="medium",
                related_user=request.user,
            )
        return Response(self.get_serializer(obj).data)

    @action(detail=True, methods=["post"], url_path="attach-document")
    def attach_document(self, request, pk=None):
        """Link the client-generated signed PDF (uploaded via /project-documents/
        first) to this PRF. Called after the chain completes."""
        obj = self._get(pk)
        if obj is None:
            return Response({"detail": "Not found."}, status=404)
        doc_id = request.data.get("document")
        if not doc_id:
            return Response({"detail": "document is required."}, status=400)
        try:
            doc = ProjectDocument.objects.get(id=doc_id, project_id=obj.project_id)
        except ProjectDocument.DoesNotExist:
            return Response({"detail": "Document not found for this project."}, status=404)
        obj.document = doc
        obj.save(update_fields=["document", "updated_at"])
        return Response(self.get_serializer(obj).data)
