from datetime import date as _date
from decimal import Decimal

from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone

from apps.rims.finance.models import (
    Budget,
    BudgetApprovalWorkflow,
    BudgetLine,
    BudgetRevision,
    BudgetRevisionLine,
    BudgetYearAllocation,
    DisbursementExecution,
    DisbursementReconciliation,
    DisbursementRequest,
    ExchangeRate,
    Expenditure,
    FinanceApproval,
    FundingSource,
    PaymentBatch,
    PaymentBatchItem,
    PaymentRetry,
)
from apps.rims.grants.models import Award
from apps.rims.projects.services import assert_disbursement_milestone_gate


class MissingExchangeRateError(ValidationError):
    """Raised by convert() when no rate is on file for the requested pair / date."""


def convert(
    amount: Decimal,
    from_currency: str,
    to_currency: str,
    *,
    as_of: _date | None = None,
) -> Decimal:
    """PRD §5.4 FRFM016 / NFRFM002 — convert an amount between currencies.

    Uses the most recent ExchangeRate row whose ``effective_date`` is at or
    before ``as_of`` (defaults to today). Same-currency conversions are a
    no-op. Raises :class:`MissingExchangeRateError` when no rate is on file
    so callers can fall back to "no conversion" or surface a friendlier error.
    """
    if not amount:
        return Decimal("0")
    if from_currency == to_currency:
        return Decimal(amount)
    if as_of is None:
        as_of = timezone.now().date()
    rate = (
        ExchangeRate.objects.filter(
            from_currency=from_currency,
            to_currency=to_currency,
            effective_date__lte=as_of,
        )
        .order_by("-effective_date", "-pk")
        .first()
    )
    if rate is None:
        # Try the inverse pair before giving up — admins commonly only enter
        # one direction (e.g. USD→UGX) and expect the system to flip it.
        inverse = (
            ExchangeRate.objects.filter(
                from_currency=to_currency,
                to_currency=from_currency,
                effective_date__lte=as_of,
            )
            .order_by("-effective_date", "-pk")
            .first()
        )
        if inverse is None:
            raise MissingExchangeRateError(
                f"No exchange rate on file for {from_currency} → {to_currency} as of {as_of}."
            )
        return (Decimal(amount) / inverse.rate).quantize(Decimal("0.01"))
    return (Decimal(amount) * rate.rate).quantize(Decimal("0.01"))


CLOSEOUT_BLOCKED_AWARD_STATUSES = frozenset(
    {
        Award.Status.CLOSEOUT_IN_PROGRESS,
        Award.Status.CLOSED,
        Award.Status.CANCELLED,
    }
)


def assert_award_allows_claims(budget: Budget, *, actor=None) -> None:
    """PRD §5.1 FRFA-CO002/003 + FRFA-AA025 + §6.1 — hard block on disbursement
    activity when the award is past its project end date, the close-out lifecycle
    has begun, or (for deferred-budget call types) the Finance Officer has not
    yet confirmed the post-award budget.

    When ``actor`` is provided, the close-out block also writes an audit row
    so compliance can see who tried to push past the gate.
    """
    award = budget.award
    if award.status in CLOSEOUT_BLOCKED_AWARD_STATUSES:
        if actor is not None:
            try:
                from apps.rims.grants.audit_helper import audit_rims

                audit_rims(
                    actor=actor,
                    action="DISBURSEMENT_BLOCKED_BY_CLOSEOUT",
                    target_model="Budget",
                    object_id=budget.pk,
                    object_repr=str(budget),
                    changes={
                        "award_id": award.pk,
                        "award_status": award.status,
                    },
                )
            except Exception:  # pragma: no cover - audit failures must not mask the block
                pass
        raise ValidationError(
            "This award is in close-out or closed; new disbursement activity is blocked."
        )
    if award.budget_deferred and award.budget_finalized_at is None:
        raise ValidationError(
            "Deferred budget has not yet been finalised by the Finance Officer; "
            "tranche requests are blocked until the post-award budget is confirmed."
        )
    if timezone.now().date() > award.project_end_date:
        raise ValidationError(
            "The approved project period has ended; new disbursement requests are not accepted."
        )


# Back-compat alias for callers that imported the prior name.
assert_award_period_allows_claims = assert_award_allows_claims


def budget_ceiling(budget: Budget) -> Decimal:
    lines = budget.lines.all()
    return sum((l.amount for l in lines), Decimal("0"))


def total_disbursed(budget: Budget) -> Decimal:
    qs = budget.disbursement_requests.filter(
        status__in=[
            DisbursementRequest.Status.APPROVED,
            DisbursementRequest.Status.PAID,
        ]
    )
    agg = qs.aggregate(s=Sum("amount"))
    return agg["s"] or Decimal("0")


def remaining_budget(budget: Budget) -> Decimal:
    return budget_ceiling(budget) - total_disbursed(budget)


def total_expended(budget: Budget) -> Decimal:
    """Sum of recorded expenditures across all lines of a budget."""
    agg = Expenditure.objects.filter(budget_line__budget=budget).aggregate(
        s=Sum("amount"),
    )
    return agg["s"] or Decimal("0")


@transaction.atomic
def record_expenditure(
    budget_line: BudgetLine,
    *,
    amount: Decimal,
    incurred_on,
    description: str = "",
    receipt=None,
    actor=None,
) -> Expenditure:
    """PRD §5.4 — record a single expenditure against a budget line.

    Validates the amount is positive and that the resulting expended total
    on the parent *budget* would not exceed the budget ceiling, so we do
    not silently book an overspend through the back door. Returns the
    persisted ``Expenditure``.
    """
    if amount is None or amount <= 0:
        raise ValidationError("Expenditure amount must be greater than zero.")
    budget = budget_line.budget
    ceiling = budget_ceiling(budget)
    if ceiling > 0:
        prospective = total_expended(budget) + amount
        if prospective > ceiling:
            raise ValidationError(
                f"Recording {amount} would push expenditures to {prospective}, "
                f"above the budget ceiling of {ceiling}."
            )
    return Expenditure.objects.create(
        budget_line=budget_line,
        amount=amount,
        incurred_on=incurred_on,
        description=(description or "").strip(),
        receipt=receipt,
        created_by=actor,
    )


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM001 / FRFM004 — multi-year budget setup.
# PRD §5.4 FRFM008 / FRFM009 / NFRFM002 — multi-currency consolidation + FX admin.
# ---------------------------------------------------------------------------
def total_budget_amount(budget: Budget) -> Decimal:
    """Sum of all line amounts on a budget (headline total)."""
    total = budget.lines.aggregate(total=Sum("amount"))["total"] or Decimal("0")
    return Decimal(total)


