"""Bug 4 regression — PRD §5.1 FRFA-CO015-017 residual-recovery gate.

``submit_award_closeout_for_approval()`` called ``record_closeout_residual()``
(which persists ``financial_snapshot["residual_returned"]``) and then
immediately overwrote ``closeout.financial_snapshot`` with a fresh
``award_financial_snapshot()`` dict that has no such key — silently wiping
the flag before the recovery-gate UI (``award_closeout.html``'s "Recovery of
unspent balance" section) ever saw it. Worse, the whole function used to be
one ``@transaction.atomic``, so even when that overwrite bug was fixed in
isolation, a *first-time* residual would still lose the flag: such a residual
almost always fails the ``recovery_confirmed_or_waived`` precondition (recovery
hasn't been confirmed/waived yet), and the resulting ``ValidationError`` would
roll back the entire transaction — including the flag write that had just
happened moments earlier.

This suite drives ``submit_award_closeout_for_approval()`` directly (per the
task brief) for the persistence assertions, and separately drives the real
``AwardCloseoutView`` POST + GET to confirm the template actually renders the
recovery section once the flag is set (tracing the fix through to the UI,
not just the backend dict).
"""
from __future__ import annotations

from datetime import timedelta
from decimal import Decimal
from unittest.mock import patch

import pytest
from django.core.exceptions import ValidationError
from django.utils import timezone

from apps.rims.finance.models import Budget, BudgetLine, DisbursementRequest
from apps.rims.grants.closeout_helpers import (
    closeout_preconditions,
    get_or_create_closeout_record,
    submit_award_closeout_for_approval,
    waive_recovery,
)
from apps.rims.grants.models import Application, Award, AwardCloseoutRecord, GrantCall
from apps.rims.grants.services import award_application, shortlist_application, submit_application


@pytest.fixture(autouse=True)
def _no_async_dispatch():
    with patch(
        "apps.core.notifications.services.dispatch_notification_channel.delay"
    ) as fake:
        yield fake


def _build_award_with_residual(applicant_user, institution, *, suffix, amount=Decimal("1000"), disbursed=Decimal("600")) -> Award:
    call = GrantCall.objects.create(
        title=f"CO015-017 residual-flag call {suffix}",
        slug=f"co015-017-flag-{suffix}-{int(timezone.now().timestamp() * 1000) % 10_000_000}",
        opens_at=timezone.now() - timedelta(days=1),
        closes_at=timezone.now() + timedelta(days=10),
        status=GrantCall.Status.PUBLISHED,
    )
    app = Application.objects.create(call=call, applicant=applicant_user, institution=institution)
    submit_application(app)
    app = Application.objects.get(pk=app.pk)
    shortlist_application(app)
    award_application(app, amount, timezone.now().date() + timedelta(days=180), narrative="")
    award = Application.objects.get(pk=app.pk).award

    budget = Budget.objects.create(award=award, name="Closeout budget")
    BudgetLine.objects.create(budget=budget, label="Line", amount=amount)
    if disbursed:
        DisbursementRequest.objects.create(
            budget=budget, amount=disbursed, status=DisbursementRequest.Status.PAID
        )
    return award


def _full_checklist_payload(closeout) -> list[dict]:
    checklist = []
    for row in closeout.checklist or []:
        row = dict(row)
        if row.get("key") != "recovery_confirmation" and row.get("required"):
            row["done"] = True
            row["evidence_note"] = "evidence captured"
        checklist.append(row)
    return checklist


@pytest.mark.django_db
def test_residual_flag_and_fresh_financials_persist_despite_blocked_submission(
    applicant_user, grants_manager_user, institution
):
    """The core regression: a first-time residual (award=1000, disbursed=600)
    blocks on the recovery gate (not yet confirmed/waived) -> ValidationError.
    Before the fix, the entire write (including the residual flag) rolled
    back inside the function's single @transaction.atomic. After the fix,
    the informational writes (checklist/residual/snapshot) persist even
    though the finalize step never runs."""
    award = _build_award_with_residual(
        applicant_user, institution, suffix="blocked", amount=Decimal("1000"), disbursed=Decimal("600")
    )
    closeout = get_or_create_closeout_record(award)

    with pytest.raises(ValidationError):
        submit_award_closeout_for_approval(
            award,
            closeout,
            narrative="Final narrative for this award.",
            checklist=_full_checklist_payload(closeout),
            actor=grants_manager_user,
        )

    closeout = AwardCloseoutRecord.objects.get(pk=closeout.pk)
    snapshot = closeout.financial_snapshot or {}
    assert snapshot.get("residual_returned") == "400.00", snapshot
    assert snapshot.get("total_disbursed") == "600.00", snapshot
    assert snapshot.get("award_amount") == "1000.00", snapshot

    # The closeout was NOT moved to PENDING_APPROVAL (the atomic finalize
    # block correctly rolled back), and the recovery gate is what's blocking.
    assert closeout.approval_status == AwardCloseoutRecord.ApprovalStatus.DRAFT
    gates = {g.key: g for g in closeout_preconditions(award)}
    assert gates["recovery_confirmed_or_waived"].passed is False


