from decimal import Decimal

from django.db.models import Sum, Q, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.utils.dateparse import parse_date
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from rest_framework.views import APIView

from apps.core.tenancy import TenantScopedMixin, get_active_organization, assert_same_organization
from apps.projects.models import Project
from .models import Account, Fund, Transaction, JournalEntry, JournalLine, Sale, SalesAccountingSettings
from .serializers import (
    AccountSerializer, FundSerializer, TransactionSerializer,
    JournalEntrySerializer, SaleSerializer, SalesAccountingSettingsSerializer,
)

# Posted expense flows reduce a fund's available balance. We approximate "spent"
# per fund as the net debit on Expense-type accounts across posted journals.
RBAC = "accounting"


class LedgerPagination(PageNumberPagination):
    """Transaction register / journal voucher lists grow without bound."""
    page_size = 100
    page_size_query_param = "page_size"
    max_page_size = 500


class AccountViewSet(viewsets.ModelViewSet):
    """Chart of Accounts CRUD."""
    rbac_domain = RBAC
    serializer_class = AccountSerializer
    pagination_class = None  # Chart of accounts is bounded reference data; the picker needs the full list.

    def get_queryset(self):
        qs = Account.objects.filter(organization=get_active_organization(self.request))
        type_ = self.request.query_params.get("type")
        if type_:
            qs = qs.filter(type=type_)
        return qs

    def perform_create(self, serializer):
        serializer.save(organization=get_active_organization(self.request))


class FundViewSet(viewsets.ModelViewSet):
    """Funds & Grants register. Annotates spent (from posted expense journals)."""
    rbac_domain = RBAC
    serializer_class = FundSerializer
    pagination_class = None  # Fund register is bounded (one row per grant/donor fund), not a growing ledger.

    def get_queryset(self):
        # Spent = net debits on Expense accounts in posted journals, per fund.
        # Annotated via a correlated subquery so this stays a real QuerySet —
        # a plain list() here breaks retrieve/update/delete's get_object().
        spent_subquery = (
            JournalLine.objects.filter(
                journal__fund=OuterRef("pk"), journal__status="Posted", account__type="Expense",
            )
            .order_by()
            .values("journal__fund")
            .annotate(s=Sum("debit_idr") - Sum("credit_idr"))
            .values("s")
        )
        return (
            Fund.objects.filter(organization=get_active_organization(self.request))
            .select_related("donor", "project", "project_fund")
            .annotate(_spent=Coalesce(Subquery(spent_subquery), Decimal("0")))
            .all()
        )

    def perform_create(self, serializer):
        serializer.save(organization=get_active_organization(self.request))

    def perform_destroy(self, instance):
        # Fund/Transaction/JournalEntry FKs are SET_NULL — deleting a fund with
        # ledger history would silently orphan it from every fund-scoped report
        # (spent/available, GL filter, dashboards) with no way to trace it back.
        if instance.transactions.exists() or instance.journal_entries.exists():
            raise ValidationError(
                "Fund has transactions or journal entries. Remove or reassign them before deleting the fund."
            )
        instance.delete()


