"""Grant / application workflow — keep views thin; invoke FSM transitions here."""

from __future__ import annotations

import random
import string
from datetime import timedelta
from decimal import Decimal

from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Avg, Count, Q, Sum
from django.db.models.functions import Coalesce
from django.utils import timezone

from apps.core.countries import to_country_name
from apps.core.money import DEFAULT_CURRENCY, is_valid_currency
from apps.core.permissions.roles import UserRole
from apps.core.storage import UploadHandler
from apps.rims.grants.funding import validate_call_publishable
from apps.rims.grants.models import (
    Application,
    Award,
    AwardAmendment,
    AwardCancellationRequest,
    AwardTranche,
    ConflictOfInterest,
    FundingRecord,
    FundingRecordBudgetRevision,
    GrantCall,
    ImplementationPlan,
    ImplementationPlanActivity,
    InterviewOutcome,
    InterviewSchedule,
    MAX_PSYCHOMETRIC_ATTEMPTS,
    PsychometricAssessmentAttempt,
    RecoveryRequest,
    Review,
    VerificationRecord,
)


def _random_code(length: int = 8) -> str:
    return "".join(random.choices(string.ascii_uppercase + string.digits, k=length))


# PRD §5.1 FRFA-AM031 — structured household verification schema for
# Scholarship call type. Validates and normalises free-form input into a
# well-typed JSON payload stored on `VerificationRecord.findings`.
HOUSEHOLD_INCOME_BANDS = ("low", "middle", "high")
HOUSEHOLD_ASSET_CLASSES = ("owns_home", "rents", "family_housing", "informal")


def build_household_verification_findings(
    *,
    guardian_name: str,
    guardian_relationship: str,
    income_band: str,
    asset_class: str,
    dependents_count: int,
    guardian_consent: bool,
    notes: str = "",
) -> dict:
    """Validate and normalise household verification fields (PRD §5.1 FRFA-AM031).

    Returns the dict that should be written to :attr:`VerificationRecord.findings`.
    Raises :class:`ValidationError` on invalid enum values, missing consent,
    or negative dependents count.
    """
    guardian_name = (guardian_name or "").strip()
    guardian_relationship = (guardian_relationship or "").strip()
    income_band = (income_band or "").strip().lower()
    asset_class = (asset_class or "").strip().lower()

    if not guardian_name:
        raise ValidationError("Guardian name is required for household verification.")
    if not guardian_relationship:
        raise ValidationError("Guardian relationship is required.")
    if income_band not in HOUSEHOLD_INCOME_BANDS:
        raise ValidationError(
            f"Income band must be one of {HOUSEHOLD_INCOME_BANDS}; got {income_band!r}."
        )
    if asset_class not in HOUSEHOLD_ASSET_CLASSES:
        raise ValidationError(
            f"Asset class must be one of {HOUSEHOLD_ASSET_CLASSES}; got {asset_class!r}."
        )
    try:
        dependents_count = int(dependents_count)
    except (TypeError, ValueError) as exc:
        raise ValidationError("Dependents count must be an integer.") from exc
    if dependents_count < 0:
        raise ValidationError("Dependents count cannot be negative.")
    if not guardian_consent:
        raise ValidationError(
            "Guardian consent is required before recording household verification findings."
        )
    return {
        "schema": "household/v1",
        "guardian_name": guardian_name,
        "guardian_relationship": guardian_relationship,
        "income_band": income_band,
        "asset_class": asset_class,
        "dependents_count": dependents_count,
        "guardian_consent": True,
        "notes": (notes or "").strip(),
    }


# PRD §5.1 FRFA-GPS006 — shared by the StrategicObjective data migration and
# any form/service that needs to parse the legacy TextField.
def split_strategic_objectives(raw: str) -> list[str]:
    """Split the legacy `FundingRecord.strategic_objectives` TextField into
    distinct objective names.

    Supports comma, semicolon, and newline separators. Whitespace trimmed,
    empty fragments removed, case-insensitive de-duplication preserves first
    occurrence order.
    """
    import re as _re

    if not raw:
        return []
    parts = _re.split(r"[,;\n]+", raw)
    seen: set[str] = set()
    ordered: list[str] = []
    for p in parts:
        clean = p.strip()
        if not clean:
            continue
        key = clean.lower()
        if key in seen:
            continue
        seen.add(key)
        ordered.append(clean)
    return ordered


def validate_grant_upload(file, *, call: "GrantCall | None" = None):
    """PRD §5.1 FRFA-CS020 — validate an applicant upload.

    When ``call`` is supplied, the call's configured
    ``applicant_attachment_max_size_mb`` and ``applicant_attachment_formats``
    override the defaults. When ``call`` is None (e.g. admin-side uploads via
    the GrantCallDocument FileField validator), the module-level UploadHandler
    defaults apply.
    """
    UploadHandler(module="rims", allowed_categories=["document", "data", "archive"]).handle(file)
    if call is None:
        return
    # Per-call size cap.
    max_mb = getattr(call, "applicant_attachment_max_size_mb", None) or 0
    if max_mb and getattr(file, "size", 0) > max_mb * 1024 * 1024:
        from django.core.exceptions import ValidationError

        raise ValidationError(
            f"Attachment exceeds this call's {max_mb} MB limit."
        )
    # Per-call format allowlist.
    raw_formats = (getattr(call, "applicant_attachment_formats", "") or "").strip()
    if raw_formats:
        allowed = {p.strip().lower().lstrip(".") for p in raw_formats.split(",") if p.strip()}
        name = (getattr(file, "name", "") or "").lower()
        ext = name.rsplit(".", 1)[-1] if "." in name else ""
        if allowed and ext not in allowed:
            from django.core.exceptions import ValidationError

            allowed_disp = ", ".join(sorted(allowed))
            raise ValidationError(
                f"Attachment format .{ext} is not allowed for this call. Allowed: {allowed_disp}."
            )


# PRD §5.1 FRFA-CS019 — versioned call-document upload.
@transaction.atomic
def upload_call_document_version(
    call: GrantCall,
    *,
    label: str,
    file,
    is_guideline: bool = False,
    actor=None,
):
    """Create a new GrantCallDocument for *call* under *label*. If an existing
    current row shares the (call, label) key, it is marked superseded and the
    new row links back via `supersedes`; `version` increments within the group.
    """
    from apps.rims.grants.models import GrantCallDocument

    prior = (
        GrantCallDocument.objects.filter(call=call, label=label, superseded=False)
        .order_by("-version", "-pk")
        .first()
    )
    next_version = (prior.version + 1) if prior else 1
    new_doc = GrantCallDocument.objects.create(
        call=call,
        label=label,
        file=file,
        is_guideline=is_guideline,
        version=next_version,
        supersedes=prior,
        uploaded_by=actor,
    )
    if prior is not None:
        prior.superseded = True
        prior.save(update_fields=["superseded"])
    return new_doc


@transaction.atomic
def publish_call(
    call: GrantCall,
    *,
    actor=None,
    request=None,
    auto_close_acknowledged: bool = False,
) -> None:
    """Publish a draft call.

    PRD §5.1 FRFA-CS022 — the administrator must acknowledge that the call will
    auto-close at the exact deadline before publish proceeds. The publish view
    surfaces the checkbox; ``auto_close_acknowledged`` is the boolean state
    that flag posted.

    PRD §5.1 FRFA-CS023/CS024 — publication notifications are dispatched only
    when the per-call ``CallNotificationConfig.publication_notify_enabled`` is
    True (default True; legacy calls keep notifying).
    """
    from apps.rims.grants.models import CallNotificationConfig

    validate_call_publishable(call)
    if not auto_close_acknowledged and call.auto_close_confirmed_at is None:
        raise ValidationError(
            "Acknowledge the auto-close behaviour (this call will close automatically at the configured deadline) before publishing."
        )
    if auto_close_acknowledged and call.auto_close_confirmed_at is None:
        call.auto_close_confirmed_at = timezone.now()
    prev_status = call.status
    call.publish()
    call.save(update_fields=["status", "auto_close_confirmed_at", "updated_at"])
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="GrantCall",
            object_id=call.pk,
            object_repr=str(call),
            changes={"status": {"old": prev_status, "new": call.status}},
            request=request,
        )
    cfg = CallNotificationConfig.get_for_call(call)
    if cfg.publication_notify_enabled:
        from apps.rims.grants.tasks import notify_call_published

        notify_call_published.delay(call.pk)


@transaction.atomic
def close_call(call: GrantCall) -> None:
    call.close()
    call.save(update_fields=["status", "updated_at"])


@transaction.atomic
def submit_application(application: Application, actor=None) -> None:
    if application.call.status != GrantCall.Status.PUBLISHED:
        raise ValidationError("This call is not accepting applications.")
    if timezone.now() > application.call.closes_at:
        raise ValidationError("The application deadline has passed.")
    _assert_call_type_budget_rules(application)
    _assert_scholarship_eligibility_inputs(application)
    application.submit()
    if application.call.blind_review and not application.anonymized_code:
        application.anonymized_code = _random_code()
    application.save(update_fields=["status", "submitted_at", "anonymized_code", "updated_at"])
    auto_screen_eligibility(application)
    # PRD §5.1 FRFA-AM008 — submission acknowledgement email to the applicant.
    # Sent after eligibility screening so the message can carry the right
    # outcome ("received and queued for review" vs "ineligible — see reasons").
    _notify_submission_acknowledgement(application)


def _assert_call_type_budget_rules(application: Application) -> None:
    """PRD §5.1 FRFA-CS021 + Table 14 — per-call-type budget rules at submit.

    Grant calls *opt in* to mandatory budget attachments via
    ``call.applicant_budget_required``. Scholarship / fellowship / challenge
    calls always reject a budget at submission (the budget is deferred to the
    award stage).
    """
    call = application.call
    has_budget_doc = application.documents.filter(is_budget=True).exists()
    if call.call_type == GrantCall.CallType.GRANT:
        if call.applicant_budget_required and not has_budget_doc:
            raise ValidationError(
                "This grant call requires a budget attachment before submission."
            )
        return
    # Non-grant call types: budget is deferred to the award stage.
    if has_budget_doc:
        raise ValidationError(
            "This call type does not accept a budget at submission; remove the "
            "budget attachment. Awardee budgets are configured at the award stage."
        )


def _assert_scholarship_eligibility_inputs(application: Application) -> None:
    """PRD §5.1 FRFA-CS013 — scholarship calls may require a target
    university and / or course on the application."""
    call = application.call
    if call.call_type != GrantCall.CallType.SCHOLARSHIP:
        return
    if call.scholarship_university_required and application.scholarship_university_choice_id is None:
        raise ValidationError("This scholarship call requires a target university.")
    if call.scholarship_course_required and not (application.scholarship_course_choice or "").strip():
        raise ValidationError("This scholarship call requires a target course / programme name.")


def _notify_submission_acknowledgement(application: Application) -> None:
    """PRD §5.1 FRFA-CS023/CS025 — applicant ack on successful submit.

    Honours the per-call ``CallNotificationConfig.acknowledgement_enabled``
    toggle (default True). URL targets ``rims_grants:application_detail`` per
    the awardee-URL rule — never a manager URL.
    """
    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.rims.grants.models import CallNotificationConfig

    cfg = CallNotificationConfig.get_for_call(application.call)
    if not cfg.acknowledgement_enabled:
        return

    if application.status == Application.Status.INELIGIBLE:
        body = (
            f'Your application to "{application.call.title}" was received but did not pass '
            f"the automated eligibility screen. Reason(s): "
            f"{application.eligibility_notes or 'see your application page for details'}."
        )
        headline = "Application received — eligibility issue"
    else:
        body = (
            f'Your application to "{application.call.title}" has been received and is now '
            f"queued for review. You will be notified when the next stage begins."
        )
        headline = "Application received"
    ctx = rims_email_context(
        subject=f"Application received: {application.call.title}",
        headline=headline,
        action_path=reverse("rims_grants:application_detail", kwargs={"pk": application.pk}),
        action_label="View application",
        preheader="Your IILMP application has been recorded.",
    )
    ctx["call_title"] = application.call.title
    send_notification(
        application.applicant,
        body,
        verb=Notification.Verb.APPLICATION_RECEIVED_ACK,
        email_context=ctx,
        action_url=reverse("rims_grants:application_detail", kwargs={"pk": application.pk}),
    )


def _profile_sector_passes(params, profile) -> bool:
    tags = {str(t).strip().lower() for t in params.get("tags", []) if t}
    if not tags:
        return True
    interests = {s.strip().lower() for s in (profile.areas_of_interest if profile else "").split(",") if s.strip()}
    return bool(tags & interests)


def _profile_age_passes(params, profile) -> bool:
    min_age = params.get("min_age")
    max_age = params.get("max_age")
    if min_age is None and max_age is None:
        return True
    age = profile.age_years if profile else None
    if age is None:
        return False
    if min_age is not None and age < min_age:
        return False
    if max_age is not None and age > max_age:
        return False
    return True


def _rule_passes(rule, application: Application) -> bool:
    applicant = application.applicant
    params = rule.params or {}
    profile = getattr(applicant, "profile", None)
    if rule.rule_type == "nationality":
        allowed = {to_country_name(c) for c in params.get("countries", []) if c}
        cc = to_country_name(profile.country if profile else "")
        return not allowed or cc in allowed
    if rule.rule_type == "degree":
        allowed = {str(x).lower() for x in params.get("levels", [])}
        if not allowed:
            return True
        deg = (profile.degree_level if profile else "") or ""
        return bool(deg) and deg.lower() in allowed
    if rule.rule_type == "institution":
        allowed_ids = params.get("institution_ids", [])
        if not allowed_ids:
            return True
        inst = application.institution_id or applicant.institution_id
        return inst in allowed_ids
    if rule.rule_type == "sector":
        return _profile_sector_passes(params, profile)
    if rule.rule_type == "age":
        return _profile_age_passes(params, profile)
    if rule.rule_type == "documentation":
        required_labels = [str(label).strip().lower() for label in params.get("required_labels", []) if label]
        if not required_labels:
            return True
        uploaded_labels = {label.lower() for label in application.documents.values_list("label", flat=True)}
        return all(any(req in up for up in uploaded_labels) for req in required_labels)
    if rule.rule_type == "scholarship_university":
        allowed_ids = params.get("institution_ids", [])
        if not allowed_ids:
            return True
        return application.scholarship_university_choice_id in allowed_ids
    if rule.rule_type == "scholarship_course":
        keywords = [str(k).strip().lower() for k in params.get("keywords", []) if k]
        if not keywords:
            return True
        course = (application.scholarship_course_choice or "").lower()
        return any(kw in course for kw in keywords)
    if rule.rule_type == "fellowship_host_institution":
        allowed = [str(i).strip().lower() for i in params.get("institutions", []) if i]
        if not allowed:
            return True
        fp = getattr(application, "fellowship_profile", None)
        host = (fp.host_institute_name if fp else "") or ""
        host = host.lower()
        return any(name in host for name in allowed)
    if rule.rule_type == "fellowship_type":
        allowed = {str(t).lower() for t in params.get("types", []) if t}
        if not allowed:
            return True
        fp = getattr(application, "fellowship_profile", None)
        return bool(fp) and (fp.form_of_service or "").lower() in allowed
    if rule.rule_type == "challenge_innovation_stage":
        allowed = {str(s).lower() for s in params.get("stages", []) if s}
        if not allowed:
            return True
        cp = getattr(application, "challenge_profile", None)
        return bool(cp) and (cp.innovation_stage or "").lower() in allowed
    return True