@transaction.atomic
def split_budget_to_years(
    budget: Budget,
    allocations: list[dict],
    *,
    actor=None,
) -> list[BudgetYearAllocation]:
    """PRD §5.4 FRFM001 — create or replace year-by-year allocations for a
    multi-year budget.

    ``allocations`` is a list of ``{"fiscal_year": 2026, "amount": Decimal,
    "currency": "UGX", "notes": ""}`` dicts. The sum of amounts must equal the
    headline budget total or a ValidationError is raised. Existing year
    allocations on the budget are replaced atomically.
    """
    if not allocations:
        raise ValidationError("At least one fiscal-year allocation is required.")
    headline = total_budget_amount(budget)
    total_alloc = sum((Decimal(a.get("amount") or 0) for a in allocations), Decimal("0"))
    if headline and total_alloc != headline:
        raise ValidationError(
            f"Year allocations sum to {total_alloc}, but the budget total is {headline}."
        )
    # Clear and re-create so the operation is idempotent.
    budget.year_allocations.all().delete()
    rows = [
        BudgetYearAllocation(
            budget=budget,
            fiscal_year=int(a["fiscal_year"]),
            amount=Decimal(a["amount"]),
            currency=(a.get("currency") or budget.currency),
            notes=(a.get("notes") or "").strip(),
        )
        for a in allocations
    ]
    created = BudgetYearAllocation.objects.bulk_create(rows)
    if actor is not None:
        try:
            from apps.rims.grants.audit_helper import audit_rims

            audit_rims(
                actor=actor,
                action="BUDGET_YEAR_ALLOCATION_CHANGED",
                target_model="Budget",
                object_id=budget.pk,
                object_repr=str(budget),
                changes={
                    "fiscal_years": [a["fiscal_year"] for a in allocations],
                    "total": str(total_alloc),
                },
            )
        except Exception:  # pragma: no cover
            pass
    return created


def consolidate_multi_currency_budget(
    budget: Budget,
    *,
    target_currency: str,
    as_of: _date | None = None,
) -> list[dict]:
    """PRD §5.4 FRFM008 / FRFM009 / NFRFM002 — render each budget line with
    its original currency amount + a converted column in ``target_currency``.

    Returns a list of dicts:
        {"line": BudgetLine, "original_amount": Decimal, "original_currency": str,
         "converted_amount": Decimal, "fx_rate": Decimal | None, "fx_as_of": date | None}
    """
    rows: list[dict] = []
    for line in budget.lines.select_related("funding_source").all():
        line_currency = budget.currency  # lines inherit budget currency today
        original = Decimal(line.amount or 0)
        if line_currency == target_currency:
            converted = original
            fx_rate = Decimal("1")
            fx_date = None
        else:
            try:
                converted = convert(
                    original, line_currency, target_currency, as_of=as_of
                )
                # Surface the rate used so the UI can footnote it.
                rate_row = (
                    ExchangeRate.objects.filter(
                        from_currency=line_currency,
                        to_currency=target_currency,
                        effective_date__lte=(as_of or timezone.now().date()),
                    )
                    .order_by("-effective_date", "-pk")
                    .first()
                )
                fx_rate = rate_row.rate if rate_row else None
                fx_date = rate_row.effective_date if rate_row else None
            except MissingExchangeRateError:
                converted = None
                fx_rate = None
                fx_date = None
        rows.append(
            {
                "line": line,
                "original_amount": original,
                "original_currency": line_currency,
                "converted_amount": converted,
                "fx_rate": fx_rate,
                "fx_as_of": fx_date,
            }
        )
    return rows


@transaction.atomic
def upsert_exchange_rate(
    *,
    actor,
    from_currency: str,
    to_currency: str,
    rate: Decimal,
    effective_date: _date,
    source: str = "",
) -> ExchangeRate:
    """PRD §5.4 FRFM008 — record a new ExchangeRate row.

    The uniqueness constraint (from_currency, to_currency, effective_date)
    means re-submitting the same triple updates the rate in place. Emits an
    ``EXCHANGE_RATE_UPDATED`` audit row so finance reviewers can see who
    edited which pair.
    """
    if not rate or rate <= 0:
        raise ValidationError("Exchange rate must be greater than zero.")
    obj, created = ExchangeRate.objects.update_or_create(
        from_currency=from_currency,
        to_currency=to_currency,
        effective_date=effective_date,
        defaults={
            "rate": Decimal(rate),
            "source": (source or "").strip(),
            "recorded_by": actor,
        },
    )
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="EXCHANGE_RATE_UPDATED",
            target_model="ExchangeRate",
            object_id=obj.pk,
            object_repr=str(obj),
            changes={
                "from_currency": from_currency,
                "to_currency": to_currency,
                "rate": str(rate),
                "effective_date": effective_date.isoformat(),
                "created": created,
            },
        )
    except Exception:  # pragma: no cover
        pass
    return obj


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM012/013/015 — approval workflow configuration.
# PRD §5.4 FRFM036/037/038 + NFRFM005 — segregation-of-duties telemetry.
# ---------------------------------------------------------------------------
def _infer_tier(actor) -> str:
    role = getattr(actor, "role", "") or ""
    return str(role)


def _record_finance_approval(
    target,
    *,
    actor,
    tier: str,
    decision: str = FinanceApproval.Decision.APPROVED,
    comment: str = "",
) -> FinanceApproval:
    from django.contrib.contenttypes.models import ContentType

    ct = ContentType.objects.get_for_model(type(target))
    return FinanceApproval.objects.create(
        target_content_type=ct,
        target_object_id=target.pk,
        actor=actor,
        tier=tier,
        decision=decision,
        comment=comment,
    )


