"""PRD §4.3.4 — Scholar lifecycle FSM.

A small service-level FSM (no django-fsm) — the codebase already mixes the
two patterns and a 5-state lifecycle with linear guards does not justify the
extra dependency. Every transition writes to ``apps.core.audit`` so the move
is reflected in FRFA-AM041 audit trails.

Allowed transitions:
- ENROLLED    → ACTIVE | PAUSED | DEFERRED | WITHDRAWN
- ACTIVE      → PAUSED | DEFERRED | DISCONTINUED | WITHDRAWN | COMPLETED
- PAUSED      → ACTIVE | COMPLETED | DISCONTINUED | WITHDRAWN
- DEFERRED    → ENROLLED | ACTIVE | WITHDRAWN
- DISCONTINUED → ACTIVE (staff reinstatement only)
- WITHDRAWN   → ACTIVE (reactivation allowed)
- COMPLETED   → (terminal)
"""
from __future__ import annotations

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

from apps.core.audit.models import AuditLog
from apps.rims.scholarships.models import Scholar, Stipend


_ALLOWED: dict[str, set[str]] = {
    Scholar.Status.ENROLLED: {
        Scholar.Status.ACTIVE,
        Scholar.Status.PAUSED,
        Scholar.Status.DEFERRED,
        Scholar.Status.WITHDRAWN,
    },
    Scholar.Status.ACTIVE: {
        Scholar.Status.PAUSED,
        Scholar.Status.DEFERRED,
        Scholar.Status.DISCONTINUED,
        Scholar.Status.WITHDRAWN,
        Scholar.Status.COMPLETED,
    },
    Scholar.Status.PAUSED: {
        Scholar.Status.ACTIVE,
        Scholar.Status.WITHDRAWN,
        Scholar.Status.DISCONTINUED,
        Scholar.Status.COMPLETED,
    },
    Scholar.Status.DEFERRED: {
        Scholar.Status.ENROLLED,
        Scholar.Status.ACTIVE,
        Scholar.Status.WITHDRAWN,
    },
    Scholar.Status.DISCONTINUED: {
        Scholar.Status.ACTIVE,  # staff reinstatement
    },
    Scholar.Status.WITHDRAWN: {Scholar.Status.ACTIVE},  # reactivation allowed
    Scholar.Status.COMPLETED: set(),  # terminal
}


@transaction.atomic
def transition_scholar(
    scholar: Scholar,
    *,
    to_status: str,
    actor=None,
    reason: str = "",
) -> Scholar:
    """Move a scholar between lifecycle states.

    Validates the transition against ``_ALLOWED``, persists the new status
    with audit metadata, and writes an :class:`AuditLog` row tagged with the
    new status code so reviewers can replay the lifecycle.

    Raises ``ValidationError`` when the transition is not permitted.
    """
    current = scholar.status
    valid = _ALLOWED.get(current, set())
    if to_status not in valid:
        raise ValidationError(
            f"Scholar {scholar.pk} cannot transition from {current} to {to_status}; "
            f"allowed: {sorted(valid) or 'none'}."
        )
    if to_status in (Scholar.Status.WITHDRAWN, Scholar.Status.DISCONTINUED) and not reason:
        raise ValidationError(
            f"A reason is required to set a scholar to {to_status}."
        )

    scholar.status = to_status
    scholar.status_changed_at = timezone.now()
    scholar.status_changed_by = actor
    scholar.status_reason = reason
    if to_status == Scholar.Status.WITHDRAWN:
        scholar.withdrawn_reason = reason
    elif to_status == Scholar.Status.ACTIVE and current in (
        Scholar.Status.WITHDRAWN, Scholar.Status.PAUSED,
        Scholar.Status.DEFERRED, Scholar.Status.DISCONTINUED,
    ):
        scholar.withdrawn_reason = ""
    scholar.save(
        update_fields=[
            "status",
            "status_changed_at",
            "status_changed_by",
            "status_reason",
            "withdrawn_reason",
        ]
    )

    # P0 (assessment §3 #9) — cancel pending stipend rows so a withdrawn
    # scholar isn't paid out for scheduled-but-unposted disbursements. Done
    # inside the transition's transaction so it can't drift out of sync.
    cancelled_audit_actor = (
        actor if (actor is not None and getattr(actor, "is_authenticated", False)) else None
    )
    if to_status == Scholar.Status.WITHDRAWN:
        pending = Stipend.objects.filter(
            scholar=scholar, status=Stipend.Status.PENDING
        )
        cancel_reason = f"auto: scholar withdrawn — {reason}"
        for stipend in pending:
            stipend.status = Stipend.Status.CANCELLED
            stipend.cancelled_at = timezone.now()
            stipend.cancelled_reason = cancel_reason
            stipend.cancelled_by = cancelled_audit_actor
            stipend.save(
                update_fields=[
                    "status",
                    "cancelled_at",
                    "cancelled_reason",
                    "cancelled_by",
                ]
            )
            AuditLog.objects.create(
                actor=cancelled_audit_actor,
                action="STIPEND_CANCELLED",
                target_app="rims_scholarships",
                target_model="Stipend",
                object_id=str(stipend.pk),
                object_repr=str(stipend)[:200] if hasattr(stipend, "__str__") else f"Stipend {stipend.pk}",
                changes={
                    "from": "pending",
                    "to": "cancelled",
                    "reason": cancel_reason,
                    "scholar_id": scholar.pk,
                },
            )

    # Write audit synchronously (not via celery/on_commit) so reviewers and
    # tests see the row inside the same transaction. Mirrors the SME-Hub
    # pattern in ``apps.smehub._shared.audit.record_audit``.
    AuditLog.objects.create(
        actor=cancelled_audit_actor,
        action=f"SCHOLAR_{to_status.upper()}",
        target_app="rims_scholarships",
        target_model="Scholar",
        object_id=str(scholar.pk),
        object_repr=str(scholar)[:200],
        changes={"from": current, "to": to_status, "reason": reason},
    )

    # P1 (assessment §4 N6) — fan out a status-change notification to finance
    # + programme officers so a withdrawn scholar doesn't surprise downstream
    # payroll or programme leads. The scholar themselves get an in-app /
    # email update too. Failures here must not block the transition.
    try:
        _notify_scholar_status_change(scholar, from_status=current, reason=reason)
    except Exception:  # noqa: BLE001 — best-effort
        import logging

        logging.getLogger(__name__).exception(
            "transition_scholar: status-change notification failed for scholar=%s", scholar.pk
        )

    return scholar


