import logging

from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
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, send_notification
from apps.core.permissions.roles import UserRole
from apps.rims.grants.models import (
    Application,
    Award,
    AwardAmendment,
    AwardCloseoutRecord,
    AwardTranche,
    FundingRecord,
    GrantCall,
    InterviewSchedule,
    MilestoneEvidence,
    Review,
    VerificationRecord,
)

logger = logging.getLogger(__name__)
User = get_user_model()


@receiver(pre_save, sender=Application)
def application_cache_previous(sender, instance, **kwargs):
    if not instance.pk:
        instance._rims_prev_status = None
        instance._rims_prev_interview_completed = None
        return
    try:
        old = Application.objects.only("status", "interview_completed").get(pk=instance.pk)
        instance._rims_prev_status = old.status
        instance._rims_prev_interview_completed = old.interview_completed
    except Application.DoesNotExist:
        instance._rims_prev_status = None
        instance._rims_prev_interview_completed = None


def _notify_managers_review_queue(application: Application) -> None:
    managers = User.objects.filter(is_active=True).filter(
        Q(
            role__in=[
                UserRole.ADMIN,
                UserRole.SYSTEM_ADMIN,
                UserRole.GRANTS_MANAGER,
                UserRole.PROGRAM_MANAGER,
            ]
        )
    )
    msg = (
        f'Application from {application.applicant.email} for "{application.call.title}" '
        "is now under review. Assign reviewers from Grants, open the call, then Applications. "
        "The Review queue page only lists applications assigned to you as a reviewer."
    )
    ctx = rims_email_context(
        subject=f"Under review — assign reviewers: {application.call.title}",
        headline="Application ready for reviewer assignment",
        action_path=reverse("rims_grants:manager_application_detail", kwargs={"pk": application.pk}),
        action_label="Open application (manager)",
        preheader="An application entered the under-review stage; assign reviewers when ready.",
    )
    ct = ContentType.objects.get_for_model(Application)
    bulk_notify(
        list(managers),
        msg,
        verb=Notification.Verb.REVIEW_INTAKE_MANAGER,
        email_context=ctx,
        content_type=ct,
        object_id=str(application.pk),
        action_url=reverse("rims_grants:manager_application_detail", kwargs={"pk": application.pk}),
    )


_DECISION_EMAIL_COPY = {
    Application.Status.SUBMITTED: (
        "Application received",
        "Application received",
        "Thank you — we've received your application. The secretariat will contact you "
        "once review begins. You can track the status from the application page.",
    ),
    Application.Status.APPROVED: (
        "You have been approved",
        "Application approved",
        "Your application has been approved by the Programme Officer. The award stage will follow.",
    ),
    Application.Status.AWARDED: (
        "Your application has been awarded",
        "You have been awarded",
        "Congratulations — your application has been awarded. Open the application page to "
        "see the award and next steps.",
    ),
    Application.Status.SHORTLISTED: (
        "Application shortlisted",
        "Your application is shortlisted",
        "Your application has been shortlisted and is being considered for award.",
    ),
    Application.Status.REJECTED: (
        "Application not selected",
        "Outcome on your application",
        "Thank you for applying. Your application was not selected on this occasion. We "
        "appreciate your interest and encourage you to consider future calls.",
    ),
    Application.Status.REJECTED_INELIGIBLE: (
        "Application did not advance",
        "Application not advancing",
        "Your application did not advance from screening. The Programme Officer's reason is "
        "available on the application page.",
    ),
    Application.Status.WITHDRAWN: (
        "Application withdrawn",
        "Application withdrawn",
        "Your application has been recorded as withdrawn.",
    ),
    Application.Status.WITHDRAWN_NO_RESPONSE: (
        "Application closed — no response",
        "Application closed — no response",
        "Your application has been closed because no response was received within the "
        "expected window.",
    ),
    Application.Status.EXPIRED_DRAFT: (
        "Draft application expired",
        "Draft expired",
        "Your draft application was not submitted before the call deadline and has been "
        "marked as expired.",
    ),
}