def _rule_passes_user(rule, user, institution_id=None) -> bool:
    """Same logic as _rule_passes but operates directly on a user (pre-application).

    Type-specific rules (documentation, scholarship_*, fellowship_*,
    challenge_*) depend on Application/profile data that doesn't exist yet
    before the applicant has started applying, so they always pass here —
    consistent with this check being advisory only (see
    evaluate_eligibility_for_user's docstring); they're still enforced for
    real at submission time via _rule_passes.
    """
    params = rule.params or {}
    profile = getattr(user, "profile", None)
    if rule.rule_type == "nationality":
        allowed = {to_country_name(c) for c in params.get("countries", []) if c}
        cc = to_country_name(profile.country if profile else "")
        return not allowed or cc in allowed
    if rule.rule_type == "degree":
        allowed = {str(x).lower() for x in params.get("levels", [])}
        if not allowed:
            return True
        deg = (profile.degree_level if profile else "") or ""
        return bool(deg) and deg.lower() in allowed
    if rule.rule_type == "institution":
        allowed_ids = params.get("institution_ids", [])
        if not allowed_ids:
            return True
        inst = institution_id or user.institution_id
        return inst in allowed_ids
    if rule.rule_type == "sector":
        return _profile_sector_passes(params, profile)
    if rule.rule_type == "age":
        return _profile_age_passes(params, profile)
    return True


def evaluate_eligibility_for_user(user, call, institution_id=None):
    """
    Check a user against a call's eligibility rules before they apply.

    Returns a dict:
        {
            "eligible": bool,          # False if any blocking rule fails
            "blocking_failures": [...], # list of (rule, reason_str) for blocking fails
            "warning_failures": [...],  # list of (rule, reason_str) for non-blocking fails
        }
    """
    blocking_failures = []
    warning_failures = []
    rules = call.eligibility_rules.all()
    for rule in rules:
        passed = _rule_passes_user(rule, user, institution_id)
        if not passed:
            reason = _eligibility_failure_reason(rule)
            if rule.is_blocking:
                blocking_failures.append((rule, reason))
            else:
                warning_failures.append((rule, reason))
    return {
        "eligible": len(blocking_failures) == 0,
        "blocking_failures": blocking_failures,
        "warning_failures": warning_failures,
    }


def _eligibility_failure_reason(rule) -> str:
    """Human-readable reason a rule failed."""
    params = rule.params or {}
    if rule.rule_type == "nationality":
        countries = params.get("countries", [])
        return f"Nationality not in allowed list ({', '.join(countries) or 'none specified'})"
    if rule.rule_type == "degree":
        levels = params.get("levels", [])
        labels = [lv.replace("_", " ").title() for lv in levels]
        return f"Degree level required: {', '.join(labels) or 'none specified'}"
    if rule.rule_type == "institution":
        return "Your institution is not in the eligible institutions list for this call"
    if rule.rule_type == "sector":
        tags = params.get("tags", [])
        return f"Sector/thematic area not in required list ({', '.join(tags) or 'none specified'})"
    if rule.rule_type == "age":
        lo, hi = params.get("min_age"), params.get("max_age")
        return f"Age must be between {lo if lo is not None else 'any'} and {hi if hi is not None else 'any'}"
    if rule.rule_type == "documentation":
        return "Not all required documents for this call have been uploaded"
    if rule.rule_type == "scholarship_university":
        return "Target university is not in the eligible universities list for this call"
    if rule.rule_type == "scholarship_course":
        keywords = params.get("keywords", [])
        return f"Target course does not match required keywords ({', '.join(keywords) or 'none specified'})"
    if rule.rule_type == "fellowship_host_institution":
        return "Host institution is not in the eligible institutions list for this call"
    if rule.rule_type == "fellowship_type":
        types = params.get("types", [])
        return f"Fellowship type must be one of: {', '.join(types) or 'none specified'}"
    if rule.rule_type == "challenge_innovation_stage":
        stages = params.get("stages", [])
        return f"Innovation stage must be one of: {', '.join(stages) or 'none specified'}"
    return rule.get_rule_type_display()


def _rule_expected_repr(rule) -> str:
    """One-line description of what the rule expects (shown in the trace UI)."""
    params = rule.params or {}
    if rule.rule_type == "nationality":
        countries = params.get("countries", [])
        return ", ".join(countries) if countries else "any nationality"
    if rule.rule_type == "degree":
        levels = params.get("levels", [])
        labels = [lv.replace("_", " ").title() for lv in levels]
        return ", ".join(labels) if labels else "any degree level"
    if rule.rule_type == "institution":
        ids = params.get("institution_ids", [])
        return f"one of {len(ids)} listed institutions" if ids else "any institution"
    if rule.rule_type == "sector":
        tags = params.get("tags", [])
        return ", ".join(tags) if tags else "any sector"
    if rule.rule_type == "age":
        lo, hi = params.get("min_age"), params.get("max_age")
        if lo is None and hi is None:
            return "any age"
        return f"{lo if lo is not None else '—'}–{hi if hi is not None else '—'} years"
    if rule.rule_type == "documentation":
        labels = params.get("required_labels", [])
        return ", ".join(labels) if labels else "no documents required"
    if rule.rule_type == "scholarship_university":
        ids = params.get("institution_ids", [])
        return f"one of {len(ids)} listed universities" if ids else "any university"
    if rule.rule_type == "scholarship_course":
        keywords = params.get("keywords", [])
        return ", ".join(keywords) if keywords else "any course"
    if rule.rule_type == "fellowship_host_institution":
        names = params.get("institutions", [])
        return ", ".join(names) if names else "any host institution"
    if rule.rule_type == "fellowship_type":
        types = params.get("types", [])
        return ", ".join(types) if types else "any type"
    if rule.rule_type == "challenge_innovation_stage":
        stages = params.get("stages", [])
        return ", ".join(stages) if stages else "any stage"
    return "—"


def _rule_actual_repr(rule, application: Application) -> str:
    """One-line description of the applicant value the rule evaluated against."""
    applicant = application.applicant
    profile = getattr(applicant, "profile", None)
    if rule.rule_type == "nationality":
        return to_country_name(profile.country if profile else "") or "—"
    if rule.rule_type == "degree":
        return (profile.degree_level if profile else "") or "—"
    if rule.rule_type == "institution":
        inst_id = application.institution_id or applicant.institution_id
        if not inst_id:
            return "—"
        from apps.core.authentication.models import Institution

        try:
            return Institution.objects.values_list("name", flat=True).get(pk=inst_id)
        except Institution.DoesNotExist:
            return f"institution#{inst_id}"
    if rule.rule_type == "sector":
        return (profile.areas_of_interest if profile else "") or "—"
    if rule.rule_type == "age":
        age = profile.age_years if profile else None
        return str(age) if age is not None else "—"
    if rule.rule_type == "documentation":
        uploaded = application.documents.count()
        return f"{uploaded} document(s) uploaded"
    if rule.rule_type == "scholarship_university":
        choice = application.scholarship_university_choice
        return choice.name if choice else "—"
    if rule.rule_type == "scholarship_course":
        return application.scholarship_course_choice or "—"
    if rule.rule_type == "fellowship_host_institution":
        fp = getattr(application, "fellowship_profile", None)
        return (fp.host_institute_name if fp else "") or "—"
    if rule.rule_type == "fellowship_type":
        fp = getattr(application, "fellowship_profile", None)
        return (fp.get_form_of_service_display() if fp and fp.form_of_service else "") or "—"
    if rule.rule_type == "challenge_innovation_stage":
        cp = getattr(application, "challenge_profile", None)
        return (cp.get_innovation_stage_display() if cp and cp.innovation_stage else "") or "—"
    return "—"


def _build_eligibility_trace(application: Application) -> tuple[list[dict], list[str]]:
    """PRD §5.1 FRFA-AM009 — produce one row per blocking eligibility rule.

    Returns (trace, blocking_failure_reasons). Each trace row carries enough
    detail (rule id, label, expected, actual, passed, evaluated_at, reason)
    to surface on the applicant + manager view and to feed the audit log.
    """
    trace: list[dict] = []
    blocking_failures: list[str] = []
    now_iso = timezone.now().isoformat()
    for rule in application.call.eligibility_rules.filter(is_blocking=True).order_by("pk"):
        passed = _rule_passes(rule, application)
        row = {
            "rule_id": rule.pk,
            "rule_type": rule.rule_type,
            "rule_label": rule.get_rule_type_display(),
            "expected": _rule_expected_repr(rule),
            "actual": _rule_actual_repr(rule, application),
            "passed": passed,
            "evaluated_at": now_iso,
            "reason": "" if passed else _eligibility_failure_reason(rule),
        }
        trace.append(row)
        if not passed:
            blocking_failures.append(row["reason"] or row["rule_label"])
    return trace, blocking_failures


def record_psychometric_attempt(assessment) -> PsychometricAssessmentAttempt:
    """SRS §4.1.4 — cap psychometric test attempts at MAX_PSYCHOMETRIC_ATTEMPTS.

    Old system had no attempt cap; this enforces the SRS's "maximum of three
    psychometric test attempts" business rule.
    """
    used = assessment.attempts.count()
    if used >= MAX_PSYCHOMETRIC_ATTEMPTS:
        raise ValidationError("Maximum number of psychometric test attempts reached.")
    return PsychometricAssessmentAttempt.objects.create(assessment=assessment, attempt_number=used + 1)


@transaction.atomic
def auto_screen_eligibility(application: Application) -> None:
    """PRD §5.1 FRFA-AM007 + FRFA-AM009 + FRFA-AM013–017 — automated eligibility screening.

    Eligibility is **advisory, not a gate**. The blocking-rule check always runs
    and its per-rule trace is persisted, but a failing rule no longer rejects the
    application: it is *flagged* (``eligibility_passed=False`` + a note) and still
    enters the normal review queue so a human always screens the supporting
    documents (a degree, for instance, is evidenced by an upload — not by the
    applicant's profile enum). Managers / Programme Officers make the final call.

    Routing is identical for passing and flagged applications: when
    ``call.programme_officer_screening_enabled`` is False (the default) the
    application is auto-advanced to UNDER_REVIEW; when True it stays at SUBMITTED
    awaiting an explicit Programme Officer decision via
    :func:`program_officer_advance`.

    Per FRFA-AM009 the per-rule trace is persisted on
    ``Application.eligibility_trace`` (structured) and composed into
    ``eligibility_notes`` (legacy free-text). An audit row records the trace
    so screening decisions are reconstructable.
    """
    from apps.rims.grants.audit_helper import action_update, audit_rims

    trace, blocking_failures = _build_eligibility_trace(application)

    # SRS §4.1.4 / Table 10 — "all scholarship applications MUST have
    # accompanying psychometric tests." This is a hardcoded rule (not a
    # configurable EligibilityRule) since it applies to every scholarship
    # call regardless of call-specific configuration.
    if application.call.call_type == GrantCall.CallType.SCHOLARSHIP and not application.psychometric_score:
        row = {
            "rule_id": None,
            "rule_type": "psychometric_test",
            "rule_label": "Psychometric test completed",
            "expected": "Completed",
            "actual": "Not completed",
            "passed": False,
            "evaluated_at": timezone.now().isoformat(),
            "reason": "Psychometric test not completed",
        }
        trace.append(row)
        blocking_failures.append(row["reason"])

    application.eligibility_trace = trace
    application.eligibility_passed = not blocking_failures
    if blocking_failures:
        application.eligibility_notes = (
            "Flagged for manual eligibility check — automated rules not met: "
            + "; ".join(blocking_failures)
            + ". Review the submitted documents before deciding."
        )
        outcome = "flagged"
    else:
        application.eligibility_notes = "All blocking eligibility rules passed."
        outcome = "passed"

    if application.call.programme_officer_screening_enabled:
        # Stay at SUBMITTED; a Programme Officer screens the documents and
        # decides — for flagged applications too (the flag is advisory).
        application.save(
            update_fields=[
                "eligibility_passed",
                "eligibility_notes",
                "eligibility_trace",
                "updated_at",
            ]
        )
        audit_rims(
            actor=application.applicant,
            action=action_update(),
            target_model="Application",
            object_id=application.pk,
            object_repr=str(application),
            changes={"eligibility_trace": trace, "outcome": f"{outcome}_awaiting_po"},
        )
        return
    application.pass_screening()
    application.save(
        update_fields=[
            "status",
            "eligibility_passed",
            "eligibility_notes",
            "eligibility_trace",
            "updated_at",
        ]
    )
    audit_rims(
        actor=application.applicant,
        action=action_update(),
        target_model="Application",
        object_id=application.pk,
        object_repr=str(application),
        changes={"eligibility_trace": trace, "outcome": f"{outcome}_under_review"},
    )


class ProgramOfficerDecision:
    """PRD §5.1 FRFA-AM013–017 — Program Officer screening outcomes."""

    ELIGIBLE_READY = "eligible_ready"
    REJECTED_INCOMPLETE = "rejected_incomplete"
    REVISION_REQUESTED = "revision_requested"

    ALL = (ELIGIBLE_READY, REJECTED_INCOMPLETE, REVISION_REQUESTED)


@transaction.atomic
def program_officer_advance(
    application: Application,
    decision: str,
    *,
    note: str = "",
    actor=None,
    request=None,
) -> Application:
    """Program Officer decision on a SUBMITTED, eligibility-passed application.

    PRD §5.1 FRFA-AM014–017. The three valid decisions are kept on
    :class:`ProgramOfficerDecision`. Raises :class:`ValidationError` for
    mis-ordered or incomplete inputs (e.g., missing note when required).
    """
    if decision not in ProgramOfficerDecision.ALL:
        raise ValidationError(f"Unknown Program Officer decision: {decision!r}.")
    if application.status != Application.Status.SUBMITTED:
        raise ValidationError(
            "Program Officer screening can only advance applications currently in SUBMITTED."
        )
    # Eligibility is advisory: a flagged application (eligibility_passed=False)
    # is NOT blocked here — screening the documents is precisely the Programme
    # Officer's job, and they may advance, reject, or request a revision.
    note = (note or "").strip()
    if decision in (
        ProgramOfficerDecision.REJECTED_INCOMPLETE,
        ProgramOfficerDecision.REVISION_REQUESTED,
    ) and not note:
        raise ValidationError(
            "A written reason is required to reject or request revisions on an application."
        )

    if decision == ProgramOfficerDecision.ELIGIBLE_READY:
        application.pass_screening()
        application.save(update_fields=["status", "updated_at"])
        # PRD §5.1 FRFA-AM017 — notify the reviewer pool that an application
        # is queued for scoring. Skip reviewers who already have an active
        # Review row (they were assigned earlier).
        _notify_reviewer_pool_ready_for_review(application)
    elif decision == ProgramOfficerDecision.REJECTED_INCOMPLETE:
        application.reject_ineligible()
        application.eligibility_notes = (
            f"{application.eligibility_notes}\n[PO] {note}" if application.eligibility_notes else note
        )
        application.save(update_fields=["status", "eligibility_notes", "updated_at"])
        # PRD §5.1 FRFA-AM015 — applicant gets the specific reason, not a
        # generic "status changed" notice.
        _notify_po_decision(application, decision, note)
    elif decision == ProgramOfficerDecision.REVISION_REQUESTED:
        # No FSM transition — the existing revision flow is expressed via the
        # `revision_requested_at` timestamp + `revision_note` on the app.
        application.revision_requested_at = timezone.now()
        application.revision_note = note
        application.save(
            update_fields=["revision_requested_at", "revision_note", "updated_at"]
        )
        # PRD §5.1 FRFA-AM016 — applicant must be notified with instructions
        # and the revision deadline. No status flip happened, so the generic
        # post_save signal won't fire — explicit dispatch required.
        _notify_po_decision(application, decision, note)

    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="Application",
            object_id=application.pk,
            object_repr=str(application),
            changes={"po_decision": decision, "po_note": note},
            request=request,
        )
    return application