class TransactionViewSet(viewsets.ModelViewSet):
    """Operational transaction register. Filter by type/status/fund."""
    rbac_domain = RBAC
    serializer_class = TransactionSerializer
    pagination_class = LedgerPagination

    def get_queryset(self):
        qs = Transaction.objects.filter(organization=get_active_organization(self.request)).select_related(
            "fund", "account", "budget_item",
            "source_receivable_invoice", "source_payable_invoice",
            "source_payment", "source_project_fund",
        ).all()
        for param, field in (
            ("transaction_type", "transaction_type"), ("status", "status"),
            ("fund", "fund_id"), ("budget_item", "budget_item_id"),
            ("source_project_fund", "source_project_fund_id"),
        ):
            val = self.request.query_params.get(param)
            if val:
                qs = qs.filter(**{field: val})
        return qs

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        self._validate_postable(serializer.validated_data, None)
        self._assert_fks_in_org(serializer.validated_data, org)
        obj = serializer.save(organization=org)
        self._recompute(obj)

    def perform_update(self, serializer):
        self._validate_postable(serializer.validated_data, serializer.instance)
        self._assert_fks_in_org(serializer.validated_data, serializer.instance.organization)
        obj = serializer.save()
        self._recompute(obj)

    @staticmethod
    def _assert_fks_in_org(data, org):
        assert_same_organization(
            org, data.get("fund"), data.get("account"),
            data.get("source_receivable_invoice"), data.get("source_payable_invoice"),
            data.get("source_payment"), data.get("source_project_fund"),
        )
        budget_item = data.get("budget_item")
        if budget_item is not None and budget_item.category.organization_id != org.id:
            raise ValidationError("budget_item is outside your organization.")

    @staticmethod
    def _validate_postable(data, instance):
        status_ = data.get("status", instance.status if instance else "Draft")
        if status_ != "Posted":
            return
        # Posting the operational register must not be able to diverge from the
        # ledger: require a linked JournalEntry that is itself Posted, so a
        # Transaction can't show as realized spend while the GL/fund totals
        # never see it.
        source_transaction = instance if instance and instance.pk else None
        has_posted_journal = source_transaction is not None and source_transaction.journal_entries.filter(
            status="Posted"
        ).exists()
        if not has_posted_journal:
            raise ValidationError(
                "Cannot post a transaction without a linked, Posted journal entry "
                "(create a JournalEntry with source_transaction set to this transaction and post it)."
            )
        debit = data.get("debit_idr", instance.debit_idr if instance else Decimal("0")) or Decimal("0")
        credit = data.get("credit_idr", instance.credit_idr if instance else Decimal("0")) or Decimal("0")
        original_amount = data.get("original_amount", instance.original_amount if instance else Decimal("0")) or Decimal("0")
        exchange_rate = data.get("exchange_rate", instance.exchange_rate if instance else Decimal("1")) or Decimal("1")
        if original_amount:
            expected_idr = original_amount * exchange_rate
            booked_idr = debit or credit
            if abs(expected_idr - booked_idr) > Decimal("1"):
                raise ValidationError(
                    f"debit/credit ({booked_idr}) does not match original_amount x exchange_rate ({expected_idr})."
                )

    @staticmethod
    def _recompute(obj):
        # Net expense in IDR from original amount x rate (debits are outflows).
        net = (obj.debit_idr or Decimal("0")) - (obj.credit_idr or Decimal("0"))
        if obj.net_expense_idr != net:
            obj.net_expense_idr = net
            obj.save(update_fields=["net_expense_idr"])


class JournalEntryViewSet(viewsets.ModelViewSet):
    """Double-entry journal vouchers with nested lines."""
    rbac_domain = RBAC
    serializer_class = JournalEntrySerializer
    pagination_class = LedgerPagination

    def get_queryset(self):
        qs = JournalEntry.objects.filter(organization=get_active_organization(self.request)).prefetch_related("lines__account").select_related(
            "fund", "budget_item", "source_transaction",
            "source_receivable_invoice", "source_payable_invoice",
            "source_payment", "source_project_fund",
        ).all()
        status_ = self.request.query_params.get("status")
        if status_:
            qs = qs.filter(status=status_)
        return qs

    def perform_create(self, serializer):
        serializer.save(organization=get_active_organization(self.request))

    @action(detail=True, methods=["post"])
    def post_entry(self, request, pk=None):
        """Mark a balanced journal as Posted. Rejects if unbalanced or empty."""
        entry = self.get_object()
        if entry.lines.count() < 2:
            return Response(
                {"detail": "A journal entry needs at least 2 lines before it can be posted."},
                status=400,
            )
        debit, credit = entry.totals()
        if debit != credit:
            return Response(
                {"detail": f"Unbalanced: debit {debit} != credit {credit}."},
                status=400,
            )
        entry.status = "Posted"
        entry.save(update_fields=["status"])
        return Response(self.get_serializer(entry).data)