@receiver(post_save, sender=Application)
def application_post_save_notifications(sender, instance, created, **kwargs):
    if created:
        return

    prev = getattr(instance, "_rims_prev_status", None)
    if prev is not None and prev != instance.status:
        copy = _DECISION_EMAIL_COPY.get(instance.status)
        if copy:
            headline, subject_tail, body = copy
        else:
            headline = "Your application status changed"
            subject_tail = "Application update"
            body = (
                f'Your application to "{instance.call.title}" is now: '
                f"{instance.get_status_display()}."
            )
        ctx = rims_email_context(
            subject=f"{subject_tail}: {instance.call.title}",
            headline=headline,
            action_path=reverse("rims_grants:application_detail", kwargs={"pk": instance.pk}),
            action_label="View application",
            preheader=headline,
        )
        ctx["call_title"] = instance.call.title
        ctx["application_status"] = instance.get_status_display()

        # PRD §5.1 FRFA-AM038 — admin-controlled toggle on the call to include
        # reviewer comments in rejection emails. The NotificationTemplate body
        # can render the comments using {% if include_reviewer_comments %}.
        if instance.status in (Application.Status.REJECTED, Application.Status.REJECTED_INELIGIBLE):
            ctx["include_reviewer_comments"] = bool(
                getattr(instance.call, "include_reviewer_comments_in_decision_email", False)
            )
            if ctx["include_reviewer_comments"]:
                ctx["reviewer_comments"] = list(
                    instance.reviews.exclude(recommendation="").values_list(
                        "recommendation", flat=True
                    )
                )

        send_notification(
            instance.applicant,
            body,
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=reverse("rims_grants:application_detail", kwargs={"pk": instance.pk}),
        )
        if prev == Application.Status.SUBMITTED and instance.status == Application.Status.UNDER_REVIEW:
            _notify_managers_review_queue(instance)

    prev_iv = getattr(instance, "_rims_prev_interview_completed", None)
    if prev_iv is False and instance.interview_completed:
        ctx = rims_email_context(
            subject=f"Interview recorded: {instance.call.title}",
            headline="Interview stage complete",
            action_path=reverse("rims_grants:application_detail", kwargs={"pk": instance.pk}),
            action_label="View application",
            preheader="Your interview has been marked complete for this application.",
        )
        send_notification(
            instance.applicant,
            (
                f'Your interview for "{instance.call.title}" has been marked complete. '
                "The secretariat may proceed with the next steps in the award process."
            ),
            verb=Notification.Verb.INTERVIEW_COMPLETED,
            email_context=ctx,
            action_url=reverse("rims_grants:application_detail", kwargs={"pk": instance.pk}),
        )


@receiver(pre_save, sender=Review)
def review_cache_submitted_at(sender, instance, **kwargs):
    if not instance.pk:
        instance._rims_prev_review_submitted = None
        return
    try:
        old = Review.objects.get(pk=instance.pk)
        instance._rims_prev_review_submitted = old.submitted_at
    except Review.DoesNotExist:
        instance._rims_prev_review_submitted = None


@receiver(post_save, sender=Review)
def review_reset_conflict_ack_on_submit(sender, instance, **kwargs):
    prev = getattr(instance, "_rims_prev_review_submitted", None)
    if instance.submitted_at and prev is None:
        Application.objects.filter(pk=instance.application_id).update(review_conflict_acknowledged=False)