def require_two_eyes(target, *, actor) -> None:
    """PRD §5.4 NFRFM005 — block self-double-approval.

    Raises :class:`ValidationError` if ``actor`` already has an APPROVED
    FinanceApproval row for ``target``. Emits a TWO_EYES_VIOLATION_BLOCKED
    audit row so reviewers can see attempted breaches. **Callers must not
    wrap this in @transaction.atomic** — the ValidationError that follows
    would otherwise roll back the audit row.
    """
    from django.contrib.contenttypes.models import ContentType

    ct = ContentType.objects.get_for_model(type(target))
    prior = FinanceApproval.objects.filter(
        target_content_type=ct,
        target_object_id=target.pk,
        actor=actor,
        decision=FinanceApproval.Decision.APPROVED,
    ).exists()
    if prior:
        try:
            from apps.rims.grants.audit_helper import audit_rims

            audit_rims(
                actor=actor,
                action="TWO_EYES_VIOLATION_BLOCKED",
                target_model=target.__class__.__name__,
                object_id=target.pk,
                object_repr=str(target),
                changes={"actor_id": actor.pk},
            )
        except Exception:  # pragma: no cover
            pass
        raise ValidationError(
            "Two-eyes rule: this approver has already approved this record."
        )


@transaction.atomic
def configure_budget_workflow(
    *,
    actor,
    award=None,
    funding_source: FundingSource | None = None,
    approval_mode: str = BudgetApprovalWorkflow.Mode.TOTAL,
    threshold_amount: Decimal | None = None,
    required_tiers: list[str] | None = None,
) -> BudgetApprovalWorkflow:
    """PRD §5.4 FRFM012/013/015 — configure approval workflow for an award /
    funding source.
    """
    if award is None and funding_source is None:
        raise ValidationError(
            "Provide either an award or a funding source for the workflow scope."
        )
    workflow = BudgetApprovalWorkflow.objects.create(
        award=award,
        funding_source=funding_source,
        approval_mode=approval_mode,
        threshold_amount=threshold_amount,
        required_tiers=list(required_tiers or []),
        created_by=actor,
    )
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="BUDGET_APPROVAL_WORKFLOW_CONFIGURED",
            target_model="BudgetApprovalWorkflow",
            object_id=workflow.pk,
            object_repr=str(workflow),
            changes={
                "approval_mode": approval_mode,
                "threshold_amount": str(threshold_amount) if threshold_amount else None,
                "required_tiers": list(required_tiers or []),
            },
        )
    except Exception:  # pragma: no cover
        pass
    return workflow


def approve_budget_line(
    line: BudgetLine,
    *,
    actor,
    tier: str,
    comment: str = "",
) -> FinanceApproval:
    """PRD §5.4 FRFM013 — record a line-level budget approval.

    Note: not wrapped in @transaction.atomic so the TWO_EYES_VIOLATION_BLOCKED
    audit row survives a parent rollback (see require_two_eyes docstring).
    """
    require_two_eyes(line, actor=actor)
    record = _record_finance_approval(line, actor=actor, tier=tier, comment=comment)
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="BUDGET_APPROVED_LINE",
            target_model="BudgetLine",
            object_id=line.pk,
            object_repr=f"{line.label} ({line.budget.name})",
            changes={"tier": tier, "amount": str(line.amount)},
        )
    except Exception:  # pragma: no cover
        pass
    return record


def approve_budget_total(
    budget: Budget,
    *,
    actor,
    tier: str,
    comment: str = "",
) -> FinanceApproval:
    """PRD §5.4 FRFM012/015 — record a total-level budget approval.

    Note: not wrapped in @transaction.atomic so the TWO_EYES_VIOLATION_BLOCKED
    audit row survives a parent rollback (see require_two_eyes docstring).
    """
    require_two_eyes(budget, actor=actor)
    record = _record_finance_approval(budget, actor=actor, tier=tier, comment=comment)
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="BUDGET_APPROVED_TOTAL",
            target_model="Budget",
            object_id=budget.pk,
            object_repr=str(budget),
            changes={"tier": tier},
        )
    except Exception:  # pragma: no cover
        pass
    return record


def list_approval_history(target) -> "models.QuerySet[FinanceApproval]":
    """PRD §5.4 FRFM036/037/038 — return FinanceApproval rows for a target,
    newest first. Used by the detail-page telemetry panel."""
    from django.contrib.contenttypes.models import ContentType

    ct = ContentType.objects.get_for_model(type(target))
    return (
        FinanceApproval.objects.filter(
            target_content_type=ct, target_object_id=target.pk
        )
        .select_related("actor")
        .order_by("-decided_at")
    )


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM010 / FRFM011 / NFRFM003 — formal Budget revision services.
# ---------------------------------------------------------------------------
@transaction.atomic
def create_budget_revision(
    budget: Budget,
    *,
    change_summary: str,
    line_changes: list[dict],
    actor=None,
) -> BudgetRevision:
    """Create a BudgetRevision in REQUESTED state.

    ``line_changes`` is a list of dicts with at minimum ``line_id`` and
    ``new_amount``. ``old_amount`` is read from the BudgetLine row; the
    snapshot lives on the revision so future approvers can audit it without
    relying on mutable BudgetLine state.
    """
    change_summary = (change_summary or "").strip()
    if not change_summary:
        raise ValidationError("A change summary is required.")
    if not line_changes:
        raise ValidationError("At least one line change is required.")

    next_version = (
        BudgetRevision.objects.filter(budget=budget)
        .order_by("-version")
        .values_list("version", flat=True)
        .first()
        or 0
    ) + 1
    revision = BudgetRevision.objects.create(
        budget=budget,
        version=next_version,
        change_summary=change_summary,
        requested_by=actor,
    )
    for entry in line_changes:
        line = budget.lines.filter(pk=entry["line_id"]).first()
        if line is None:
            raise ValidationError(
                f"Line id {entry['line_id']} does not belong to budget {budget.pk}."
            )
        BudgetRevisionLine.objects.create(
            revision=revision,
            line=line,
            old_amount=line.amount,
            new_amount=Decimal(str(entry["new_amount"])),
            justification=(entry.get("justification") or ""),
        )
    return revision