class SaleViewSet(viewsets.ModelViewSet):
    """Book/accessory sales. Posting happens only via `post_sale`, which
    creates the stock-out movement and the balanced revenue/COGS journal
    entry together — never through a plain status PATCH."""
    rbac_domain = RBAC
    serializer_class = SaleSerializer
    pagination_class = LedgerPagination

    def get_queryset(self):
        qs = Sale.objects.filter(organization=get_active_organization(self.request)).select_related(
            "item", "item__stock_item", "buyer_contact", "stock_movement", "journal_entry",
        ).all()
        status_ = self.request.query_params.get("status")
        if status_:
            qs = qs.filter(status=status_)
        return qs

    def perform_create(self, serializer):
        serializer.save(organization=get_active_organization(self.request))

    def perform_update(self, serializer):
        if serializer.instance.status == "Posted":
            raise ValidationError("Cannot edit a Posted sale.")
        serializer.save()

    def perform_destroy(self, instance):
        if instance.status == "Posted":
            raise ValidationError("Cannot delete a Posted sale. Void it instead.")
        instance.delete()

    @action(detail=True, methods=["post"])
    def post_sale(self, request, pk=None):
        """Post a Draft sale: stock-out the item and record revenue + COGS."""
        from apps.asset_management.models import StockMovement, StockLevel, AssetLocation

        sale = self.get_object()
        if sale.status != "Draft":
            return Response({"detail": f"Sale is {sale.status}, not Draft."}, status=400)

        settings_row = SalesAccountingSettings.get_solo(sale.organization)
        if settings_row is None:
            return Response(
                {"detail": "No SalesAccountingSettings configured. Set up the 4 posting accounts first."},
                status=400,
            )

        stock_item = sale.item.stock_item
        unit_cost = stock_item.unit_cost or Decimal("0")
        cogs_amount = unit_cost * sale.quantity

        # Stock out from wherever the item currently has stock (first location with a level).
        level = StockLevel.objects.filter(item=stock_item).order_by("-quantity").first()
        location = level.location if level else AssetLocation.objects.filter(organization=sale.organization).first()
        if location is None:
            return Response({"detail": "No AssetLocation exists to stock out from."}, status=400)

        movement = StockMovement.objects.create(
            item=stock_item, location=location, movement_type="out",
            quantity=-sale.quantity, reason=f"Sale {sale.sale_no}",
            performed_by=request.user if request.user.is_authenticated else None,
        )
        movement.apply()

        journal = JournalEntry.objects.create(
            organization=sale.organization,
            journal_no=f"JV-{sale.sale_no}", date=sale.date,
            reference=sale.sale_no, description=f"Sale {sale.sale_no}",
            source_sale=sale, status="Draft",
        )
        JournalLine.objects.create(
            journal=journal, account=settings_row.cash_account,
            description="Sale proceeds", debit_idr=sale.total_amount, credit_idr=0,
        )
        JournalLine.objects.create(
            journal=journal, account=settings_row.revenue_account,
            description="Exchange revenue", debit_idr=0, credit_idr=sale.total_amount,
        )
        if cogs_amount:
            JournalLine.objects.create(
                journal=journal, account=settings_row.cogs_account,
                description="Cost of goods sold", debit_idr=cogs_amount, credit_idr=0,
            )
            JournalLine.objects.create(
                journal=journal, account=settings_row.inventory_account,
                description="Inventory reduction", debit_idr=0, credit_idr=cogs_amount,
            )
        journal.status = "Posted"
        journal.save(update_fields=["status"])

        sale.stock_movement = movement
        sale.journal_entry = journal
        sale.status = "Posted"
        sale.save(update_fields=["stock_movement", "journal_entry", "status"])
        return Response(self.get_serializer(sale).data)


class SalesAccountingSettingsViewSet(viewsets.ModelViewSet):
    """Single-row-per-org settings for which accounts Sale postings hit."""
    rbac_domain = RBAC
    serializer_class = SalesAccountingSettingsSerializer

    def get_queryset(self):
        return SalesAccountingSettings.objects.filter(
            organization=get_active_organization(self.request)
        ).select_related("cash_account", "revenue_account", "cogs_account", "inventory_account")

    def perform_create(self, serializer):
        serializer.save(organization=get_active_organization(self.request))


class JournalControlView(APIView):
    """Derived: per-journal balance control (debit vs credit, balanced, posting)."""
    rbac_domain = RBAC

    def get(self, request):
        rows = []
        entries = JournalEntry.objects.filter(organization=get_active_organization(request)).prefetch_related("lines").all()
        for e in entries:
            debit, credit = e.totals()
            diff = debit - credit
            balanced = diff == 0
            rows.append({
                "journal_no": e.journal_no,
                "total_debit": debit,
                "total_credit": credit,
                "difference": diff,
                "balance_status": "Balanced" if balanced else "Unbalanced",
                "posting_status": (
                    "Posted" if e.status == "Posted"
                    else ("Ready to Post" if balanced else "Blocked")
                ),
            })
        return Response(rows)