@receiver(post_save, sender=Award)
def award_created_notify_and_mel_stub(sender, instance, created, **kwargs):
    if created:
        try:
            from apps.mel.tracking.services import record_award

            record_award(instance)
        except Exception:  # pragma: no cover - M&EL write failures must not roll back awards
            logger.exception("mel.record_award failed award_id=%s", instance.pk)

        # PRD §5.1 FRFA-AA002/AA003 — render the award letter PDF and attach
        # it to the awardee's notification. WeasyPrint is heavy and the PDF
        # only needs to be generated once per award, so guard with the
        # letter_generated_at stamp and swallow render failures so the email
        # still goes (without PDF) and the award row survives.
        attachment_spec = None
        if instance.letter_pdf_file and instance.letter_generated_at:
            attachment_spec = {
                "app_label": "rims_grants",
                "model_name": "Award",
                "pk": instance.pk,
                "field_name": "letter_pdf_file",
                "filename": f"award-letter-{instance.pk}.pdf",
                "mimetype": "application/pdf",
            }
        else:
            try:
                from apps.rims.grants.services import generate_award_letter_pdf

                generate_award_letter_pdf(instance)
                attachment_spec = {
                    "app_label": "rims_grants",
                    "model_name": "Award",
                    "pk": instance.pk,
                    "field_name": "letter_pdf_file",
                    "filename": f"award-letter-{instance.pk}.pdf",
                    "mimetype": "application/pdf",
                }
            except Exception:  # pragma: no cover - PDF render failures must not roll back awards
                logger.exception("award_letter_pdf failed award_id=%s", instance.pk)

        applicant = instance.application.applicant
        applicant_url = reverse(
            "rims_grants:application_detail", kwargs={"pk": instance.application_id}
        )
        ctx = rims_email_context(
            subject=f"Award: {instance.application.call.title}",
            headline="Congratulations — you received an award",
            action_path=applicant_url,
            action_label="View award",
            preheader="Your grant application has been awarded.",
        )
        ctx["call_title"] = instance.application.call.title
        if attachment_spec is not None:
            ctx["attachment_from_model"] = attachment_spec
        send_notification(
            applicant,
            (
                f'You received an award for "{instance.application.call.title}". '
                "Your award letter is attached as a PDF."
            ),
            verb=Notification.Verb.GRANT_AWARDED,
            email_context=ctx,
            action_url=applicant_url,
        )


@receiver(post_save, sender=GrantCall)
def grant_call_repository_stub(sender, instance, **kwargs):
    if instance.status == GrantCall.Status.COMPLETED:
        logger.info("repository.stub: grant call completed slug=%s", instance.slug)


@receiver(pre_save, sender=Award)
def award_capture_previous_status(sender, instance, **kwargs):
    """Cache prior status so post_save can distinguish a CLOSED transition
    from the row being re-saved while already CLOSED (idempotency)."""
    if not instance.pk:
        instance._rims_prev_award_status = None
        return
    try:
        old = Award.objects.get(pk=instance.pk)
        instance._rims_prev_award_status = old.status
    except Award.DoesNotExist:
        instance._rims_prev_award_status = None


@receiver(post_save, sender=Award)
def award_closed_check_funding_record_closeout(sender, instance, created, **kwargs):
    """PRD §5.1 FRFA-CO026–029 — when every Award under a FundingRecord
    reaches Status.CLOSED, flag the FundingRecord as ready for programme-level
    close-out. The check runs on any Award post_save; duplicate signal fires
    are idempotent (get_or_create) and safe."""
    if created:
        return
    prev = getattr(instance, "_rims_prev_award_status", None)
    if instance.status != Award.Status.CLOSED or prev == Award.Status.CLOSED:
        return
    try:
        funding_record = instance.application.call.funding_record
    except AttributeError:
        funding_record = None
    if funding_record is None:
        return
    from apps.rims.grants.models import FundingRecordCloseout

    sibling_statuses = (
        Award.objects.filter(application__call__funding_record=funding_record)
        .values_list("status", flat=True)
    )
    if any(s != Award.Status.CLOSED for s in sibling_statuses):
        return
    _, created_closeout = FundingRecordCloseout.objects.get_or_create(
        funding_record=funding_record,
        defaults={"status": FundingRecordCloseout.Status.READY},
    )
    if created_closeout:
        logger.info(
            "funding_record_closeout: auto-flagged funding_record=%s as READY",
            funding_record.pk,
        )

    # P1 (assessment §3 #5) — when every child Award is closed AND the
    # close-out flag is CLOSED (i.e. final close-out has run, not just
    # READY), transition the parent FundingRecord.status → CLOSED.
    # Defensive: only fire from APPROVED so a DRAFT_REVISION_REQUIRED record
    # isn't accidentally retired. Idempotent — the prev-status guard above
    # already prevents double-fires when an Award is re-saved while CLOSED.
    try:
        closeout = funding_record.closeout_record
    except FundingRecordCloseout.DoesNotExist:
        closeout = None
    if (
        closeout is not None
        and closeout.status == FundingRecordCloseout.Status.CLOSED
        and funding_record.status == FundingRecord.Status.APPROVED
    ):
        funding_record.status = FundingRecord.Status.CLOSED
        funding_record.save(update_fields=["status"])
        logger.info(
            "funding_record_closed: parent FundingRecord=%s moved to CLOSED",
            funding_record.pk,
        )
        _notify_funding_record_closed(funding_record)


