from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Count, Q, Sum, Prefetch
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views import View
from django.views.generic import CreateView, DetailView, ListView, UpdateView

from apps.core.lists import CsvExportMixin, SortableListMixin, paginate_qs
from apps.core.permissions.mixins import RimsAccessMixin
from apps.core.permissions.roles import UserRole
from apps.rims.grants import rims_perms
from apps.rims.grants.models import GrantCall
from apps.rims.scholarships.forms import (
    ConferencePaperFormSet,
    GraduationSubmitForm,
    ManuscriptFormSet,
    PresentationFormSet,
    ProgressReportForm,
    ScholarAcademicProfileForm,
    ScholarEditForm,
    ScholarEnrolForm,
    ScholarStatusUpdateForm,
    ScholarshipForm,
    SkillsImprovementFormSet,
    StipendForm,
    SupervisorFeedbackFormSet,
)
from apps.rims.scholarships.models import (
    GraduationRecord,
    ProgressReport,
    Scholar,
    Scholarship,
    Stipend,
)
from apps.rims.scholarships.services import (
    process_stipend,
    submit_graduation,
    submit_progress_report,
)


class ScholarshipsStaffMixin(RimsAccessMixin):
    allowed_roles = (UserRole.ADMIN, UserRole.GRANTS_MANAGER)
    rims_permissions = (rims_perms.MANAGE_SCHOLARSHIPS, rims_perms.MANAGE_CALLS)


class ScholarshipsFinanceMixin(RimsAccessMixin):
    """Stipend-recording role gate: admins, grants managers, finance officers."""

    allowed_roles = (
        UserRole.ADMIN,
        UserRole.GRANTS_MANAGER,
        UserRole.FINANCE_OFFICER,
    )
    rims_permissions = (rims_perms.MANAGE_SCHOLARSHIPS, rims_perms.MANAGE_FINANCE)