def _notify_scholar_status_change(scholar, *, from_status: str, reason: str) -> None:
    """RIMS P1 N6 — emit a Notification.Verb.RIMS_SCHOLAR_STATUS_CHANGED to
    the scholar themselves plus the finance + programme officer pool.

    Done at the workflow boundary rather than via a Django signal because the
    rest of the scholarship app already uses the service-FSM pattern (see
    workflows.py docstring); a signal would split the cascade across two
    files unnecessarily.
    """
    from django.contrib.auth import get_user_model
    from django.contrib.contenttypes.models import ContentType
    from django.db.models import Q
    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

    User = get_user_model()
    to_status_display = scholar.get_status_display()
    scholar_name = scholar.user.get_full_name() or scholar.user.email
    scholarship_title = getattr(scholar.scholarship, "title", str(scholar.scholarship))

    detail_path = reverse("rims_scholarships:scholar_detail", kwargs={"pk": scholar.pk})
    ctx = rims_email_context(
        subject=f"Scholar {to_status_display.lower()}: {scholar_name}",
        headline="Scholar status changed",
        action_path=detail_path,
        action_label="Open scholar record",
        preheader=f"{scholar_name} — {from_status} → {scholar.status}",
    )
    body_for_staff = (
        f"Scholar {scholar_name} on {scholarship_title!r} moved from "
        f"{from_status} → {scholar.status}."
    )
    if reason:
        body_for_staff += f"\nReason: {reason}"

    staff_qs = (
        User.objects.filter(is_active=True)
        .filter(
            Q(
                role__in=[
                    UserRole.FINANCE_OFFICER,
                    UserRole.FINANCE_MANAGER,
                    UserRole.PROGRAM_MANAGER,
                    UserRole.PROGRAMME_OFFICER,
                ]
            )
        )
        .distinct()
    )

    ct = ContentType.objects.get_for_model(scholar.__class__)
    bulk_notify(
        list(staff_qs),
        body_for_staff,
        verb=Notification.Verb.RIMS_SCHOLAR_STATUS_CHANGED,
        email_context=ctx,
        content_type=ct,
        object_id=str(scholar.pk),
        action_url=detail_path,
    )

    body_for_scholar = (
        f"Your status on {scholarship_title!r} has been recorded as {to_status_display}."
    )
    if reason and scholar.status == "withdrawn":
        body_for_scholar += f"\nReason: {reason}"

    try:
        send_notification(
            scholar.user,
            body_for_scholar,
            verb=Notification.Verb.RIMS_SCHOLAR_STATUS_CHANGED,
            email_context=ctx,
            content_type=ct,
            object_id=str(scholar.pk),
            action_url=detail_path,
        )
    except Exception:  # noqa: BLE001
        # Scholar may not have an email or login profile; silent best-effort
        pass