def _notify_funding_record_closed(funding_record) -> None:
    """Notify grant manager + finance officer when a FundingRecord auto-closes."""
    grant_manager = getattr(funding_record, "grant_manager", None)
    finance_officer = getattr(funding_record, "finance_officer", None)
    if not grant_manager and not finance_officer:
        return

    ctx = rims_email_context(
        subject=f"Funding record closed: {funding_record.title}",
        headline="Funding record closed",
        action_path=reverse(
            "rims_grants:funding_detail", kwargs={"pk": funding_record.pk}
        ),
        action_label="Open funding record",
        preheader="All child awards completed; the record is now archived.",
    )
    body = (
        f'Funding record "{funding_record.title}" has been formally closed — '
        "every award under it is closed and the programme-level close-out is "
        "complete."
    )
    ct = ContentType.objects.get_for_model(funding_record.__class__)
    recipients = {u for u in (grant_manager, finance_officer) if u is not None}
    for user in recipients:
        send_notification(
            user,
            body,
            verb=Notification.Verb.RIMS_FUNDING_RECORD_CLOSED,
            email_context=ctx,
            content_type=ct,
            object_id=str(funding_record.pk),
            action_url=reverse(
                "rims_grants:funding_detail", kwargs={"pk": funding_record.pk}
            ),
        )


@receiver(post_save, sender=InterviewSchedule)
def interview_notifications(sender, instance, created, **kwargs):
    applicant = instance.application.applicant
    if created:
        ctx = rims_email_context(
            subject=f"Interview scheduled: {instance.application.call.title}",
            headline="Your interview has been scheduled",
            action_path=reverse("rims_grants:application_detail", kwargs={"pk": instance.application_id}),
            action_label="View application",
            preheader="Check the date, time, and format for your interview.",
        )
        # Attach an .ics file so the applicant can drop the interview straight
        # into their calendar. The Celery task carries the context as JSON, so
        # bytes are base64-encoded for transport — EmailChannel decodes them.
        try:
            import base64

            from apps.rims.grants.calendar_ics import (
                build_interview_ics,
                ics_filename_for_interview,
            )

            ics_bytes = build_interview_ics(instance)
            ctx["inline_attachments"] = [
                (
                    ics_filename_for_interview(instance),
                    base64.b64encode(ics_bytes).decode("ascii"),
                    "text/calendar",
                )
            ]
        except Exception:  # noqa: BLE001 — best-effort, do not block the email
            logger.exception("interview_notifications: ics generation failed")

        send_notification(
            applicant,
            (
                f'An interview has been scheduled for "{instance.application.call.title}" on '
                f'{instance.scheduled_for:%d %b %Y %H:%M}. An .ics calendar invite is attached.'
            ),
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=reverse("rims_grants:application_detail", kwargs={"pk": instance.application_id}),
        )