@transaction.atomic
def approve_budget_revision(
    revision: BudgetRevision,
    *,
    actor=None,
    comment: str = "",
) -> BudgetRevision:
    """PRD §5.4 — apply a BudgetRevision to its budget lines and freeze the
    revision in APPROVED state. The previous BudgetLine.amount values live on
    the revision rows for audit."""
    if revision.status != BudgetRevision.Status.REQUESTED:
        raise ValidationError("Only REQUESTED revisions can be approved.")
    for rline in revision.lines.select_related("line"):
        rline.line.amount = rline.new_amount
        rline.line.save(update_fields=["amount"])
    revision.status = BudgetRevision.Status.APPROVED
    revision.decided_by = actor
    revision.decided_at = timezone.now()
    revision.decided_comment = (comment or "").strip()
    revision.save(update_fields=["status", "decided_by", "decided_at", "decided_comment"])
    return revision


@transaction.atomic
def reject_budget_revision(
    revision: BudgetRevision,
    *,
    actor=None,
    comment: str,
) -> BudgetRevision:
    if revision.status != BudgetRevision.Status.REQUESTED:
        raise ValidationError("Only REQUESTED revisions can be rejected.")
    comment = (comment or "").strip()
    if not comment:
        raise ValidationError("A rejection comment is required.")
    revision.status = BudgetRevision.Status.REJECTED
    revision.decided_by = actor
    revision.decided_at = timezone.now()
    revision.decided_comment = comment
    revision.save(update_fields=["status", "decided_by", "decided_at", "decided_comment"])
    return revision


def finance_health_snapshot(budget: Budget, *, report_currency: str | None = None) -> dict:
    """Snapshot budget health figures.

    When ``report_currency`` is supplied and differs from the budget's own
    currency, monetary amounts are converted on the fly via :func:`convert`.
    The snapshot also includes the source currency and the report currency
    so callers (templates, dashboards) can label the values clearly.
    """
    requests = budget.disbursement_requests.select_related("execution", "reconciliation")
    pending_approval = requests.filter(status=DisbursementRequest.Status.SUBMITTED).count()
    awaiting_execution = requests.filter(status=DisbursementRequest.Status.APPROVED).count()
    awaiting_reconciliation = requests.filter(
        status=DisbursementRequest.Status.PAID,
        reconciliation__status__in=[
            DisbursementReconciliation.Status.PENDING,
            DisbursementReconciliation.Status.DISCREPANCY,
        ],
    ).count()
    ceiling = budget_ceiling(budget)
    disbursed = total_disbursed(budget)
    overspend_flag = disbursed > ceiling if ceiling else False
    overspend_amount = (disbursed - ceiling) if overspend_flag else Decimal("0")
    source_currency = budget.currency or ""
    target_currency = (report_currency or source_currency or "").upper()
    if source_currency and target_currency and target_currency != source_currency:
        ceiling = convert(ceiling, source_currency, target_currency)
        disbursed = convert(disbursed, source_currency, target_currency)
        overspend_amount = convert(overspend_amount, source_currency, target_currency)
    return {
        "budget_ceiling": ceiling,
        "total_disbursed": disbursed,
        "remaining_budget": ceiling - disbursed,
        "pending_approval": pending_approval,
        "awaiting_execution": awaiting_execution,
        "awaiting_reconciliation": awaiting_reconciliation,
        "overspend_flag": overspend_flag,
        "overspend_amount": overspend_amount,
        "source_currency": source_currency,
        "report_currency": target_currency,
    }


def assert_award_has_no_overspend(award) -> None:
    """PRD §5.1 FRFA-CO018 — raise if any budget linked to *award* is in an
    overspend position (total disbursed > ceiling). Used as a gate on close-out
    approval so the outcome is caught before final report acceptance."""
    for b in award.budgets.all():
        snap = finance_health_snapshot(b)
        if snap["overspend_flag"]:
            raise ValidationError(
                f"Budget {b.name!r} is in overspend ({snap['total_disbursed']} > "
                f"{snap['budget_ceiling']}); resolve before closing the award."
            )


def assert_disbursement_allowed(budget: Budget, amount: Decimal) -> None:
    """PRD §5.4 FRFM017 / FRFM031 — verify approved budget can absorb the request."""
    if amount <= 0:
        raise ValidationError("Amount must be positive.")
    if amount > remaining_budget(budget):
        raise ValidationError("Disbursement exceeds remaining budget.")


@transaction.atomic
def submit_disbursement_request(req: DisbursementRequest, *, actor=None) -> None:
    """PRD §5.4 FRFM028/029/030 — submit a payment request with details + attachments."""
    if req.status != DisbursementRequest.Status.DRAFT:
        raise ValidationError("Only draft requests can be submitted.")
    assert_award_allows_claims(req.budget, actor=actor or getattr(req, "requested_by", None))
    req.status = DisbursementRequest.Status.SUBMITTED
    req.save(update_fields=["status", "updated_at"])
    from apps.rims.finance.notifications import notify_disbursement_submitted

    notify_disbursement_submitted(req)


@transaction.atomic
def approve_disbursement_request(req: DisbursementRequest, *, actor=None, tier: str = "") -> None:
    """PRD §5.4 FRFM033/037/038 + NFRFM005 — route + approve a request, then
    forward to execution. Wrapped with require_two_eyes so the same actor
    cannot approve a request twice across tiers.
    """
    if req.status != DisbursementRequest.Status.SUBMITTED:
        raise ValidationError("Only submitted requests can be approved.")
    assert_award_allows_claims(req.budget, actor=actor)
    project = getattr(req.budget.award, "project", None)
    if project is not None:
        assert_disbursement_milestone_gate(project, req.milestone)
    assert_disbursement_allowed(req.budget, req.amount)
    if actor is not None:
        require_two_eyes(req, actor=actor)
    req.status = DisbursementRequest.Status.APPROVED
    req.approved_by = actor
    req.approved_at = timezone.now()
    req.rejection_reason = ""
    req.save(update_fields=["status", "approved_by", "approved_at", "rejection_reason", "updated_at"])
    execution, _ = DisbursementExecution.objects.get_or_create(
        request=req,
        defaults={
            "payment_method": req.payment_method,
            "scheduled_for": req.scheduled_for,
            "destination_account": req.account_reference,
        },
    )
    if not execution.scheduled_for and req.scheduled_for:
        execution.scheduled_for = req.scheduled_for
        execution.save(update_fields=["scheduled_for", "updated_at"])
    # PRD §5.4 FRFM036/037/038 — segregation-of-duties telemetry row.
    if actor is not None:
        _record_finance_approval(req, actor=actor, tier=tier or _infer_tier(actor))
    from apps.rims.finance.notifications import notify_disbursement_decision

    notify_disbursement_decision(req, approved=True)