def _notify_reviewer_pool_ready_for_review(application: Application) -> None:
    """PRD §5.1 FRFA-AM017 — fan out the 'ready for review' notification.

    Each member of the call's ``reviewer_pool`` who is not already on a Review
    row for this application gets one notification. The action URL targets
    the reviewer queue, never a manager URL (feedback_awardee_url_rule applies
    symmetrically: reviewers go to reviewer surfaces).
    """
    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

    pool = list(application.call.reviewer_pool.all())
    if not pool:
        return
    already_assigned = set(
        Review.objects.filter(application=application).values_list("reviewer_id", flat=True)
    )
    ref = application.anonymized_code or f"application #{application.pk}"
    action_path = reverse("rims_grants:review_queue")
    for reviewer in pool:
        if reviewer.pk in already_assigned:
            continue
        ctx = rims_email_context(
            subject=f"Ready for review: {application.call.title}",
            headline="An application is ready for review",
            action_path=action_path,
            action_label="Open reviewer queue",
            preheader="An application has cleared programme officer screening.",
        )
        ctx["call_title"] = application.call.title
        ctx["application_reference"] = ref
        body = (
            f'The programme officer has cleared an application on "{application.call.title}" '
            f"(reference {ref}). Open your reviewer queue to pick it up."
        )
        send_notification(
            reviewer,
            body,
            verb=Notification.Verb.APPLICATION_READY_FOR_REVIEW,
            email_context=ctx,
            action_url=action_path,
        )


def _notify_po_decision(application: Application, decision: str, note: str) -> None:
    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

    if decision == ProgramOfficerDecision.REJECTED_INCOMPLETE:
        headline = "Application returned — not eligible for review"
        subject = f"Application not advancing: {application.call.title}"
        body = (
            f'Your application to "{application.call.title}" did not advance to review. '
            f"Reason from the Programme Officer: {note}"
        )
    else:  # REVISION_REQUESTED
        headline = "Revisions requested on your application"
        subject = f"Revisions requested: {application.call.title}"
        body = (
            f'The Programme Officer has requested revisions on your application to '
            f'"{application.call.title}". Please review the instructions below and resubmit '
            f"before the revision deadline.\n\nInstructions: {note}"
        )
    ctx = rims_email_context(
        subject=subject,
        headline=headline,
        action_path=reverse("rims_grants:application_detail", kwargs={"pk": application.pk}),
        action_label="View application",
        preheader="Action required on your IILMP application.",
    )
    ctx["call_title"] = application.call.title
    ctx["po_note"] = note
    send_notification(
        application.applicant,
        body,
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
        action_url=reverse("rims_grants:application_detail", kwargs={"pk": application.pk}),
    )


@transaction.atomic
def assign_reviewer(application: Application, reviewer, *, declare_conflict: bool | None = None):
    if application.status != Application.Status.UNDER_REVIEW:
        raise ValidationError(
            "Reviewers can only be assigned to applications in the review queue (eligible, screened)."
        )
    ConflictOfInterest.objects.update_or_create(
        application=application,
        reviewer=reviewer,
        defaults={"has_conflict": bool(declare_conflict), "notes": ""},
    )
    if declare_conflict:
        return
    next_sequence = (
        Review.objects.filter(application=application).aggregate(max_seq=Count("pk")).get("max_seq") or 0
    ) + 1
    due_at = timezone.now() + timedelta(days=max(application.call.review_deadline_days, 1))
    _, created = Review.objects.get_or_create(
        application=application,
        reviewer=reviewer,
        defaults={
            "assigned_by": getattr(application.call, "created_by", None),
            "due_at": due_at,
            "assignment_sequence": next_sequence,
        },
    )
    if created:
        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

        ref = application.anonymized_code or f"application #{application.pk}"
        send_notification(
            reviewer,
            (
                f'You were assigned to review "{application.call.title}". '
                f"Reference: {ref}. Please open your review queue in IILMP."
            ),
            verb=Notification.Verb.REVIEWER_ASSIGNED,
            email_context=rims_email_context(
                subject=f"Review assignment: {application.call.title}",
                headline="You have a new review assignment",
                action_path=reverse("rims_grants:review_queue"),
                action_label="Open review queue",
                preheader="A grant application was assigned to you for review.",
            ),
            action_url=reverse("rims_grants:review_queue"),
        )


def _scores_for_json(scores: dict) -> dict:
    """JSONField cannot store Decimal; persist criterion scores as JSON numbers."""
    out = {}
    for k, v in scores.items():
        if v is None:
            continue
        out[k] = float(v) if isinstance(v, Decimal) else float(v)
    return out


@transaction.atomic
def submit_review(review: Review, scores: dict, recommendation: str):
    # PRD §5.1 FRFA-AM024 — score dict must match the call's scoring rubric
    # when one is configured, and every per-criterion score must respect the
    # rubric's max_points. Without a rubric we accept any shape (legacy).
    rubric = review.application.call.scoring_rubric or []
    if rubric:
        by_name = {item.get("criterion"): item for item in rubric if item.get("criterion")}
        extra = set(scores) - set(by_name)
        if extra:
            raise ValidationError(
                f"Unknown scoring criteria: {sorted(extra)}. Rubric defines: {sorted(by_name)}."
            )
        for crit, entry in by_name.items():
            if crit not in scores:
                continue
            max_points = entry.get("max_points")
            if max_points is None:
                continue
            try:
                value = Decimal(str(scores[crit]))
                max_d = Decimal(str(max_points))
            except (TypeError, ValueError) as exc:
                raise ValidationError(
                    f"Score for {crit!r} must be numeric."
                ) from exc
            if value < 0 or value > max_d:
                raise ValidationError(
                    f"Score for {crit!r} must be between 0 and {max_points}; got {value}."
                )
    safe = _scores_for_json(scores)
    review.scores = safe
    review.total_score = sum((Decimal(str(v)) for v in safe.values()), Decimal("0"))
    review.recommendation = recommendation
    if not review.recommendation_code:
        low = (recommendation or "").strip().lower()
        if "reject" in low or "do not recommend" in low:
            review.recommendation_code = Review.RecommendationCode.REJECT
        elif "revise" in low or "minor" in low or "major" in low:
            review.recommendation_code = Review.RecommendationCode.REVISE
        elif low:
            review.recommendation_code = Review.RecommendationCode.APPROVE
    review.submitted_at = timezone.now()
    review.save(
        update_fields=["scores", "total_score", "recommendation", "recommendation_code", "submitted_at"]
    )


def aggregate_average_score(application: Application) -> Decimal | None:
    reviews = application.reviews.exclude(submitted_at__isnull=True)
    if not reviews.exists():
        return None
    total = sum((r.total_score for r in reviews), Decimal("0"))
    return (total / reviews.count()).quantize(Decimal("0.01"))


def aggregate_weighted_score(application: Application) -> dict | None:
    """PRD §5.1 FRFA-AM026 — weighted aggregate of reviewer scores.

    Each criterion's contribution to the total is weighted by the rubric
    criterion's ``max_points``. Returns ``None`` when no reviews are in.

    Shape::

        {
            "weighted_pct": Decimal,        # 0-100 normalized
            "raw_total_mean": Decimal,      # mean of submitted reviewers' total_score
            "max_total": Decimal,           # sum of max_points across criteria
            "reviewers_counted": int,
            "per_criterion": [
                {"label", "weight", "mean_score", "share_pct"},
                ...
            ],
        }
    """
    reviews = list(application.reviews.exclude(submitted_at__isnull=True))
    if not reviews:
        return None

    rubric = list(application.call.scoring_rubric or [])
    # Fall back to a single "Overall" pseudo-criterion when no rubric is configured.
    if not rubric:
        avg = aggregate_average_score(application) or Decimal("0")
        return {
            "weighted_pct": avg.quantize(Decimal("0.01")),
            "raw_total_mean": avg.quantize(Decimal("0.01")),
            "max_total": Decimal("100"),
            "reviewers_counted": len(reviews),
            "per_criterion": [
                {
                    "label": "Overall",
                    "weight": Decimal("100"),
                    "mean_score": avg.quantize(Decimal("0.01")),
                    "share_pct": avg.quantize(Decimal("0.01")),
                }
            ],
        }

    per_criterion: list[dict] = []
    max_total = Decimal("0")
    weighted_sum = Decimal("0")
    for entry in rubric:
        label = str(entry.get("criterion") or "").strip() or "Criterion"
        try:
            max_points = Decimal(str(entry.get("max_points", 0)))
        except Exception:
            max_points = Decimal("0")
        max_total += max_points

        # Collect this criterion's score across all submitted reviewers.
        per_reviewer: list[Decimal] = []
        for r in reviews:
            score = (r.scores or {}).get(label)
            if score is None:
                continue
            try:
                per_reviewer.append(Decimal(str(score)))
            except Exception:
                continue
        if per_reviewer:
            mean = (sum(per_reviewer, Decimal("0")) / Decimal(len(per_reviewer))).quantize(Decimal("0.01"))
        else:
            mean = Decimal("0.00")
        weighted_sum += mean  # mean is already on the criterion's own scale (max_points)
        share_pct = (
            (mean / max_points * Decimal("100")).quantize(Decimal("0.01"))
            if max_points > 0
            else Decimal("0.00")
        )
        per_criterion.append(
            {
                "label": label,
                "weight": max_points,
                "mean_score": mean,
                "share_pct": share_pct,
            }
        )

    weighted_pct = (
        (weighted_sum / max_total * Decimal("100")).quantize(Decimal("0.01"))
        if max_total > 0
        else Decimal("0.00")
    )
    raw_total_mean = (
        sum((r.total_score for r in reviews), Decimal("0")) / Decimal(len(reviews))
    ).quantize(Decimal("0.01"))

    return {
        "weighted_pct": weighted_pct,
        "raw_total_mean": raw_total_mean,
        "max_total": max_total,
        "reviewers_counted": len(reviews),
        "per_criterion": per_criterion,
    }


def reviewer_open_assignment_count(user) -> int:
    return Review.objects.filter(reviewer=user, submitted_at__isnull=True).count()


def recommended_reviewer_queryset(call: GrantCall):
    pool = call.reviewer_pool.all()
    if pool.exists():
        return pool.order_by("email")
    return Review.reviewer.field.remote_field.model.objects.filter(
        role__in=[UserRole.REVIEWER, UserRole.ADMIN, UserRole.GRANTS_MANAGER]
    ).order_by("email")


@transaction.atomic
def auto_assign_reviewers(application: Application, *, actor=None) -> list[Review]:
    """PRD §5.1 FRFA-AM018 — equitable workload-aware reviewer assignment.

    Filters out reviewers whose ReviewerProfile (operations.ReviewerProfile)
    marks them as unavailable or who are already at their max_concurrent_reviews
    cap. Falls back gracefully when no profile row exists for a reviewer.
    """
    from apps.rims.operations.models import ReviewerProfile

    if application.status != Application.Status.UNDER_REVIEW:
        raise ValidationError("Only under-review applications can be auto-assigned.")
    desired = max(int(application.call.reviewers_per_application or 1), 1)
    existing_ids = set(application.reviews.values_list("reviewer_id", flat=True))
    candidates = list(recommended_reviewer_queryset(application.call))
    if not candidates:
        raise ValidationError("No eligible reviewers are available for this call.")

    profiles = {
        p.user_id: p
        for p in ReviewerProfile.objects.filter(user_id__in=[u.pk for u in candidates])
    }

    def _eligible(user) -> bool:
        prof = profiles.get(user.pk)
        if prof is None:
            return True  # legacy: no profile → assume available
        if not prof.available:
            return False
        if reviewer_open_assignment_count(user) >= prof.max_concurrent_reviews:
            return False
        return True

    candidates = [u for u in candidates if _eligible(u)]
    if not candidates:
        raise ValidationError(
            "All eligible reviewers are unavailable or at their concurrent-review cap."
        )

    if application.call.reviewer_workload_mode == GrantCall.ReviewerWorkloadMode.ROUND_ROBIN:
        candidates.sort(key=lambda u: (u.pk, reviewer_open_assignment_count(u)))
    else:
        candidates.sort(key=lambda u: (reviewer_open_assignment_count(u), u.email))
    created_reviews: list[Review] = []
    for reviewer in candidates:
        if reviewer.pk in existing_ids:
            continue
        before = set(application.reviews.values_list("pk", flat=True))
        assign_reviewer(application, reviewer, declare_conflict=False)
        after = application.reviews.exclude(pk__in=before).first()
        if after is not None:
            if actor is not None and after.assigned_by_id is None:
                after.assigned_by = actor
                after.save(update_fields=["assigned_by"])
            created_reviews.append(after)
            existing_ids.add(reviewer.pk)
        if len(existing_ids) >= desired:
            break
    return created_reviews


def _verification_score(application: Application) -> Decimal:
    record = application.verification_records.order_by("-verified_at", "-created_at").first()
    if not record or record.score is None:
        return Decimal("0.00")
    weight = application.call.verification_score_weight or Decimal("0.00")
    return (Decimal(str(record.score)) * Decimal(str(weight))).quantize(Decimal("0.01"))


def _interview_score(application: Application) -> Decimal:
    latest = application.interviews.filter(status=InterviewSchedule.Status.COMPLETED).order_by("-scheduled_for").first()
    if latest and hasattr(latest, "outcome") and latest.outcome.score is not None:
        return Decimal(str(latest.outcome.score)).quantize(Decimal("0.01"))
    return Decimal("0.00")


def ranking_score(application: Application) -> Decimal | None:
    avg = aggregate_average_score(application)
    if avg is None:
        return None
    return (avg + _verification_score(application) + _interview_score(application)).quantize(Decimal("0.01"))