@receiver(post_save, sender=VerificationRecord)
def verification_notifications(sender, instance, created, **kwargs):
    if created:
        ctx = rims_email_context(
            subject=f"Verification update: {instance.application.call.title}",
            headline="Verification recorded on your application",
            action_path=reverse("rims_grants:application_detail", kwargs={"pk": instance.application_id}),
            action_label="View application",
            preheader="A verification step has been recorded in the grants workflow.",
        )
        send_notification(
            instance.application.applicant,
            (
                f'Verification for "{instance.application.call.title}" is now '
                f'{instance.get_status_display().lower()}.'
            ),
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=reverse("rims_grants:application_detail", kwargs={"pk": instance.application_id}),
        )


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-CO005 — close-out initiation notification.
# When an AwardCloseoutRecord is created (the system flag that close-out has
# begun), notify both the awardee and the Grants Manager.
# ---------------------------------------------------------------------------
@receiver(post_save, sender=AwardCloseoutRecord)
def closeout_record_created_notify(sender, instance, created, **kwargs):
    if not created:
        return
    award = instance.award
    funding_record = getattr(award.application.call, "funding_record", None)
    grants_manager = getattr(funding_record, "grant_manager", None) if funding_record else None
    awardee = award.application.applicant
    awardee_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    manager_url = reverse("rims_grants:award_detail", kwargs={"pk": award.pk})

    awardee_ctx = rims_email_context(
        subject=f"Close-out initiated: {award.application.call.title}",
        headline="Close-out has begun for your award",
        action_path=awardee_url,
        action_label="Open application",
        preheader="Final reports and asset verification are now required.",
    )
    awardee_ctx["call_title"] = award.application.call.title
    manager_ctx = rims_email_context(
        subject=f"Close-out initiated: {award.application.call.title}",
        headline="Close-out has begun for an award",
        action_path=manager_url,
        action_label="Open award",
        preheader="Final reports and asset verification are now required.",
    )
    manager_ctx["call_title"] = award.application.call.title

    body_for_awardee = (
        f'Close-out has been initiated for your award under "{award.application.call.title}". '
        f"Please prepare and submit the final narrative and financial reports through your "
        f"application page. Your Grants Manager will guide you through the close-out checklist."
    )
    body_for_manager = (
        f'Close-out has been initiated for the award held by {awardee.email} under '
        f'"{award.application.call.title}". The funding-type-specific checklist is now '
        f"available on the award page."
    )

    send_notification(
        awardee,
        body_for_awardee,
        verb=Notification.Verb.APPLICATION_STATUS,
        email_context=awardee_ctx,
        action_url=awardee_url,
    )
    if grants_manager and grants_manager != awardee:
        send_notification(
            grants_manager,
            body_for_manager,
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=manager_ctx,
            action_url=manager_url,
        )


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-CO021 — formal closure notification.
# When the Award status transitions to CLOSED, notify all parties.
# Re-uses the existing _rims_prev_award_status cached by the close-out chain
# signal so we don't double-cache the value.
# ---------------------------------------------------------------------------
@receiver(post_save, sender=Award)
def award_closed_notify_parties(sender, instance, created, **kwargs):
    if created:
        return
    prev = getattr(instance, "_rims_prev_award_status", None)
    if instance.status != Award.Status.CLOSED or prev == Award.Status.CLOSED:
        return

    funding_record = getattr(instance.application.call, "funding_record", None)
    grants_manager = getattr(funding_record, "grant_manager", None) if funding_record else None
    finance_officer = getattr(funding_record, "finance_officer", None) if funding_record else None
    awardee = instance.application.applicant
    awardee_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": instance.application_id}
    )
    manager_url = reverse("rims_grants:award_detail", kwargs={"pk": instance.pk})

    body = (
        f'The award under "{instance.application.call.title}" has been formally closed. '
        f"All close-out checklist items are complete, final reports are accepted, and the "
        f"award is now archived. Thank you for your work."
    )
    recipients = {awardee}
    if grants_manager:
        recipients.add(grants_manager)
    if finance_officer:
        recipients.add(finance_officer)
    for u in recipients:
        is_awardee = u == awardee
        target_url = awardee_url if is_awardee else manager_url
        ctx = rims_email_context(
            subject=f"Award closed: {instance.application.call.title}",
            headline="Award formally closed",
            action_path=target_url,
            action_label="Open application" if is_awardee else "Open award",
            preheader="All obligations under this award have been fulfilled.",
        )
        ctx["call_title"] = instance.application.call.title
        send_notification(
            u,
            body,
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=target_url,
        )


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-AA018 — milestone met + payment release.
# When AwardTranche.status transitions to DISBURSED, notify the awardee.
# ---------------------------------------------------------------------------
@receiver(pre_save, sender=AwardTranche)
def tranche_cache_previous_status(sender, instance, **kwargs):
    if not instance.pk:
        instance._rims_prev_tranche_status = None
        return
    try:
        old = AwardTranche.objects.only("status").get(pk=instance.pk)
        instance._rims_prev_tranche_status = old.status
    except AwardTranche.DoesNotExist:
        instance._rims_prev_tranche_status = None