class ScholarshipListView(ScholarshipsStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Scholarship
    template_name = "scholarships/scholarship_list.html"
    context_object_name = "scholarships"
    paginate_by = 25

    sortable_fields = {
        "name": "name",
        "active": "is_active",
        "scholars": "scholar_count",
    }
    default_sort = "name"

    csv_columns = (
        ("Programme", "name"),
        ("Sponsor", "partner.name"),
        ("Grant call", "grant_call.title"),
        ("Active", lambda s: "Yes" if s.is_active else "No"),
        ("Scholars", "scholar_count"),
        ("Active scholars", "active_count"),
    )
    csv_filename = "scholarship_programmes"

    def get_queryset(self):
        qs = (
            Scholarship.objects.select_related("partner", "grant_call")
            .annotate(
                scholar_count=Count("scholars", distinct=True),
                active_count=Count(
                    "scholars",
                    filter=Q(scholars__graduation__isnull=True),
                    distinct=True,
                ),
            )
        )
        self._search = (self.request.GET.get("q") or "").strip()
        active = (self.request.GET.get("active") or "").strip()
        self._active_filter = active
        if self._search:
            qs = qs.filter(Q(name__icontains=self._search))
        if active in ("1", "true", "yes"):
            qs = qs.filter(is_active=True)
        elif active in ("0", "false", "no"):
            qs = qs.filter(is_active=False)
        return self.apply_sort(qs)

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["scholarships/partials/scholarship_list_results.html"]
        return super().get_template_names()

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        total = Scholarship.objects.count()
        n_active = Scholarship.objects.filter(is_active=True).count()
        active = getattr(self, "_active_filter", "")
        ctx["facets"] = [
            {"label": "All", "href": self.request.path, "count": total, "active": active == ""},
            {"label": "Active", "href": f"{self.request.path}?active=1", "count": n_active, "active": active in ("1", "true", "yes")},
            {"label": "Inactive", "href": f"{self.request.path}?active=0", "count": total - n_active, "active": active in ("0", "false", "no")},
        ]
        ctx["search_value"] = getattr(self, "_search", "")
        return ctx


class ScholarListView(ScholarshipsStaffMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Scholar
    template_name = "scholarships/scholar_list.html"
    context_object_name = "scholars"
    paginate_by = 12

    sortable_fields = {
        "name": "user__last_name",
        "enrolled": "enrolled_at",
        "status": "status",
        "programme": "scholarship__name",
    }
    default_sort = "-enrolled_at"

    csv_columns = (
        ("Name", lambda s: f"{s.user.first_name} {s.user.last_name}".strip() or s.user.email),
        ("Email", lambda s: s.user.email),
        ("Programme", lambda s: s.scholarship.name if s.scholarship_id else ""),
        ("Institution", lambda s: s.institution.name if s.institution_id else ""),
        ("Status", "get_status_display"),
        ("Enrolled", lambda s: s.enrolled_at.isoformat() if s.enrolled_at else ""),
    )
    csv_filename = "scholars"

    # Expected duration in years per degree level used for estimation
    _DEGREE_DURATION = {"msc": 2, "mphil": 3, "phd": 4, "bachelor": 4, "pgdip": 1}
    # Only flag as overdue if expected_graduation is within this window (avoids legacy garbage)
    _MAX_OVERDUE_YEARS = 15

    def get_queryset(self):
        from django.db.models import DateField, ExpressionWrapper, F, Value
        from django.db.models.functions import Now
        from datetime import date, timedelta

        qs = (
            Scholar.objects.select_related("user", "scholarship", "institution")
            .prefetch_related("graduation")
            .annotate(report_count=Count("progress_reports", distinct=True))
        )

        institution_id = (self.request.GET.get("institution") or "").strip()
        if institution_id.isdigit():
            qs = qs.filter(institution_id=int(institution_id))

        status = (self.request.GET.get("status") or "").strip()
        if status == "active":
            qs = qs.filter(status__in=[Scholar.Status.ENROLLED, Scholar.Status.ACTIVE])
        elif status == "paused":
            qs = qs.filter(status__in=[Scholar.Status.PAUSED, Scholar.Status.DEFERRED])
        elif status == "completed":
            qs = qs.filter(status=Scholar.Status.COMPLETED)
        elif status == "withdrawn":
            qs = qs.filter(status__in=[Scholar.Status.WITHDRAWN, Scholar.Status.DISCONTINUED])
        elif status == "overdue":
            # Only scholars expected to graduate 2020+ are flagged as genuinely
            # overdue. Pre-2020 records are legacy data quality issues (likely
            # graduated but never recorded) — shown separately in the at-risk report.
            from datetime import date as _d
            qs = qs.filter(
                expected_graduation__lt=_d.today(),
                expected_graduation__gte=_d(2020, 1, 1),
                graduation__isnull=True,
                status__in=[Scholar.Status.ENROLLED, Scholar.Status.ACTIVE,
                             Scholar.Status.PAUSED, Scholar.Status.DEFERRED],
            ).order_by("expected_graduation")
        elif status in [s.value for s in Scholar.Status]:
            qs = qs.filter(status=status)

        programme_type = (self.request.GET.get("programme_type") or "").strip()
        if programme_type:
            qs = qs.filter(scholarship__programme_type=programme_type)

        programme_id = (self.request.GET.get("programme") or "").strip()
        if programme_id.isdigit():
            qs = qs.filter(scholarship_id=int(programme_id))

        q = (self.request.GET.get("q") or "").strip()
        if q:
            qs = qs.filter(
                Q(user__email__icontains=q)
                | Q(user__first_name__icontains=q)
                | Q(user__last_name__icontains=q)
                | Q(scholarship__name__icontains=q)
                | Q(thesis_title__icontains=q)
                | Q(university_reg_no__icontains=q)
            )
        return self.apply_sort(qs)

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["scholarships/partials/scholar_list_results.html"]
        return super().get_template_names()

    def get_context_data(self, **kwargs):
        from urllib.parse import urlencode
        from django.db.models import Count
        from django.urls import reverse

        ctx = super().get_context_data(**kwargs)
        base = Scholar.objects.all()
        total = base.count()

        stats = {
            "active": base.filter(status__in=[Scholar.Status.ENROLLED, Scholar.Status.ACTIVE]).count(),
            "paused": base.filter(status__in=[Scholar.Status.PAUSED, Scholar.Status.DEFERRED]).count(),
            "completed": base.filter(status=Scholar.Status.COMPLETED).count(),
            "withdrawn": base.filter(status__in=[Scholar.Status.WITHDRAWN, Scholar.Status.DISCONTINUED]).count(),
        }
        ctx["scholar_stats"] = stats
        ctx["scholar_total"] = total

        # Graduation rate
        graduated = Scholar.objects.filter(graduation__isnull=False).count()
        ctx["graduated_count"] = graduated
        ctx["graduation_rate"] = round(100 * graduated / total) if total else 0

        # Degree level distribution
        degree_rows = (
            base.exclude(degree_level="")
            .values("degree_level")
            .annotate(n=Count("id"))
            .order_by("-n")
        )
        degree_label_map = dict(Scholar.DegreeLevel.choices)
        ctx["degree_distribution"] = [
            {"label": degree_label_map.get(r["degree_level"], r["degree_level"]), "count": r["n"],
             "pct": round(100 * r["n"] / total) if total else 0}
            for r in degree_rows
        ]

        # Top institutions
        ctx["top_institutions"] = (
            base.exclude(institution__isnull=True)
            .values("institution__name")
            .annotate(n=Count("id"))
            .order_by("-n")[:6]
        )

        # Cohort year distribution (last 8 cohorts)
        ctx["cohort_distribution"] = (
            base.exclude(cohort_year__isnull=True)
            .values("cohort_year")
            .annotate(n=Count("id"))
            .order_by("-cohort_year")[:8]
        )

        # Student type breakdown
        type_rows = base.values("student_type").annotate(n=Count("id")).order_by("-n")
        type_label_map = dict(Scholar.StudentType.choices)
        ctx["type_distribution"] = [
            {"label": type_label_map.get(r["student_type"], r["student_type"]), "count": r["n"]}
            for r in type_rows
        ]

        # Gender breakdown — sourced from ScholarshipApplicationProfile where available
        try:
            from apps.rims.grants.models import ScholarshipApplicationProfile
            gender_rows = (
                ScholarshipApplicationProfile.objects
                .exclude(gender="")
                .values("gender")
                .annotate(n=Count("id"))
                .order_by("-n")
            )
            ctx["gender_distribution"] = [
                {"label": r["gender"].capitalize(), "count": r["n"]}
                for r in gender_rows if r["n"] > 0
            ]
        except Exception:
            ctx["gender_distribution"] = []

        # Programme type distribution
        from apps.rims.scholarships.models import Scholarship as ScholarshipModel
        type_rows = (
            base.values("scholarship__programme_type")
            .annotate(n=Count("id"))
            .order_by("-n")
        )
        type_meta = {
            ScholarshipModel.ProgrammeType.SCHOLARSHIP:    ("Scholarship students", "bg-violet-100 text-violet-800", "text-violet-700", "Full scholarship (fees + stipend)"),
            ScholarshipModel.ProgrammeType.RESEARCH_GRANT: ("Research grant holders", "bg-emerald-100 text-emerald-800", "text-emerald-700", "GRG, CARP+, FAPA — thesis research funding"),
            ScholarshipModel.ProgrammeType.FELLOWSHIP:     ("Fellowship holders", "bg-sky-100 text-sky-800", "text-sky-700", "GTA, Post-Doctoral — teaching & research fellowships"),
            ScholarshipModel.ProgrammeType.OTHER:          ("Other programmes", "bg-gray-100 text-gray-700", "", "Legacy or uncategorised"),
        }
        ctx["programme_type_stats"] = [
            {
                "type": r["scholarship__programme_type"],
                "type_display": dict(ScholarshipModel.ProgrammeType.choices).get(r["scholarship__programme_type"], r["scholarship__programme_type"]),
                "label": type_meta.get(r["scholarship__programme_type"], ("", "", "", ""))[0],
                "badge_class": type_meta.get(r["scholarship__programme_type"], ("", "", "", ""))[1],
                "value_class": type_meta.get(r["scholarship__programme_type"], ("", "", "", ""))[2],
                "description": type_meta.get(r["scholarship__programme_type"], ("", "", "", ""))[3],
                "count": r["n"],
            }
            for r in type_rows if r["scholarship__programme_type"]
        ]

        # Overdue reports count
        from datetime import timedelta
        from django.utils import timezone
        cutoff = timezone.now() - timedelta(days=180)
        ctx["overdue_count"] = (
            Scholar.objects.filter(
                status__in=[Scholar.Status.ACTIVE, Scholar.Status.ENROLLED],
                enrolled_at__lte=cutoff,
            )
            .exclude(progress_reports__submitted_at__gte=cutoff)
            .distinct()
            .count()
        )

        active_status = (self.request.GET.get("status") or "").strip()
        active_programme = (self.request.GET.get("programme") or "").strip()
        active_programme_type = (self.request.GET.get("programme_type") or "").strip()
        q = (self.request.GET.get("q") or "").strip()
        list_url = reverse("rims_scholarships:scholar_list")

        def _facet_href(value: str) -> str:
            params: dict[str, str] = {}
            if value:
                params["status"] = value
            if q:
                params["q"] = q
            if active_programme:
                params["programme"] = active_programme
            if active_programme_type:
                params["programme_type"] = active_programme_type
            return f"{list_url}?{urlencode(params)}" if params else list_url

        # Programmes grouped by type for the filter dropdown
        from apps.rims.scholarships.models import Scholarship as ScholarshipModel
        programme_groups = {}
        for s in (
            ScholarshipModel.objects.annotate(scholar_count=Count("scholars"))
            .filter(scholar_count__gt=0)
            .order_by("programme_type", "-scholar_count")
        ):
            label = dict(ScholarshipModel.ProgrammeType.choices).get(s.programme_type, s.programme_type)
            programme_groups.setdefault(label, []).append({"id": s.pk, "name": s.name, "count": s.scholar_count})
        ctx["programme_groups"] = programme_groups
        ctx["active_programme"] = active_programme
        ctx["active_programme_type"] = active_programme_type

        # Overdue count — 2020+ only (pre-2020 are legacy data quality, not shown as alert)
        from datetime import date as _date
        _today = _date.today()
        overdue_count = base.filter(
            expected_graduation__lt=_today,
            expected_graduation__gte=_date(2020, 1, 1),
            graduation__isnull=True,
            status__in=[Scholar.Status.ENROLLED, Scholar.Status.ACTIVE,
                         Scholar.Status.PAUSED, Scholar.Status.DEFERRED],
        ).count()
        ctx["overdue_count"] = overdue_count

        # Scholars with intake_year but no expected_graduation (can estimate)
        estimable = base.filter(
            expected_graduation__isnull=True,
            intake_year__isnull=False,
            graduation__isnull=True,
            status__in=[Scholar.Status.ENROLLED, Scholar.Status.ACTIVE],
        ).count()
        ctx["estimable_count"] = estimable

        ctx["scholar_facets"] = [
            {"label": "All", "href": _facet_href(""), "count": total, "active": not active_status},
            {"label": "Active", "href": _facet_href("active"), "count": stats["active"], "active": active_status == "active"},
            {"label": "Paused / Deferred", "href": _facet_href("paused"), "count": stats["paused"], "active": active_status == "paused"},
            {"label": "Completed", "href": _facet_href("completed"), "count": stats["completed"], "active": active_status == "completed"},
            {"label": "Withdrawn", "href": _facet_href("withdrawn"), "count": stats["withdrawn"], "active": active_status == "withdrawn"},
            {
                "label": f"Overdue ({overdue_count})",
                "href": _facet_href("overdue"),
                "count": overdue_count,
                "active": active_status == "overdue",
                "danger": True,
            },
        ]
        ctx["scholar_enrol_url"] = reverse("rims_scholarships:scholar_enrol")

        # Count distinct countries for the map teaser
        from apps.core.authentication.models import Institution
        _NAME_TO_ISO2 = {
            "Uganda": "UG", "Kenya": "KE", "Tanzania": "TZ", "Zimbabwe": "ZW",
            "Ethiopia": "ET", "Malawi": "MW", "Zambia": "ZM", "Ghana": "GH",
            "South Africa": "ZA", "Rwanda": "RW", "Mozambique": "MZ", "Sudan": "SD",
            "Nigeria": "NG", "Cameroon": "CM", "Benin": "BJ", "Botswana": "BW",
        }
        inst_countries = set(
            _NAME_TO_ISO2.get(r["institution__country"], r["institution__country"])
            for r in base.exclude(institution__isnull=True)
            .exclude(institution__country="")
            .values("institution__country")
            .distinct()
            if r["institution__country"]
        )
        ctx["country_count_map"] = len(inst_countries)

        return ctx


class ScholarshipCreateView(ScholarshipsStaffMixin, CreateView):
    model = Scholarship
    form_class = ScholarshipForm
    template_name = "scholarships/scholarship_form.html"

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, "Scholarship programme created.")
        return redirect("rims_scholarships:scholarship_list")


class ScholarEnrolView(ScholarshipsStaffMixin, CreateView):
    model = Scholar
    form_class = ScholarEnrolForm
    template_name = "scholarships/scholar_enrol.html"

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, f"Scholar enrolled in {self.object.scholarship}.")
        return redirect("rims_scholarships:scholar_detail", pk=self.object.pk)