@transaction.atomic
def refresh_call_rankings(call: GrantCall) -> list[Application]:
    apps = list(
        Application.objects.filter(call=call, status__in=[Application.Status.UNDER_REVIEW, Application.Status.SHORTLISTED, Application.Status.APPROVED])
        .prefetch_related("reviews", "verification_records", "interviews__outcome")
        .order_by("submitted_at", "pk")
    )
    scored: list[tuple[Application, Decimal]] = []
    for app in apps:
        score = ranking_score(app)
        if score is not None:
            scored.append((app, score))
        else:
            app.ranking_score = None
            app.ranking_position = None
            app.tied_in_ranking = False
            app.shortlist_recommended = False
            app.save(update_fields=["ranking_score", "ranking_position", "tied_in_ranking", "shortlist_recommended", "updated_at"])
    scored.sort(key=lambda item: (-item[1], item[0].submitted_at or timezone.now(), item[0].pk))
    threshold = call.review_score_threshold
    max_awards = call.max_awards or len(scored)
    for idx, (app, score) in enumerate(scored, start=1):
        tied = False
        if idx > 1 and scored[idx - 2][1] == score:
            tied = True
        if idx < len(scored) and scored[idx][1] == score:
            tied = True
        shortlist_ok = idx <= max_awards and (threshold is None or score >= threshold)
        app.ranking_score = score
        app.ranking_position = idx
        app.tied_in_ranking = tied
        app.shortlist_recommended = shortlist_ok
        app.save(
            update_fields=["ranking_score", "ranking_position", "tied_in_ranking", "shortlist_recommended", "updated_at"]
        )
    return [app for app, _ in scored]


def ensure_awarding_phase(call: GrantCall) -> None:
    """Move call into awarding when the first award is created."""
    if call.status in (GrantCall.Status.AWARDING,):
        return
    if call.status == GrantCall.Status.COMPLETED:
        raise ValidationError("This call is already completed.")
    if call.status == GrantCall.Status.PUBLISHED:
        call.start_awarding()
        call.save(update_fields=["status", "updated_at"])
    elif call.status == GrantCall.Status.CLOSED:
        call.reopen_for_awarding()
        call.save(update_fields=["status", "updated_at"])
    elif call.status == GrantCall.Status.DRAFT:
        raise ValidationError("Publish the call before awarding.")
    else:
        raise ValidationError("Call cannot enter awarding from its current status.")


@transaction.atomic
def shortlist_application(application: Application) -> None:
    if application.status != Application.Status.UNDER_REVIEW:
        raise ValidationError("Only under-review applications can be shortlisted.")
    refresh_call_rankings(application.call)
    application.shortlist()
    application.save(update_fields=["status", "updated_at"])


# PRD §5.1 FRFA-AM032 — automated ranked shortlist.
from dataclasses import dataclass, field  # noqa: E402


@dataclass
class ShortlistResult:
    """Outcome of a `generate_shortlist` run, before any state transition.

    - `chosen`: applications that unambiguously meet the shortlist criteria.
    - `tied`: applications tied at the threshold/max-awards boundary that need
      manual adjudication (when tie_break_policy == MANAGER_REVIEW) or are
      disambiguated by the call's tie-break policy.
    - `below_threshold`: applications under the minimum score.
    - `unscored`: applications whose review scores are incomplete.
    """

    chosen: list[Application] = field(default_factory=list)
    tied: list[Application] = field(default_factory=list)
    below_threshold: list[Application] = field(default_factory=list)
    unscored: list[Application] = field(default_factory=list)


def _tie_break(
    tied_apps: list[Application],
    slots: int,
    policy: str,
) -> tuple[list[Application], list[Application]]:
    """Apply *policy* to pick `slots` winners from a group of score-tied apps.

    Returns `(chosen, remaining)`. For MANAGER_REVIEW the entire tied group is
    flagged for manual decision (remaining == tied_apps).
    """
    if slots <= 0 or not tied_apps:
        return [], tied_apps
    if policy == GrantCall.TieBreakPolicy.MANAGER_REVIEW:
        return [], tied_apps
    if policy == GrantCall.TieBreakPolicy.EARLIEST_SUBMISSION:
        ordered = sorted(
            tied_apps, key=lambda a: (a.submitted_at or timezone.now(), a.pk)
        )
        return ordered[:slots], ordered[slots:]
    if policy == GrantCall.TieBreakPolicy.INTERVIEW_SCORE:
        ordered = sorted(
            tied_apps,
            key=lambda a: (-_interview_score(a), a.submitted_at or timezone.now(), a.pk),
        )
        return ordered[:slots], ordered[slots:]
    return [], tied_apps


@transaction.atomic
def generate_shortlist(call: GrantCall) -> ShortlistResult:
    """PRD §5.1 FRFA-AM032 — rank all UNDER_REVIEW applications on *call* and
    build a shortlist using the call's `review_score_threshold`, `max_awards`
    cap, and `tie_break_policy`. Idempotent: does not transition any
    Application state; callers apply the result via `apply_shortlist_result`.
    """
    refresh_call_rankings(call)
    result = ShortlistResult()

    under_review = list(
        Application.objects.filter(call=call, status=Application.Status.UNDER_REVIEW)
        .select_related("call")
        .prefetch_related("reviews", "interviews__outcome", "verification_records")
    )
    scored_apps: list[tuple[Application, Decimal]] = []
    for app in under_review:
        score = ranking_score(app)
        if score is None:
            result.unscored.append(app)
        else:
            scored_apps.append((app, score))

    threshold = call.review_score_threshold
    if threshold is not None:
        eligible: list[tuple[Application, Decimal]] = []
        for app, score in scored_apps:
            if score >= threshold:
                eligible.append((app, score))
            else:
                result.below_threshold.append(app)
    else:
        eligible = scored_apps

    eligible.sort(key=lambda pair: (-pair[1], pair[0].submitted_at or timezone.now(), pair[0].pk))

    max_awards = call.max_awards if call.max_awards is not None else len(eligible)
    slots_remaining = max_awards

    i = 0
    while i < len(eligible) and slots_remaining > 0:
        score = eligible[i][1]
        group = [eligible[i]]
        j = i + 1
        while j < len(eligible) and eligible[j][1] == score:
            group.append(eligible[j])
            j += 1
        if len(group) <= slots_remaining:
            result.chosen.extend(app for app, _ in group)
            slots_remaining -= len(group)
        else:
            # Tie straddles the max_awards boundary — apply policy.
            tied_apps = [app for app, _ in group]
            chosen, remaining = _tie_break(tied_apps, slots_remaining, call.tie_break_policy)
            result.chosen.extend(chosen)
            result.tied.extend(remaining)
            slots_remaining = 0
        i = j
    # Any eligible apps past max_awards that we haven't categorised are tied
    # candidates for manual review.
    if i < len(eligible):
        result.tied.extend(app for app, _ in eligible[i:])
    return result


@transaction.atomic
def apply_shortlist_result(
    call: GrantCall, result: ShortlistResult, *, actor=None, request=None
) -> int:
    """Transition every application in `result.chosen` to SHORTLISTED. Other
    buckets are left untouched (managers resolve `tied` manually, `below_threshold`
    / `unscored` are reported for transparency)."""
    applied = 0
    for app in result.chosen:
        if app.status != Application.Status.UNDER_REVIEW:
            continue
        app.shortlist()
        app.save(update_fields=["status", "updated_at"])
        applied += 1
    if actor is not None and applied:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="GrantCall",
            object_id=call.pk,
            object_repr=str(call),
            changes={
                "event": "shortlist_applied",
                "chosen_count": applied,
                "tied_count": len(result.tied),
                "below_threshold_count": len(result.below_threshold),
            },
            request=request,
        )
    return applied


@transaction.atomic
def reject_application(application: Application) -> None:
    application.reject()
    application.save(update_fields=["status", "updated_at"])


def check_award_preconditions(application: Application, amount: Decimal) -> dict:
    """PRD §5.1 FRFA-AA001 — non-raising precondition probe for UI surfaces.

    Returns a dict ``{key: {ok: bool, message: str, value: ...}}`` covering the
    four FRFA-AA001 preconditions. Use ``_enforce_award_preconditions`` from
    service code to raise a ``ValidationError`` listing failures.
    """
    from django.template.loader import TemplateDoesNotExist, get_template

    from apps.rims.grants.funding import uncommitted_balance

    results: dict = {}
    call = application.call
    funding = getattr(call, "funding_record", None)

    if funding is None:
        results["funding"] = {
            "ok": False,
            "label": "Funding record",
            "message": "The call has no parent funding record assigned.",
        }
        results["balance"] = {
            "ok": False,
            "label": "Uncommitted balance",
            "message": "Cannot verify balance without a funding record.",
        }
        results["grants_manager"] = {
            "ok": False,
            "label": "Grants Manager",
            "message": "Cannot verify assignment without a funding record.",
        }
        results["finance_officer"] = {
            "ok": False,
            "label": "Finance Officer",
            "message": "Cannot verify assignment without a funding record.",
        }
    else:
        results["funding"] = {
            "ok": True,
            "label": "Funding record",
            "message": str(funding),
        }
        room = uncommitted_balance(funding, exclude_call_id=call.pk)
        # Calls in committing statuses already commit their full budget_ceiling
        # to the parent funding; an award draws *from* that commitment, not on
        # top of it. So the relevant comparator is the unspent ceiling, not the
        # global uncommitted balance.
        call_ceiling = call.budget_ceiling or Decimal("0")
        already_committed_to_other_awards = (
            Award.objects.filter(application__call=call)
            .exclude(status=Award.Status.CANCELLED)
            .exclude(application=application)
            .aggregate(s=Coalesce(Sum("amount"), Decimal("0")))["s"]
            or Decimal("0")
        )
        call_room = call_ceiling - already_committed_to_other_awards
        room_ok = (amount or Decimal("0")) <= call_room
        results["balance"] = {
            "ok": room_ok,
            "label": "Uncommitted balance",
            "value": call_room,
            "needed": amount,
            "message": (
                f"Call has {call_room} available against ceiling of {call_ceiling}."
                if room_ok
                else f"Need {amount}; only {call_room} remains in this call's allocation."
            ),
        }
        results["grants_manager"] = {
            "ok": bool(funding.grant_manager_id),
            "label": "Grants Manager",
            "message": (
                funding.grant_manager.get_full_name() or funding.grant_manager.email
                if funding.grant_manager_id
                else "Assign a grants manager on the parent funding record before awarding."
            ),
        }
        results["finance_officer"] = {
            "ok": bool(funding.finance_officer_id),
            "label": "Finance Officer",
            "message": (
                funding.finance_officer.get_full_name() or funding.finance_officer.email
                if funding.finance_officer_id
                else "Assign a finance officer on the parent funding record before awarding."
            ),
        }

    try:
        get_template("grants/award_letter.html")
        template_ok = True
        template_msg = "Award letter template available."
    except TemplateDoesNotExist:
        template_ok = False
        template_msg = "Award letter template is missing from the configured template loaders."
    results["letter_template"] = {
        "ok": template_ok,
        "label": "Award letter template",
        "message": template_msg,
    }
    return results


def _enforce_award_preconditions(application: Application, amount: Decimal) -> None:
    """Raise ValidationError summarising any unmet FRFA-AA001 preconditions.

    Only enforced when the parent funding record is APPROVED. The SRS publish
    flow (``validate_call_publishable``) requires ``funding.status == APPROVED``
    before a call can be published, so production awards always exercise the
    checks. Pre-Phase-4 regression tests that build a minimal funding record
    without going through the publish flow stay outside the enforcement window.
    """
    funding = getattr(application.call, "funding_record", None)
    if funding is None or funding.status != FundingRecord.Status.APPROVED:
        return
    probe = check_award_preconditions(application, amount)
    failures = [item["message"] for item in probe.values() if not item["ok"]]
    if failures:
        raise ValidationError(
            "FRFA-AA001: " + " ".join(failures)
        )


@transaction.atomic
def award_application(
    application: Application,
    amount: Decimal,
    project_end_date,
    narrative: str = "",
    *,
    currency: str = DEFAULT_CURRENCY,
    actor=None,
    request=None,
):
    if application.status not in [Application.Status.SHORTLISTED, Application.Status.APPROVED]:
        raise ValidationError("Application must be shortlisted or approved before award.")
    if application.call.interview_required and not application.interview_completed:
        raise ValidationError(
            "This call requires an interview. Mark the interview complete on the application before awarding."
        )
    if application.call.verification_required and not application.verification_records.exists():
        raise ValidationError("This call requires a verification record before awarding.")
    # PRD §5.1 FRFA-AA001 — precondition checks before award creation.
    _enforce_award_preconditions(application, amount)
    call = GrantCall.objects.select_for_update().get(pk=application.call_id)
    ensure_awarding_phase(call)
    application = Application.objects.select_for_update().get(pk=application.pk)
    # Concurrency guard (PRD NFRFA001 spirit + avoid IntegrityError on the
    # OneToOneField) — if a sibling transaction already created the Award,
    # surface a clean ValidationError to the caller instead of the raw
    # IntegrityError or FSM TransitionNotAllowed that would otherwise occur.
    if Award.objects.select_for_update().filter(application=application).exists():
        raise ValidationError("An award already exists for this application.")
    if application.status == Application.Status.SHORTLISTED:
        application.approve()
        application.save(update_fields=["status", "updated_at"])
    application.mark_awarded()
    application.save(update_fields=["status", "updated_at"])
    code = (currency or DEFAULT_CURRENCY).strip().upper()
    if not is_valid_currency(code):
        raise ValidationError("Invalid currency code.")
    # PRD §5.1 FRFA-AA025 + Table 14 — Scholarship / Fellowship / Challenge
    # call types defer the awardee-specific budget to the award stage.
    deferred_call_types = {
        GrantCall.CallType.SCHOLARSHIP,
        GrantCall.CallType.FELLOWSHIP,
        GrantCall.CallType.CHALLENGE,
    }
    budget_is_deferred = application.call.call_type in deferred_call_types
    award = Award.objects.create(
        application=application,
        amount=amount,
        currency=code,
        project_end_date=project_end_date,
        narrative=narrative,
        status=Award.Status.ACTIVE,
        budget_deferred=budget_is_deferred,
    )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_create, audit_rims

        audit_rims(
            actor=actor,
            action=action_create(),
            target_model="Award",
            object_id=award.pk,
            object_repr=str(award),
            changes={
                "amount": str(amount),
                "currency": award.currency,
                "project_end_date": str(project_end_date),
                "application_id": application.pk,
            },
            request=request,
        )
    return award


@transaction.atomic
def approve_application(application: Application, *, comment: str = "") -> None:
    if application.status != Application.Status.SHORTLISTED:
        raise ValidationError("Only shortlisted applications can be approved.")
    application.approve()
    application.final_decision_comment = (comment or "").strip()
    application.save(update_fields=["status", "final_decision_comment", "updated_at"])


@transaction.atomic
def create_or_update_interview(application: Application, *, scheduled_for, format, venue="", notes="", actor=None, status=InterviewSchedule.Status.SCHEDULED) -> InterviewSchedule:
    interview = application.interviews.order_by("-scheduled_for").first()
    if interview and interview.status in [InterviewSchedule.Status.SCHEDULED, InterviewSchedule.Status.RESCHEDULED]:
        interview.scheduled_for = scheduled_for
        interview.format = format
        interview.venue = venue
        interview.notes = notes
        interview.status = status
        interview.updated_by = actor
        interview.save()
        return interview
    return InterviewSchedule.objects.create(
        application=application,
        scheduled_for=scheduled_for,
        format=format,
        venue=venue,
        notes=notes,
        status=status,
        created_by=actor,
        updated_by=actor,
    )