@transaction.atomic
def reject_disbursement_request(req: DisbursementRequest, *, reason: str = "") -> None:
    """PRD §5.4 FRFM035 — reject a request and surface a reason for resubmission."""
    if req.status != DisbursementRequest.Status.SUBMITTED:
        raise ValidationError("Only submitted requests can be rejected.")
    req.status = DisbursementRequest.Status.REJECTED
    req.rejection_reason = reason
    req.save(update_fields=["status", "rejection_reason", "updated_at"])
    from apps.rims.finance.notifications import notify_disbursement_decision

    notify_disbursement_decision(req, approved=False)


@transaction.atomic
def resubmit_disbursement_request(
    req: DisbursementRequest,
    *,
    actor=None,
    amount: Decimal | None = None,
    justification: str | None = None,
    payment_method: str | None = None,
    scheduled_for=None,
    account_reference: str | None = None,
    supporting_document=None,
) -> DisbursementRequest:
    """PRD §5.4 FRFM035 — revise + resubmit a rejected request.

    P0 (assessment §3 #7) — a rejected request previously had no resubmit
    path. Walks the request back to ``DRAFT`` so the requester can revise
    attachments / justification, clears prior approval metadata and the
    rejection_reason, and audits the resubmission.
    """
    if req.status != DisbursementRequest.Status.REJECTED:
        raise ValidationError("Only rejected requests can be resubmitted.")
    changes: dict[str, str] = {"from": "rejected", "to": "draft"}
    if amount is not None and amount != req.amount:
        if amount <= 0:
            raise ValidationError("Disbursement amount must be greater than zero.")
        changes["amount"] = f"{req.amount} → {amount}"
        req.amount = amount
    if justification is not None and justification != req.justification:
        changes["justification"] = "updated"
        req.justification = justification
    if payment_method is not None and payment_method != req.payment_method:
        changes["payment_method"] = f"{req.payment_method} → {payment_method}"
        req.payment_method = payment_method
    if scheduled_for is not None and scheduled_for != req.scheduled_for:
        changes["scheduled_for"] = f"{req.scheduled_for} → {scheduled_for}"
        req.scheduled_for = scheduled_for
    if account_reference is not None and account_reference != req.account_reference:
        changes["account_reference"] = "updated"
        req.account_reference = account_reference
    if supporting_document is not None:
        changes["supporting_document"] = "updated"
        req.supporting_document = supporting_document

    req.status = DisbursementRequest.Status.DRAFT
    req.rejection_reason = ""
    req.approved_by = None
    req.approved_at = None
    req.save(
        update_fields=[
            "amount",
            "justification",
            "payment_method",
            "scheduled_for",
            "account_reference",
            "supporting_document",
            "status",
            "rejection_reason",
            "approved_by",
            "approved_at",
            "updated_at",
        ]
    )

    from apps.core.audit.models import AuditLog

    AuditLog.objects.create(
        actor=actor if (actor is not None and getattr(actor, "is_authenticated", False)) else None,
        action="DISBURSEMENT_RESUBMIT",
        target_app="rims_finance",
        target_model="DisbursementRequest",
        object_id=str(req.pk),
        object_repr=str(req)[:200],
        changes=changes,
    )
    return req


@transaction.atomic
def save_execution_plan(
    req: DisbursementRequest,
    *,
    status: str,
    payment_method: str,
    scheduled_for=None,
    execution_reference: str = "",
    destination_account: str = "",
    notes: str = "",
    proof_of_payment=None,
    proof_channel: str = "",
    actor=None,
):
    """PRD §5.4 FRFM039/041/042/043/044/045 — execute or reschedule a single payment.

    Updates ``DisbursementExecution`` (status/method/reference/destination) and
    flips the underlying request to PAID on EXECUTED transitions. FRFM044
    failure handling lives in the FAILED status branch; reschedules append to
    ``reschedule_history`` for audit.

    FRFM045 — when ``proof_of_payment`` is supplied the file is attached and
    the upload metadata (``proof_received_at``, ``proof_uploaded_by``) is
    stamped. Existing proofs are preserved if the executor doesn't pass a new
    one.

    Note: FRFM049 (automated General Ledger update) is intentionally out of
    scope at this layer — it requires accounting-system integration (Sage,
    QuickBooks, or custom GL) and is tracked as a follow-up.
    """
    if req.status not in [DisbursementRequest.Status.APPROVED, DisbursementRequest.Status.PAID]:
        raise ValidationError("Only approved or paid requests can be scheduled for payment.")
    if status == DisbursementExecution.Status.EXECUTED and not (execution_reference or "").strip():
        # FRFM041 — block execution without a transaction reference; reconciliation
        # has nothing to match against otherwise.
        raise ValidationError(
            "A bank reference or mobile-money transaction ID is required to mark this payment EXECUTED."
        )
    execution, _ = DisbursementExecution.objects.get_or_create(request=req)
    if status == DisbursementExecution.Status.EXECUTED:
        req.status = DisbursementRequest.Status.PAID
        req.save(update_fields=["status", "updated_at"])
    execution.status = status
    execution.payment_method = payment_method
    execution.scheduled_for = scheduled_for
    execution.execution_reference = execution_reference
    execution.destination_account = destination_account
    execution.notes = notes
    execution.executed_at = timezone.now() if status == DisbursementExecution.Status.EXECUTED else None
    if status == DisbursementExecution.Status.RESCHEDULED:
        history = list(execution.reschedule_history or [])
        history.append(
            {
                "scheduled_for": scheduled_for.isoformat() if scheduled_for else "",
                "changed_at": timezone.now().isoformat(),
            }
        )
        execution.reschedule_history = history
    if proof_of_payment is not None:
        execution.proof_of_payment = proof_of_payment
        execution.proof_received_at = timezone.now()
        execution.proof_uploaded_by = actor
    if proof_channel:
        execution.proof_channel = proof_channel
    execution.save()
    DisbursementReconciliation.objects.get_or_create(request=req)
    # PRD §5.4 FRFM046 — payee notification on EXECUTED transitions.
    if status == DisbursementExecution.Status.EXECUTED:
        from apps.rims.finance.notifications import notify_payment_executed_payee

        notify_payment_executed_payee(req, execution=execution)
    return execution