class ScholarDetailView(ScholarshipsStaffMixin, DetailView):
    model = Scholar
    template_name = "scholarships/scholar_detail.html"
    context_object_name = "scholar"

    def get_queryset(self):
        return Scholar.objects.select_related("user", "scholarship", "institution", "award", "award__application__call").prefetch_related(
            "progress_reports",
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        from apps.rims.grants.ai_helpers import scholar_academic_trend

        ctx["ai_scholar_trend"] = scholar_academic_trend(self.object)
        ctx["progress_reports"] = paginate_qs(
            self.request,
            self.object.progress_reports.order_by("-submitted_at"),
            per_page=12,
            param="report_page",
        )
        return ctx


class ScholarshipDetailView(ScholarshipsStaffMixin, DetailView):
    model = Scholarship
    template_name = "scholarships/scholarship_detail.html"
    context_object_name = "scholarship"

    def get_queryset(self):
        return Scholarship.objects.select_related("grant_call", "partner")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        scholarship = self.object

        scholars_qs = scholarship.scholars.select_related("user", "institution").prefetch_related("graduation")
        scholar_count = scholars_qs.count()
        graduated_count = scholars_qs.filter(graduation__isnull=False).count()
        certificates_issued = scholars_qs.filter(graduation__certificate_issued=True).count()

        # Cohort distribution — { year: count }, sorted by year desc.
        cohort_rows = (
            scholars_qs.exclude(cohort_year__isnull=True)
            .values("cohort_year")
            .annotate(count=Count("id"))
            .order_by("-cohort_year")
        )

        # Institution distribution — top 5 by scholar count.
        institution_rows = (
            scholars_qs.exclude(institution__isnull=True)
            .values("institution__id", "institution__name")
            .annotate(count=Count("id"))
            .order_by("-count", "institution__name")[:5]
        )

        # Stipend totals — grouped per currency so multi-currency programmes
        # don't get summed into nonsense totals.
        stipend_qs = Stipend.objects.filter(scholar__scholarship=scholarship)
        stipend_count = stipend_qs.count()
        stipend_totals = (
            stipend_qs.values("currency")
            .annotate(total=Sum("amount"), count=Count("id"))
            .order_by("currency")
        )

        progress_report_count = ProgressReport.objects.filter(
            scholar__scholarship=scholarship
        ).count()

        ctx["scholar_count"] = scholar_count
        ctx["active_scholar_count"] = scholar_count - graduated_count
        ctx["graduated_count"] = graduated_count
        ctx["certificates_issued"] = certificates_issued
        ctx["graduation_rate"] = (
            round(100 * graduated_count / scholar_count) if scholar_count else 0
        )
        ctx["cohort_rows"] = list(cohort_rows)
        ctx["institution_rows"] = list(institution_rows)
        ctx["stipend_count"] = stipend_count
        ctx["stipend_totals"] = list(stipend_totals)
        ctx["progress_report_count"] = progress_report_count
        ctx["recent_scholars"] = list(scholars_qs.order_by("-enrolled_at")[:8])
        return ctx


class ScholarshipUpdateView(ScholarshipsStaffMixin, UpdateView):
    model = Scholarship
    form_class = ScholarshipForm
    template_name = "scholarships/scholarship_edit.html"

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, "Scholarship programme updated.")
        return redirect("rims_scholarships:scholarship_detail", pk=self.object.pk)