@transaction.atomic
def record_interview_outcome(interview: InterviewSchedule, *, attendance, score=None, recommendation_code="", summary="", actor=None) -> InterviewOutcome:
    outcome, _created = InterviewOutcome.objects.update_or_create(
        interview=interview,
        defaults={
            "attendance": attendance,
            "score": score,
            "recommendation_code": recommendation_code,
            "summary": summary,
            "recorded_by": actor,
        },
    )
    interview.status = InterviewSchedule.Status.COMPLETED if attendance != InterviewOutcome.Attendance.RESCHEDULED else InterviewSchedule.Status.RESCHEDULED
    interview.updated_by = actor
    interview.save(update_fields=["status", "updated_by", "updated_at"])
    if interview.application.call.interview_required and attendance == InterviewOutcome.Attendance.ATTENDED:
        interview.application.interview_completed = True
        interview.application.save(update_fields=["interview_completed", "updated_at"])
    refresh_call_rankings(interview.application.call)
    return outcome


@transaction.atomic
def record_verification(application: Application, *, verification_type, status, score=None, summary="", findings=None, actor=None) -> VerificationRecord:
    record = VerificationRecord.objects.create(
        application=application,
        verification_type=verification_type,
        status=status,
        score=score,
        summary=summary,
        findings=findings or {},
        verified_by=actor,
        verified_at=timezone.now(),
    )
    refresh_call_rankings(application.call)
    return record


@transaction.atomic
def accept_award_agreement(award: Award, agreement, *, actor=None) -> None:
    prior_status = award.status
    agreement.accepted = True
    agreement.accepted_at = timezone.now()
    agreement.accepted_by = actor
    agreement.save(update_fields=["accepted", "accepted_at", "accepted_by"])
    award.status = Award.Status.ACTIVE
    award.activation_date = timezone.now().date()
    award.save(update_fields=["status", "activation_date"])
    _notify_agreement_received_to_grants_manager(award, agreement)
    if prior_status != Award.Status.ACTIVE:
        _notify_award_activation(award)


def _notify_agreement_received_to_grants_manager(award: Award, agreement) -> None:
    """PRD §5.1 FRFA-AA006 — Grants Manager is notified when an awardee's
    signed agreement is uploaded and accepted."""
    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

    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 is None:
        return
    ctx = rims_email_context(
        subject=f"Signed agreement received: {award.application.call.title}",
        headline="Awardee returned a signed agreement",
        action_path=reverse("rims_grants:award_detail", kwargs={"pk": award.pk}),
        action_label="Open award",
        preheader="The awardee uploaded a signed agreement and it has been accepted.",
    )
    ctx["call_title"] = award.application.call.title
    ctx["awardee_email"] = award.application.applicant.email
    send_notification(
        grants_manager,
        (
            f"Awardee {award.application.applicant.email} uploaded a signed agreement for "
            f'"{award.application.call.title}". The award is now active.'
        ),
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
        action_url=reverse("rims_grants:award_detail", kwargs={"pk": award.pk}),
    )


def _notify_award_activation(award: Award) -> None:
    """PRD §5.1 FRFA-AA013 — awardee is notified of activation with the
    current milestone / tranche schedule for visibility."""
    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

    tranches = list(award.tranches.order_by("sort_order", "pk").values("label", "amount", "due_on", "status"))
    schedule_lines = []
    for t in tranches:
        due = t["due_on"].isoformat() if t["due_on"] else "TBD"
        schedule_lines.append(f"- {t['label']}: {award.currency} {t['amount']} (due {due})")
    schedule_text = "\n".join(schedule_lines) if schedule_lines else "(no tranches scheduled yet)"

    applicant_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    ctx = rims_email_context(
        subject=f"Award active: {award.application.call.title}",
        headline="Your award is now active",
        action_path=applicant_url,
        action_label="Open application",
        preheader="Your milestone and disbursement schedule is attached.",
    )
    ctx["call_title"] = award.application.call.title
    ctx["schedule_text"] = schedule_text
    body = (
        f'Your award for "{award.application.call.title}" is active as of '
        f"{award.activation_date}. Below is the current disbursement schedule. You will be "
        f"reminded as each milestone approaches.\n\n{schedule_text}"
    )
    send_notification(
        award.application.applicant,
        body,
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
        action_url=applicant_url,
    )


@transaction.atomic
def return_award_agreement_for_correction(award: Award, agreement, *, instructions: str, actor=None) -> None:
    """PRD §5.1 FRFA-AA008 — Grants Manager rejects an uploaded agreement and
    sends written instructions back to the awardee for resubmission."""
    instructions = (instructions or "").strip()
    if not instructions:
        raise ValidationError("Written instructions are required when returning an agreement for correction.")
    agreement.accepted = False
    agreement.accepted_at = None
    agreement.accepted_by = None
    agreement.save(update_fields=["accepted", "accepted_at", "accepted_by"])
    award.status = Award.Status.AGREEMENT_PENDING
    award.activation_date = None
    award.save(update_fields=["status", "activation_date"])

    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

    applicant_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    ctx = rims_email_context(
        subject=f"Agreement correction required: {award.application.call.title}",
        headline="Please resubmit a corrected agreement",
        action_path=applicant_url,
        action_label="Open application",
        preheader="The Grants Manager has returned your agreement with correction notes.",
    )
    ctx["call_title"] = award.application.call.title
    ctx["instructions"] = instructions
    send_notification(
        award.application.applicant,
        (
            f"The signed agreement you uploaded for "
            f'"{award.application.call.title}" was returned for correction.\n\n'
            f"Instructions from the Grants Manager:\n{instructions}"
        ),
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
        action_url=applicant_url,
    )

    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardAgreement",
            object_id=agreement.pk,
            object_repr=str(agreement),
            changes={"returned_for_correction": True, "instructions": instructions},
        )


@transaction.atomic
def create_award_amendment(award: Award, *, change_summary, justification="", previous_terms=None, new_terms=None, actor=None) -> AwardAmendment:
    next_num = (award.amendments.aggregate(max_num=Count("pk")).get("max_num") or 0) + 1
    # PRD FRFA-AA021 — if the caller does not provide a pre-computed snapshot,
    # capture the award's current material terms so the amendment record is a
    # complete before/after audit entry.
    previous_terms = previous_terms or {
        "amount": str(award.amount),
        "currency": award.currency,
        "project_end_date": award.project_end_date.isoformat() if award.project_end_date else None,
        "status": award.status,
    }
    return AwardAmendment.objects.create(
        award=award,
        amendment_number=next_num,
        change_summary=change_summary,
        justification=justification,
        previous_terms=previous_terms,
        new_terms=new_terms or {},
        requested_by=actor,
    )


@transaction.atomic
def approve_award_amendment(
    amendment: AwardAmendment,
    *,
    actor=None,
    apply_terms: bool = True,
    request=None,
) -> AwardAmendment:
    """PRD §5.1 FRFA-AA022 — approve a requested amendment. When *apply_terms*
    is True (the default) and the amendment carries updated material fields in
    `new_terms` (currently `amount`, `currency`, `project_end_date`), those
    fields are written onto the award. The prior values live in `previous_terms`
    for audit / rollback reference."""
    amendment.approve()
    amendment.approver = actor
    amendment.save(update_fields=["status", "approver", "decided_at"])
    if apply_terms and amendment.new_terms:
        award = amendment.award
        new = amendment.new_terms
        updates: list[str] = []
        if "amount" in new:
            award.amount = Decimal(str(new["amount"]))
            updates.append("amount")
        if "currency" in new:
            code = str(new["currency"]).strip().upper()
            if not is_valid_currency(code):
                raise ValidationError("Invalid currency code in amendment terms.")
            award.currency = code
            updates.append("currency")
        if "project_end_date" in new and new["project_end_date"]:
            from datetime import date as _date

            award.project_end_date = _date.fromisoformat(str(new["project_end_date"]))
            updates.append("project_end_date")
        if updates:
            award.save(update_fields=updates)
    # PRD §5.1 FRFA-AA004 — regenerate the award letter so the version log
    # reflects the change in terms. Wrapped in a try so a render failure
    # doesn't roll back the approval (consistent with award_created signal).
    try:
        regenerate_award_letter(amendment.award, triggering_amendment=amendment, actor=actor)
    except Exception:  # pragma: no cover - PDF render failures must not roll back amendment
        import logging as _logging

        _logging.getLogger(__name__).exception(
            "regenerate_award_letter failed amendment_id=%s", amendment.pk
        )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardAmendment",
            object_id=amendment.pk,
            object_repr=str(amendment),
            changes={
                "status": {"old": AwardAmendment.Status.REQUESTED, "new": AwardAmendment.Status.APPROVED},
                "applied_terms": bool(apply_terms and amendment.new_terms),
            },
            request=request,
        )
    _notify_amendment_decision(amendment, approved=True)
    return amendment


@transaction.atomic
def reject_award_amendment(
    amendment: AwardAmendment,
    *,
    reason: str,
    actor=None,
    request=None,
) -> AwardAmendment:
    """PRD §5.1 FRFA-AA022 — reject a requested amendment with a mandatory
    written reason. Award terms are left unchanged."""
    reason = (reason or "").strip()
    if not reason:
        raise ValidationError("A written reason is required to reject an amendment.")
    amendment.reject()
    amendment.approver = actor
    amendment.rejection_reason = reason
    amendment.save(
        update_fields=["status", "approver", "rejection_reason", "decided_at"]
    )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardAmendment",
            object_id=amendment.pk,
            object_repr=str(amendment),
            changes={
                "status": {"old": AwardAmendment.Status.REQUESTED, "new": AwardAmendment.Status.REJECTED},
                "rejection_reason": reason,
            },
            request=request,
        )
    _notify_amendment_decision(amendment, approved=False, reason=reason)
    return amendment


@transaction.atomic
def resubmit_award_amendment(
    amendment: AwardAmendment,
    *,
    actor=None,
    change_summary: str | None = None,
    justification: str | None = None,
    new_terms: dict | None = None,
    request=None,
) -> AwardAmendment:
    """PRD §5.1 FRFA-AA022 — revise + resubmit a rejected amendment.

    P0 (assessment §3 #4) — keep the same row + amendment_number; FSM walks
    REJECTED → REQUESTED so reviewers see a single amendment iterating
    rather than a duplicate row. The prior ``rejection_reason`` is cleared
    in the model's ``resubmit()`` transition; the AuditLog row preserves
    the rejection history.
    """
    if amendment.status != AwardAmendment.Status.REJECTED:
        raise ValidationError("Only rejected amendments can be resubmitted.")
    fields = ["status", "decided_at", "rejection_reason", "approver"]
    if change_summary is not None and change_summary.strip():
        amendment.change_summary = change_summary.strip()
        fields.append("change_summary")
    if justification is not None:
        amendment.justification = justification
        fields.append("justification")
    if new_terms is not None:
        amendment.new_terms = new_terms
        fields.append("new_terms")
    amendment.resubmit()
    amendment.save(update_fields=fields)

    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardAmendment",
            object_id=amendment.pk,
            object_repr=str(amendment),
            changes={
                "status": {
                    "old": AwardAmendment.Status.REJECTED,
                    "new": AwardAmendment.Status.REQUESTED,
                },
                "resubmitted": True,
            },
            request=request,
        )
    return amendment


def _notify_amendment_decision(amendment, *, approved: bool, reason: str = "") -> None:
    """PRD §5.1 FRFA-AA022 — notify Grants Manager and the awardee on amendment
    approval or rejection."""
    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 = amendment.award
    funding_record = getattr(award.application.call, "funding_record", None)
    grants_manager = getattr(funding_record, "grant_manager", None) if funding_record else None
    applicant = award.application.applicant

    if approved:
        subject = f"Amendment approved: {award.application.call.title}"
        headline = "Award amendment approved"
        body = (
            f"Amendment #{amendment.amendment_number} on award for "
            f'"{award.application.call.title}" has been approved.\n\n'
            f"Change summary: {amendment.change_summary}"
        )
    else:
        subject = f"Amendment rejected: {award.application.call.title}"
        headline = "Award amendment rejected"
        body = (
            f"Amendment #{amendment.amendment_number} on award for "
            f'"{award.application.call.title}" has been rejected.\n\n'
            f"Reason: {reason}"
        )

    applicant_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    manager_url = reverse("rims_grants:award_detail", kwargs={"pk": award.pk})

    recipients = [applicant]
    if grants_manager and grants_manager != applicant:
        recipients.append(grants_manager)
    for u in recipients:
        is_applicant = u == applicant
        target_url = applicant_url if is_applicant else manager_url
        ctx = rims_email_context(
            subject=subject,
            headline=headline,
            action_path=target_url,
            action_label="Open application" if is_applicant else "Open award",
            preheader=headline,
        )
        ctx["call_title"] = award.application.call.title
        ctx["amendment_number"] = amendment.amendment_number
        send_notification(
            u,
            body,
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=target_url,
        )


@transaction.atomic
def create_award_cancellation(award: Award, *, reason, actor=None) -> AwardCancellationRequest:
    """PRD §5.1 FRFA-AA016 — open a cancellation request in REQUESTED state.

    The Grants Manager can either withdraw the request or submit it for
    director review. Approval triggers ``apply_cancellation_cascade``.
    """
    cancellation = AwardCancellationRequest.objects.create(
        award=award, reason=reason, requested_by=actor
    )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_create, audit_rims

        audit_rims(
            actor=actor,
            action=action_create(),
            target_model="AwardCancellationRequest",
            object_id=cancellation.pk,
            object_repr=str(cancellation),
            changes={"award_id": award.pk, "reason": reason},
        )
    return cancellation


@transaction.atomic
def submit_cancellation_for_review(
    cancellation: AwardCancellationRequest, *, actor=None
) -> AwardCancellationRequest:
    """PRD §5.1 FRFA-AA016 — REQUESTED → DIRECTOR_REVIEW.

    Notifies Programme Directors that a cancellation needs a decision.
    """
    if cancellation.status != AwardCancellationRequest.Status.REQUESTED:
        raise ValidationError("Only cancellations in 'Requested' state can be submitted for review.")
    cancellation.status = AwardCancellationRequest.Status.DIRECTOR_REVIEW
    cancellation.save(update_fields=["status"])
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardCancellationRequest",
            object_id=cancellation.pk,
            object_repr=str(cancellation),
            changes={"status": {"old": "requested", "new": "director_review"}},
        )
    _notify_cancellation_for_review(cancellation)
    return cancellation


@transaction.atomic
def withdraw_cancellation(
    cancellation: AwardCancellationRequest, *, actor=None
) -> AwardCancellationRequest:
    """PRD §5.1 FRFA-AA016 — REQUESTED → WITHDRAWN (Grants Manager only)."""
    if cancellation.status != AwardCancellationRequest.Status.REQUESTED:
        raise ValidationError("Only cancellations in 'Requested' state can be withdrawn.")
    cancellation.status = AwardCancellationRequest.Status.WITHDRAWN
    cancellation.decided_by = actor
    cancellation.decided_at = timezone.now()
    cancellation.save(update_fields=["status", "decided_by", "decided_at"])
    return cancellation