@transaction.atomic
def finalize_deferred_budget(
    award,
    lines: list[dict],
    *,
    actor=None,
    name: str = "Deferred budget",
) -> Budget:
    """PRD §5.1 FRFA-AA025 — Finance Officer confirms the post-award
    line-item budget for Scholarship / Fellowship / Challenge awards.

    *lines*: list of {"label": str, "amount": Decimal|str, "category": str?}.
    Validates that sum(lines.amount) equals `award.amount` and that the award
    is in a state that can accept a deferred budget (not closed/cancelled).
    """
    from apps.rims.finance.models import Budget as _Budget, BudgetLine as _BudgetLine
    from apps.rims.grants.models import Award as _Award

    if not award.budget_deferred:
        raise ValidationError(
            "This award's call type does not defer budget assignment; create "
            "the budget through the standard planning flow."
        )
    if award.status in (
        _Award.Status.CLOSED,
        _Award.Status.CANCELLED,
        _Award.Status.CLOSEOUT_IN_PROGRESS,
    ):
        raise ValidationError(
            "Cannot finalise a deferred budget on a closed or cancelled award."
        )
    if award.budget_finalized_at is not None:
        raise ValidationError("This award already has a finalised deferred budget.")
    if not lines:
        raise ValidationError("At least one budget line is required.")

    parsed_lines: list[tuple[str, Decimal, str]] = []
    total = Decimal("0")
    for item in lines:
        label = (item.get("label") or "").strip()
        if not label:
            raise ValidationError("Every budget line must have a label.")
        try:
            amount = Decimal(str(item["amount"]))
        except (KeyError, ValueError, TypeError) as exc:
            raise ValidationError(f"Invalid amount on line {label!r}.") from exc
        if amount <= 0:
            raise ValidationError(f"Line {label!r} must have a positive amount.")
        category = (item.get("category") or _BudgetLine.Category.OTHER).strip()
        parsed_lines.append((label, amount, category))
        total += amount

    if total != award.amount:
        raise ValidationError(
            f"Line-item total {total} does not equal award amount {award.amount}."
        )

    budget = _Budget.objects.create(
        award=award,
        name=name,
        status=_Budget.Status.APPROVED,
    )
    for label, amount, category in parsed_lines:
        _BudgetLine.objects.create(
            budget=budget, label=label, amount=amount, category=category
        )

    award.budget_finalized_at = timezone.now()
    award.budget_finalized_by = actor
    award.save(update_fields=["budget_finalized_at", "budget_finalized_by"])
    return budget


@transaction.atomic
def reconcile_disbursement(
    req: DisbursementRequest,
    *,
    status: str,
    notes: str = "",
    statement_excerpt=None,
    statement_source: str = "",
    statement_period_start=None,
    statement_period_end=None,
    discrepancy_amount=None,
    actor=None,
):
    """PRD §5.4 FRFM047/048 — reconcile a paid request against bank statements.

    The status enum (PENDING/RECONCILED/DISCREPANCY) covers FRFM048 — finance
    staff mark discrepancies which then surface in dashboards for resolution.
    When a bank or mobile-money statement excerpt is provided it is attached
    as audit evidence (FRFM047 input requirement).
    """
    if req.status != DisbursementRequest.Status.PAID:
        raise ValidationError("Only paid requests can be reconciled.")
    if status == DisbursementReconciliation.Status.DISCREPANCY and discrepancy_amount in (
        None,
        "",
    ):
        raise ValidationError(
            "A discrepancy amount is required when reconciliation status is DISCREPANCY."
        )
    reconciliation, _ = DisbursementReconciliation.objects.get_or_create(request=req)
    reconciliation.status = status
    reconciliation.notes = notes
    reconciliation.reconciled_by = actor
    reconciliation.reconciled_at = timezone.now()
    if statement_excerpt is not None:
        reconciliation.statement_excerpt = statement_excerpt
    if statement_source:
        reconciliation.statement_source = statement_source
    if statement_period_start is not None:
        reconciliation.statement_period_start = statement_period_start
    if statement_period_end is not None:
        reconciliation.statement_period_end = statement_period_end
    if discrepancy_amount not in (None, ""):
        reconciliation.discrepancy_amount = discrepancy_amount
    reconciliation.save()
    return reconciliation


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM040 — payment batching services.
#
# A PaymentBatch groups approved DisbursementRequests so finance staff can
# execute many payments in one operator action. Lifecycle:
#   draft   → batch is being assembled, items can be added/removed
#   locked  → no further edits; ready for execution review
#   executed → every item's DisbursementExecution flipped to EXECUTED and the
#              underlying request moved to PAID; per-payee email fires once
#              per item via the existing notify_payment_executed_payee hook.
#   cancelled → operator abandoned the batch; items detach so they can be
#               re-batched.
# ---------------------------------------------------------------------------

_BATCHABLE_REQUEST_STATUSES = frozenset({DisbursementRequest.Status.APPROVED})


def _assert_request_batchable(req: DisbursementRequest) -> None:
    if req.status not in _BATCHABLE_REQUEST_STATUSES:
        raise ValidationError(
            f"Disbursement request {req.pk} is not approved (status={req.status}); "
            "only approved requests can be batched."
        )
    # Use an explicit DB query rather than ``hasattr(req, "batch_item")``: the
    # OneToOne reverse descriptor caches a deleted item on the in-memory
    # request object, so a cancel-then-rebatch cycle can falsely report the
    # request as already batched.
    existing = PaymentBatchItem.objects.filter(request=req).only("batch_id").first()
    if existing is not None:
        raise ValidationError(
            f"Disbursement request {req.pk} is already in batch {existing.batch_id}; "
            "remove it from that batch first."
        )