@receiver(post_save, sender=AwardTranche)
def tranche_disbursed_notify(sender, instance, created, **kwargs):
    prev = getattr(instance, "_rims_prev_tranche_status", None)
    if instance.status != AwardTranche.Status.DISBURSED:
        return
    if prev == AwardTranche.Status.DISBURSED:
        return  # idempotent re-saves

    award = instance.award
    awardee = award.application.applicant
    awardee_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    ctx = rims_email_context(
        subject=f"Payment released: {award.application.call.title}",
        headline="A payment has been released for your award",
        action_path=awardee_url,
        action_label="Open award",
        preheader="Your milestone was accepted and the linked tranche has been paid.",
    )
    ctx["call_title"] = award.application.call.title
    ctx["tranche_label"] = instance.label
    ctx["tranche_amount"] = str(instance.amount)
    ctx["tranche_currency"] = award.currency
    body = (
        f'Tranche "{instance.label}" ({award.currency} {instance.amount}) has been '
        f'disbursed for your award under "{award.application.call.title}".'
    )
    if instance.payment_reference:
        body += f"\nPayment reference: {instance.payment_reference}"
    send_notification(
        awardee,
        body,
        verb=Notification.Verb.FINANCE_DISBURSEMENT,
        email_context=ctx,
        action_url=awardee_url,
    )


# ---------------------------------------------------------------------------
# PRD §5.1 FRFA-GPS019 — staff added to a grant must be notified by email.
# Detect first-time assignment of grant_manager / finance_officer / program_lead
# on a FundingRecord.
# ---------------------------------------------------------------------------
@receiver(pre_save, sender=FundingRecord)
def funding_record_cache_previous_staff(sender, instance, **kwargs):
    if not instance.pk:
        instance._rims_prev_grant_manager_id = None
        instance._rims_prev_finance_officer_id = None
        instance._rims_prev_program_lead_id = None
        return
    try:
        old = FundingRecord.objects.only(
            "grant_manager_id", "finance_officer_id", "program_lead_id"
        ).get(pk=instance.pk)
        instance._rims_prev_grant_manager_id = old.grant_manager_id
        instance._rims_prev_finance_officer_id = old.finance_officer_id
        instance._rims_prev_program_lead_id = old.program_lead_id
    except FundingRecord.DoesNotExist:
        instance._rims_prev_grant_manager_id = None
        instance._rims_prev_finance_officer_id = None
        instance._rims_prev_program_lead_id = None


@receiver(post_save, sender=FundingRecord)
def funding_record_notify_new_staff(sender, instance, created, **kwargs):
    """Fire one notification per newly-assigned staff role.

    On create: every set FK is a new assignment, so all assigned staff are
    notified. On update: only FKs that *changed* fire — preventing spam every
    time the funding record is edited.
    """
    if created:
        prev_gm = None
        prev_fo = None
        prev_pl = None
    else:
        prev_gm = getattr(instance, "_rims_prev_grant_manager_id", None)
        prev_fo = getattr(instance, "_rims_prev_finance_officer_id", None)
        prev_pl = getattr(instance, "_rims_prev_program_lead_id", None)

    notifications = []
    if instance.grant_manager_id and instance.grant_manager_id != prev_gm:
        notifications.append((instance.grant_manager, "Grant Manager"))
    if instance.finance_officer_id and instance.finance_officer_id != prev_fo:
        notifications.append((instance.finance_officer, "Finance Officer"))
    if instance.program_lead_id and instance.program_lead_id != prev_pl:
        notifications.append((instance.program_lead, "Programme Lead"))

    for staff, role_label in notifications:
        ctx = rims_email_context(
            subject=f"You were assigned as {role_label}: {instance.title}",
            headline=f"New assignment: {role_label}",
            action_path=reverse("rims_grants:funding_detail", kwargs={"pk": instance.pk}),
            action_label="Open funding record",
            preheader=f"You have been added as {role_label} on a funding record.",
        )
        ctx["funding_title"] = instance.title
        ctx["role_label"] = role_label
        send_notification(
            staff,
            (
                f'You have been added as {role_label} on the funding record '
                f'"{instance.title}". Open the record to review the details.'
            ),
            verb=Notification.Verb.APPLICATION_STATUS,
            email_context=ctx,
            action_url=reverse("rims_grants:funding_detail", kwargs={"pk": instance.pk}),
        )