@transaction.atomic
def reject_cancellation(
    cancellation: AwardCancellationRequest, *, decision_note: str, actor=None
) -> AwardCancellationRequest:
    """PRD §5.1 FRFA-AA016 — DIRECTOR_REVIEW → REJECTED with mandatory note."""
    if cancellation.status != AwardCancellationRequest.Status.DIRECTOR_REVIEW:
        raise ValidationError("Only cancellations awaiting director review can be rejected.")
    note = (decision_note or "").strip()
    if not note:
        raise ValidationError("A decision note is required to reject a cancellation.")
    cancellation.status = AwardCancellationRequest.Status.REJECTED
    cancellation.decision_note = note
    cancellation.decided_by = actor
    cancellation.decided_at = timezone.now()
    cancellation.save(
        update_fields=["status", "decision_note", "decided_by", "decided_at"]
    )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardCancellationRequest",
            object_id=cancellation.pk,
            object_repr=str(cancellation),
            changes={
                "status": {"old": "director_review", "new": "rejected"},
                "decision_note": note,
            },
        )
    return cancellation


@transaction.atomic
def approve_cancellation_and_cascade(
    cancellation: AwardCancellationRequest, *, decision_note: str = "", actor=None
) -> AwardCancellationRequest:
    """PRD §5.1 FRFA-AA017 — DIRECTOR_REVIEW → APPROVED with full cascade.

    Cascade:
    - Mark all non-DISBURSED tranches as CANCELLED (planned/requested/approved/held).
    - Auto-create ``RecoveryRequest`` rows for already-DISBURSED tranches so
      Finance can chase recovery.
    - Transition the award to ``CANCELLED`` (records ``cancelled_at``).
    - Stamp a ``cascade_summary`` JSON blob on the cancellation row capturing
      tranche counts + amounts + funding-balance delta for the audit trail.
    - Notify the awardee using the applicant-facing URL.

    The funding-record uncommitted balance is recomputed live by
    ``apps.rims.grants.funding.uncommitted_balance`` which already filters out
    cancelled awards, so no manual balance update is needed.
    """
    if cancellation.status != AwardCancellationRequest.Status.DIRECTOR_REVIEW:
        raise ValidationError("Only cancellations awaiting director review can be approved.")
    award = cancellation.award

    tranche_qs = AwardTranche.objects.filter(award=award)
    undisbursed = tranche_qs.exclude(
        status__in=[AwardTranche.Status.DISBURSED, AwardTranche.Status.CANCELLED]
    )
    undisbursed_count = undisbursed.count()
    undisbursed_total = (
        undisbursed.aggregate(s=Coalesce(Sum("amount"), Decimal("0")))["s"] or Decimal("0")
    )
    now = timezone.now()
    undisbursed.update(status=AwardTranche.Status.CANCELLED, cancelled_at=now)

    disbursed = tranche_qs.filter(status=AwardTranche.Status.DISBURSED)
    recovery_rows = []
    recovery_total = Decimal("0")
    for tranche in disbursed:
        recovery_rows.append(
            RecoveryRequest.objects.create(
                award=award,
                cancellation=cancellation,
                tranche=tranche,
                amount=tranche.amount,
                reason=f"Award cancelled — recovery for tranche {tranche.label}",
            )
        )
        recovery_total += tranche.amount

    award.status = Award.Status.CANCELLED
    award.cancelled_at = now
    award.save(update_fields=["status", "cancelled_at"])

    cancellation.status = AwardCancellationRequest.Status.APPROVED
    cancellation.decision_note = (decision_note or "").strip()
    cancellation.decided_by = actor
    cancellation.decided_at = now
    cancellation.cascade_summary = {
        "cancelled_tranches": undisbursed_count,
        "cancelled_amount": str(undisbursed_total),
        "recovery_count": len(recovery_rows),
        "recovery_amount": str(recovery_total),
        "currency": award.currency,
    }
    cancellation.save(
        update_fields=[
            "status",
            "decision_note",
            "decided_by",
            "decided_at",
            "cascade_summary",
        ]
    )

    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="AwardCancellationRequest",
            object_id=cancellation.pk,
            object_repr=str(cancellation),
            changes={
                "status": {"old": "director_review", "new": "approved"},
                "cascade": cancellation.cascade_summary,
            },
        )
        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="Award",
            object_id=award.pk,
            object_repr=str(award),
            changes={"status": {"old": "active", "new": "cancelled"}},
        )

    _notify_cancellation_approved(cancellation)
    return cancellation


def _notify_cancellation_for_review(cancellation: AwardCancellationRequest) -> None:
    """Notify Programme Directors that a cancellation needs a decision."""
    from django.contrib.auth import get_user_model
    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

    User = get_user_model()
    award = cancellation.award
    directors = User.objects.filter(
        is_active=True, role=UserRole.PROGRAM_DIRECTOR
    )
    try:
        action_path = reverse(
            "rims_grants:award_cancellation_detail", kwargs={"pk": cancellation.pk}
        )
    except Exception:
        action_path = reverse("rims_grants:award_detail", kwargs={"pk": award.pk})
    ctx = rims_email_context(
        subject=f"Cancellation requested: {award.application.call.title}",
        headline="A cancellation needs your decision",
        action_path=action_path,
        action_label="Review cancellation",
        preheader="An award cancellation is awaiting director review.",
    )
    for director in directors:
        send_notification(
            director,
            (
                f"A cancellation has been raised for the award under "
                f'"{award.application.call.title}" and is awaiting your review.'
            ),
            verb=Notification.Verb.AWARD_CANCELLATION_REQUESTED,
            email_context=ctx,
            action_url=action_path,
        )


def _notify_cancellation_approved(cancellation: AwardCancellationRequest) -> None:
    """Notify the awardee (applicant-facing URL) and the Grants Manager."""
    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 = cancellation.award
    applicant = award.application.applicant
    applicant_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    ctx = rims_email_context(
        subject=f"Award cancelled: {award.application.call.title}",
        headline="Your award has been cancelled",
        action_path=applicant_url,
        action_label="View application",
        preheader="The cancellation has been approved.",
    )
    send_notification(
        applicant,
        (
            f'Your award for "{award.application.call.title}" has been cancelled. '
            "Please review the application page for next steps."
        ),
        verb=Notification.Verb.AWARD_CANCELLATION_APPROVED,
        email_context=ctx,
        action_url=applicant_url,
    )


# PRD §5.1 FRFA-AA005 — awardee acknowledgement.
def generate_award_letter_pdf(award: Award, *, triggering_amendment=None) -> bytes:
    """PRD §5.1 FRFA-AA002 / FRFA-AA004 — render the award letter PDF for ``award``.

    Always stamps a new ``AwardLetterVersion`` row so prior versions are
    preserved when an amendment regenerates the letter. The latest bytes are
    mirrored onto ``Award.letter_pdf_file`` so existing callers (signals,
    emailing, chase reminders) don't need to change.

    Returns the bytes.
    """
    from django.core.files.base import ContentFile
    from django.utils import timezone as _timezone

    from apps.core.utils.pdf import render_pdf
    from apps.rims.grants.models import AwardLetterVersion

    funding_record = getattr(award.application.call, "funding_record", None)
    funding_type_display = (
        award.application.call.get_call_type_display()
        if hasattr(award.application.call, "get_call_type_display")
        else ""
    )
    strategic_objectives_qs = (
        funding_record.strategic_objective_refs.values_list("name", flat=True)
        if funding_record
        else []
    )
    call_strategic_objectives = ", ".join(strategic_objectives_qs) if strategic_objectives_qs else ""

    recipient_name = (
        award.application.applicant.get_full_name()
        or (award.application.applicant.email or "").split("@")[0]
    )

    latest = AwardLetterVersion.objects.filter(award=award).order_by("-version_number").first()
    next_version = (latest.version_number if latest else 0) + 1

    # Stamp letter_generated_at *before* render so the template can show it.
    award.letter_generated_at = _timezone.now()

    pdf_bytes = render_pdf(
        "grants/award_letter.html",
        {
            "award": award,
            "funding_record": funding_record,
            "funding_type_display": funding_type_display,
            "call_strategic_objectives": call_strategic_objectives,
            "recipient_name": recipient_name,
            "version_number": next_version,
            "triggering_amendment": triggering_amendment,
        },
    )

    fname = f"award-letter-{award.pk}-v{next_version}.pdf"
    award.letter_pdf_file.save(fname, ContentFile(pdf_bytes), save=False)
    award.save(update_fields=["letter_pdf_file", "letter_generated_at"])

    version_row = AwardLetterVersion.objects.create(
        award=award,
        version_number=next_version,
        generated_at=award.letter_generated_at,
        triggering_amendment=triggering_amendment,
    )
    version_row.file.save(fname, ContentFile(pdf_bytes), save=True)
    return pdf_bytes


@transaction.atomic
def regenerate_award_letter(award: Award, *, triggering_amendment, actor=None) -> Award:
    """PRD §5.1 FRFA-AA004 — re-render letter after an amendment is approved.

    Wraps ``generate_award_letter_pdf`` with audit logging and is the seam
    called by the AwardAmendment post_save signal.
    """
    generate_award_letter_pdf(award, triggering_amendment=triggering_amendment)
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="Award",
            object_id=award.pk,
            object_repr=str(award),
            changes={
                "letter_regenerated": True,
                "triggering_amendment_id": getattr(triggering_amendment, "pk", None),
            },
        )
    return award


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-AA007 / FRFA-AA012 — Tranche schedule generator + reconcile
# ---------------------------------------------------------------------------


@transaction.atomic
def generate_tranche_schedule(
    award: Award,
    *,
    milestones=None,
    even_split_count: int = 4,
    actor=None,
):
    """PRD §5.1 FRFA-AA007 — generate tranches for an award.

    If ``milestones`` is provided, one tranche per milestone with an even-split
    of the award amount. Otherwise create ``even_split_count`` quarterly
    tranches dated from award.awarded_at to award.project_end_date. Existing
    tranches are left untouched so callers can re-run this after manual edits.

    Returns the list of created tranches.
    """
    from datetime import timedelta as _td

    existing_total = (
        AwardTranche.objects.filter(award=award)
        .aggregate(s=Coalesce(Sum("amount"), Decimal("0")))["s"]
        or Decimal("0")
    )
    remaining = (award.amount - existing_total).quantize(Decimal("0.01"))
    if remaining <= Decimal("0"):
        return []

    created: list[AwardTranche] = []
    if milestones:
        count = len(milestones)
        per = (remaining / count).quantize(Decimal("0.01"))
        for idx, milestone in enumerate(milestones, start=1):
            amount = per if idx < count else (remaining - per * (count - 1))
            created.append(
                AwardTranche.objects.create(
                    award=award,
                    milestone=milestone,
                    label=f"Milestone {idx}",
                    amount=amount,
                    due_on=getattr(milestone, "due_date", None),
                    sort_order=idx,
                )
            )
    else:
        count = max(1, int(even_split_count))
        per = (remaining / count).quantize(Decimal("0.01"))
        start = award.awarded_at.date() if award.awarded_at else timezone.now().date()
        end = award.project_end_date or (start + _td(days=365))
        span_days = max(1, (end - start).days)
        step = span_days // count
        for idx in range(1, count + 1):
            amount = per if idx < count else (remaining - per * (count - 1))
            due = start + _td(days=step * idx)
            created.append(
                AwardTranche.objects.create(
                    award=award,
                    label=f"Tranche {idx}",
                    amount=amount,
                    due_on=due,
                    sort_order=idx,
                )
            )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_create, audit_rims

        audit_rims(
            actor=actor,
            action=action_create(),
            target_model="AwardTranche",
            object_id=award.pk,
            object_repr=f"Award {award.pk} schedule",
            changes={"created": len(created), "total": str(remaining)},
        )
    return created


def reconcile_tranche(tranche: AwardTranche) -> dict:
    """PRD §5.1 FRFA-AA012 — back-reconcile a tranche to its budget allocations.

    Returns a dict:
    ``{
        "tranche_amount": Decimal,
        "allocated_amount": Decimal,
        "variance": Decimal,
        "balanced": bool,
        "allocations": [{"budget_line_id": int, "label": str, "amount": Decimal}],
        "budget_line_warnings": [str],
    }``
    """
    from apps.rims.grants.models import TrancheBudgetAllocation

    allocations = list(
        TrancheBudgetAllocation.objects.filter(tranche=tranche).select_related("budget_line")
    )
    allocated_total = sum((a.amount for a in allocations), Decimal("0"))
    variance = (tranche.amount - allocated_total).quantize(Decimal("0.01"))
    warnings: list[str] = []
    for alloc in allocations:
        line = alloc.budget_line
        line_total = line.amount or Decimal("0")
        used = (
            TrancheBudgetAllocation.objects.filter(budget_line=line)
            .aggregate(s=Coalesce(Sum("amount"), Decimal("0")))["s"]
            or Decimal("0")
        )
        if used > line_total:
            warnings.append(
                f"BudgetLine '{line.label or line.pk}' is over-allocated: "
                f"{used} > line total {line_total}."
            )
    return {
        "tranche_amount": tranche.amount,
        "allocated_amount": allocated_total,
        "variance": variance,
        "balanced": variance == Decimal("0") and not warnings,
        "allocations": [
            {
                "budget_line_id": a.budget_line_id,
                "label": a.budget_line.label,
                "amount": a.amount,
            }
            for a in allocations
        ],
        "budget_line_warnings": warnings,
    }


def reconcile_award_tranches(award: Award) -> dict:
    """Aggregate reconciliation across every tranche on the award."""
    rows = [reconcile_tranche(t) for t in AwardTranche.objects.filter(award=award)]
    total_amount = sum((r["tranche_amount"] for r in rows), Decimal("0"))
    allocated_amount = sum((r["allocated_amount"] for r in rows), Decimal("0"))
    variance = (total_amount - allocated_amount).quantize(Decimal("0.01"))
    return {
        "rows": rows,
        "tranche_total": total_amount,
        "allocated_total": allocated_amount,
        "variance": variance,
        "balanced": variance == Decimal("0") and all(r["balanced"] for r in rows),
    }


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-AA023-030 — Cohort addenda for multi-year awards
# ---------------------------------------------------------------------------


def _snapshot_award_cohorts(award: Award) -> dict:
    from apps.rims.grants.models import AwardCohort

    return {
        "amount": str(award.amount),
        "currency": award.currency,
        "project_end_date": str(award.project_end_date) if award.project_end_date else None,
        "cohorts": [
            {
                "cohort_number": c.cohort_number,
                "label": c.label,
                "target_participants": c.target_participants,
                "budget_allocation": str(c.budget_allocation),
                "status": c.status,
            }
            for c in AwardCohort.objects.filter(award=award).order_by("cohort_number")
        ],
    }