class GeneralLedgerView(APIView):
    """Derived: posted journal lines, optionally filtered. Mirrors GL tab.

    Grows without bound like the journal/transaction registers, so it's
    paginated the same way (see LedgerPagination) instead of returning
    every posted line in one response.
    """
    rbac_domain = RBAC
    pagination_class = LedgerPagination

    def get(self, request):
        qs = JournalLine.objects.filter(
            journal__status="Posted", journal__organization=get_active_organization(request),
        ).select_related(
            "journal", "journal__fund", "account",
        ).order_by("journal__date", "journal__journal_no")
        for param, field in (
            ("account", "account__code"), ("fund", "journal__fund_id"),
            ("project", "journal__project_code"), ("budget_line", "journal__budget_line"),
        ):
            val = request.query_params.get(param)
            if val:
                qs = qs.filter(**{field: val})

        paginator = self.pagination_class()
        page = paginator.paginate_queryset(qs, request, view=self)
        rows = [{
            "date": l.journal.date,
            "journal_no": l.journal.journal_no,
            "reference": l.journal.reference,
            "account_code": l.account.code,
            "account_name": l.account.name,
            "description": l.description or l.journal.description,
            "debit_idr": l.debit_idr,
            "credit_idr": l.credit_idr,
            "net_movement_idr": (l.debit_idr or 0) - (l.credit_idr or 0),
            "fund_id": l.journal.fund.fund_id if l.journal.fund_id else l.journal.donor_name,
            "donor": l.journal.donor_name,
            "project": l.journal.project_code,
            "budget_line": l.journal.budget_line,
        } for l in page]
        return paginator.get_paginated_response(rows)


class TrialBalanceView(APIView):
    """Derived: per-account debit/credit totals + balances from posted journals."""
    rbac_domain = RBAC

    def get(self, request):
        org = get_active_organization(request)
        agg = JournalLine.objects.filter(journal__status="Posted", journal__organization=org).values(
            "account__code", "account__name", "account__type",
        ).annotate(total_debit=Sum("debit_idr"), total_credit=Sum("credit_idr"))
        by_code = {a["account__code"]: a for a in agg}
        rows = []
        for acc in Account.objects.filter(organization=org):
            a = by_code.get(acc.code, {})
            td = a.get("total_debit") or Decimal("0")
            tc = a.get("total_credit") or Decimal("0")
            net = td - tc
            rows.append({
                "account_code": acc.code,
                "account_name": acc.name,
                "account_type": acc.type,
                "total_debit": td,
                "total_credit": tc,
                "debit_balance": net if net > 0 else Decimal("0"),
                "credit_balance": -net if net < 0 else Decimal("0"),
                "check": "Balance" if net != 0 else "Zero",
            })
        total_d = sum((r["debit_balance"] for r in rows), Decimal("0"))
        total_c = sum((r["credit_balance"] for r in rows), Decimal("0"))
        return Response({
            "rows": rows,
            "total_debit_balance": total_d,
            "total_credit_balance": total_c,
            "balanced": total_d == total_c,
        })


class BudgetVsActualView(APIView):
    """Derived: budget vs actual per fund + budget line.

    Actual expense = net debits on Expense accounts grouped by fund + budget line
    from posted journals. Budget figures come from posted Allocation transactions
    (Transaction.transaction_type == 'Allocation').
    """
    rbac_domain = RBAC

    def get(self, request):
        org = get_active_organization(request)
        # Actuals from posted expense journal lines, grouped by fund + budget line.
        actuals = {}
        lines = JournalLine.objects.filter(
            journal__status="Posted", account__type="Expense", journal__organization=org,
        ).select_related("journal", "journal__fund")
        for l in lines:
            key = (l.journal.fund_id, l.journal.budget_line, l.journal.project_code)
            actuals.setdefault(key, Decimal("0"))
            actuals[key] += (l.debit_idr or 0) - (l.credit_idr or 0)

        # Budgets from Allocation transactions.
        budgets = {}
        for t in Transaction.objects.filter(transaction_type="Allocation", organization=org).select_related("fund"):
            key = (t.fund_id, t.budget_line, t.project_code)
            budgets.setdefault(key, Decimal("0"))
            budgets[key] += t.original_amount or 0

        fund_codes = {f.id: f.fund_id for f in Fund.objects.filter(organization=org)}
        keys = set(actuals) | set(budgets)
        rows = []
        for (fund_id, budget_line, project) in sorted(keys, key=lambda k: (str(k[0]), str(k[1]))):
            budget = budgets.get((fund_id, budget_line, project), Decimal("0"))
            actual = actuals.get((fund_id, budget_line, project), Decimal("0"))
            available = budget - actual
            util = float(actual / budget) if budget else 0.0
            rows.append({
                "fund_id": fund_codes.get(fund_id, ""),
                "project": project,
                "budget_line": budget_line,
                "original_budget": budget,
                "revised_budget": budget,
                "actual_expense": actual,
                "outstanding_commitment": Decimal("0"),
                "available_budget": available,
                "utilization": round(util, 4),
                "status": "Over Budget" if util > 1 else "Available",
            })
        return Response(rows)