@pytest.mark.django_db
def test_submission_succeeds_once_recovery_waived_and_snapshot_keeps_both(
    applicant_user, grants_manager_user, institution
):
    """Once the residual is waived, resubmission succeeds and the saved
    financial_snapshot still carries both the (now-moot) residual figure
    the gate last saw and fresh finance totals -- the merge must never drop
    the fresh numbers either."""
    from django.contrib.auth import get_user_model
    from apps.core.permissions.roles import UserRole

    User = get_user_model()
    pd = User.objects.create_user(email="pd.residualflag@example.com", password="pw-residualflag")
    pd.role = UserRole.PROGRAM_DIRECTOR
    pd.save()

    award = _build_award_with_residual(
        applicant_user, institution, suffix="waived", amount=Decimal("1000"), disbursed=Decimal("600")
    )
    closeout = get_or_create_closeout_record(award)

    waive_recovery(
        award,
        amount=Decimal("400"),
        justification="Below the recovery pursuit threshold.",
        approver=pd,
    )

    closeout = AwardCloseoutRecord.objects.get(pk=closeout.pk)
    submit_award_closeout_for_approval(
        award,
        closeout,
        narrative="Final narrative for this award.",
        checklist=_full_checklist_payload(closeout),
        actor=grants_manager_user,
    )

    closeout = AwardCloseoutRecord.objects.get(pk=closeout.pk)
    assert closeout.approval_status == AwardCloseoutRecord.ApprovalStatus.PENDING_APPROVAL
    snapshot = closeout.financial_snapshot or {}
    assert snapshot.get("residual_returned") == "400.00", snapshot
    assert snapshot.get("total_disbursed") == "600.00", snapshot
    assert snapshot.get("award_amount") == "1000.00", snapshot


@pytest.mark.django_db
def test_award_closeout_view_renders_recovery_section_for_first_time_residual(
    client, applicant_user, grants_manager_user, institution
):
    """Traces the backend fix through to the template: after a blocked
    first-time-residual submit attempt, the close-out page must render the
    'Recovery of unspent balance' section so the GM/Finance Officer can
    actually act on it -- this is the CO015-017 UI the audit flagged as
    unreachable."""
    from django.urls import reverse
    from apps.core.authentication.models import MFADevice

    MFADevice.objects.get_or_create(
        user=grants_manager_user,
        device_type=MFADevice.DeviceType.TOTP,
        defaults={"name": "Test TOTP", "secret": "JBSWY3DPEHPK3PXP", "is_verified": True},
    )

    award = _build_award_with_residual(
        applicant_user, institution, suffix="view-render", amount=Decimal("1000"), disbursed=Decimal("600")
    )
    closeout = get_or_create_closeout_record(award)

    client.force_login(grants_manager_user)
    url = reverse("rims_grants:award_closeout", kwargs={"pk": award.pk})
    payload = {"narrative": "Final narrative for this award."}
    for row in closeout.checklist or []:
        if row.get("required") and row.get("key") != "recovery_confirmation":
            payload[f"co_done__{row['key']}"] = "on"
            payload[f"co_note__{row['key']}"] = "evidence captured"

    resp = client.post(url, payload)
    # Blocked by the recovery gate -> re-rendered form (form_invalid), not a redirect.
    assert resp.status_code == 200

    closeout = AwardCloseoutRecord.objects.get(pk=closeout.pk)
    assert (closeout.financial_snapshot or {}).get("residual_returned") == "400.00"

    resp = client.get(url)
    assert resp.status_code == 200
    content = resp.content.decode()
    # NOTE: the pre-flight gates card ALSO renders a gate labelled "Recovery
    # of unspent balance confirmed or waived" regardless of the flag, so
    # "Recovery of unspent balance" alone is not a discriminating check --
    # use the section's unique subtitle copy, which only renders inside the
    # actual recovery card (gated on the residual flag / requests / waivers).
    assert "Finance must confirm receipt of any unspent balance" in content

    # The "Raise recovery request" action itself is role-gated to Finance
    # Officer/Admin (can_act_as_finance) -- but AwardCloseoutView itself is
    # GrantsManagerMixin-gated to Grants Manager/Admin, so a plain Finance
    # Officer can never GET this page at all (they act via the
    # award_recovery_raise/confirm endpoints directly, not this workbench).
    # Use an Admin to confirm the raise-request form actually renders once
    # the residual flag is set, since Admin clears both gates at once.
    from django.contrib.auth import get_user_model
    from apps.core.permissions.roles import UserRole

    User = get_user_model()
    admin_user = User.objects.create_user(email="admin.residualflagview@example.com", password="pw-residualflagview")
    admin_user.role = UserRole.ADMIN
    admin_user.save()
    MFADevice.objects.get_or_create(
        user=admin_user,
        device_type=MFADevice.DeviceType.TOTP,
        defaults={"name": "Test TOTP", "secret": "JBSWY3DPEHPK3PXP", "is_verified": True},
    )
    client.force_login(admin_user)
    resp = client.get(url)
    assert resp.status_code == 200
    content = resp.content.decode()
    assert "Finance must confirm receipt of any unspent balance" in content
    # {% url %} renders the resolved path, not the URL name -- check the
    # visible button label, which is unique to this form.
    assert "Raise recovery request" in content