@transaction.atomic
def create_payment_batch(
    requests,
    *,
    batch_reference: str,
    scheduled_for=None,
    payment_method: str | None = None,
    currency: str | None = None,
    notes: str = "",
    actor=None,
) -> PaymentBatch:
    """PRD §5.4 FRFM040 — assemble approved disbursement requests into a batch.

    Validates per-request eligibility, snapshots the current request amount
    onto each item, and asserts every request shares one currency. Returns
    the new ``PaymentBatch`` row.
    """
    requests = list(requests)
    if not requests:
        raise ValidationError("Cannot create an empty batch — pick at least one request.")
    # Single-currency check before mutating anything
    currencies = {r.budget.currency for r in requests}
    if len(currencies) > 1:
        raise ValidationError(
            "Batch contains mixed currencies; create separate batches per currency."
        )
    detected_currency = next(iter(currencies))
    if currency and currency != detected_currency:
        raise ValidationError(
            f"Requested currency {currency} does not match request currency {detected_currency}."
        )
    methods = {r.payment_method for r in requests}
    if payment_method is None:
        if len(methods) > 1:
            raise ValidationError(
                "Batch contains mixed payment methods; pick a single method explicitly "
                "or split the batch."
            )
        payment_method = next(iter(methods))

    for req in requests:
        _assert_request_batchable(req)

    batch = PaymentBatch.objects.create(
        batch_reference=batch_reference,
        scheduled_for=scheduled_for,
        payment_method=payment_method,
        currency=currency or detected_currency,
        notes=notes,
        created_by=actor,
        status=PaymentBatch.Status.DRAFT,
    )
    for index, req in enumerate(requests):
        PaymentBatchItem.objects.create(
            batch=batch,
            request=req,
            amount=req.amount,
            sequence=index,
        )
    return batch


@transaction.atomic
def lock_payment_batch(batch: PaymentBatch, *, actor=None) -> PaymentBatch:
    """Freeze a draft batch so no further items can be added/removed."""
    if batch.status != PaymentBatch.Status.DRAFT:
        raise ValidationError(
            f"Only draft batches can be locked (current status={batch.status})."
        )
    if not batch.items.exists():
        raise ValidationError("Cannot lock an empty batch.")
    batch.status = PaymentBatch.Status.LOCKED
    batch.locked_by = actor
    batch.locked_at = timezone.now()
    batch.save(update_fields=["status", "locked_by", "locked_at", "updated_at"])
    return batch


@transaction.atomic
def cancel_payment_batch(batch: PaymentBatch, *, actor=None) -> PaymentBatch:
    """Abandon an in-flight batch; detach items so they can be re-batched."""
    if batch.status == PaymentBatch.Status.EXECUTED:
        raise ValidationError("Executed batches cannot be cancelled.")
    if batch.status == PaymentBatch.Status.CANCELLED:
        return batch
    batch.items.all().delete()
    batch.status = PaymentBatch.Status.CANCELLED
    batch.cancelled_at = timezone.now()
    batch.save(update_fields=["status", "cancelled_at", "updated_at"])
    return batch


@transaction.atomic
def reverse_payment_batch(
    batch: PaymentBatch,
    *,
    reason: str,
    reference: str = "",
    actor=None,
) -> PaymentBatch:
    """PRD §5.4 FRFM044 — reverse an executed batch (bank chargeback path).

    P0 (assessment §3 #8) — `cancel_payment_batch` rightly refuses EXECUTED
    batches, so finance had no way to undo a bounced payment. This service
    walks every child ``DisbursementExecution`` → REVERSED, returns the
    parent ``DisbursementRequest`` to APPROVED so it can be re-batched, and
    flips the batch itself to REVERSED with an audit row per item.

    A written *reason* is mandatory. *reference* should carry the bank
    chargeback / mobile-money reversal ID when available.
    """
    reason = (reason or "").strip()
    if not reason:
        raise ValidationError("A written reason is required to reverse a payment batch.")
    if batch.status != PaymentBatch.Status.EXECUTED:
        raise ValidationError(
            f"Only executed batches can be reversed (current status={batch.status})."
        )

    from apps.core.audit.models import AuditLog

    audit_actor = actor if (actor is not None and getattr(actor, "is_authenticated", False)) else None
    for item in batch.items.select_related("request", "request__execution").order_by("sequence", "pk"):
        req = item.request
        execution = getattr(req, "execution", None)
        if execution is not None:
            execution.status = DisbursementExecution.Status.REVERSED
            execution.reversed_at = timezone.now()
            execution.reversal_reference = reference
            execution.reversal_reason = reason
            execution.save(
                update_fields=[
                    "status",
                    "reversed_at",
                    "reversal_reference",
                    "reversal_reason",
                    "updated_at",
                ]
            )
        # The request was PAID; walk it back to APPROVED so it can be
        # rebatched. Clearing rejection_reason keeps the field clean.
        if req.status == DisbursementRequest.Status.PAID:
            req.status = DisbursementRequest.Status.APPROVED
            req.save(update_fields=["status", "updated_at"])
        AuditLog.objects.create(
            actor=audit_actor,
            action="DISBURSEMENT_REVERSED",
            target_app="rims_finance",
            target_model="DisbursementRequest",
            object_id=str(req.pk),
            object_repr=str(req)[:200],
            changes={
                "batch": batch.batch_reference,
                "reason": reason,
                "reference": reference,
            },
        )

    batch.status = PaymentBatch.Status.REVERSED
    batch.reversed_by = audit_actor
    batch.reversed_at = timezone.now()
    batch.reversal_reason = reason
    batch.reversal_reference = reference
    batch.save(
        update_fields=[
            "status",
            "reversed_by",
            "reversed_at",
            "reversal_reason",
            "reversal_reference",
            "updated_at",
        ]
    )
    AuditLog.objects.create(
        actor=audit_actor,
        action="PAYMENT_BATCH_REVERSED",
        target_app="rims_finance",
        target_model="PaymentBatch",
        object_id=str(batch.pk),
        object_repr=str(batch)[:200],
        changes={"reason": reason, "reference": reference},
    )
    return batch