class ScholarUpdateView(ScholarshipsStaffMixin, UpdateView):
    model = Scholar
    form_class = ScholarEditForm
    template_name = "scholarships/scholar_edit.html"

    def get_queryset(self):
        return Scholar.objects.select_related("user", "scholarship", "institution", "award", "award__application__call")

    def form_valid(self, form):
        self.object = form.save()
        messages.success(self.request, "Scholar record updated.")
        return redirect("rims_scholarships:scholar_detail", pk=self.object.pk)


class ScholarshipCallListView(SortableListMixin, ListView):
    """Pass-through over GrantCall filtered to scholarship calls.

    Scholarship calls live in the grants module (``GrantCall.call_type =
    SCHOLARSHIP``) so the review / award workflows can be reused. This view
    surfaces them under the Scholarships namespace so users do not leave the
    section to manage scholarship-specific calls.

    Per SRS 4.3.1 SP3 (guest preview), anonymous users see only published or
    closed calls and render under ``layouts/public.html``; staff see the full
    portfolio under ``layouts/dashboard.html``.
    """

    context_object_name = "calls"
    paginate_by = 25

    sortable_fields = {
        "title": "title",
        "opens": "opens_at",
        "closes": "closes_at",
    }
    default_sort = "-opens_at"

    def get_template_names(self):
        if self.request.user.is_authenticated:
            return ["scholarships/call_list.html"]
        return ["scholarships/call_list_public.html"]

    def get_queryset(self):
        qs = GrantCall.objects.scholarship_calls().select_related("funding_record")
        if not self.request.user.is_authenticated:
            qs = qs.filter(status=GrantCall.Status.PUBLISHED)
        q = (self.request.GET.get("q") or "").strip()
        if q:
            qs = qs.filter(Q(title__icontains=q) | Q(slug__icontains=q))
        status = self.request.GET.get("status")
        if status and self.request.user.is_authenticated:
            qs = qs.filter(status=status)
        return self.apply_sort(qs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["status_choices"] = GrantCall.Status.choices
        ctx["current_status"] = self.request.GET.get("status") or ""
        return ctx


class ScholarshipCallDetailView(DetailView):
    """Per SRS 4.3.1 SP3 — published scholarship calls are visible to guests."""

    template_name = "scholarships/call_detail.html"
    context_object_name = "call"
    slug_url_kwarg = "slug"

    def get_template_names(self):
        if self.request.user.is_authenticated:
            return ["scholarships/call_detail.html"]
        return ["scholarships/call_detail_public.html"]

    def get_queryset(self):
        qs = GrantCall.objects.scholarship_calls().select_related("funding_record")
        if not self.request.user.is_authenticated:
            qs = qs.filter(status=GrantCall.Status.PUBLISHED)
        return qs


class MyScholarshipsListView(LoginRequiredMixin, SortableListMixin, CsvExportMixin, ListView):
    """Scholar self-service list — every Scholar row owned by the current user."""

    model = Scholar
    template_name = "scholarships/my_scholarships.html"
    context_object_name = "scholars"
    paginate_by = 10

    sortable_fields = {
        "enrolled": "enrolled_at",
        "status": "status",
        "programme": "scholarship__name",
    }
    default_sort = "-enrolled_at"

    csv_columns = (
        ("Programme", lambda s: s.scholarship.name if s.scholarship_id else ""),
        ("Institution", lambda s: s.institution.name if s.institution_id else ""),
        ("Status", "get_status_display"),
        ("Enrolled", lambda s: s.enrolled_at.isoformat() if s.enrolled_at else ""),
    )
    csv_filename = "my_scholarships"

    def get_queryset(self):
        qs = (
            Scholar.objects.filter(user=self.request.user)
            .select_related("scholarship", "institution", "award")
        )
        return self.apply_sort(qs)


class MyScholarshipDetailView(LoginRequiredMixin, DetailView):
    """Scholar-owned detail view of a single enrolment with progress + stipends."""

    model = Scholar
    template_name = "scholarships/my_scholarship_detail.html"
    context_object_name = "scholar"

    def get_queryset(self):
        return Scholar.objects.filter(user=self.request.user).select_related(
            "scholarship", "scholarship__partner", "institution", "award"
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        scholar = self.object
        ctx["progress_reports"] = scholar.progress_reports.all().order_by("-submitted_at")
        ctx["stipends"] = scholar.stipends.all()
        ctx["graduation"] = getattr(scholar, "graduation", None)
        return ctx


# ---------------------------------------------------------------------------
# PRD §5.3 — Progress report submission (scholar self-service).
# ---------------------------------------------------------------------------
class ProgressReportSubmitView(LoginRequiredMixin, CreateView):
    model = ProgressReport
    form_class = ProgressReportForm
    template_name = "scholarships/progress_report_form.html"

    def dispatch(self, request, *args, **kwargs):
        self.scholar = get_object_or_404(
            Scholar.objects.select_related("user", "scholarship"),
            pk=kwargs["scholar_pk"],
        )
        if request.user.is_authenticated and not request.user.is_superuser:
            if self.scholar.user_id != request.user.id:
                raise PermissionDenied(
                    "Only the enrolled scholar can submit their progress reports."
                )
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["scholar"] = self.scholar
        return ctx

    def form_valid(self, form):
        try:
            self.object = submit_progress_report(
                self.scholar,
                period_label=form.cleaned_data["period_label"],
                file=form.cleaned_data["file"],
                notes=form.cleaned_data.get("notes") or "",
                actor=self.request.user,
            )
        except ValidationError as exc:
            for field, errors in (exc.message_dict.items() if hasattr(exc, "message_dict") else {}.items()):
                for err in errors:
                    form.add_error(field, err)
            if not hasattr(exc, "message_dict"):
                form.add_error(None, " ".join(getattr(exc, "messages", [str(exc)])))
            return self.form_invalid(form)
        messages.success(self.request, "Progress report submitted.")
        return redirect("rims_scholarships:my_scholarship_detail", pk=self.scholar.pk)


# ---------------------------------------------------------------------------
# PRD §5.3 — Stipend list / create (Finance Officer + Grants Manager).
# ---------------------------------------------------------------------------
class StipendListView(ScholarshipsFinanceMixin, SortableListMixin, CsvExportMixin, ListView):
    model = Stipend
    template_name = "scholarships/stipend_list.html"
    context_object_name = "stipends"
    paginate_by = 30

    sortable_fields = {
        "paid": "paid_on",
        "amount": "amount",
        "currency": "currency",
        "scholar": "scholar__user__last_name",
    }
    default_sort = "-paid_on"

    csv_columns = (
        ("Paid on", lambda s: s.paid_on.isoformat() if s.paid_on else ""),
        ("Scholar", lambda s: f"{s.scholar.user.first_name} {s.scholar.user.last_name}".strip() or s.scholar.user.email),
        ("Programme", lambda s: s.scholar.scholarship.name if s.scholar.scholarship_id else ""),
        ("Amount", "amount"),
        ("Currency", "currency"),
        ("Reference", "reference"),
        ("Status", "get_status_display"),
    )
    csv_filename = "stipends"

    def get_queryset(self):
        qs = (
            Stipend.objects.select_related("scholar__user", "scholar__scholarship")
        )
        params = self.request.GET
        if params.get("scholar"):
            qs = qs.filter(scholar_id=params["scholar"])
        if params.get("date_from"):
            qs = qs.filter(paid_on__gte=params["date_from"])
        if params.get("date_to"):
            qs = qs.filter(paid_on__lte=params["date_to"])
        if params.get("currency"):
            qs = qs.filter(currency=params["currency"])
        q = (params.get("q") or "").strip()
        if q:
            qs = qs.filter(
                Q(scholar__user__email__icontains=q)
                | Q(scholar__user__first_name__icontains=q)
                | Q(scholar__user__last_name__icontains=q)
                | Q(reference__icontains=q)
            )
        return self.apply_sort(qs)

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["scholarships/partials/stipend_list_results.html"]
        return super().get_template_names()

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["filter_values"] = {
            k: self.request.GET.get(k, "")
            for k in ("scholar", "date_from", "date_to", "currency", "q")
        }
        return ctx


class StipendCreateView(ScholarshipsFinanceMixin, CreateView):
    model = Stipend
    form_class = StipendForm
    template_name = "scholarships/stipend_form.html"

    def dispatch(self, request, *args, **kwargs):
        self.scholar = None
        scholar_pk = kwargs.get("scholar_pk")
        if scholar_pk:
            self.scholar = get_object_or_404(
                Scholar.objects.select_related("user", "scholarship"),
                pk=scholar_pk,
            )
        return super().dispatch(request, *args, **kwargs)

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        if self.scholar is not None:
            kwargs["scholar"] = self.scholar
        return kwargs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["scholar"] = self.scholar
        return ctx

    def form_valid(self, form):
        scholar = form.cleaned_data["scholar"]
        try:
            self.object = process_stipend(
                scholar,
                amount=form.cleaned_data["amount"],
                currency=form.cleaned_data["currency"],
                paid_on=form.cleaned_data["paid_on"],
                reference=form.cleaned_data.get("reference") or "",
                actor=self.request.user,
            )
        except ValidationError as exc:
            messages.error(self.request, " ".join(getattr(exc, "messages", [str(exc)])))
            return self.form_invalid(form)
        messages.success(self.request, f"Stipend recorded for {scholar}.")
        return redirect("rims_scholarships:scholar_detail", pk=scholar.pk)


# ---------------------------------------------------------------------------
# PRD §4.3.4 — Scholarship officer dashboard + lifecycle transitions.
# ---------------------------------------------------------------------------
class ScholarshipOfficerDashboardView(ScholarshipsStaffMixin, View):
    """Pipeline counts by status, stipend totals, overdue progress reports."""

    template_name = "scholarships/officer_dashboard.html"

    def get(self, request):
        from datetime import timedelta

        from apps.rims.scholarships.models import GraduationRecord

        status_counts = (
            Scholar.objects.values("status").annotate(n=Count("pk")).order_by("status")
        )
        status_lookup = {row["status"]: row["n"] for row in status_counts}
        # Pipeline rows align with the FSM order so the template can render
        # without any conditional plumbing.
        pipeline = [
            {"status": s.value, "label": s.label, "count": status_lookup.get(s.value, 0)}
            for s in Scholar.Status
        ]

        stipend_total = (
            Stipend.objects.aggregate(total=Sum("amount"))["total"] or 0
        )
        graduated_total = GraduationRecord.objects.count()

        # Overdue progress reports — scholars active for ≥ 6 months without a
        # progress report in the last 6 months.
        cutoff = timezone.now() - timedelta(days=180)
        overdue = list(
            Scholar.objects.filter(status=Scholar.Status.ACTIVE)
            .annotate(latest_report=Sum("progress_reports__pk"))  # presence test
            .exclude(progress_reports__submitted_at__gte=cutoff)
            .filter(enrolled_at__lte=cutoff)
            .select_related("user", "scholarship")[:25]
        )

        return render(
            request,
            self.template_name,
            {
                "pipeline": pipeline,
                "stipend_total": stipend_total,
                "graduated_total": graduated_total,
                "overdue_scholars": overdue,
            },
        )


class ScholarStatusUpdateView(ScholarshipsStaffMixin, View):
    """Service entrypoint for transitioning a scholar between lifecycle states."""

    def post(self, request, pk):
        from apps.rims.scholarships.workflows import transition_scholar

        scholar = get_object_or_404(Scholar, pk=pk)
        to_status = (request.POST.get("to_status") or "").strip()
        reason = (request.POST.get("reason") or "").strip()
        try:
            transition_scholar(
                scholar, to_status=to_status, actor=request.user, reason=reason
            )
        except ValidationError as exc:
            messages.error(
                request,
                exc.messages[0] if hasattr(exc, "messages") and exc.messages else str(exc),
            )
            return redirect("rims_scholarships:scholar_detail", pk=pk)
        messages.success(request, f"Scholar moved to {scholar.get_status_display()}.")
        return redirect("rims_scholarships:scholar_detail", pk=pk)


# ---------------------------------------------------------------------------
# Student self-service — academic profile update
# ---------------------------------------------------------------------------
class MyAcademicProfileUpdateView(LoginRequiredMixin, View):
    """Scholar updates their own academic profile (degree, thesis, supervisors)."""

    template_name = "scholarships/my_academic_profile.html"

    def _get_scholar(self, request, pk):
        scholar = get_object_or_404(Scholar, pk=pk)
        if scholar.user_id != request.user.id:
            raise PermissionDenied
        return scholar

    def get(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        form = ScholarAcademicProfileForm(instance=scholar)
        return render(request, self.template_name, {"form": form, "scholar": scholar})

    def post(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        form = ScholarAcademicProfileForm(request.POST, request.FILES, instance=scholar)
        if form.is_valid():
            form.save()
            messages.success(request, "Academic profile updated.")
            return redirect("rims_scholarships:my_scholarship_detail", pk=scholar_pk)
        return render(request, self.template_name, {"form": form, "scholar": scholar})


# ---------------------------------------------------------------------------
# Student self-service — status update (pause / defer / resume)
# ---------------------------------------------------------------------------
class MyStatusUpdateView(LoginRequiredMixin, View):
    """Scholar self-reports a status change (pause, defer, resume)."""

    template_name = "scholarships/my_status_update.html"

    def _get_scholar(self, request, pk):
        scholar = get_object_or_404(Scholar, pk=pk)
        if scholar.user_id != request.user.id:
            raise PermissionDenied
        return scholar

    def get(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        form = ScholarStatusUpdateForm()
        return render(request, self.template_name, {"form": form, "scholar": scholar})

    def post(self, request, scholar_pk):
        from apps.rims.scholarships.workflows import transition_scholar

        scholar = self._get_scholar(request, scholar_pk)
        form = ScholarStatusUpdateForm(request.POST)
        if form.is_valid():
            to_status = form.cleaned_data["to_status"]
            reason = form.cleaned_data.get("reason") or ""
            try:
                transition_scholar(scholar, to_status=to_status, actor=request.user, reason=reason)
                messages.success(request, f"Status updated to: {scholar.get_status_display()}.")
                return redirect("rims_scholarships:my_scholarship_detail", pk=scholar_pk)
            except ValidationError as exc:
                messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return render(request, self.template_name, {"form": form, "scholar": scholar})


# ---------------------------------------------------------------------------
# Student self-service — structured progress report
# ---------------------------------------------------------------------------
class MyProgressReportCreateView(LoginRequiredMixin, View):
    """Scholar submits a structured progress report with sub-items."""

    template_name = "scholarships/my_progress_report_form.html"

    def _get_scholar(self, request, pk):
        scholar = get_object_or_404(
            Scholar.objects.select_related("user", "scholarship"), pk=pk
        )
        if not request.user.is_superuser and scholar.user_id != request.user.id:
            raise PermissionDenied
        return scholar

    def _build_formsets(self, data=None, files=None):
        kw = {"data": data, "files": files} if data is not None else {}
        return {
            "supervisor_formset": SupervisorFeedbackFormSet(**kw, prefix="supervisors"),
            "skills_formset": SkillsImprovementFormSet(**kw, prefix="skills"),
            "manuscript_formset": ManuscriptFormSet(**kw, prefix="manuscripts"),
            "conference_formset": ConferencePaperFormSet(**kw, prefix="conferences"),
            "presentation_formset": PresentationFormSet(**kw, prefix="presentations"),
        }

    def get(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        form = ProgressReportForm()
        return render(request, self.template_name, {
            "form": form,
            "scholar": scholar,
            **self._build_formsets(),
        })

    def post(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        form = ProgressReportForm(request.POST, request.FILES)
        formsets = self._build_formsets(data=request.POST, files=request.FILES)

        all_valid = form.is_valid() and all(fs.is_valid() for fs in formsets.values())
        if all_valid:
            cleaned = form.cleaned_data
            extra = {
                k: cleaned[k] for k in cleaned
                if k not in ("period_label", "file", "notes")
            }
            try:
                submit_progress_report(
                    scholar,
                    period_label=cleaned["period_label"],
                    file=cleaned.get("file"),
                    notes=cleaned.get("notes") or "",
                    actor=request.user,
                    extra_fields=extra,
                    **formsets,
                )
                messages.success(request, "Progress report submitted successfully.")
                return redirect("rims_scholarships:my_scholarship_detail", pk=scholar_pk)
            except ValidationError as exc:
                for msg in getattr(exc, "messages", [str(exc)]):
                    messages.error(request, msg)

        return render(request, self.template_name, {
            "form": form,
            "scholar": scholar,
            **formsets,
        })


# ---------------------------------------------------------------------------
# Student self-service — graduation submission
# ---------------------------------------------------------------------------
class MyGraduationSubmitView(LoginRequiredMixin, View):
    """Scholar submits graduation details — triggers GraduationRecord + AlumniProfile."""

    template_name = "scholarships/my_graduation_submit.html"

    def _get_scholar(self, request, pk):
        scholar = get_object_or_404(Scholar, pk=pk)
        if scholar.user_id != request.user.id:
            raise PermissionDenied
        return scholar

    def get(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        if hasattr(scholar, "graduation"):
            messages.info(request, "Your graduation has already been recorded.")
            return redirect("rims_scholarships:my_scholarship_detail", pk=scholar_pk)
        form = GraduationSubmitForm()
        return render(request, self.template_name, {"form": form, "scholar": scholar})

    def post(self, request, scholar_pk):
        scholar = self._get_scholar(request, scholar_pk)
        if hasattr(scholar, "graduation"):
            messages.info(request, "Your graduation has already been recorded.")
            return redirect("rims_scholarships:my_scholarship_detail", pk=scholar_pk)
        form = GraduationSubmitForm(request.POST, request.FILES)
        if form.is_valid():
            try:
                submit_graduation(
                    scholar,
                    graduated_on=form.cleaned_data["graduated_on"],
                    certificate_file=form.cleaned_data.get("certificate_file"),
                    notes=form.cleaned_data.get("notes") or "",
                    actor=request.user,
                )
                messages.success(
                    request,
                    "Graduation submitted. Your alumni profile will be created shortly."
                )
                return redirect("rims_scholarships:my_scholarship_detail", pk=scholar_pk)
            except ValidationError as exc:
                messages.error(request, " ".join(getattr(exc, "messages", [str(exc)])))
        return render(request, self.template_name, {"form": form, "scholar": scholar})


# ---------------------------------------------------------------------------
# Student self-service — dashboard
# ---------------------------------------------------------------------------
class MyScholarDashboardView(LoginRequiredMixin, View):
    """Student dashboard — lifecycle overview, pending actions, REP courses, stipends."""

    template_name = "scholarships/my_dashboard.html"

    def get(self, request):
        from datetime import timedelta

        scholars = (
            Scholar.objects.filter(user=request.user)
            .select_related("scholarship", "institution", "award")
            .prefetch_related("stipends", "progress_reports")
        )

        dashboard_items = []
        for scholar in scholars:
            stipends = list(scholar.stipends.order_by("-paid_on")[:5])
            reports = list(scholar.progress_reports.order_by("-submitted_at")[:3])
            graduation = getattr(scholar, "graduation", None)

            # Overdue report check — no report in last 6 months and active > 6m
            overdue_report = False
            if scholar.is_active_status:
                cutoff = timezone.now() - timedelta(days=180)
                if scholar.enrolled_at and scholar.enrolled_at <= cutoff:
                    latest = scholar.progress_reports.order_by("-submitted_at").first()
                    if not latest or latest.submitted_at < cutoff:
                        overdue_report = True

            # Scholar training completions were sourced from the REP LMS (retired
            # for Moodle); a Moodle-backed feed can repopulate these via MEL later.
            rep_completions = []

            dashboard_items.append({
                "scholar": scholar,
                "stipends": stipends,
                "reports": reports,
                "graduation": graduation,
                "overdue_report": overdue_report,
                "rep_completions": rep_completions,
            })

        return render(request, self.template_name, {
            "dashboard_items": dashboard_items,
            "has_scholars": bool(dashboard_items),
        })


# ---------------------------------------------------------------------------
# Scholar origin map
# ---------------------------------------------------------------------------
class ScholarOriginMapView(ScholarshipsStaffMixin, View):
    """Full-page Leaflet map of scholar country distribution."""

    template_name = "scholarships/scholar_origin_map.html"

    # ISO 3166-1 alpha-2 / full-name → (lat, lng, display_name)
    _CENTROIDS = {
        "UG": ( 1.37,  32.29, "Uganda"),        "KE": (-0.02,  37.91, "Kenya"),
        "CM": ( 7.37,  12.35, "Cameroon"),       "MW": (-13.25, 34.30, "Malawi"),
        "BJ": ( 9.31,   2.32, "Benin"),          "NG": ( 9.08,   8.68, "Nigeria"),
        "GH": ( 7.95,  -1.02, "Ghana"),          "ZW": (-19.02, 29.15, "Zimbabwe"),
        "ZA": (-30.56, 22.94, "South Africa"),   "SS": ( 6.88,  31.31, "South Sudan"),
        "RW": (-1.94,  29.87, "Rwanda"),         "MA": (31.79,  -7.09, "Morocco"),
        "TZ": (-6.37,  34.89, "Tanzania"),       "BI": (-3.37,  29.92, "Burundi"),
        "ET": ( 9.15,  40.49, "Ethiopia"),       "LR": ( 6.43,  -9.43, "Liberia"),
        "NA": (-22.96, 18.49, "Namibia"),        "CD": (-4.04,  21.76, "DR Congo"),
        "SO": ( 5.15,  46.20, "Somalia"),        "ZM": (-13.13, 27.85, "Zambia"),
        "LS": (-29.61, 28.23, "Lesotho"),        "SD": (12.86,  30.22, "Sudan"),
        "SL": ( 8.46, -11.78, "Sierra Leone"),   "MZ": (-18.67, 35.53, "Mozambique"),
        "GM": (13.44, -15.31, "Gambia"),         "LY": (26.34,  17.23, "Libya"),
        "TG": ( 8.62,   0.82, "Togo"),           "BW": (-22.33, 24.68, "Botswana"),
        "CI": ( 7.54,  -5.55, "Côte d'Ivoire"), "TD": (15.45,  18.73, "Chad"),
        "SN": (14.50, -14.45, "Senegal"),        "GA": (-0.80,  11.61, "Gabon"),
        "IN": (20.59,  78.96, "India"),          "MU": (-20.35,  57.55, "Mauritius"),
        "EG": (26.82,  30.80, "Egypt"),          "ER": (15.18,  39.78, "Eritrea"),
        "DJ": (11.83,  42.59, "Djibouti"),       "ML": (17.57,  -3.99, "Mali"),
        "GN": (11.74, -15.31, "Guinea"),         "NE": (17.61,   8.08, "Niger"),
        "GW": (11.80, -15.18, "Guinea-Bissau"), "CG": (-0.23,  15.83, "Republic of Congo"),
        "AO": (-11.20, 17.87, "Angola"),         "MG": (-18.77, 46.87, "Madagascar"),
        "KM": (-11.88, 43.87, "Comoros"),        "SC": (-4.68,  55.49, "Seychelles"),
        "CV": (16.54, -23.04, "Cape Verde"),     "ST": ( 0.19,   6.61, "São Tomé"),
        "MR": (21.01, -10.94, "Mauritania"),     "EH": (24.21, -12.89, "Western Sahara"),
        "CF": ( 6.61,  20.94, "Central African Republic"),
        "GQ": ( 1.65,  10.27, "Equatorial Guinea"),
    }

    # Full country names → ISO2
    _NAME_TO_ISO2 = {
        "Uganda": "UG", "Kenya": "KE", "Tanzania": "TZ", "Zimbabwe": "ZW",
        "Ethiopia": "ET", "Malawi": "MW", "Zambia": "ZM", "Ghana": "GH",
        "South Africa": "ZA", "Rwanda": "RW", "Mozambique": "MZ", "Sudan": "SD",
        "Nigeria": "NG", "Cameroon": "CM", "Benin": "BJ", "Botswana": "BW",
        "Senegal": "SN", "Côte d'Ivoire": "CI", "Ivory Coast": "CI",
        "DR Congo": "CD", "Democratic Republic of Congo": "CD",
        "South Sudan": "SS", "Burundi": "BI", "Lesotho": "LS",
        "Liberia": "LR", "Sierra Leone": "SL", "Namibia": "NA",
        "Togo": "TG", "Gambia": "GM", "Somalia": "SO",
        "Morocco": "MA", "Libya": "LY", "Chad": "TD", "Mali": "ML",
        "Niger": "NE", "Guinea": "GN", "Mauritius": "MU", "India": "IN",
        "Egypt": "EG", "Eritrea": "ER", "Gabon": "GA", "Angola": "AO",
        "Madagascar": "MG", "Republic of Congo": "CG", "Congo": "CG",
        "Central African Republic": "CF", "Equatorial Guinea": "GQ",
        "Djibouti": "DJ", "Guinea-Bissau": "GW", "Cape Verde": "CV",
        "Seychelles": "SC", "Comoros": "KM", "São Tomé and Príncipe": "ST",
    }

    def get(self, request):
        import json
        from django.db.models import Count, Q as DQ

        programme_type = request.GET.get("programme_type", "all")
        degree_filter  = request.GET.get("degree", "all")

        qs = Scholar.objects.select_related("institution", "scholarship")

        if programme_type != "all":
            qs = qs.filter(scholarship__programme_type=programme_type)
        if degree_filter != "all":
            qs = qs.filter(degree_level=degree_filter)

        # ── Institution country (primary source) ──────────────────────────
        inst_rows = (
            qs.exclude(institution__isnull=True)
            .exclude(institution__country="")
            .values("institution__country")
            .annotate(n=Count("id", distinct=True))
        )

        by_country: dict[str, int] = {}
        for r in inst_rows:
            raw = (r["institution__country"] or "").strip()
            iso2 = self._NAME_TO_ISO2.get(raw) or (raw if raw in self._CENTROIDS else None)
            if iso2:
                by_country[iso2] = by_country.get(iso2, 0) + r["n"]

        # Build map points
        points = []
        for code, count in sorted(by_country.items(), key=lambda x: -x[1]):
            if code not in self._CENTROIDS:
                continue
            lat, lng, name = self._CENTROIDS[code]
            points.append({"lat": lat, "lng": lng, "name": name,
                            "count": count, "code": code})

        total  = sum(p["count"] for p in points)
        top10  = points[:10]

        # Totals per programme type for sidebar
        from apps.rims.scholarships.models import Scholarship as ScholarshipModel
        type_totals = {
            r["scholarship__programme_type"]: r["n"]
            for r in Scholar.objects.values("scholarship__programme_type")
            .annotate(n=Count("id"))
        }

        # Degree distribution for filter
        degree_rows = (
            Scholar.objects.exclude(degree_level="")
            .values("degree_level")
            .annotate(n=Count("id"))
            .order_by("-n")
        )
        degree_label_map = dict(Scholar.DegreeLevel.choices)
        degree_options = [
            {"value": r["degree_level"], "label": degree_label_map.get(r["degree_level"], r["degree_level"]), "count": r["n"]}
            for r in degree_rows
        ]

        programme_type_options = [
            ("all",          "All programmes",   Scholar.objects.count()),
            ("research_grant", "Research Grants", type_totals.get("research_grant", 0)),
            ("fellowship",   "Fellowships",      type_totals.get("fellowship", 0)),
            ("scholarship",  "Scholarships",     type_totals.get("scholarship", 0)),
        ]

        colour_map = {
            "research_grant": "#059669",
            "fellowship":     "#0284c7",
            "scholarship":    "#7c3aed",
            "all":            "#6b5317",
        }
        colour = colour_map.get(programme_type, "#6b5317")

        return render(request, self.template_name, {
            "points_json":           json.dumps(points),
            "colour":                colour,
            "total":                 total,
            "country_count":         len(points),
            "top10":                 top10,
            "programme_type":        programme_type,
            "degree_filter":         degree_filter,
            "programme_type_options": programme_type_options,
            "degree_options":        degree_options,
        })


# ---------------------------------------------------------------------------
# At-risk / overdue scholars report
# ---------------------------------------------------------------------------
class ScholarAtRiskView(ScholarshipsStaffMixin, View):
    """Full report of scholars past expected graduation or estimably overdue."""

    template_name = "scholarships/scholar_at_risk.html"

    _DEGREE_DURATION = {"msc": 2, "mphil": 3, "phd": 4, "bachelor": 4, "pgdip": 1}

    def get(self, request):
        from datetime import date, timedelta

        today = date.today()

        # Only flag scholars whose expected graduation was 2020 or later as
        # "genuinely overdue". Pre-2020 records from the legacy system very
        # likely graduated but were never recorded — flagging them as at-risk
        # would be misleading noise. They are shown separately as a data-quality
        # group so officers can review and mark them as graduated/withdrawn.
        RECENT_CUTOFF = date(2020, 1, 1)
        LEGACY_CUTOFF = date(today.year - 15, 1, 1)

        active_statuses = [
            Scholar.Status.ENROLLED, Scholar.Status.ACTIVE,
            Scholar.Status.PAUSED, Scholar.Status.DEFERRED,
        ]

        base_filter = dict(
            graduation__isnull=True,
            status__in=active_statuses,
        )

        # ── Group 1: genuinely overdue (2020+) ───────────────────────────────
        overdue_qs = (
            Scholar.objects.filter(
                expected_graduation__lt=today,
                expected_graduation__gte=RECENT_CUTOFF,
                **base_filter,
            )
            .select_related("user", "scholarship", "institution")
            .order_by("expected_graduation")
        )

        overdue = []
        for s in overdue_qs:
            days = (today - s.expected_graduation).days
            years = round(days / 365, 1)
            overdue.append({
                "scholar": s,
                "days_overdue": days,
                "years_overdue": years,
                "severity": "critical" if days > 730 else "warning" if days > 365 else "info",
            })

        # ── Group 2: legacy unrecorded graduates (pre-2020) ──────────────────
        legacy_qs = (
            Scholar.objects.filter(
                expected_graduation__lt=RECENT_CUTOFF,
                expected_graduation__gte=LEGACY_CUTOFF,
                **base_filter,
            )
            .select_related("user", "scholarship", "institution")
            .order_by("-expected_graduation")
        )

        legacy_unrecorded = []
        for s in legacy_qs:
            years = round((today - s.expected_graduation).days / 365, 1)
            legacy_unrecorded.append({
                "scholar": s,
                "years_overdue": years,
            })

        # ── Group 2: no expected_graduation but estimable from intake_year ────
        estimable_qs = (
            Scholar.objects.filter(
                expected_graduation__isnull=True,
                intake_year__isnull=False,
                graduation__isnull=True,
                status__in=active_statuses,
            )
            .select_related("user", "scholarship", "institution")
            .order_by("intake_year", "degree_level")
        )

        estimable = []
        for s in estimable_qs:
            duration = self._DEGREE_DURATION.get(s.degree_level, 3)
            est_grad_year = s.intake_year + duration
            est_grad = date(est_grad_year, 12, 31)
            if est_grad < today:
                days = (today - est_grad).days
                years = round(days / 365, 1)
                estimable.append({
                    "scholar": s,
                    "est_graduation": est_grad,
                    "days_overdue": days,
                    "years_overdue": years,
                    "severity": "critical" if days > 730 else "warning" if days > 365 else "info",
                })

        # ── Severity breakdown ────────────────────────────────────────────────
        def _count_severity(lst, level):
            return sum(1 for x in lst if x["severity"] == level)

        summary = {
            "confirmed_overdue": len(overdue),
            "estimated_overdue": len(estimable),
            "legacy_unrecorded": len(legacy_unrecorded),
            "critical": _count_severity(overdue, "critical") + _count_severity(estimable, "critical"),
            "warning": _count_severity(overdue, "warning") + _count_severity(estimable, "warning"),
            "mild": _count_severity(overdue, "info") + _count_severity(estimable, "info"),
        }

        # ── Programme breakdown ───────────────────────────────────────────────
        from django.db.models import Count
        prog_breakdown = {}
        for item in overdue + estimable:
            name = item["scholar"].scholarship.name
            prog_breakdown[name] = prog_breakdown.get(name, 0) + 1
        prog_breakdown = sorted(prog_breakdown.items(), key=lambda x: -x[1])

        return render(request, self.template_name, {
            "overdue": overdue,
            "estimable": estimable,
            "legacy_unrecorded": legacy_unrecorded,
            "summary": summary,
            "prog_breakdown": prog_breakdown,
            "today": today,
            "recent_cutoff_year": RECENT_CUTOFF.year,
        })
