"""Award close-out checklist defaults and financial snapshot (PRD grant close-out, phased)."""

from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal

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

from apps.rims.finance import services as fin_services
from apps.rims.finance.models import Budget
from apps.rims.grants.models import Award, AwardCloseoutRecord


@dataclass(frozen=True)
class CloseoutGate:
    """PRD §5.1 FRFA-CO001 — one row in the close-out precondition catalogue."""

    key: str
    label: str
    passed: bool
    blocker_message: str = ""
    detail: dict | None = field(default=None)


def _common_closeout_items() -> list[dict]:
    return [
        {
            "key": "final_narrative",
            "label": "Final narrative captured and reviewed",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "financial_reconciliation",
            "label": "Financial reconciliation reviewed (disbursed vs budgets)",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "no_outstanding_flags",
            "label": "No outstanding compliance or risk flags on the award",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
    ]


# PRD §5.1 FRFA-CO004 / Table 14 — funding-type-specific checklist items
# layered on top of the common close-out items. Each entry mirrors the close-out
# requirements that the PRD lists for the matching funding type.
_TYPE_SPECIFIC_ITEMS: dict[str, list[dict]] = {
    "grant": [
        {
            "key": "research_outputs",
            "label": "Research outputs and publications archived",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "asset_verification",
            "label": "Asset / deliverable verification",
            "required": False,
            "done": False,
            "evidence_note": "",
        },
    ],
    "scholarship": [
        {
            "key": "academic_completion_certificate",
            "label": "Academic completion certificate received",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "institutional_confirmation",
            "label": "Institutional confirmation of completion",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "household_verification_followup",
            "label": "Household verification follow-up (if required at award)",
            "required": False,
            "done": False,
            "evidence_note": "",
        },
    ],
    "fellowship": [
        {
            "key": "fellowship_completion_report",
            "label": "Fellowship completion report received",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "host_institution_confirmation",
            "label": "Host institution confirmation",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "output_verification",
            "label": "Output / deliverable verification",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
    ],
    "challenge": [
        {
            "key": "innovation_implementation_report",
            "label": "Innovation implementation report received",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "impact_evidence",
            "label": "Impact evidence captured",
            "required": True,
            "done": False,
            "evidence_note": "",
        },
        {
            "key": "ip_transfer",
            "label": "IP / asset transfer recorded (where applicable)",
            "required": False,
            "done": False,
            "evidence_note": "",
        },
    ],
}


def default_closeout_checklist(*, funding_type: str | None = None) -> list[dict]:
    """PRD §5.1 FRFA-CO004 — produce the close-out checklist for an award.

    When ``funding_type`` is omitted, returns the common items only (used when
    the award has no parent funding record yet — pre-PRD legacy data). Pass the
    GrantCall.CallType value (``grant``, ``scholarship``, ``fellowship``,
    ``challenge``) to layer in the type-specific items per Table 14.
    """
    items = _common_closeout_items()
    extra = _TYPE_SPECIFIC_ITEMS.get((funding_type or "").lower(), [])
    return items + extra


def award_financial_snapshot(award: Award) -> dict:
    budgets = Budget.objects.filter(award=award).prefetch_related("lines", "disbursement_requests")
    rows: list[dict] = []
    total_ceiling = Decimal("0")
    total_disbursed = Decimal("0")
    pending_approval = 0
    awaiting_execution = 0
    awaiting_reconciliation = 0
    for b in budgets:
        ceiling = fin_services.budget_ceiling(b)
        disbursed = fin_services.total_disbursed(b)
        health = fin_services.finance_health_snapshot(b)
        total_ceiling += ceiling
        total_disbursed += disbursed
        pending_approval += health["pending_approval"]
        awaiting_execution += health["awaiting_execution"]
        awaiting_reconciliation += health["awaiting_reconciliation"]
        rows.append(
            {
                "budget_id": b.pk,
                "name": b.name,
                "ceiling": str(ceiling),
                "disbursed": str(disbursed),
                "pending_approval": health["pending_approval"],
                "awaiting_execution": health["awaiting_execution"],
                "awaiting_reconciliation": health["awaiting_reconciliation"],
            }
        )
    return {
        "budgets": rows,
        "total_budget_ceiling": str(total_ceiling),
        "total_disbursed": str(total_disbursed),
        "pending_approval": pending_approval,
        "awaiting_execution": awaiting_execution,
        "awaiting_reconciliation": awaiting_reconciliation,
        "award_amount": str(award.amount),
        "currency": award.currency,
    }


def get_or_create_closeout_record(award: Award) -> AwardCloseoutRecord:
    rec, created = AwardCloseoutRecord.objects.get_or_create(award=award)
    if created or not rec.checklist:
        funding_type = getattr(award.application.call, "call_type", None)
        rec.checklist = default_closeout_checklist(funding_type=funding_type)
        rec.save(update_fields=["checklist", "updated_at"])
    return rec


@transaction.atomic
def force_award_closeout(award: Award, *, justification: str, approver) -> AwardCloseoutRecord:
    """PRD §5.1 FRFA-CO023 — formally close an award with outstanding checklist
    items unresolved.

    Requires:
      - a non-empty written justification, and
      - an approver with the PROGRAM_DIRECTOR role (or admin role).

    Side effects:
      - Award.status flips to CLOSED.
      - AwardCloseoutRecord captures forced_closure_justification, forced_at,
        and forced_by.
      - Audit row is written (action=FORCED_CLOSEOUT_APPROVED).
      - Awardee + Grants Manager are notified.
    """
    from django.core.exceptions import ValidationError as _VE
    from django.urls import reverse

    from apps.core.notifications.emailing import rims_email_context
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification
    from apps.core.permissions.roles import UserRole
    from apps.rims.grants.audit_helper import audit_rims

    justification = (justification or "").strip()
    if not justification:
        raise _VE("A written justification is required for a forced close-out.")

    approver_role = getattr(approver, "role", None)
    if approver_role not in {UserRole.PROGRAM_DIRECTOR, UserRole.ADMIN, UserRole.SYSTEM_ADMIN}:
        raise _VE("Only the Programme Director (or system admin) can force a close-out.")

    rec = get_or_create_closeout_record(award)
    rec.forced_closure_justification = justification
    rec.forced_at = timezone.now()
    rec.forced_by = approver
    rec.save(update_fields=["forced_closure_justification", "forced_at", "forced_by", "updated_at"])

    award.status = Award.Status.CLOSED
    award.closed_at = timezone.now()
    award.save(update_fields=["status", "closed_at"])

    audit_rims(
        actor=approver,
        action="FORCED_CLOSEOUT_APPROVED",
        target_model="Award",
        object_id=award.pk,
        object_repr=str(award),
        changes={"justification": justification, "forced_by_id": approver.pk},
    )

    # The Award.status → CLOSED transition triggers the
    # award_closed_notify_parties signal (FRFA-CO021), so an explicit
    # awardee/GM email here would be redundant. Instead, surface the
    # forced-closure note specifically to the grants manager so they have
    # the audit context inline.
    funding_record = getattr(award.application.call, "funding_record", None)
    grants_manager = getattr(funding_record, "grant_manager", None) if funding_record else None
    if grants_manager:
        ctx = rims_email_context(
            subject=f"Forced close-out approved: {award.application.call.title}",
            headline="A forced close-out has been approved",
            action_path=reverse("rims_grants:award_detail", kwargs={"pk": award.pk}),
            action_label="Open award",
            preheader="Programme Director closed this award with outstanding items.",
        )
        send_notification(
            grants_manager,
            (
                f'A forced close-out was approved on award "{award.application.call.title}". '
                f"Justification: {justification}"
            ),
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=reverse("rims_grants:award_detail", kwargs={"pk": award.pk}),
        )
    return rec


def assert_checklist_items_have_evidence(checklist: list[dict]) -> None:
    """PRD §5.1 FRFA-CO014 — every checklist item marked ``done=True`` must
    carry a non-empty ``evidence_note`` so the Program Director's approval is
    grounded in a concrete record. Raises ValidationError listing the
    offending item keys."""
    from django.core.exceptions import ValidationError as _VE

    missing = [
        item.get("key") or item.get("label") or "<unnamed>"
        for item in checklist or []
        if item.get("done") and not (item.get("evidence_note") or "").strip()
    ]
    if missing:
        raise _VE(
            "Every completed close-out checklist item requires evidence. "
            f"Missing evidence on: {', '.join(missing)}."
        )


# PRD §5.1 FRFA-CO012/013 — tranche residual reconciliation at close-out.
def record_closeout_residual(award: Award, *, actor) -> Decimal:
    """If total_disbursed < award.amount at close, record the residual delta.

    The residual is **derived** — ``apps.rims.grants.funding.uncommitted_balance``
    already excludes closed awards, so the parent FundingRecord's available
    balance rebalances automatically once the Award flips to CLOSED. This
    helper exists to make the residual an **explicit, auditable event** so:
      - the donor close-out report (FRFA-CO019) can list returned funds, and
      - the auditor's archived-awards surface (FRFA-CO015–017) can show it.

    Idempotent: if no residual exists, no audit row is written.
    """
    from apps.rims.grants.audit_helper import audit_rims

    snapshot = award_financial_snapshot(award)
    try:
        disbursed = Decimal(snapshot.get("total_disbursed", "0"))
        award_amount = Decimal(snapshot.get("award_amount", "0"))
    except Exception:  # noqa: BLE001 — snapshot is strings; be tolerant.
        return Decimal("0")

    residual = award_amount - disbursed
    if residual <= 0:
        return Decimal("0")

    # OneToOne reverse may not be cached freshly after a get_or_create — always
    # look up via the manager so we get the latest row.
    closeout = AwardCloseoutRecord.objects.filter(award=award).first()
    if closeout is None:
        closeout = get_or_create_closeout_record(award)
    fs = dict(closeout.financial_snapshot or {})
    fs["residual_returned"] = str(residual)
    closeout.financial_snapshot = fs
    closeout.save(update_fields=["financial_snapshot", "updated_at"])

    funding_record = getattr(award.application.call, "funding_record", None)
    audit_rims(
        actor=actor,
        action="CLOSEOUT_RESIDUAL_RECONCILED",
        target_model="Award",
        object_id=award.pk,
        object_repr=str(award),
        changes={
            "residual": str(residual),
            "currency": award.currency,
            "funding_record_id": getattr(funding_record, "pk", None),
            "award_amount": str(award_amount),
            "total_disbursed": str(disbursed),
        },
    )
    return residual


# PRD §5.1 FRFA-CO001 — close-out precondition catalogue surfaced before submit.
def closeout_preconditions(award: Award) -> list[CloseoutGate]:
    """Return the seven typed pre-flight gates for an award's close-out.

    Surfaced on the close-out page so the Grants Manager sees go/no-go status
    before pressing Submit. The same gates are re-evaluated inside the atomic
    block in ``AwardCloseoutView.form_valid`` so a Submit can never bypass an
    unmet gate even if state changes between page render and POST.
    """
    from apps.rims.grants.models import (
        AssetVerification,
        FinalFinancialReport,
        FinalNarrativeReport,
    )

    closeout = get_or_create_closeout_record(award)
    snapshot = award_financial_snapshot(award)

    gates: list[CloseoutGate] = []

    narrative_ok = bool((award.narrative or "").strip())
    gates.append(
        CloseoutGate(
            key="narrative_present",
            label="Final narrative captured",
            passed=narrative_ok,
            blocker_message=""
            if narrative_ok
            else "Capture the final narrative before close-out.",
        )
    )

    missing_required = [
        (r.get("label") or r.get("key") or "<unnamed>")
        for r in (closeout.checklist or [])
        if r.get("required")
        and (not r.get("done") or not (r.get("evidence_note") or "").strip())
    ]
    gates.append(
        CloseoutGate(
            key="mandatory_checklist_done",
            label="Mandatory checklist items resolved with evidence",
            passed=not missing_required,
            blocker_message=(
                ""
                if not missing_required
                else "Complete and document: " + "; ".join(missing_required[:5])
                + ("…" if len(missing_required) > 5 else "")
            ),
            detail={"missing": missing_required} if missing_required else None,
        )
    )

    try:
        disbursed = Decimal(snapshot.get("total_disbursed", "0"))
        ceiling = Decimal(snapshot.get("total_budget_ceiling", "0"))
        award_amount = Decimal(snapshot.get("award_amount", "0"))
        overspend = disbursed > award_amount or disbursed > ceiling
    except Exception:  # noqa: BLE001 — snapshot is a dict of strings, be tolerant.
        overspend = False
    gates.append(
        CloseoutGate(
            key="disbursement_within_budget",
            label="Disbursement within award and budget ceilings",
            passed=not overspend,
            blocker_message=(
                ""
                if not overspend
                else "Disbursement totals exceed the award amount or budget ceiling. Resolve reconciliation first."
            ),
        )
    )

    pending = (
        int(snapshot.get("pending_approval", 0) or 0)
        + int(snapshot.get("awaiting_execution", 0) or 0)
        + int(snapshot.get("awaiting_reconciliation", 0) or 0)
    )
    gates.append(
        CloseoutGate(
            key="no_pending_finance",
            label="No outstanding disbursement requests",
            passed=pending == 0,
            blocker_message=(
                ""
                if pending == 0
                else f"{pending} disbursement request(s) still pending approval, execution, or reconciliation."
            ),
        )
    )

    project = getattr(award, "project", None)
    if project is not None:
        pending_ms = list(
            project.milestones.exclude(status="accepted").filter(report_due=True)[:6]
        )
        gates.append(
            CloseoutGate(
                key="milestones_resolved",
                label="Required project milestones accepted",
                passed=not pending_ms,
                blocker_message=(
                    ""
                    if not pending_ms
                    else "Unresolved milestones: "
                    + "; ".join(m.name for m in pending_ms[:5])
                    + ("…" if len(pending_ms) > 5 else "")
                ),
            )
        )
    else:
        gates.append(
            CloseoutGate(
                key="milestones_resolved",
                label="Required project milestones accepted",
                passed=True,
                blocker_message="",
            )
        )

    open_narrative = closeout.narrative_reports.exclude(
        status=FinalNarrativeReport.Status.ACCEPTED
    ).count()
    open_financial = closeout.financial_reports.exclude(
        status=FinalFinancialReport.Status.ACCEPTED
    ).count()
    reports_ok = open_narrative == 0 and open_financial == 0
    gates.append(
        CloseoutGate(
            key="reports_accepted",
            label="Submitted final reports accepted",
            passed=reports_ok,
            blocker_message=(
                ""
                if reports_ok
                else f"{open_narrative} narrative + {open_financial} financial report(s) still pending acceptance."
            ),
        )
    )

    unaccounted = closeout.asset_verifications.filter(
        status=AssetVerification.Status.UNACCOUNTED
    ).count()
    gates.append(
        CloseoutGate(
            key="assets_resolved",
            label="All assets verified",
            passed=unaccounted == 0,
            blocker_message=(
                ""
                if unaccounted == 0
                else f"{unaccounted} asset(s) still UNACCOUNTED. Resolve asset verification before closing."
            ),
        )
    )

    return gates


# PRD §5.1 FRFA-CO008–010 — final narrative / financial report review services.
from apps.rims.grants.models import FinalFinancialReport, FinalNarrativeReport  # noqa: E402


def _notify_awardee_of_report_review(
    report,
    *,
    headline: str,
    subject_tail: str,
    body: str,
) -> None:
    """PRD §5.1 FRFA-CO009/010 — awardee notification when their final report
    is accepted, revised, or queried during close-out review."""
    from django.urls import reverse

    from apps.core.notifications.emailing import rims_email_context
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification

    award = report.closeout.award
    awardee = award.application.applicant
    awardee_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    ctx = rims_email_context(
        subject=f"{subject_tail}: {award.application.call.title}",
        headline=headline,
        action_path=awardee_url,
        action_label="Open application",
        preheader=headline,
    )
    ctx["call_title"] = award.application.call.title
    send_notification(
        awardee,
        body,
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
        action_url=awardee_url,
    )


@transaction.atomic
def accept_narrative_report(
    report: FinalNarrativeReport, *, actor=None, comment: str = ""
) -> FinalNarrativeReport:
    report.accept()
    report.reviewed_by = actor
    report.review_comment = (comment or "").strip()
    report.save(update_fields=["status", "reviewed_by", "reviewed_at", "review_comment"])
    _notify_awardee_of_report_review(
        report,
        headline="Final narrative report accepted",
        subject_tail="Narrative accepted",
        body=(
            f"Your final narrative report has been accepted by the Grants Manager."
            + (f"\n\nReviewer comment: {report.review_comment}" if report.review_comment else "")
        ),
    )
    return report


@transaction.atomic
def request_narrative_revision(
    report: FinalNarrativeReport, *, comment: str, actor=None
) -> FinalNarrativeReport:
    comment = (comment or "").strip()
    if not comment:
        raise ValidationError("A reviewer comment is required to request revisions.")
    report.request_revision()
    report.reviewed_by = actor
    report.review_comment = comment
    report.save(update_fields=["status", "reviewed_by", "reviewed_at", "review_comment"])
    _notify_awardee_of_report_review(
        report,
        headline="Final narrative — revisions requested",
        subject_tail="Narrative revision requested",
        body=(
            f"The Grants Manager has requested revisions to your final narrative report.\n\n"
            f"Feedback: {comment}\n\nPlease resubmit before the close-out deadline."
        ),
    )
    return report


@transaction.atomic
def accept_financial_report(
    report: FinalFinancialReport, *, actor=None
) -> FinalFinancialReport:
    report.accept()
    report.reviewed_by = actor
    report.save(update_fields=["status", "reviewed_by", "reviewed_at"])
    _notify_awardee_of_report_review(
        report,
        headline="Final financial report accepted",
        subject_tail="Financial accepted",
        body="Your final financial report has been reconciled and accepted.",
    )
    return report


@transaction.atomic
def raise_financial_queries(
    report: FinalFinancialReport, *, queries: str, actor=None
) -> FinalFinancialReport:
    queries = (queries or "").strip()
    if not queries:
        raise ValidationError("Written queries are required to raise them on a financial report.")
    report.raise_queries()
    report.reviewed_by = actor
    report.queries = queries
    report.save(update_fields=["status", "reviewed_by", "reviewed_at", "queries"])
    _notify_awardee_of_report_review(
        report,
        headline="Final financial report — queries raised",
        subject_tail="Financial queries",
        body=(
            f"The Finance Officer has raised the following queries on your final financial "
            f"report. Please respond through the award page.\n\nQueries: {queries}"
        ),
    )
    return report


@transaction.atomic
def request_financial_revision(
    report: FinalFinancialReport, *, comment: str, actor=None
) -> FinalFinancialReport:
    comment = (comment or "").strip()
    if not comment:
        raise ValidationError("A reviewer comment is required to request revisions.")
    report.request_revision()
    report.reviewed_by = actor
    report.queries = comment
    report.save(update_fields=["status", "reviewed_by", "reviewed_at", "queries"])
    _notify_awardee_of_report_review(
        report,
        headline="Final financial — revision required",
        subject_tail="Financial revision required",
        body=(
            f"The Finance Officer has requested revisions to your final financial report.\n\n"
            f"Feedback: {comment}\n\nPlease resubmit before the close-out deadline."
        ),
    )
    return report


# PRD §5.1 FRFA-CO006 — awardee resubmit services (close the comment loop).
def _notify_manager_of_report_resubmit(report, *, kind: str, response_note: str) -> None:
    """Notify the Grants Manager / Finance Officer when an awardee resubmits a
    final report. action_url points at the manager's close-out workbench."""
    from django.urls import reverse

    from apps.core.notifications.emailing import rims_email_context
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification

    closeout = report.closeout
    award = closeout.award
    funding_record = getattr(award.application.call, "funding_record", None)
    manager = getattr(funding_record, "grant_manager", None) if funding_record else None
    if manager is None:
        return
    action_url = reverse("rims_grants:award_closeout", kwargs={"pk": award.pk})
    ctx = rims_email_context(
        subject=f"Awardee resubmitted {kind} report: {award.application.call.title}",
        headline=f"Awardee resubmitted their {kind} report",
        action_path=action_url,
        action_label="Open close-out",
        preheader=f"Award #{award.pk} — {award.application.call.title}",
    )
    body = (
        f'The awardee has resubmitted their {kind} report on "{award.application.call.title}".'
    )
    if response_note:
        body += f"\n\nAwardee response: {response_note}"
    send_notification(
        manager,
        body,
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
        action_url=action_url,
    )


@transaction.atomic
def resubmit_narrative_report(
    report: FinalNarrativeReport,
    *,
    response_note: str,
    new_file=None,
    actor=None,
) -> FinalNarrativeReport:
    """Awardee resubmits a final narrative report after a revision request.

    Allowed only from REVISION_REQUIRED. Saves the optional response note +
    new file, flips the FSM back to SUBMITTED, and notifies the Grants Manager.
    """
    if report.status != FinalNarrativeReport.Status.REVISION_REQUIRED:
        raise ValidationError(
            "Only narrative reports in 'revision required' may be resubmitted."
        )
    response_note = (response_note or "").strip()
    report.response_note = response_note
    update_fields = ["status", "reviewed_at", "response_note"]
    if new_file is not None:
        report.report = new_file
        update_fields.append("report")
    report.resubmit()
    report.save(update_fields=update_fields)
    _notify_manager_of_report_resubmit(report, kind="narrative", response_note=response_note)
    return report


@transaction.atomic
def resubmit_financial_report(
    report: FinalFinancialReport,
    *,
    response_note: str,
    new_file=None,
    actor=None,
) -> FinalFinancialReport:
    """Awardee resubmits a final financial report after a query or revision request.

    Allowed from QUERIES_RAISED or REVISION_REQUIRED. Notifies the Finance
    Officer / Grants Manager.
    """
    if report.status not in {
        FinalFinancialReport.Status.QUERIES_RAISED,
        FinalFinancialReport.Status.REVISION_REQUIRED,
    }:
        raise ValidationError(
            "Only financial reports in 'queries raised' or 'revision required' may be resubmitted."
        )
    response_note = (response_note or "").strip()
    report.response_note = response_note
    update_fields = ["status", "reviewed_at", "response_note"]
    if new_file is not None:
        report.report = new_file
        update_fields.append("report")
    report.resubmit()
    report.save(update_fields=update_fields)
    _notify_manager_of_report_resubmit(report, kind="financial", response_note=response_note)
    return report