# --- ISAK 335: Indonesian non-profit financial statement presentation ---
#
# ISAK 335 classifies net assets and comprehensive income into two classes by
# donor restriction: "tanpa pembatasan" (without restriction) and "dengan
# pembatasan" (with restriction). We derive a journal line's class from the
# fund behind its journal: Restricted funds -> with restriction; Unrestricted,
# Designated (board-designated, not donor-imposed), or no fund -> without.

WITHOUT = "tanpa_pembatasan"
WITH = "dengan_pembatasan"


def _restriction_class(fund_type):
    """Map a Fund.fund_type to an ISAK 335 net-asset class."""
    return WITH if fund_type == "Restricted" else WITHOUT


def _posted_lines(organization):
    return JournalLine.objects.filter(journal__status="Posted", journal__organization=organization).select_related(
        "account", "journal", "journal__fund",
    )


def _class_for_line(line):
    fund = line.journal.fund
    return _restriction_class(fund.fund_type) if fund else WITHOUT


class StatementOfActivitiesView(APIView):
    """ISAK 335 Laporan Penghasilan Komprehensif (Statement of Comprehensive Income).

    Income and expense, by restriction class, netting to surplus/deficit.
    Income accounts net credit is positive revenue; Expense accounts net debit
    is positive expense. Income within each class is further split by
    PSAK 35 revenue type: donor Contribution vs commercial Exchange (book/
    accessory sales). Accounts with no revenue_type set fall under
    "contribution" (today's implicit default — see Account.revenue_type).
    """
    rbac_domain = RBAC

    def get(self, request):
        income = {WITHOUT: Decimal("0"), WITH: Decimal("0")}
        expense = {WITHOUT: Decimal("0"), WITH: Decimal("0")}
        income_lines = {WITHOUT: {}, WITH: {}}
        expense_lines = {WITHOUT: {}, WITH: {}}
        income_by_revenue_type = {
            WITHOUT: {"contribution": Decimal("0"), "exchange": Decimal("0")},
            WITH: {"contribution": Decimal("0"), "exchange": Decimal("0")},
        }

        for l in _posted_lines(get_active_organization(request)):
            cls = _class_for_line(l)
            net = (l.debit_idr or 0) - (l.credit_idr or 0)
            if l.account.type == "Income":
                amt = -net  # income is a credit-normal account
                income[cls] += amt
                income_lines[cls][l.account.name] = income_lines[cls].get(l.account.name, Decimal("0")) + amt
                rtype = l.account.revenue_type or "contribution"
                income_by_revenue_type[cls][rtype] += amt
            elif l.account.type == "Expense":
                expense[cls] += net
                expense_lines[cls][l.account.name] = expense_lines[cls].get(l.account.name, Decimal("0")) + net

        def section(d):
            return [{"name": k, "amount": v} for k, v in sorted(d.items())]

        out = {}
        for cls in (WITHOUT, WITH):
            surplus = income[cls] - expense[cls]
            out[cls] = {
                "income": section(income_lines[cls]),
                "total_income": income[cls],
                "contribution_income": income_by_revenue_type[cls]["contribution"],
                "exchange_income": income_by_revenue_type[cls]["exchange"],
                "expense": section(expense_lines[cls]),
                "total_expense": expense[cls],
                "surplus": surplus,
            }
        out["total_surplus"] = out[WITHOUT]["surplus"] + out[WITH]["surplus"]
        return Response(out)


