import os
import re
from collections import OrderedDict
from datetime import date, datetime, timedelta

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

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):
    """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 OrgProfile, Department

    lines = []

    # Organization identity (from the singleton OrgProfile + research divisions).
    op = OrgProfile.objects.first()
    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(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).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,
    FundingOpportunity, FundingProposal, FundReport,
    ProjectFundTranche, ProjectFundCompliance, FundDocument,
    ProjectResource, ProjectLog,
)
from .serializers import (
    ProjectSerializer, ProjectDocumentSerializer, ProjectTeamMemberSerializer,
    ProjectFinanceSerializer, ProjectDisseminationSerializer, ProjectScheduleSerializer,
    ProjectFundSerializer, ProjectFundAllocationSerializer, ProjectSettingsSerializer,
    FundingOpportunitySerializer, FundingProposalSerializer, FundReportSerializer,
    ProjectFundTrancheSerializer, ProjectFundComplianceSerializer, FundDocumentSerializer,
    ProjectResourceSerializer, ProjectLogSerializer,
)
from .models import ProjectSettings as ProjectSettingsModel
from .logging import log_project


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


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(start=None, end=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.
    """
    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 = []

    pf_qs = (ProjectFinance.objects.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))
    for e in pf_qs.iterator():
        d = e.date or e.created_at.date()
        if not in_range(d):
            continue
        rows.append({
            "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(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))
    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)
        rows.append({
            "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(status="paid")
                .select_related("requester"))
    if bounded:
        reimb_qs = reimb_qs.filter(_date_bound_q(["paid_at__date", "created_at__date"], start, end))
    for r in reimb_qs.iterator():
        d = (r.paid_at or r.created_at).date()
        if not in_range(d):
            continue
        rows.append({
            "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(status="paid")
                 .select_related("requester"))
    if bounded:
        payreq_qs = payreq_qs.filter(_date_bound_q(["paid_at__date", "created_at__date"], start, end))
    for pr in payreq_qs.iterator():
        d = (pr.paid_at or pr.created_at).date()
        if not in_range(d):
            continue
        rows.append({
            "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(status="paid")
                  .select_related("employee", "employee__user"))
    if bounded:
        payroll_qs = payroll_qs.filter(_date_bound_q(["paid_at__date", "period_end"], start, 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
        rows.append({
            "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(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)
        )
    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
        rows.append({
            "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,
        })

    rows.sort(key=lambda r: r["date"], reverse=True)
    return rows


class ProjectViewSet(viewsets.ModelViewSet):
    """ViewSet for Project management."""
    rbac_domain = "project"
    queryset = Project.objects.all().select_related("lead__user").prefetch_related(
        Prefetch("funds", queryset=ProjectFund.objects.select_related("donor"))
    )
    serializer_class = ProjectSerializer

    def perform_create(self, serializer):
        project = serializer.save()
        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):
        log_project(instance.id, self.request.user, "project", f"deleted project “{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."""
        cache_key = "projects:finance_summary"
        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
            .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.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(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
            .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
            .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
        if user.is_staff or user.is_superuser:
            project_ids = list(Project.objects.values_list("id", flat=True))
        else:
            project_ids = list(
                Project.objects.filter(
                    Q(lead__user=user) | Q(team_members__user=user)
                ).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))

    @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.
        """
        return Response(_collect_cash_transactions())

    @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(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(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."""
        cache_key = "projects:dashboard"
        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.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(created_at__gte=target_start)
            .annotate(m=TruncMonth("created_at"))
            .values("m").annotate(c=Count("id"))
        )
        task_monthly = (
            Task.objects
            .filter(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)
        ).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(
            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
            .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.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 = {
                "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)
            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(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(project_id=None))

    @staticmethod
    def _calendar_items(project_id):
        from apps.tasks.models import Task
        from apps.activities.models import Activity

        task_qs = Task.objects.filter(due_date__isnull=False)
        act_qs = Activity.objects.filter(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):
        if request.method == "POST":
            data = {**request.data, "project": pk}
            serializer = ProjectFinanceSerializer(data=data)
            serializer.is_valid(raise_exception=True)
            obj = serializer.save()
            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("budget_category", "budget_item", "budget_item__category")
            .order_by("-date", "-created_at")
        )
        return Response(ProjectFinanceSerializer(qs, many=True).data)

    @action(detail=True, methods=["get", "post"], url_path="disseminations")
    def disseminations(self, request, pk=None):
        if request.method == "POST":
            data = {**request.data, "project": pk}
            serializer = ProjectDisseminationSerializer(data=data)
            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).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":
            data = {**request.data, "project": pk}
            serializer = ProjectScheduleSerializer(data=data)
            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=["get", "post"], url_path="team")
    def team(self, request, pk=None):
        """Team members for project. POST to add."""
        if request.method == "POST":
            data = {**request.data, "project": pk}
            serializer = ProjectTeamMemberSerializer(data=data)
            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"))

        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.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(viewsets.ModelViewSet):
    """ViewSet for ProjectTeamMember management."""
    rbac_domain = "project"
    queryset = ProjectTeamMember.objects.all()
    serializer_class = ProjectTeamMemberSerializer

    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(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()
        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(
        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(viewsets.ModelViewSet):
    """Allocations of a fund across budget items."""
    rbac_domain = "project"
    queryset = ProjectFundAllocation.objects.select_related(
        "fund", "fund__donor", "budget_item", "budget_item__category",
    ).all()
    serializer_class = ProjectFundAllocationSerializer
    pagination_class = None  # allocation sets are small; the fund detail wants them all

    def perform_create(self, serializer):
        from rest_framework.exceptions import ValidationError
        fund = serializer.validated_data.get("fund")
        if fund and fund.frozen:
            raise ValidationError("Fund is frozen. Unfreeze it before adding allocations.")
        serializer.save()

    @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):
        qs = self.get_queryset()
        fund = request.query_params.get("fund")
        if fund:
            qs = qs.filter(fund_id=fund)
        project = request.query_params.get("project")
        if project:
            qs = qs.filter(fund__project_id=project)
        return Response(self.get_serializer(qs, many=True).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():
    """Short organization context to ground the draft (no fabrication)."""
    from apps.companies.models import OrgProfile, Department

    op = OrgProfile.objects.first()
    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(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()]

    def _get_singleton(self):
        obj = ProjectSettingsModel.objects.first()
        if obj is None:
            obj = ProjectSettingsModel.objects.create()
        return obj

    @action(detail=False, methods=["get", "patch", "put"], url_path="current")
    def current(self, request):
        obj = self._get_singleton()
        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()}"
        )

        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(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 = ()

    @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


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


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


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


class FundDocumentViewSet(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]

    @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)