@transaction.atomic
def execute_payment_batch(
    batch: PaymentBatch,
    *,
    item_references: dict | None = None,
    item_proofs: dict | None = None,
    actor=None,
) -> PaymentBatch:
    """Execute every item in a locked batch.

    Each item runs through ``save_execution_plan`` with status=EXECUTED so the
    existing per-payment side-effects (request → PAID, payee email) keep
    firing. ``item_references`` is an optional ``{item_id: bank_reference}``
    mapping used to populate ``DisbursementExecution.execution_reference``.

    PRD §5.4 FRFM045 — ``item_proofs`` is an optional
    ``{item_id: (uploaded_file, channel)}`` mapping that attaches the
    platform-issued receipt to each item's execution.

    FRFM041 — every item must carry a non-empty execution reference (either
    via ``item_references`` or a default derived from the batch reference).
    """
    if batch.status != PaymentBatch.Status.LOCKED:
        raise ValidationError(
            f"Only locked batches can be executed (current status={batch.status})."
        )
    item_references = item_references or {}
    item_proofs = item_proofs or {}
    for item in batch.items.select_related("request").order_by("sequence", "pk"):
        req = item.request
        # Trust but verify — somebody could have flipped the request out of
        # APPROVED between lock and execute. Refuse rather than silently skip.
        if req.status != DisbursementRequest.Status.APPROVED:
            raise ValidationError(
                f"Request {req.pk} in batch {batch.batch_reference} is no longer "
                f"approved (status={req.status}); cancel and rebuild the batch."
            )
        ref = (item_references.get(item.pk, "") or "").strip()
        if not ref:
            # FRFM041 — synthesise a per-item reference from the batch when the
            # operator didn't paste one. Better than silently failing the
            # FRFM041 guard inside save_execution_plan; the operator can
            # overwrite from the detail page later.
            ref = f"{batch.batch_reference}-{item.sequence + 1:03d}"
        proof_file = None
        proof_channel = ""
        proof_payload = item_proofs.get(item.pk)
        if proof_payload:
            if isinstance(proof_payload, tuple):
                proof_file, proof_channel = proof_payload[0], (proof_payload[1] or "")
            else:
                proof_file = proof_payload
        save_execution_plan(
            req,
            status=DisbursementExecution.Status.EXECUTED,
            payment_method=batch.payment_method,
            scheduled_for=batch.scheduled_for,
            execution_reference=ref,
            destination_account=req.account_reference,
            notes=f"Executed via batch {batch.batch_reference}",
            proof_of_payment=proof_file,
            proof_channel=proof_channel,
            actor=actor,
        )
    batch.status = PaymentBatch.Status.EXECUTED
    batch.executed_by = actor
    batch.executed_at = timezone.now()
    batch.save(update_fields=["status", "executed_by", "executed_at", "updated_at"])
    return batch


# ---------------------------------------------------------------------------
# PRD §5.4 FRFM042 / FRFM043 — payment failure + retry queue services.
# ---------------------------------------------------------------------------
@transaction.atomic
def mark_execution_failed(
    execution: DisbursementExecution,
    *,
    actor,
    failure_category: str,
    failure_reason: str,
) -> PaymentRetry:
    """PRD §5.4 FRFM042 — flip an execution to FAILED, capture the
    NSF / wrong-account / bank-rejection category, and auto-enqueue a
    PaymentRetry for finance staff to handle. Returns the queued retry."""
    if not failure_category:
        raise ValidationError("Failure category is required.")
    execution.status = DisbursementExecution.Status.FAILED
    execution.failure_category = failure_category
    execution.failure_reason = (failure_reason or "")[:255]
    execution.save(
        update_fields=["status", "failure_category", "failure_reason", "updated_at"]
    )
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="PAYMENT_EXECUTION_FAILED",
            target_model="DisbursementExecution",
            object_id=execution.pk,
            object_repr=str(execution),
            changes={
                "failure_category": failure_category,
                "failure_reason": (failure_reason or "")[:255],
            },
        )
    except Exception:  # pragma: no cover
        pass
    try:
        from apps.rims.finance.notifications import notify_payment_failure

        notify_payment_failure(execution)
    except Exception:  # pragma: no cover - notification failure must not block
        pass
    return queue_payment_retry(execution)


def queue_payment_retry(
    execution: DisbursementExecution,
    *,
    scheduled_for=None,
) -> PaymentRetry:
    """PRD §5.4 FRFM043 — create a PaymentRetry row in QUEUED state."""
    retry = PaymentRetry.objects.create(
        execution=execution,
        status=PaymentRetry.Status.QUEUED,
        next_retry_at=scheduled_for,
    )
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=None,
            action="PAYMENT_RETRY_QUEUED",
            target_model="PaymentRetry",
            object_id=retry.pk,
            object_repr=str(retry),
            changes={"execution_id": execution.pk},
        )
    except Exception:  # pragma: no cover
        pass
    return retry


@transaction.atomic
def attempt_payment_retry(retry: PaymentRetry, *, actor) -> PaymentRetry:
    """PRD §5.4 FRFM043 — escalate FSM toward RETRYING/RETRIED."""
    if retry.status not in (PaymentRetry.Status.QUEUED, PaymentRetry.Status.RETRYING):
        raise ValidationError("Only queued or retrying retries can be attempted.")
    retry.status = PaymentRetry.Status.RETRYING
    retry.attempts = (retry.attempts or 0) + 1
    retry.last_attempt_at = timezone.now()
    retry.save(update_fields=["status", "attempts", "last_attempt_at", "updated_at"])
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="PAYMENT_RETRY_ATTEMPTED",
            target_model="PaymentRetry",
            object_id=retry.pk,
            object_repr=str(retry),
            changes={"attempt": retry.attempts},
        )
    except Exception:  # pragma: no cover
        pass
    return retry


@transaction.atomic
def abandon_payment_retry(retry: PaymentRetry, *, actor, reason: str) -> PaymentRetry:
    """PRD §5.4 FRFM043 — terminal: finance staff give up on this retry."""
    retry.status = PaymentRetry.Status.ABANDONED
    retry.last_error = (reason or "")[:500]
    retry.resolved_by = actor
    retry.resolved_at = timezone.now()
    retry.save(
        update_fields=["status", "last_error", "resolved_by", "resolved_at", "updated_at"]
    )
    try:
        from apps.rims.grants.audit_helper import audit_rims

        audit_rims(
            actor=actor,
            action="PAYMENT_RETRY_ABANDONED",
            target_model="PaymentRetry",
            object_id=retry.pk,
            object_repr=str(retry),
            changes={"reason": reason[:200]},
        )
    except Exception:  # pragma: no cover
        pass
    return retry