class StatementOfFinancialPositionView(APIView):
    """ISAK 335 Laporan Posisi Keuangan (Statement of Financial Position) — Format B.

    Assets and liabilities at their balances; net assets shown by restriction
    class. Total Assets must equal Total Liabilities + Total Net Assets.
    """
    rbac_domain = RBAC

    def get(self, request):
        # Per-account net balances from posted lines.
        bal = {}  # account -> net debit (debit positive)
        net_assets = {WITHOUT: Decimal("0"), WITH: Decimal("0")}
        for l in _posted_lines(get_active_organization(request)):
            net = (l.debit_idr or 0) - (l.credit_idr or 0)
            bal[l.account] = bal.get(l.account, Decimal("0")) + net
            # Net assets accumulate the surplus (income - expense) by class.
            if l.account.type == "Income":
                net_assets[_class_for_line(l)] += -net
            elif l.account.type == "Expense":
                net_assets[_class_for_line(l)] -= net
            elif l.account.type == "Equity":
                net_assets[_class_for_line(l)] += -net

        assets, liabilities = [], []
        total_assets = total_liabilities = Decimal("0")
        for acc, net in bal.items():
            if acc.type == "Asset":
                assets.append({"code": acc.code, "name": acc.name, "amount": net})
                total_assets += net
            elif acc.type == "Liability":
                amt = -net  # liability is credit-normal
                liabilities.append({"code": acc.code, "name": acc.name, "amount": amt})
                total_liabilities += amt

        total_net_assets = net_assets[WITHOUT] + net_assets[WITH]
        return Response({
            "assets": sorted(assets, key=lambda r: r["code"]),
            "total_assets": total_assets,
            "liabilities": sorted(liabilities, key=lambda r: r["code"]),
            "total_liabilities": total_liabilities,
            "net_assets": {
                "tanpa_pembatasan": net_assets[WITHOUT],
                "dengan_pembatasan": net_assets[WITH],
                "total": total_net_assets,
            },
            "total_liabilities_and_net_assets": total_liabilities + total_net_assets,
            "balanced": total_assets == total_liabilities + total_net_assets,
        })


class StatementOfChangesInNetAssetsView(APIView):
    """ISAK 335 Laporan Perubahan Aset Neto (Statement of Changes in Net Assets).

    For each class: opening + change (period surplus) = closing. With a single
    period of data, opening is derived as closing minus the period surplus.
    """
    rbac_domain = RBAC

    def get(self, request):
        change = {WITHOUT: Decimal("0"), WITH: Decimal("0")}   # period surplus
        closing = {WITHOUT: Decimal("0"), WITH: Decimal("0")}  # net assets to date
        for l in _posted_lines(get_active_organization(request)):
            cls = _class_for_line(l)
            net = (l.debit_idr or 0) - (l.credit_idr or 0)
            if l.account.type == "Income":
                change[cls] += -net
                closing[cls] += -net
            elif l.account.type == "Expense":
                change[cls] -= net
                closing[cls] -= net
            elif l.account.type == "Equity":
                closing[cls] += -net  # opening equity, not part of period change

        rows = []
        for cls, label in ((WITHOUT, "tanpa_pembatasan"), (WITH, "dengan_pembatasan")):
            opening = closing[cls] - change[cls]
            rows.append({
                "class": label,
                "opening": opening,
                "change": change[cls],
                "closing": closing[cls],
            })
        return Response({
            "rows": rows,
            "total_opening": sum((r["opening"] for r in rows), Decimal("0")),
            "total_change": sum((r["change"] for r in rows), Decimal("0")),
            "total_closing": sum((r["closing"] for r in rows), Decimal("0")),
        })


class StatementOfCashFlowsView(APIView):
    """ISAK 335 Laporan Arus Kas (Statement of Cash Flows) — direct method.

    Cash is the movement on accounts flagged is_cash. For each posted journal,
    the net cash delta is classified into Operasi / Investasi / Pendanaan by the
    cash_flow_category of the journal's NON-cash counterpart lines (split
    proportionally to each counterpart's value when several categories appear).
    """
    rbac_domain = RBAC

    def get(self, request):
        sections = {"operating": Decimal("0"), "investing": Decimal("0"), "financing": Decimal("0")}
        opening = ending = Decimal("0")

        # Group posted lines by journal.
        by_journal = {}
        for l in _posted_lines(get_active_organization(request)):
            by_journal.setdefault(l.journal_id, []).append(l)

        for lines in by_journal.values():
            cash_delta = Decimal("0")
            counterparts = []  # (category, magnitude)
            for l in lines:
                net = (l.debit_idr or 0) - (l.credit_idr or 0)
                if l.account.is_cash:
                    cash_delta += net
                    ending += net
                else:
                    cat = l.account.cash_flow_category
                    if cat in sections:
                        counterparts.append((cat, abs(net)))
            if cash_delta == 0 or not counterparts:
                continue
            total_mag = sum((m for _, m in counterparts), Decimal("0")) or Decimal("1")
            for cat, mag in counterparts:
                sections[cat] += cash_delta * (mag / total_mag)

        net_change = sections["operating"] + sections["investing"] + sections["financing"]
        return Response({
            "operating": sections["operating"],
            "investing": sections["investing"],
            "financing": sections["financing"],
            "net_change": net_change,
            "ending_cash": ending,
            "beginning_cash": ending - net_change,
        })