# ---------------------------------------------------------------------------
# P1 (assessment §3 #6) — MilestoneEvidence.ACCEPTED → AwardTranche.APPROVED.
# When the Grants Manager accepts the last outstanding evidence row on a
# tranche, the tranche advances from REQUESTED / PLANNED to APPROVED. This
# closes the dead leaf the assessment identifies: previously the GM had to
# flip the tranche manually after accepting evidence.
# ---------------------------------------------------------------------------
@receiver(pre_save, sender=MilestoneEvidence)
def milestone_evidence_cache_previous(sender, instance, **kwargs):
    if not instance.pk:
        instance._rims_prev_evidence_status = None
        return
    try:
        old = MilestoneEvidence.objects.only("review_status").get(pk=instance.pk)
        instance._rims_prev_evidence_status = old.review_status
    except MilestoneEvidence.DoesNotExist:
        instance._rims_prev_evidence_status = None


@receiver(post_save, sender=MilestoneEvidence)
def milestone_evidence_release_tranche(sender, instance, created, **kwargs):
    if created:
        return
    prev = getattr(instance, "_rims_prev_evidence_status", None)
    if (
        instance.review_status != MilestoneEvidence.ReviewStatus.ACCEPTED
        or prev == MilestoneEvidence.ReviewStatus.ACCEPTED
    ):
        return
    tranche = instance.tranche
    # A tranche can collect multiple evidence rows. Only release the payment
    # when every row is accepted — partial acceptance is not enough.
    outstanding = MilestoneEvidence.objects.filter(tranche=tranche).exclude(
        review_status=MilestoneEvidence.ReviewStatus.ACCEPTED
    )
    if outstanding.exists():
        return
    if tranche.status not in {AwardTranche.Status.PLANNED, AwardTranche.Status.REQUESTED}:
        return
    tranche.status = AwardTranche.Status.APPROVED
    tranche.save(update_fields=["status"])
    logger.info(
        "milestone_evidence_release_tranche: tranche=%s moved to APPROVED",
        tranche.pk,
    )
    _notify_tranche_approved(tranche)


def _notify_tranche_approved(tranche) -> None:
    """Notify the awardee + finance officer that a tranche is cleared for
    payment after milestone evidence acceptance."""
    award = tranche.award
    awardee = award.application.applicant
    funding_record = getattr(award.application.call, "funding_record", None)
    finance_officer = (
        getattr(funding_record, "finance_officer", None) if funding_record else None
    )
    awardee_url = reverse(
        "rims_grants:application_detail", kwargs={"pk": award.application_id}
    )
    manager_url = reverse("rims_grants:award_detail", kwargs={"pk": award.pk})
    awardee_ctx = rims_email_context(
        subject=f"Tranche cleared for payment: {tranche.label}",
        headline="Milestone evidence accepted — tranche approved",
        action_path=awardee_url,
        action_label="Open application",
        preheader=f"Tranche {tranche.label} is now approved for disbursement.",
    )
    finance_ctx = rims_email_context(
        subject=f"Tranche cleared for payment: {tranche.label}",
        headline="Milestone evidence accepted — tranche approved",
        action_path=manager_url,
        action_label="Open award",
        preheader=f"Tranche {tranche.label} is now approved for disbursement.",
    )
    ct = ContentType.objects.get_for_model(AwardTranche)
    body_awardee = (
        f'Your milestone evidence for tranche "{tranche.label}" on award '
        f'"{award.application.call.title}" has been accepted. The tranche is '
        f"now cleared for payment processing."
    )
    body_finance = (
        f'Tranche "{tranche.label}" ({tranche.amount}) on award '
        f"#{award.pk} is cleared for disbursement — all evidence accepted."
    )
    send_notification(
        awardee,
        body_awardee,
        verb=Notification.Verb.RIMS_TRANCHE_APPROVED,
        email_context=awardee_ctx,
        content_type=ct,
        object_id=str(tranche.pk),
        action_url=awardee_url,
    )
    if finance_officer and finance_officer != awardee:
        send_notification(
            finance_officer,
            body_finance,
            verb=Notification.Verb.RIMS_TRANCHE_APPROVED,
            email_context=finance_ctx,
            content_type=ct,
            object_id=str(tranche.pk),
            action_url=manager_url,
        )