@transaction.atomic
def request_cohort_addendum(
    award: Award,
    *,
    reason: str,
    new_cohort_terms: dict,
    actor=None,
) -> "CohortAddendum":
    """PRD §5.1 FRFA-AA023 — open a cohort addendum in REQUESTED state.

    ``new_cohort_terms`` should contain at minimum ``label``,
    ``target_participants``, ``budget_allocation``. ``cohort_number`` is
    computed from the existing cohort count at approval-time so concurrent
    addenda don't collide.
    """
    from apps.rims.grants.models import CohortAddendum

    reason = (reason or "").strip()
    if not reason:
        raise ValidationError("A reason is required when requesting a cohort addendum.")
    if not new_cohort_terms.get("label"):
        raise ValidationError("Cohort label is required.")
    addendum = CohortAddendum.objects.create(
        award=award,
        reason=reason,
        previous_terms=_snapshot_award_cohorts(award),
        new_terms=dict(new_cohort_terms),
        created_by=actor,
    )
    if actor is not None:
        from apps.rims.grants.audit_helper import action_create, audit_rims

        audit_rims(
            actor=actor,
            action=action_create(),
            target_model="CohortAddendum",
            object_id=addendum.pk,
            object_repr=str(addendum),
            changes={"award_id": award.pk, "new_terms": new_cohort_terms},
        )
    return addendum


@transaction.atomic
def submit_cohort_addendum_for_review(
    addendum, *, actor=None
):
    from apps.rims.grants.models import CohortAddendum

    if addendum.status != CohortAddendum.Status.REQUESTED:
        raise ValidationError("Only requested addenda can be submitted for director review.")
    addendum.status = CohortAddendum.Status.DIRECTOR_REVIEW
    addendum.save(update_fields=["status"])
    _notify_cohort_addendum_for_review(addendum)
    return addendum


@transaction.atomic
def withdraw_cohort_addendum(addendum, *, actor=None):
    from apps.rims.grants.models import CohortAddendum

    if addendum.status != CohortAddendum.Status.REQUESTED:
        raise ValidationError("Only requested addenda can be withdrawn.")
    addendum.status = CohortAddendum.Status.WITHDRAWN
    addendum.decided_by = actor
    addendum.decided_at = timezone.now()
    addendum.save(update_fields=["status", "decided_by", "decided_at"])
    return addendum


@transaction.atomic
def reject_cohort_addendum(addendum, *, decision_notes: str, actor=None):
    from apps.rims.grants.models import CohortAddendum

    if addendum.status != CohortAddendum.Status.DIRECTOR_REVIEW:
        raise ValidationError("Only addenda awaiting director review can be rejected.")
    notes = (decision_notes or "").strip()
    if not notes:
        raise ValidationError("Decision notes are required to reject an addendum.")
    addendum.status = CohortAddendum.Status.REJECTED
    addendum.decision_notes = notes
    addendum.decided_by = actor
    addendum.decided_at = timezone.now()
    addendum.save(
        update_fields=["status", "decision_notes", "decided_by", "decided_at"]
    )
    _notify_cohort_addendum_decision(addendum, approved=False)
    return addendum


@transaction.atomic
def approve_cohort_addendum(addendum, *, decision_notes: str = "", actor=None):
    """PRD §5.1 FRFA-AA024 — DIRECTOR_REVIEW → APPROVED.

    Creates the ``AwardCohort`` row from ``new_terms`` and regenerates the
    award letter so the cohort is reflected in the audit trail.
    """
    from apps.rims.grants.models import AwardCohort, CohortAddendum

    if addendum.status != CohortAddendum.Status.DIRECTOR_REVIEW:
        raise ValidationError("Only addenda awaiting director review can be approved.")

    award = addendum.award
    next_cohort_number = (
        AwardCohort.objects.filter(award=award)
        .order_by("-cohort_number")
        .values_list("cohort_number", flat=True)
        .first()
        or 0
    ) + 1
    terms = addendum.new_terms or {}
    cohort = AwardCohort.objects.create(
        award=award,
        cohort_number=next_cohort_number,
        label=terms.get("label", f"Cohort {next_cohort_number}"),
        start_date=terms.get("start_date") or None,
        end_date=terms.get("end_date") or None,
        target_participants=int(terms.get("target_participants") or 0),
        budget_allocation=Decimal(str(terms.get("budget_allocation") or "0")),
        status=AwardCohort.Status.PLANNED,
        created_by=actor,
    )

    addendum.status = CohortAddendum.Status.APPROVED
    addendum.cohort = cohort
    addendum.decision_notes = (decision_notes or "").strip()
    addendum.decided_by = actor
    addendum.decided_at = timezone.now()
    addendum.save(
        update_fields=[
            "status",
            "cohort",
            "decision_notes",
            "decided_by",
            "decided_at",
        ]
    )

    # FRFA-AA004 — regenerate letter so the version log records the addendum.
    try:
        generate_award_letter_pdf(award)
    except Exception:  # pragma: no cover
        import logging as _logging

        _logging.getLogger(__name__).exception(
            "regenerate_award_letter failed after cohort addendum approval id=%s",
            addendum.pk,
        )

    if actor is not None:
        from apps.rims.grants.audit_helper import action_create, action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_create(),
            target_model="AwardCohort",
            object_id=cohort.pk,
            object_repr=str(cohort),
            changes={"award_id": award.pk, "addendum_id": addendum.pk},
        )
        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="CohortAddendum",
            object_id=addendum.pk,
            object_repr=str(addendum),
            changes={"status": {"old": "director_review", "new": "approved"}},
        )

    _notify_cohort_addendum_decision(addendum, approved=True)
    return addendum


def _notify_cohort_addendum_for_review(addendum) -> None:
    from django.contrib.auth import get_user_model
    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

    User = get_user_model()
    award = addendum.award
    directors = User.objects.filter(is_active=True, role=UserRole.PROGRAM_DIRECTOR)
    try:
        action_path = reverse(
            "rims_grants:cohort_addendum_detail", kwargs={"pk": addendum.pk}
        )
    except Exception:
        action_path = reverse("rims_grants:award_detail", kwargs={"pk": award.pk})
    ctx = rims_email_context(
        subject=f"Cohort addendum: {award.application.call.title}",
        headline="A cohort addendum needs your decision",
        action_path=action_path,
        action_label="Review addendum",
        preheader="A cohort addendum is awaiting director review.",
    )
    for director in directors:
        send_notification(
            director,
            (
                f"A cohort addendum has been raised on the award for "
                f'"{award.application.call.title}".'
            ),
            verb=Notification.Verb.COHORT_ADDENDUM_REQUESTED,
            email_context=ctx,
            action_url=action_path,
        )


def _notify_cohort_addendum_decision(addendum, *, approved: bool) -> None:
    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 = addendum.award
    applicant = award.application.applicant
    applicant_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    if approved:
        ctx = rims_email_context(
            subject=f"Cohort addendum approved: {award.application.call.title}",
            headline="A new cohort has been added to your award",
            action_path=applicant_url,
            action_label="View application",
            preheader="An approved cohort addendum is recorded on your award.",
        )
        verb = Notification.Verb.COHORT_ADDENDUM_APPROVED
        message = (
            f'A new cohort has been approved for your award under '
            f'"{award.application.call.title}". See your application for details.'
        )
    else:
        ctx = rims_email_context(
            subject=f"Cohort addendum rejected: {award.application.call.title}",
            headline="The proposed cohort was not approved",
            action_path=applicant_url,
            action_label="View application",
            preheader="The cohort addendum has been rejected.",
        )
        verb = Notification.Verb.COHORT_ADDENDUM_REJECTED
        message = (
            f'The cohort addendum proposed on your award under '
            f'"{award.application.call.title}" was not approved.'
        )
    send_notification(
        applicant,
        message,
        verb=verb,
        email_context=ctx,
        action_url=applicant_url,
    )


@transaction.atomic
def acknowledge_award(award: Award, *, actor=None) -> Award:
    """Awardee confirms receipt of the award letter. Called from the
    awardee-facing landing view; idempotent — once set, further calls are
    no-ops."""
    if award.acknowledged_at is not None:
        return award
    award.acknowledged_at = timezone.now()
    award.save(update_fields=["acknowledged_at"])
    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="Award",
            object_id=award.pk,
            object_repr=str(award),
            changes={"acknowledged_at": award.acknowledged_at.isoformat()},
        )
    return award


# PRD §5.1 FRFA-GPS027 — FundingRecord budget revision workflow.
@transaction.atomic
def create_funding_revision(
    funding_record: FundingRecord,
    *,
    change_summary: str,
    justification: str = "",
    new_terms: dict | None = None,
    actor=None,
) -> FundingRecordBudgetRevision:
    if funding_record.status not in (
        FundingRecord.Status.APPROVED,
        FundingRecord.Status.PENDING_APPROVAL,
    ):
        raise ValidationError(
            "Budget revisions can only be requested on approved (or pending-approval) funding records."
        )
    next_num = (
        funding_record.budget_revisions.aggregate(max_num=Count("pk")).get("max_num") or 0
    ) + 1
    previous = {
        "amount": str(funding_record.amount),
        "start_date": funding_record.start_date.isoformat(),
        "end_date": funding_record.end_date.isoformat(),
        "reporting_financial_frequency": funding_record.reporting_financial_frequency,
        "reporting_narrative_frequency": funding_record.reporting_narrative_frequency,
    }
    return FundingRecordBudgetRevision.objects.create(
        funding_record=funding_record,
        revision_number=next_num,
        change_summary=change_summary,
        justification=justification,
        previous_terms=previous,
        new_terms=new_terms or {},
        requested_by=actor,
    )


@transaction.atomic
def approve_funding_revision(
    revision: FundingRecordBudgetRevision,
    *,
    actor=None,
    apply_terms: bool = True,
) -> FundingRecordBudgetRevision:
    revision.approve()
    revision.approver = actor
    revision.save(update_fields=["status", "approver", "decided_at"])
    if apply_terms and revision.new_terms:
        fr = revision.funding_record
        terms = revision.new_terms
        updates: list[str] = []
        if "amount" in terms:
            fr.amount = Decimal(str(terms["amount"]))
            updates.append("amount")
        if "end_date" in terms and terms["end_date"]:
            from datetime import date as _date

            fr.end_date = _date.fromisoformat(str(terms["end_date"]))
            updates.append("end_date")
        if "start_date" in terms and terms["start_date"]:
            from datetime import date as _date

            fr.start_date = _date.fromisoformat(str(terms["start_date"]))
            updates.append("start_date")
        for freq_key in (
            "reporting_financial_frequency",
            "reporting_narrative_frequency",
        ):
            if freq_key in terms:
                setattr(fr, freq_key, terms[freq_key])
                updates.append(freq_key)
        if updates:
            fr.save(update_fields=updates)
    return revision


@transaction.atomic
def reject_funding_revision(
    revision: FundingRecordBudgetRevision,
    *,
    reason: str,
    actor=None,
) -> FundingRecordBudgetRevision:
    reason = (reason or "").strip()
    if not reason:
        raise ValidationError("A written reason is required to reject a funding revision.")
    revision.reject()
    revision.approver = actor
    revision.rejection_reason = reason
    revision.save(
        update_fields=["status", "approver", "rejection_reason", "decided_at"]
    )
    return revision


def award_portfolio_snapshot(award: Award) -> dict:
    tranche_total = sum((t.amount for t in award.tranches.all()), Decimal("0"))
    disbursed = sum(
        (t.amount for t in award.tranches.filter(status=AwardTranche.Status.DISBURSED)),
        Decimal("0"),
    )
    milestone_count = getattr(getattr(award, "project", None), "milestones", None)
    completed_milestones = 0
    total_milestones = 0
    if milestone_count is not None:
        total_milestones = award.project.milestones.count()
        completed_milestones = award.project.milestones.filter(status="accepted").count()
    return {
        "planned_tranches_total": tranche_total,
        "disbursed_total": disbursed,
        "remaining_balance": award.amount - disbursed,
        "total_milestones": total_milestones,
        "completed_milestones": completed_milestones,
    }


@transaction.atomic
def withdraw_call(call: GrantCall, *, reason: str, actor=None) -> GrantCall:
    """PRD §5.1 FRFA-CS030 — withdraw a published or draft call.

    Side effects:
      - Status flips to WITHDRAWN via the FSM transition.
      - Withdrawal reason / time / actor recorded on the call.
      - In-progress (DRAFT) applications are locked via expire_draft so
        applicants can no longer submit; submitted applications are left in
        place — staff decide what to do with them per PRD's "suspended state"
        guidance.
      - All applicants for the call get a notification with the reason.
    """
    reason = (reason or "").strip()
    if not reason:
        raise ValidationError("A written withdrawal reason is required.")
    call.withdraw()
    call.withdrawal_reason = reason
    call.withdrawn_at = timezone.now()
    call.withdrawn_by = actor
    call.save(
        update_fields=["status", "withdrawal_reason", "withdrawn_at", "withdrawn_by"]
    )
    drafts = list(
        Application.objects.filter(call=call, status=Application.Status.DRAFT)
    )
    for app in drafts:
        try:
            app.expire_draft()
            app.save(update_fields=["status", "updated_at"])
        except Exception as exc:  # pragma: no cover - keep going on per-row failure
            logger = __import__("logging").getLogger(__name__)
            logger.warning("withdraw_call: failed to expire draft=%s error=%s", app.pk, exc)
    _notify_call_withdrawal(call, reason)
    return call


def _notify_call_withdrawal(call: GrantCall, reason: str) -> None:
    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 bulk_notify

    applicants = list(
        Application.objects.filter(call=call)
        .values_list("applicant", flat=True)
        .distinct()
    )
    if not applicants:
        return
    # Re-fetch User objects for bulk_notify.
    User = __import__("django.contrib.auth", fromlist=["get_user_model"]).get_user_model()
    users = list(User.objects.filter(pk__in=applicants))
    ctx = rims_email_context(
        subject=f"Call withdrawn: {call.title}",
        headline="A call you applied to has been withdrawn",
        action_path=reverse("rims_grants:call_list"),
        action_label="Browse open calls",
        preheader=reason[:200],
    )
    ctx["call_title"] = call.title
    ctx["withdrawal_reason"] = reason
    bulk_notify(
        users,
        f'The call "{call.title}" has been withdrawn. Reason: {reason}',
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=ctx,
    )


def snapshot_call_version(call: "GrantCall", *, actor=None, change_summary: str = ""):
    """PRD §5.1 FRFA-CS033 — write a versioned snapshot of a GrantCall.

    Called by CS028 active-call edits and CS029 deadline extensions so
    reviewers can diff against the live row. The audit log (CS032) covers
    the *action*; this is the dedicated *state-snapshot* contract.
    """
    from apps.rims.grants.models import GrantCallVersion

    next_version = (
        GrantCallVersion.objects.filter(call=call)
        .order_by("-version_number")
        .values_list("version_number", flat=True)
        .first()
        or 0
    ) + 1
    snapshot = {
        "title": call.title,
        "description": call.description,
        "guidelines": call.guidelines,
        "opens_at": call.opens_at.isoformat() if call.opens_at else None,
        "closes_at": call.closes_at.isoformat() if call.closes_at else None,
        "review_starts_at": call.review_starts_at.isoformat() if call.review_starts_at else None,
        "review_ends_at": call.review_ends_at.isoformat() if call.review_ends_at else None,
        "budget_ceiling": str(call.budget_ceiling) if call.budget_ceiling is not None else None,
        "max_awards": call.max_awards,
        "status": call.status,
    }
    return GrantCallVersion.objects.create(
        call=call,
        version_number=next_version,
        snapshot=snapshot,
        change_summary=change_summary,
        created_by=actor,
    )