class DashboardView(APIView):
    """Derived: top-line metrics + per-fund received/spent. Mirrors Dashboard tab."""
    rbac_domain = RBAC

    def get(self, request):
        org = get_active_organization(request)
        funds = list(Fund.objects.filter(organization=org))
        total_award = sum((f.award_amount or 0 for f in funds), Decimal("0"))
        total_received = sum((f.received_amount or 0 for f in funds), Decimal("0"))

        spent_by_fund = dict(
            JournalLine.objects.filter(
                journal__status="Posted", account__type="Expense", journal__organization=org,
            ).values_list("journal__fund").annotate(s=Sum("debit_idr") - Sum("credit_idr"))
        )
        per_fund = []
        total_spent = Decimal("0")
        for f in funds:
            spent = spent_by_fund.get(f.id, Decimal("0")) or Decimal("0")
            total_spent += spent
            per_fund.append({
                "fund_id": f.fund_id,
                "name": f.name,
                "received": f.received_amount or 0,
                "spent": spent,
                "available": (f.received_amount or 0) - spent,
            })
        return Response({
            "metrics": {
                "total_award": total_award,
                "total_received": total_received,
                "total_spent": total_spent,
                "total_available": total_received - total_spent,
                "fund_count": len(funds),
                "account_count": Account.objects.filter(organization=org).count(),
                "posted_journals": JournalEntry.objects.filter(status="Posted", organization=org).count(),
            },
            "funds": per_fund,
        })