@transaction.atomic
def extend_call_deadline(
    call: "GrantCall",
    new_deadline,
    *,
    actor=None,
    request=None,
) -> None:
    """PRD §5.1 FRFA-CS029 — extend the submission deadline on an active call.

    Validates that the new deadline is strictly after the current one; if a
    review period is configured, the review-period start must remain after
    the new deadline. Atomically updates ``closes_at``, writes an audit row,
    snapshots a new ``GrantCallVersion``, and dispatches notifications to
    in-progress applicants + the review pool.
    """
    if call.status != GrantCall.Status.PUBLISHED:
        raise ValidationError("Deadline extensions are only allowed on PUBLISHED calls.")
    if new_deadline is None:
        raise ValidationError("Provide a new deadline.")
    if new_deadline <= call.closes_at:
        raise ValidationError("The new deadline must be after the current deadline.")
    if call.review_starts_at and new_deadline >= call.review_starts_at:
        raise ValidationError("The new deadline must be before the review period starts.")

    previous = call.closes_at
    call.closes_at = new_deadline
    call.save(update_fields=["closes_at", "updated_at"])

    if actor is not None:
        from apps.rims.grants.audit_helper import action_update, audit_rims

        audit_rims(
            actor=actor,
            action=action_update(),
            target_model="GrantCall",
            object_id=call.pk,
            object_repr=str(call),
            changes={"closes_at": {"old": previous.isoformat(), "new": new_deadline.isoformat()}},
            request=request,
        )

    snapshot_call_version(
        call,
        actor=actor,
        change_summary=f"Deadline extended from {previous.date()} to {new_deadline.date()}",
    )

    # PRD §5.1 FRFA-CS029 — notify in-progress applicants + review pool.
    from apps.core.notifications.emailing import rims_email_context
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import bulk_notify
    from django.urls import reverse as _reverse

    drafts = list(
        Application.objects.filter(call=call, status=Application.Status.DRAFT).select_related("applicant")
    )
    if drafts:
        ctx = rims_email_context(
            subject=f"Deadline extended: {call.title}",
            headline="More time to submit",
            action_path=_reverse("rims_grants:call_apply", kwargs={"slug": call.slug}),
            action_label="Continue application",
            preheader=f"New deadline: {new_deadline.date()}.",
        )
        bulk_notify(
            [d.applicant for d in drafts],
            f'"{call.title}" deadline extended from {previous.date()} to {new_deadline.date()}.',
            verb=Notification.Verb.DEADLINE_REMINDER,
            email_context=ctx,
        )

    reviewers = list(call.reviewer_pool.all()) if call.reviewer_pool.exists() else []
    if reviewers:
        ctx = rims_email_context(
            subject=f"Deadline extended: {call.title}",
            headline="Review timing updated",
            action_path=_reverse("rims_grants:call_detail_manage", kwargs={"slug": call.slug}),
            action_label="View call",
            preheader=f"New deadline: {new_deadline.date()}.",
        )
        bulk_notify(
            reviewers,
            f"Submission deadline for \"{call.title}\" extended to {new_deadline.date()}.",
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
        )


def snapshot_funding_record_version(funding_record, *, actor=None):
    """PRD §5.1 FRFA-GPS026 — write a versioned snapshot of a FundingRecord.

    Called from any governed-resubmission event so reviewers can audit the
    diff between the live row and prior versions."""
    from apps.rims.grants.models import FundingRecordVersion

    next_version = (
        FundingRecordVersion.objects.filter(funding_record=funding_record)
        .order_by("-version_number")
        .values_list("version_number", flat=True)
        .first()
        or 0
    ) + 1
    snapshot = {
        "title": funding_record.title,
        "amount": str(funding_record.amount),
        "currency": funding_record.currency,
        "start_date": funding_record.start_date.isoformat() if funding_record.start_date else None,
        "end_date": funding_record.end_date.isoformat() if funding_record.end_date else None,
        "reporting_financial_frequency": funding_record.reporting_financial_frequency,
        "reporting_narrative_frequency": funding_record.reporting_narrative_frequency,
        "grant_manager_id": funding_record.grant_manager_id,
        "finance_officer_id": funding_record.finance_officer_id,
        "program_lead_id": funding_record.program_lead_id,
    }
    return FundingRecordVersion.objects.create(
        funding_record=funding_record,
        version_number=next_version,
        snapshot=snapshot,
        created_by=actor,
    )


def generate_reporting_calendar(funding_record):
    """PRD §5.1 FRFA-GPS015 — produce a reporting calendar for *funding_record*.

    Reads the financial + narrative frequencies and the start/end dates,
    emits ReportingCalendarEntry rows at the calendar cadence. Idempotent
    against (funding_record, kind, due_on) via the unique constraint.

    Returns the list of (newly-created) entries.
    """
    from datetime import timedelta as _td

    from apps.rims.grants.models import FundingRecordVersion, ReportingCalendarEntry

    if not funding_record.start_date or not funding_record.end_date:
        return []
    cadence_days = {
        FundingRecord.ReportingFrequency.MONTHLY: 30,
        FundingRecord.ReportingFrequency.QUARTERLY: 91,
        FundingRecord.ReportingFrequency.SEMI_ANNUAL: 182,
        FundingRecord.ReportingFrequency.ANNUAL: 365,
        FundingRecord.ReportingFrequency.ACADEMIC_SEMESTER: 182,
        FundingRecord.ReportingFrequency.ACADEMIC_YEAR: 365,
        FundingRecord.ReportingFrequency.AD_HOC: None,
    }
    created = []
    for kind, freq in (
        (ReportingCalendarEntry.Kind.FINANCIAL, funding_record.reporting_financial_frequency),
        (ReportingCalendarEntry.Kind.NARRATIVE, funding_record.reporting_narrative_frequency),
    ):
        step = cadence_days.get(freq)
        if not step:
            continue
        due = funding_record.start_date + _td(days=step)
        i = 1
        while due <= funding_record.end_date:
            entry, made = ReportingCalendarEntry.objects.get_or_create(
                funding_record=funding_record,
                kind=kind,
                due_on=due,
                defaults={"label": f"{kind.label} #{i}"},
            )
            if made:
                created.append(entry)
            i += 1
            due = funding_record.start_date + _td(days=step * i)
    return created


@transaction.atomic
def close_grant_call(call: GrantCall):
    if call.status != GrantCall.Status.AWARDING:
        raise ValidationError("Call must be in awarding to mark completed.")
    call.complete()
    call.save(update_fields=["status", "updated_at"])


# ─────────────────────────────────────────────────────────────────────────────
# SRS §4.1.7 "Implementation and Progress Monitoring" — bridges the award-
# scoped ImplementationPlan to the existing programme-wide mel_indicators
# engine (LogFrame/Indicator/IndicatorTarget/DataPoint) rather than
# duplicating RAG/variance/alert logic in the grants app.
# ─────────────────────────────────────────────────────────────────────────────

def _generate_indicators_for_row(indicator_qs, logframe_row, actor):
    """Create a mel_indicators.Indicator + IndicatorTarget for each
    ImplementationPlanIndicator attached to one outcome/output, linking the
    plan-side row back via ``mel_indicator``."""
    from django.utils.text import slugify

    from apps.mel.indicators.models import Indicator as MelIndicator
    from apps.mel.indicators.models import IndicatorTarget as MelIndicatorTarget

    today = timezone.now().date()
    for plan_indicator in indicator_qs:
        base_code = slugify(f"award-{logframe_row.logframe_id}-{plan_indicator.name}")[:55] or "indicator"
        code = base_code
        suffix = 1
        while MelIndicator.objects.filter(code=code).exists():
            suffix += 1
            code = f"{base_code}-{suffix}"[:60]

        mel_indicator = MelIndicator.objects.create(
            logframe_row=logframe_row,
            code=code,
            name=plan_indicator.name,
            unit="",
            calculation_method="Derived from the award's Implementation Plan indicator.",
            data_source=plan_indicator.get_means_of_verification_display(),
            frequency=plan_indicator.reporting_frequency,
            responsible=actor,
        )
        MelIndicatorTarget.objects.create(
            indicator=mel_indicator,
            period_label="Baseline",
            period_start=today,
            period_end=today + timedelta(days=365),
            baseline_value=plan_indicator.baseline_value,
            target_value=plan_indicator.target_value,
        )
        plan_indicator.mel_indicator = mel_indicator
        plan_indicator.save(update_fields=["mel_indicator"])


@transaction.atomic
def generate_results_framework(plan: ImplementationPlan, *, actor=None):
    """SRS §4.1.7 "Generate Results Framework" — derive a real
    ``mel_indicators.LogFrame`` (Impact→Outcome→Output→Activity) from an
    approved :class:`ImplementationPlan`, with one Indicator+IndicatorTarget
    per :class:`ImplementationPlanIndicator`. Idempotent — returns the
    existing LogFrame if already generated.
    """
    from django.utils.text import slugify

    from apps.mel.indicators.models import LogFrame, LogFrameLevel, LogFrameRow

    if plan.status != ImplementationPlan.Status.APPROVED:
        raise ValidationError("Results Framework can only be generated for an approved implementation plan.")
    if plan.results_framework_id:
        return plan.results_framework

    award = plan.award
    base_slug = slugify(f"award-{award.pk}-results-framework") or f"award-{award.pk}-results-framework"
    slug = base_slug
    suffix = 1
    while LogFrame.objects.filter(slug=slug).exists():
        suffix += 1
        slug = f"{base_slug}-{suffix}"

    logframe = LogFrame.objects.create(
        name=f"Award {award.pk} — Results Framework",
        slug=slug,
        programme=str(award),
        owner=actor,
        period_start=award.activation_date,
        period_end=award.project_end_date,
    )

    impact_row = LogFrameRow.objects.create(
        logframe=logframe,
        level=LogFrameLevel.IMPACT,
        title=(plan.objective or "Objective")[:255],
        description=plan.objective,
        assumptions=plan.assumptions,
    )

    for outcome in plan.outcomes.all():
        outcome_row = LogFrameRow.objects.create(
            logframe=logframe,
            parent=impact_row,
            level=LogFrameLevel.OUTCOME,
            title=outcome.title,
            description=outcome.description,
            order=outcome.order,
        )
        _generate_indicators_for_row(outcome.indicators.all(), outcome_row, actor)

        for output in outcome.outputs.all():
            output_row = LogFrameRow.objects.create(
                logframe=logframe,
                parent=outcome_row,
                level=LogFrameLevel.OUTPUT,
                title=output.title,
                description=output.description,
                order=output.order,
            )
            _generate_indicators_for_row(output.indicators.all(), output_row, actor)

            for activity in output.activities.filter(parent__isnull=True):
                activity_row = LogFrameRow.objects.create(
                    logframe=logframe,
                    parent=output_row,
                    level=LogFrameLevel.ACTIVITY,
                    title=activity.title,
                    description=activity.risks,
                )
                activity.mel_logframe_row = activity_row
                activity.save(update_fields=["mel_logframe_row"])

    plan.results_framework = logframe
    plan.save(update_fields=["results_framework", "updated_at"])
    return logframe


@transaction.atomic
def approve_implementation_plan(plan: ImplementationPlan, *, actor=None) -> ImplementationPlan:
    """Approve the plan's FSM transition, then generate its Results
    Framework as one atomic operation — approval and Results-Framework
    generation are meant to happen together per the SRS."""
    plan.approve()
    plan.approved_by = actor
    plan.save(update_fields=["status", "approved_at", "approved_by", "updated_at"])
    generate_results_framework(plan, actor=actor)
    return plan


def record_activity_progress(
    activity: ImplementationPlanActivity,
    *,
    percent_complete: int,
    status: str | None = None,
    actor=None,
) -> ImplementationPlanActivity:
    """SRS §4.1.7 "Implement Activities & Report Progress" — update an
    activity's status/percent-complete and auto-propagate it to the parent
    output's linked indicator(s) as a mel_indicators DataPoint (the
    "system automatically updates outputs and milestones... aggregates
    activity progress into output-level... indicators" requirement).

    Propagation value = the average percent_complete across all activities
    under the same output; this is a defensible, simple aggregation — richer
    weighting (e.g. by activity effort) is a future refinement, not a data
    gap, since the underlying DataPoint history is preserved either way.
    """
    from apps.mel.indicators.services import record_data_point

    activity.percent_complete = max(0, min(100, percent_complete))
    if status:
        activity.status = status
    activity.save(update_fields=["percent_complete", "status", "updated_at"])

    output = activity.output
    sibling_qs = output.activities.filter(parent__isnull=True)
    avg_percent = sibling_qs.aggregate(avg=Avg("percent_complete"))["avg"] or 0

    # record_data_point() dedupes on the exact (indicator, source_module,
    # source_object_id) triple and returns the FIRST matching row unchanged
    # on every later call — so the period label must be part of
    # source_object_id, or every progress update after the first would be a
    # silent no-op. Including it makes each new period get a fresh
    # DataPoint while multiple saves *within* the same period still collapse
    # into one (reasonable "idempotent per period" semantics).
    period_label = timezone.now().strftime("%Y-%m")
    for plan_indicator in output.indicators.filter(mel_indicator__isnull=False):
        record_data_point(
            indicator=plan_indicator.mel_indicator,
            value=avg_percent,
            period_label=period_label,
            source_module="rims_grants",
            source_event="activity_progress",
            source_object_id=f"output-{output.pk}-{period_label}",
            reported_by=actor,
            auto_verify=True,
            raise_on_invalid=False,
        )
    return activity


def lock_monitoring_visit(visit, *, actor=None):
    """SRS §4.1.7 "Monitoring Visit" — lock the visit record after
    submission; the service layer (not the model) enforces that a locked
    visit's findings can no longer be edited (see
    :func:`record_monitoring_visit_findings`)."""
    if visit.is_locked:
        return visit
    visit.conducted_at = visit.conducted_at or timezone.now()
    visit.locked_at = timezone.now()
    visit.save(update_fields=["conducted_at", "locked_at", "updated_at"])
    return visit


def record_monitoring_visit_findings(visit, **fields):
    """Refuses writes once :func:`lock_monitoring_visit` has locked the
    record — "no edits allowed" per the SRS."""
    if visit.is_locked:
        raise ValidationError("This monitoring visit is locked and can no longer be edited.")
    allowed = {
        "findings", "strengths", "gaps", "risks_identified", "recommendations",
        "latitude", "longitude", "participants", "agenda",
    }
    update_fields = []
    for field_name, value in fields.items():
        if field_name not in allowed:
            raise ValueError(f"Cannot set field {field_name!r} via record_monitoring_visit_findings.")
        setattr(visit, field_name, value)
        update_fields.append(field_name)
    if update_fields:
        update_fields.append("updated_at")
        visit.save(update_fields=update_fields)
    return visit