class ReceiptsAndDisbursementsView(APIView):
    """Laporan Penerimaan dan Pengeluaran — per project, cash basis.

    Mirrors the internal report the finance team keeps in Excel: opening
    balance, receipts by income account, direct expenses grouped under their
    header accounts, indirect (overhead) expenses, and the closing balance.

    Scope is one project at a time. Projects are matched through the fund a
    journal is posted against: `Fund.project` when the FK is set, otherwise
    `Fund.project_code` (the free-text tag still used by most records).

    Query params:
        project      Project UUID. Either this or project_code is required.
        project_code Free-text project tag, e.g. PRJ-HEALTH.
        date_from    ISO date. Opening balance is everything strictly before it.
        date_to      ISO date, inclusive.
    """
    rbac_domain = RBAC

    def get(self, request):
        org = get_active_organization(request)
        project_id = request.query_params.get("project") or ""
        project_code = (request.query_params.get("project_code") or "").strip()
        if not project_id and not project_code:
            raise ValidationError({"project": "Provide either project or project_code."})

        date_from = parse_date(request.query_params.get("date_from") or "") if request.query_params.get("date_from") else None
        date_to = parse_date(request.query_params.get("date_to") or "") if request.query_params.get("date_to") else None
        if date_from and date_to and date_from > date_to:
            raise ValidationError({"date_from": "date_from must be on or before date_to."})

        funds = Fund.objects.filter(organization=org)
        project = None
        if project_id:
            project = Project.objects.filter(pk=project_id, organization=org).first()
            if project is None:
                raise ValidationError({"project": "Unknown project for this organization."})
            # Funds reach a project three ways: the direct FK, the ProjectFund
            # grant record, or the free-text project_code tag still used by most
            # ledger rows. Match on all of them so nothing is silently dropped.
            fund_codes = [
                c for c in project.funds.values_list("fund_code", flat=True) if c
            ]
            fund_q = Q(project=project) | Q(project_fund__project=project)
            if fund_codes:
                fund_q |= Q(project_code__in=fund_codes)
            funds = funds.filter(fund_q).distinct()
        else:
            funds = funds.filter(project_code=project_code)

        fund_ids = list(funds.values_list("id", flat=True))
        base = JournalLine.objects.filter(
            journal__status="Posted",
            journal__organization=org,
            journal__fund_id__in=fund_ids,
        ).select_related("account", "account__parent", "journal")

        # Opening balance: net cash movement strictly before the window.
        opening = Decimal("0")
        if date_from:
            for line in base.filter(journal__date__lt=date_from, account__is_cash=True):
                opening += (line.debit_idr or 0) - (line.credit_idr or 0)

        period = base
        if date_from:
            period = period.filter(journal__date__gte=date_from)
        if date_to:
            period = period.filter(journal__date__lte=date_to)

        receipts = {}
        direct = {}
        indirect = {}

        # Receipts are CASH IN, not income recognition. A grant instalment is
        # normally booked Cash / Deferred Grant Income (a liability) and only
        # becomes income as it is spent — counting Income accounts alone would
        # report zero receipts for exactly the vouchers this report exists to
        # show. So group the journal's cash movement by its non-cash counterpart.
        by_journal = {}
        for line in period:
            by_journal.setdefault(line.journal_id, []).append(line)

        for lines in by_journal.values():
            cash_in = sum(
                ((l.debit_idr or 0) - (l.credit_idr or 0)) for l in lines if l.account.is_cash
            )
            counterparts = [l for l in lines if not l.account.is_cash]
            if cash_in > 0 and counterparts:
                # Attribute the inflow across the crediting counterpart accounts.
                credited = [l for l in counterparts if (l.credit_idr or 0) > (l.debit_idr or 0)]
                for l in credited or counterparts:
                    account = l.account
                    amount = (l.credit_idr or 0) - (l.debit_idr or 0)
                    if amount <= 0:
                        continue
                    receipts.setdefault(
                        account.code, {"code": account.code, "name": account.name, "amount": Decimal("0")},
                    )
                    receipts[account.code]["amount"] += amount

        # Expenses stay accrual-shaped: an expense account's net debit is spend,
        # whether it was paid in cash or settled against an advance.
        for line in period:
            account = line.account
            if account.type != "Expense":
                continue
            net = (line.debit_idr or 0) - (line.credit_idr or 0)
            if not net:
                continue
            bucket = indirect if account.expense_class == "indirect" else direct
            header = account.parent or account
            group = bucket.setdefault(
                header.code,
                {"code": header.code, "name": header.name, "amount": Decimal("0"), "lines": {}},
            )
            group["amount"] += net
            if account.parent is not None:
                group["lines"].setdefault(
                    account.code, {"code": account.code, "name": account.name, "amount": Decimal("0")},
                )
                group["lines"][account.code]["amount"] += net

        def groups(bucket):
            out = []
            for g in sorted(bucket.values(), key=lambda x: x["code"]):
                out.append({
                    "code": g["code"],
                    "name": g["name"],
                    "amount": g["amount"],
                    "lines": sorted(g["lines"].values(), key=lambda x: x["code"]),
                })
            return out

        receipt_lines = sorted(receipts.values(), key=lambda x: x["code"])
        total_receipts = sum((r["amount"] for r in receipt_lines), Decimal("0"))
        direct_groups = groups(direct)
        indirect_groups = groups(indirect)
        total_direct = sum((g["amount"] for g in direct_groups), Decimal("0"))
        total_indirect = sum((g["amount"] for g in indirect_groups), Decimal("0"))
        total_disbursements = total_direct + total_indirect
        closing = opening + total_receipts - total_disbursements

        # Outstanding advances at period end (asset accounts still carrying a
        # debit balance), so Kas dan setara Kas nets them out like the sheet.
        advances = Decimal("0")
        advance_scope = base.filter(account__type="Asset", account__is_cash=False, account__code="1200")
        if date_to:
            advance_scope = advance_scope.filter(journal__date__lte=date_to)
        for line in advance_scope:
            advances += (line.debit_idr or 0) - (line.credit_idr or 0)

        return Response({
            "project": {
                "id": str(project.id) if project else None,
                "name": project.name if project else project_code,
                "code": project_code,
            },
            "period": {"date_from": date_from, "date_to": date_to},
            "funds": [{"id": str(f.id), "fund_id": f.fund_id, "name": f.name} for f in funds],
            "opening_balance": opening,
            "receipts": receipt_lines,
            "total_receipts": total_receipts,
            "direct_expenses": direct_groups,
            "total_direct_expenses": total_direct,
            "indirect_expenses": indirect_groups,
            "total_indirect_expenses": total_indirect,
            "total_disbursements": total_disbursements,
            "closing_balance": closing,
            "advances": advances,
            "cash_and_equivalents": closing - advances,
            "fund_balance": closing,
        })
