from __future__ import annotations

from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import ValidationError
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views import View
from django.views.generic import CreateView, DetailView, ListView, UpdateView

from apps.core.audit.models import AuditLog
from apps.core.lists import paginate_qs
from apps.core.permissions.mixins import RoleRequiredMixin
from apps.core.permissions.roles import UserRole
from apps.mel.indicators.forms import (
    DataPointForm,
    DataPointImportForm,
    EvidenceAttachmentForm,
    ImpactDatapointForm,
    ImpactEvaluationForm,
    IndicatorForm,
    IndicatorTargetForm,
    IndicatorTargetRevisionForm,
    LogFrameForm,
    LogFrameRowForm,
    OutcomeAssessmentForm,
    ValidationRuleForm,
)
from apps.mel.indicators.models import (
    DataPoint,
    DisaggregationCategory,
    EvidenceAttachment,
    ImpactDatapoint,
    ImpactEvaluation,
    Indicator,
    IndicatorTarget,
    IndicatorVariance,
    LogFrame,
    LogFrameLevel,
    LogFrameRow,
    OutcomeAssessment,
    OutputType,
    ValidationRule,
)
from apps.mel.indicators.services import (
    attach_evidence,
    calculate_cohens_d,
    calculate_progress,
    compute_variance,
    current_period_label,
    distinct_period_labels,
    import_data_points_csv,
    record_data_point,
    reject_datapoint,
    validate_hierarchy,
    verify_datapoint,
)

OFFICER_ROLES = (UserRole.MEL_OFFICER, UserRole.ADMIN, UserRole.SYSTEM_ADMIN)


# ---------------------------------------------------------------------------
# Indicator list / detail
# ---------------------------------------------------------------------------


class IndicatorListView(RoleRequiredMixin, ListView):
    allowed_roles = OFFICER_ROLES + (
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.GRANTS_MANAGER,
    )
    model = Indicator
    context_object_name = "indicators"
    paginate_by = 10
    template_name = "indicators/indicator_list.html"

    def get_queryset(self):
        qs = (
            Indicator.objects.select_related("logframe_row__logframe", "responsible")
            .filter(is_active=True)
            .order_by("code")
        )
        q = self.request.GET.get("q", "").strip()
        if q:
            from django.db.models import Q

            qs = qs.filter(Q(name__icontains=q) | Q(code__icontains=q))
        level = self.request.GET.get("level", "").strip()
        if level:
            qs = qs.filter(logframe_row__level=level)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        period = self.request.GET.get("period") or current_period_label("monthly")
        rows = []
        rag_counts = {"green": 0, "amber": 0, "red": 0, "na": 0}
        for ind in ctx["indicators"]:
            progress = calculate_progress(ind, period)
            rows.append({"indicator": ind, "progress": progress})
            rag = (progress.get("rag") if isinstance(progress, dict) else None) or "na"
            rag_counts[rag] = rag_counts.get(rag, 0) + 1
        ctx["rows"] = rows
        ctx["rag_counts"] = rag_counts
        ctx["period"] = period
        # Distinguish "no indicators exist at all" from "a filter matched none"
        # so the empty state can offer a clear-filter affordance (defect 47a).
        ctx["q"] = self.request.GET.get("q", "").strip()
        ctx["level_filter"] = self.request.GET.get("level", "").strip()
        ctx["has_filters"] = bool(ctx["q"] or ctx["level_filter"])
        ctx["total_active_indicators"] = Indicator.objects.filter(is_active=True).count()
        # Dropdown options — union the current period so it's always selectable
        # even when no data has landed yet for it.
        options = distinct_period_labels()
        if period and period not in options:
            options = [period, *options]
        ctx["period_options"] = options
        return ctx

    def get_template_names(self):
        if getattr(self.request, "htmx", False):
            return ["indicators/partials/indicator_table.html"]
        return [self.template_name]


class IndicatorDetailView(RoleRequiredMixin, DetailView):
    allowed_roles = OFFICER_ROLES + (
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.GRANTS_MANAGER,
    )
    model = Indicator
    slug_field = "code"
    slug_url_kwarg = "code"
    context_object_name = "indicator"
    template_name = "indicators/indicator_detail.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        indicator: Indicator = ctx["indicator"]
        period = self.request.GET.get("period") or current_period_label(indicator.frequency)
        ctx["period"] = period
        ctx["progress"] = calculate_progress(indicator, period)
        ctx["data_points"] = paginate_qs(
            self.request, indicator.data_points.all(), per_page=25, param="dp_page"
        )
        # FRMFL014–017 — surface the verify/reject review queue. Only VERIFIED
        # points count toward RAG/variance, so pending manual+CSV entries need an
        # officer action or they contribute nothing (defect 7).
        pending_qs = indicator.data_points.filter(
            status__in=[
                DataPoint.Status.PENDING,
                DataPoint.Status.NEEDS_CLARIFICATION,
            ]
        ).select_related("reported_by")
        ctx["pending_data_points"] = list(pending_qs.order_by("-reported_at")[:50])
        ctx["pending_count"] = pending_qs.count()
        ctx["can_verify_data_points"] = (
            self.request.user.is_authenticated
            and getattr(self.request.user, "role", None) in {r.value for r in OFFICER_ROLES}
        )
        ctx["targets"] = indicator.targets.all()
        # Sparkline uses its own recent slice so it stays stable across paging.
        sparkline_points = list(indicator.data_points.all()[:30])
        ctx["sparkline"] = _build_sparkline(reversed(sparkline_points))
        # G8 — short-horizon forecast overlay.
        from apps.mel.indicators.forecasting import build_forecast_chart, forecast_indicator

        forecast_result = forecast_indicator(indicator)
        ctx["forecast"] = build_forecast_chart(forecast_result)
        ctx["forecast_result"] = forecast_result
        options = distinct_period_labels(indicator)
        if period and period not in options:
            options = [period, *options]
        ctx["period_options"] = options
        # Cross-links: an outcome-level indicator has assessments tied to its
        # row; an impact-level indicator has evaluations tied to its logframe.
        if indicator.logframe_row.level == "outcome":
            ctx["related_outcome_assessments"] = (
                indicator.logframe_row.outcome_assessments
                .select_related("assessor")
                .order_by("-assessed_at")[:5]
            )
        elif indicator.logframe_row.level == "impact":
            ctx["related_impact_evaluations"] = (
                indicator.logframe_row.logframe.impact_evaluations
                .select_related("evaluator")
                .order_by("-completed_on", "-created_at")[:5]
            )
        # G9 — validation rules surfaced on the detail page (officer-only edit).
        ctx["validation_rules"] = list(indicator.validation_rules.all())
        ctx["validation_rule_form"] = ValidationRuleForm()
        # G21 — polymorphic evidence gallery context (indicator-level evidence).
        from django.contrib.contenttypes.models import ContentType

        ct = ContentType.objects.get_for_model(Indicator)
        ctx["attachment_ct_id"] = ct.pk
        ctx["attachments"] = list(
            EvidenceAttachment.objects.filter(content_type=ct, object_id=indicator.pk)
        )
        return ctx


# ---------------------------------------------------------------------------
# G9 — Validation rule CRUD
# ---------------------------------------------------------------------------


class ValidationRuleCreateView(RoleRequiredMixin, View):
    """POST handler — adds a ValidationRule from the inline form on indicator_detail."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, code: str):
        indicator = get_object_or_404(Indicator, code=code)
        form = ValidationRuleForm(request.POST)
        if not form.is_valid():
            messages.error(request, form.errors.as_text())
            return HttpResponseRedirect(
                reverse("mel_indicators:indicator_detail", kwargs={"code": code})
            )
        rule = form.save(commit=False)
        rule.indicator = indicator
        rule.created_by = request.user
        rule.save()
        messages.success(request, f"Validation rule added: {rule.get_rule_type_display()}.")
        return HttpResponseRedirect(
            reverse("mel_indicators:indicator_detail", kwargs={"code": code})
        )


class ValidationRuleToggleView(RoleRequiredMixin, View):
    """POST handler — flips the is_active flag on a ValidationRule."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, rule_pk: int):
        rule = get_object_or_404(ValidationRule, pk=rule_pk)
        rule.is_active = not rule.is_active
        rule.save(update_fields=["is_active", "updated_at"])
        messages.success(
            request,
            f"Rule {'enabled' if rule.is_active else 'disabled'}.",
        )
        return HttpResponseRedirect(
            reverse("mel_indicators:indicator_detail", kwargs={"code": rule.indicator.code})
        )


class ValidationRuleDeleteView(RoleRequiredMixin, View):
    """POST handler — removes a ValidationRule. History row preserves the prior state."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, rule_pk: int):
        rule = get_object_or_404(ValidationRule, pk=rule_pk)
        code = rule.indicator.code
        rule.delete()
        messages.success(request, "Validation rule removed.")
        return HttpResponseRedirect(
            reverse("mel_indicators:indicator_detail", kwargs={"code": code})
        )


def _build_sparkline(points, *, width: int = 560, height: int = 140, pad: int = 10) -> dict:
    """Return an inline-SVG-ready payload for a server-rendered sparkline."""
    values: list[float] = []
    labels: list[str] = []
    for dp in points:
        try:
            values.append(float(dp.value))
        except (TypeError, ValueError):
            continue
        labels.append(dp.reported_at.strftime("%d %b"))
    if len(values) < 2:
        return {"has_data": False, "width": width, "height": height}

    lo = min(values)
    hi = max(values)
    span = hi - lo if hi != lo else 1.0
    step = (width - 2 * pad) / max(len(values) - 1, 1)
    coords: list[tuple[float, float]] = []
    for i, v in enumerate(values):
        x = pad + i * step
        y = pad + (height - 2 * pad) * (1 - (v - lo) / span)
        coords.append((x, y))
    line_points = " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
    area_points = (
        f"{coords[0][0]:.1f},{height - pad} "
        + line_points
        + f" {coords[-1][0]:.1f},{height - pad}"
    )
    return {
        "has_data": True,
        "width": width,
        "height": height,
        "line_points": line_points,
        "area_points": area_points,
        "first_label": labels[0] if labels else "",
        "last_label": labels[-1] if labels else "",
        "lo": lo,
        "hi": hi,
    }


_INDICATOR_WIZARD_STEPS = [
    {"n": 1, "title": "Identity"},
    {"n": 2, "title": "Measurement"},
    {"n": 3, "title": "Automation"},
]


class IndicatorCreateView(RoleRequiredMixin, CreateView):
    allowed_roles = OFFICER_ROLES
    model = Indicator
    form_class = IndicatorForm
    template_name = "indicators/indicator_form.html"

    def get_initial(self):
        initial = super().get_initial()
        row_id = self.request.GET.get("logframe_row")
        if row_id:
            initial["logframe_row"] = row_id
        return initial

    def get_form_kwargs(self):
        # M&E SRS 4.4.2 — scope the output picker to the chosen project.
        kwargs = super().get_form_kwargs()
        slug = self.request.GET.get("rf")
        if slug:
            kwargs["framework"] = LogFrame.objects.filter(slug=slug).first()
        return kwargs

    def form_valid(self, form):
        from apps.mel.indicators.models import IndicatorState
        from apps.mel.indicators.services import LogFrameClosedError, assert_logframe_editable

        row = form.cleaned_data.get("logframe_row")
        try:
            assert_logframe_editable(getattr(row, "logframe", None), action="add indicators")
        except LogFrameClosedError as exc:
            form.add_error("logframe_row", str(exc))
            return self.form_invalid(form)
        # M&E SRS — a new indicator is created in DRAFT state, then disaggregation
        # is defined before it is published.
        form.instance.workflow_status = IndicatorState.DRAFT
        response = super().form_valid(form)
        messages.success(
            self.request,
            f"Indicator “{self.object.code}” created as a draft — define its "
            f"disaggregation next, then publish it.",
        )
        return response

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:indicator_disaggregation", kwargs={"code": self.object.code}
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _INDICATOR_WIZARD_STEPS
        ctx["cancel_url"] = reverse("mel_indicators:indicator_list")
        return ctx


class IndicatorUpdateView(RoleRequiredMixin, UpdateView):
    allowed_roles = OFFICER_ROLES
    model = Indicator
    form_class = IndicatorForm
    slug_field = "code"
    slug_url_kwarg = "code"
    template_name = "indicators/indicator_form.html"

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        slug = self.request.GET.get("rf")
        if slug:
            kwargs["framework"] = LogFrame.objects.filter(slug=slug).first()
        return kwargs

    def get_success_url(self):
        return reverse_lazy("mel_indicators:indicator_detail", kwargs={"code": self.object.code})

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _INDICATOR_WIZARD_STEPS
        ctx["cancel_url"] = reverse(
            "mel_indicators:indicator_detail", kwargs={"code": self.object.code}
        )
        return ctx


# ---------------------------------------------------------------------------
# M&E SRS Tables 63-64 — Indicator disaggregation
# ---------------------------------------------------------------------------


class IndicatorDisaggregationView(RoleRequiredMixin, View):
    """Manage an indicator's disaggregation categories (M&E SRS Table 64).

    Lists categories + their values and lets the officer add a new category.
    """

    allowed_roles = OFFICER_ROLES
    template_name = "indicators/disaggregation.html"

    def _indicator(self, code):
        return get_object_or_404(
            Indicator.objects.prefetch_related(
                "disaggregation_categories__values__responsible"
            ),
            code=code,
        )

    def get(self, request, code):
        from apps.mel.indicators.forms import DisaggregationCategoryForm
        from apps.mel.indicators.services import indicator_publish_blockers

        indicator = self._indicator(code)
        return render(
            request,
            self.template_name,
            {
                "indicator": indicator,
                "categories": indicator.disaggregation_categories.all(),
                "form": DisaggregationCategoryForm(),
                "publish_blockers": (
                    indicator_publish_blockers(indicator) if indicator.is_draft else []
                ),
            },
        )

    def post(self, request, code):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.forms import DisaggregationCategoryForm

        indicator = self._indicator(code)
        form = DisaggregationCategoryForm(request.POST)
        if form.is_valid():
            category = form.save(commit=False)
            category.indicator = indicator
            try:
                category.full_clean()
            except ValidationError as exc:
                form.add_error(None, exc.messages)
            else:
                category.save()
                log_audit(
                    actor=request.user,
                    action=AuditLog.Action.CREATE,
                    target_app="mel_indicators",
                    target_model="DisaggregationCategory",
                    object_id=category.pk,
                    object_repr=str(category),
                    changes={"name": category.name},
                )
                messages.success(request, f"Added disaggregation category “{category.name}”.")
                return HttpResponseRedirect(
                    reverse(
                        "mel_indicators:disaggregation_values",
                        kwargs={"code": code, "pk": category.pk},
                    )
                )
        from apps.mel.indicators.services import indicator_publish_blockers

        return render(
            request,
            self.template_name,
            {
                "indicator": indicator,
                "categories": indicator.disaggregation_categories.all(),
                "form": form,
                "publish_blockers": (
                    indicator_publish_blockers(indicator) if indicator.is_draft else []
                ),
            },
        )


class DisaggregationValuesView(RoleRequiredMixin, View):
    """Add / edit the values of a disaggregation category via an inline formset."""

    allowed_roles = OFFICER_ROLES
    template_name = "indicators/disaggregation_values.html"

    def _category(self, code, pk):
        return get_object_or_404(
            DisaggregationCategory.objects.select_related("indicator"),
            pk=pk,
            indicator__code=code,
        )

    def get(self, request, code, pk):
        from apps.mel.indicators.forms import disaggregation_value_formset

        category = self._category(code, pk)
        return render(
            request,
            self.template_name,
            {
                "category": category,
                "indicator": category.indicator,
                "formset": disaggregation_value_formset(instance=category),
            },
        )

    def post(self, request, code, pk):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.forms import disaggregation_value_formset

        category = self._category(code, pk)
        formset = disaggregation_value_formset(data=request.POST, instance=category)
        if formset.is_valid():
            formset.save()
            log_audit(
                actor=request.user,
                action=AuditLog.Action.UPDATE,
                target_app="mel_indicators",
                target_model="DisaggregationCategory",
                object_id=category.pk,
                object_repr=str(category),
                changes={"values_updated": category.values.count()},
            )
            messages.success(request, f"Saved values for “{category.name}”.")
            return HttpResponseRedirect(
                reverse(
                    "mel_indicators:indicator_disaggregation", kwargs={"code": code}
                )
            )
        return render(
            request,
            self.template_name,
            {"category": category, "indicator": category.indicator, "formset": formset},
        )


class DisaggregationCategoryDeleteView(RoleRequiredMixin, View):
    allowed_roles = OFFICER_ROLES

    def post(self, request, code, pk):
        from apps.core.audit.mixins import log_audit

        category = get_object_or_404(
            DisaggregationCategory, pk=pk, indicator__code=code
        )
        name = category.name
        category_pk = category.pk
        category.delete()
        log_audit(
            actor=request.user,
            action=AuditLog.Action.DELETE,
            target_app="mel_indicators",
            target_model="DisaggregationCategory",
            object_id=category_pk,
            object_repr=f"{code}: {name}",
            changes={"name": name},
        )
        messages.success(request, f"Removed disaggregation category “{name}”.")
        return HttpResponseRedirect(
            reverse("mel_indicators:indicator_disaggregation", kwargs={"code": code})
        )


class IndicatorPublishView(RoleRequiredMixin, View):
    """M&E SRS — publish a DRAFT indicator so it becomes available for monitoring.

    Table 64 — publish is validation-gated: every disaggregation category must
    carry at least one value and every value its full performance information.
    """

    allowed_roles = OFFICER_ROLES

    def post(self, request, code):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.models import IndicatorState
        from apps.mel.indicators.services import indicator_publish_blockers

        indicator = get_object_or_404(Indicator, code=code)
        blockers = indicator_publish_blockers(indicator)
        if blockers:
            messages.error(
                request,
                "Cannot publish yet — " + " ".join(blockers),
            )
            return HttpResponseRedirect(
                reverse(
                    "mel_indicators:indicator_disaggregation", kwargs={"code": code}
                )
            )
        already_active = indicator.workflow_status == IndicatorState.ACTIVE
        indicator.workflow_status = IndicatorState.ACTIVE
        indicator.save(update_fields=["workflow_status", "updated_at"])
        if not already_active:
            log_audit(
                actor=request.user,
                action=AuditLog.Action.UPDATE,
                target_app="mel_indicators",
                target_model="Indicator",
                object_id=indicator.pk,
                object_repr=str(indicator),
                changes={"workflow_status": "active"},
            )
        messages.success(
            request,
            f"Indicator “{indicator.code}” published — it is now available for "
            f"monitoring and reporting.",
        )
        return HttpResponseRedirect(
            reverse("mel_indicators:indicator_detail", kwargs={"code": code})
        )


class IndicatorTargetCreateView(RoleRequiredMixin, CreateView):
    allowed_roles = OFFICER_ROLES
    model = IndicatorTarget
    form_class = IndicatorTargetForm
    template_name = "indicators/target_form.html"

    def get_initial(self):
        initial = super().get_initial()
        indicator_id = self.request.GET.get("indicator")
        if indicator_id:
            initial["indicator"] = indicator_id
        return initial

    def form_valid(self, form):
        from apps.mel.indicators.services import LogFrameClosedError, assert_logframe_editable

        indicator = form.cleaned_data.get("indicator")
        lf = getattr(getattr(indicator, "logframe_row", None), "logframe", None)
        try:
            assert_logframe_editable(lf, action="add targets")
        except LogFrameClosedError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        return super().form_valid(form)

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

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:indicator_detail",
            kwargs={"code": self.object.indicator.code},
        )


class IndicatorTargetUpdateView(RoleRequiredMixin, UpdateView):
    """FRSME-MEI010 — mid-cycle target revision with audit trail.

    ``original_target_value`` is frozen by ``IndicatorTarget.save()``;
    this view captures the actor + reason + timestamp.
    """

    allowed_roles = OFFICER_ROLES
    model = IndicatorTarget
    form_class = IndicatorTargetRevisionForm
    template_name = "indicators/target_revision_form.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        target: IndicatorTarget = ctx["object"]
        ctx["indicator"] = target.indicator
        ctx["history"] = (
            target.history.select_related("history_user")
            .order_by("-history_date")[:20]
            if hasattr(target, "history")
            else []
        )
        return ctx

    def form_valid(self, form):
        from apps.core.audit.mixins import log_audit

        # The bound ModelForm has ALREADY mutated self.object with the submitted
        # value during is_valid()'s _post_clean(), so reading self.object here
        # would return the *new* value and the "changed?" test below could never
        # be true (MEI010 audit was dead code). Re-fetch the persisted row to
        # learn the ORIGINAL target. IndicatorTarget has no FSMField, so a plain
        # .get()/values_list is safe.
        original_target = (
            IndicatorTarget.objects.filter(pk=self.object.pk)
            .values_list("target_value", flat=True)
            .first()
        )
        new_target = form.cleaned_data.get("target_value")
        response = super().form_valid(form)
        if new_target != original_target:
            IndicatorTarget.objects.filter(pk=self.object.pk).update(
                revised_by=self.request.user,
                revised_at=timezone.now(),
            )
            log_audit(
                actor=self.request.user,
                action=AuditLog.Action.UPDATE,
                target_app="mel_indicators",
                target_model="IndicatorTarget",
                object_id=self.object.pk,
                object_repr=str(self.object),
                changes={
                    "indicator_target_revised": True,
                    "original": str(self.object.original_target_value or "—"),
                    "previous": str(original_target),
                    "new": str(new_target),
                    "reason": form.cleaned_data.get("revision_reason", ""),
                },
            )
            messages.success(
                self.request,
                f"Target revised for {self.object.indicator.code} — {self.object.period_label}.",
            )
        return response

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:indicator_detail",
            kwargs={"code": self.object.indicator.code},
        )


class IndicatorTargetHistoryView(RoleRequiredMixin, DetailView):
    """Read-only audit panel for an IndicatorTarget (FRSME-MEI010)."""

    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR)
    model = IndicatorTarget
    template_name = "indicators/target_history.html"
    context_object_name = "target"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        target: IndicatorTarget = ctx["target"]
        ctx["indicator"] = target.indicator
        ctx["history"] = (
            target.history.select_related("history_user").order_by("-history_date")
            if hasattr(target, "history")
            else []
        )
        return ctx


# ---------------------------------------------------------------------------
# Data points
# ---------------------------------------------------------------------------


class DataEntryView(RoleRequiredMixin, CreateView):
    """Record a data point.

    M&E SRS Table 65 — officers submit for any indicator; Program
    Implementers ("Program Implementers shall only submit data for activities
    assigned to them") may submit only for indicators where they are the
    responsible officer. Either way the point lands as PENDING for the M&E
    Officer to validate.
    """

    allowed_roles = ()  # scoped per-user below
    model = DataPoint
    form_class = DataPointForm
    template_name = "indicators/data_entry_form.html"

    def _is_officer(self) -> bool:
        return (
            self.request.user.is_superuser
            or getattr(self.request.user, "role", "") in OFFICER_ROLES
        )

    def dispatch(self, request, *args, **kwargs):
        if request.user.is_authenticated and not self._is_officer():
            # Implementers need at least one indicator assigned to them.
            if not Indicator.objects.filter(
                responsible=request.user, is_active=True
            ).exists():
                from django.core.exceptions import PermissionDenied

                raise PermissionDenied
        return super().dispatch(request, *args, **kwargs)

    def get_form(self, form_class=None):
        form = super().get_form(form_class)
        if not self._is_officer():
            form.fields["indicator"].queryset = form.fields["indicator"].queryset.filter(
                responsible=self.request.user
            )
        return form

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

    def form_valid(self, form):
        from apps.mel.indicators.services import (
            DataPointValidationError,
            LogFrameClosedError,
            assert_logframe_editable,
        )

        dp = form.save(commit=False)
        # G6 — refuse data entry when the indicator's log frame is closed.
        try:
            lf = getattr(getattr(dp.indicator, "logframe_row", None), "logframe", None)
            assert_logframe_editable(lf, action="record data points")
        except LogFrameClosedError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        # Route through the service layer to keep idempotency + verified-flag
        # logic consistent. The service — not CreateView's default save — is the
        # single writer here, so we must NOT also call super().form_valid()
        # (which would save a second, orphaned DataPoint). G9 validation rules
        # raise DataPointValidationError; mirror the CSV path and re-render the
        # form with the rule's message instead of returning a 500.
        try:
            result = record_data_point(
                indicator=dp.indicator,
                value=dp.value,
                period_label=dp.period_label or current_period_label(dp.indicator.frequency),
                reported_at=dp.reported_at or timezone.now(),
                qualitative_note=dp.qualitative_note,
                reported_by=self.request.user,
                auto_verify=False,
            )
        except DataPointValidationError as exc:
            form.add_error(exc.field or "value", str(exc))
            return self.form_invalid(form)
        self.object = result.data_point
        if form.cleaned_data.get("evidence"):
            self.object.evidence = form.cleaned_data["evidence"]
            self.object.save(update_fields=["evidence"])
        messages.success(self.request, "Data point recorded.")
        if getattr(self.request, "htmx", False):
            return HttpResponse(status=204)
        return HttpResponseRedirect(self.get_success_url())

    def get_success_url(self):
        # The indicator detail page is officer-gated, so sending an implementer
        # there after they submit bounces them to a 403. Route non-officers to
        # their own submissions queue instead (reachable by any reporter).
        if not self._is_officer():
            return reverse_lazy("mel_indicators:validation_queue")
        return reverse_lazy(
            "mel_indicators:indicator_detail",
            kwargs={"code": self.object.indicator.code},
        )


# ---------------------------------------------------------------------------
# Log frame
# ---------------------------------------------------------------------------


class LogFrameListView(RoleRequiredMixin, ListView):
    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR)
    model = LogFrame
    context_object_name = "logframes"
    template_name = "indicators/logframe_list.html"

    def get_queryset(self):
        from django.db.models import Count

        return (
            LogFrame.objects.annotate(
                row_count=Count("rows", distinct=True),
                indicator_count=Count("rows__indicators", distinct=True),
            )
            .order_by("-updated_at")
        )

    def get_context_data(self, **kwargs):
        from apps.mel.indicators.models import LogFrameStatus

        ctx = super().get_context_data(**kwargs)
        qs = list(ctx["logframes"])
        active = [lf for lf in qs if lf.status == LogFrameStatus.ACTIVE]
        closed = [lf for lf in qs if lf.status in (LogFrameStatus.CLOSED, LogFrameStatus.ARCHIVED)]
        ctx["logframes_active"] = active
        ctx["logframes_closed"] = closed
        ctx["active_count"] = len(active)
        ctx["closed_count"] = len(closed)
        return ctx


class LogFrameDetailView(RoleRequiredMixin, DetailView):
    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR)
    model = LogFrame
    slug_field = "slug"
    slug_url_kwarg = "slug"
    context_object_name = "logframe"
    template_name = "indicators/logframe.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        lf: LogFrame = ctx["logframe"]
        rows = list(lf.rows.select_related("parent").prefetch_related("indicators").order_by("order", "pk"))
        # M&E SRS Table 62 — "Parent-child relationships shall be maintained
        # and displayed correctly." Render a depth-first tree so every child
        # sits directly beneath its own parent (two Impacts no longer
        # interleave their Outcomes), with the depth driving the indent.
        from apps.mel.indicators.models import LEVEL_ORDER

        children: dict[int | None, list[LogFrameRow]] = {}
        for row in rows:
            children.setdefault(row.parent_id, []).append(row)

        def sort_key(row: LogFrameRow):
            return (LEVEL_ORDER.get(row.level, 99), row.order, row.pk)

        ordered: list[LogFrameRow] = []
        seen: set[int] = set()

        def walk(parent_id: int | None, depth: int) -> None:
            for row in sorted(children.get(parent_id, []), key=sort_key):
                if row.pk in seen:  # cycle guard — clean() forbids, be safe
                    continue
                seen.add(row.pk)
                row.tree_depth = depth
                row.indent_rem = f"{depth * 1.75:g}"
                ordered.append(row)
                walk(row.pk, depth + 1)

        walk(None, 0)
        # Rows whose parent points at another log frame (legacy bad data)
        # would vanish from the walk — append them flat so nothing is hidden.
        for row in rows:
            if row.pk not in seen:
                row.tree_depth = 0
                row.indent_rem = "0"
                ordered.append(row)
        ctx["rows"] = ordered
        ctx["issues"] = validate_hierarchy(lf)
        # G3 — surface a short budget summary when a Budget is bound.
        ctx["finance_summary"] = None
        if lf.finance_budget_id:
            try:
                from apps.mel.reports.builders import build_budget_variance

                ctx["finance_summary"] = build_budget_variance(logframe=lf)
            except Exception:
                ctx["finance_summary"] = None
        # G6 — read-only banner state for templates.
        ctx["is_editable"] = lf.is_editable
        ctx["is_closed"] = lf.is_closed
        # G4 — current + trailing programme health for the detail card.
        from apps.mel.indicators.models import ProgrammeHealthScore

        scores = list(
            ProgrammeHealthScore.objects.filter(logframe=lf)
            .order_by("-period_start", "-computed_at")[:6]
        )
        ctx["health_current"] = scores[0] if scores else None
        ctx["health_trend"] = list(reversed(scores))  # oldest → newest for sparkline
        return ctx


class LogFrameCreateView(RoleRequiredMixin, SuccessMessageMixin, CreateView):
    allowed_roles = OFFICER_ROLES
    model = LogFrame
    form_class = LogFrameForm
    template_name = "indicators/logframe_form.html"
    success_message = "Strategic Objective “%(name)s” created — add its Impacts next."

    def form_valid(self, form):
        from apps.core.audit.mixins import log_audit

        response = super().form_valid(form)
        # M&E framework — Strategic Objective creation is recorded in the
        # consolidated audit trail, matching row/approval events.
        log_audit(
            actor=self.request.user,
            action=AuditLog.Action.CREATE,
            target_app="mel_indicators",
            target_model="LogFrame",
            object_id=self.object.pk,
            object_repr=str(self.object),
            changes={"code": self.object.code, "name": self.object.name},
        )
        return response

    def get_success_url(self):
        return reverse_lazy("mel_indicators:logframe_detail", kwargs={"slug": self.object.slug})


class LogFrameUpdateView(RoleRequiredMixin, UpdateView):
    """M&E framework — edit a Strategic Objective (code, title, planning period,
    description, dates). Row-level components (Impact / Intermediate Outcome /
    Output) are edited via :class:`LogFrameRowUpdateView`."""

    allowed_roles = OFFICER_ROLES
    model = LogFrame
    form_class = LogFrameForm
    template_name = "indicators/logframe_form.html"

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

    def form_valid(self, form):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.services import LogFrameClosedError, assert_logframe_editable

        try:
            assert_logframe_editable(self.object, action="edit the framework")
        except LogFrameClosedError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        response = super().form_valid(form)
        log_audit(
            actor=self.request.user,
            action=AuditLog.Action.UPDATE,
            target_app="mel_indicators",
            target_model="LogFrame",
            object_id=self.object.pk,
            object_repr=str(self.object),
            changes={"code": self.object.code, "name": self.object.name},
        )
        messages.success(self.request, f"Updated Strategic Objective “{self.object.name}”.")
        return response

    def get_success_url(self):
        return reverse_lazy("mel_indicators:logframe_detail", kwargs={"slug": self.object.slug})


class LogFrameCloseView(RoleRequiredMixin, View):
    """G6 — close-out a programme. POST-only; requires reason + confirm."""

    allowed_roles = OFFICER_ROLES

    template_name = "indicators/logframe_close.html"

    def get(self, request, slug: str):
        from apps.mel.indicators.forms import LogFrameCloseForm

        logframe = get_object_or_404(LogFrame, slug=slug)
        return render(
            request,
            self.template_name,
            {"logframe": logframe, "form": LogFrameCloseForm()},
        )

    def post(self, request, slug: str):
        from apps.mel.indicators.forms import LogFrameCloseForm
        from apps.mel.indicators.services import close_logframe

        logframe = get_object_or_404(LogFrame, slug=slug)
        if logframe.is_closed:
            messages.info(request, "Programme is already closed.")
            return HttpResponseRedirect(
                reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
            )
        form = LogFrameCloseForm(request.POST)
        if not form.is_valid():
            return render(
                request,
                self.template_name,
                {"logframe": logframe, "form": form},
            )
        close_logframe(
            logframe,
            closed_by=request.user,
            reason=form.cleaned_data["reason"],
        )
        messages.success(
            request,
            f"Programme '{logframe.name}' closed. Post-project tracer dispatch will "
            f"fire in {logframe.post_project_window_months} month(s).",
        )
        return HttpResponseRedirect(
            reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
        )


class LogFrameReopenView(RoleRequiredMixin, View):
    """G6 — undo a close (admin only). POST-only."""

    allowed_roles = (UserRole.ADMIN, UserRole.SYSTEM_ADMIN)

    def post(self, request, slug: str):
        from apps.mel.indicators.services import reopen_logframe

        logframe = get_object_or_404(LogFrame, slug=slug)
        reopen_logframe(logframe, reopened_by=request.user)
        messages.success(request, f"Programme '{logframe.name}' reopened.")
        return HttpResponseRedirect(
            reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
        )


class LogFrameRowCreateView(RoleRequiredMixin, CreateView):
    allowed_roles = OFFICER_ROLES
    model = LogFrameRow
    form_class = LogFrameRowForm
    template_name = "indicators/logframe_row_form.html"

    def get_initial(self):
        initial = super().get_initial()
        logframe_id = self.request.GET.get("logframe")
        if logframe_id:
            initial["logframe"] = logframe_id
        # M&E framework — contextual authoring: "Add Intermediate Outcome" on an
        # Impact row (etc.) arrives with the parent, level and output type
        # already decided so the officer only types the content.
        parent_id = self.request.GET.get("parent")
        if parent_id:
            parent = (
                LogFrameRow.objects.select_related("logframe")
                .filter(pk=parent_id)
                .first()
            )
            if parent is not None:
                initial["parent"] = parent.pk
                initial.setdefault("logframe", parent.logframe_id)
        level = self.request.GET.get("level")
        if level in LogFrameLevel.values:
            initial["level"] = level
        output_type = self.request.GET.get("output_type")
        if output_type in OutputType.values:
            initial["output_type"] = output_type
        return initial

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        # Scope the parent dropdown to the chosen log frame (defect 6). On create
        # the log frame arrives via ?logframe=<pk>; on a re-render after a
        # validation error it comes from the submitted data. A ?parent= handoff
        # (contextual add-child buttons) implies the parent's own log frame.
        logframe_id = (
            self.request.POST.get("logframe")
            or self.request.GET.get("logframe")
        )
        if not logframe_id:
            parent_id = self.request.GET.get("parent")
            if parent_id:
                logframe_id = (
                    LogFrameRow.objects.filter(pk=parent_id)
                    .values_list("logframe_id", flat=True)
                    .first()
                )
        if logframe_id:
            kwargs["logframe_scope"] = LogFrame.objects.filter(pk=logframe_id).first()
        return kwargs

    def form_valid(self, form):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.services import LogFrameClosedError, assert_logframe_editable

        try:
            assert_logframe_editable(form.cleaned_data.get("logframe"), action="add rows")
        except LogFrameClosedError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        response = super().form_valid(form)
        log_audit(
            actor=self.request.user,
            action=AuditLog.Action.CREATE,
            target_app="mel_indicators",
            target_model="LogFrameRow",
            object_id=self.object.pk,
            object_repr=str(self.object),
            changes={"level": self.object.level, "title": self.object.title},
        )
        return response

    def get_success_url(self):
        return reverse_lazy("mel_indicators:logframe_detail", kwargs={"slug": self.object.logframe.slug})


class LogFrameRowUpdateView(RoleRequiredMixin, UpdateView):
    """M&E SRS — edit any Results Framework component (validated + logged)."""

    allowed_roles = OFFICER_ROLES
    model = LogFrameRow
    form_class = LogFrameRowForm
    template_name = "indicators/logframe_row_form.html"

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        # Scope the parent dropdown to this row's own log frame.
        kwargs["logframe_scope"] = self.object.logframe
        return kwargs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["is_edit"] = True
        ctx["logframe"] = self.object.logframe
        return ctx

    def form_valid(self, form):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.services import LogFrameClosedError, assert_logframe_editable

        try:
            assert_logframe_editable(self.object.logframe, action="edit rows")
        except LogFrameClosedError as exc:
            form.add_error(None, str(exc))
            return self.form_invalid(form)
        response = super().form_valid(form)
        log_audit(
            actor=self.request.user,
            action=AuditLog.Action.UPDATE,
            target_app="mel_indicators",
            target_model="LogFrameRow",
            object_id=self.object.pk,
            object_repr=str(self.object),
            changes={"level": self.object.level, "title": self.object.title},
        )
        messages.success(self.request, f"Updated {self.object.get_level_display()} “{self.object.title}”.")
        return response

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:logframe_detail", kwargs={"slug": self.object.logframe.slug}
        )


class LogFrameSubmitView(RoleRequiredMixin, View):
    """M&E SRS — submit a Results Framework for approval (completeness-gated)."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, slug: str):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.services import (
            ResultsFrameworkIncomplete,
            submit_logframe_for_approval,
        )

        logframe = get_object_or_404(LogFrame, slug=slug)
        try:
            submit_logframe_for_approval(logframe, submitted_by=request.user)
        except ResultsFrameworkIncomplete as exc:
            messages.error(
                request,
                "Cannot submit — the Strategic Objective is incomplete: "
                + "; ".join(exc.blockers),
            )
            return HttpResponseRedirect(
                reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
            )
        log_audit(
            actor=request.user,
            action=AuditLog.Action.UPDATE,
            target_app="mel_indicators",
            target_model="LogFrame",
            object_id=logframe.pk,
            object_repr=str(logframe),
            changes={"approval_status": "submitted"},
        )
        messages.success(request, f"“{logframe.name}” submitted for approval.")
        return HttpResponseRedirect(
            reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
        )


class LogFrameApproveView(RoleRequiredMixin, View):
    """M&E SRS — approve or return a submitted Results Framework."""

    allowed_roles = (
        UserRole.ADMIN,
        UserRole.SYSTEM_ADMIN,
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
    )

    def post(self, request, slug: str):
        from apps.core.audit.mixins import log_audit
        from apps.mel.indicators.services import (
            approve_logframe,
            return_logframe_to_draft,
        )

        logframe = get_object_or_404(LogFrame, slug=slug)
        decision = request.POST.get("decision", "approve")
        if decision == "return":
            return_logframe_to_draft(logframe, actor=request.user)
            action_label = "returned to draft"
            changes = {"approval_status": "draft"}
        else:
            approve_logframe(logframe, approved_by=request.user)
            action_label = "approved"
            changes = {"approval_status": "approved"}
        log_audit(
            actor=request.user,
            action=AuditLog.Action.UPDATE,
            target_app="mel_indicators",
            target_model="LogFrame",
            object_id=logframe.pk,
            object_repr=str(logframe),
            changes=changes,
        )
        messages.success(request, f"“{logframe.name}” {action_label}.")
        return HttpResponseRedirect(
            reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
        )


# ---------------------------------------------------------------------------
# DataPoint verify / reject (FRMFL014–017)
# ---------------------------------------------------------------------------


def _datapoint_redirect(request, dp: DataPoint) -> HttpResponseRedirect:
    """Redirect back to a validation surface after a data-point action.

    Honours a same-origin ``next`` (the validation queue posts one) and falls
    back to the indicator detail page.
    """
    from django.utils.http import url_has_allowed_host_and_scheme

    next_url = request.POST.get("next", "")
    if next_url and url_has_allowed_host_and_scheme(
        next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
    ):
        return HttpResponseRedirect(next_url)
    return HttpResponseRedirect(
        reverse("mel_indicators:indicator_detail", kwargs={"code": dp.indicator.code})
    )


class DataValidationQueueView(RoleRequiredMixin, ListView):
    """M&E SRS Table 65 — the consolidated pending-validation workspace.

    Every submitted data point awaiting review (PENDING) or stuck in the
    clarification loop (NEEDS_CLARIFICATION) across all programmes, filterable
    by results framework, reporting period and status, with inline verify /
    reject / clarify / resubmit controls.

    Officers and programme managers see everything; any other authenticated
    user (a Program Implementer) sees only their own submissions — giving the
    reporter a place to answer clarification requests.
    """

    allowed_roles = ()  # per-user scoping below
    model = DataPoint
    context_object_name = "data_points"
    paginate_by = 25
    template_name = "indicators/validation_queue.html"

    OPEN_STATUSES = (
        DataPoint.Status.PENDING,
        DataPoint.Status.NEEDS_CLARIFICATION,
    )

    REVIEWER_ROLES = OFFICER_ROLES + (
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
    )

    def _is_reviewer(self) -> bool:
        return (
            self.request.user.is_superuser
            or getattr(self.request.user, "role", "") in self.REVIEWER_ROLES
        )

    def get_queryset(self):
        qs = (
            DataPoint.objects.select_related(
                "indicator__logframe_row__logframe", "reported_by", "indicator__responsible",
            )
            .order_by("-reported_at", "-pk")
        )
        if not self._is_reviewer():
            qs = qs.filter(reported_by=self.request.user)
        status = self.request.GET.get("status", "").strip()
        if status in DataPoint.Status.values:
            qs = qs.filter(status=status)
        else:
            qs = qs.filter(status__in=self.OPEN_STATUSES)
        logframe = self.request.GET.get("logframe", "").strip()
        if logframe:
            qs = qs.filter(indicator__logframe_row__logframe__slug=logframe)
        period = self.request.GET.get("period", "").strip()
        if period:
            qs = qs.filter(period_label=period)
        return qs

    def get_context_data(self, **kwargs):
        from django.db.models import Count

        ctx = super().get_context_data(**kwargs)
        count_qs = DataPoint.objects.filter(status__in=self.OPEN_STATUSES)
        if not self._is_reviewer():
            count_qs = count_qs.filter(reported_by=self.request.user)
        counts = dict(count_qs.values_list("status").annotate(n=Count("pk")))
        ctx["pending_count"] = counts.get(DataPoint.Status.PENDING, 0)
        ctx["clarification_count"] = counts.get(
            DataPoint.Status.NEEDS_CLARIFICATION, 0
        )
        ctx["status_filter"] = self.request.GET.get("status", "").strip()
        ctx["logframe_filter"] = self.request.GET.get("logframe", "").strip()
        ctx["period_filter"] = self.request.GET.get("period", "").strip()
        ctx["logframe_options"] = LogFrame.objects.order_by("name")
        ctx["period_options"] = distinct_period_labels()
        ctx["queue_url"] = self.request.get_full_path()
        ctx["is_officer"] = (
            self.request.user.is_superuser
            or getattr(self.request.user, "role", "") in OFFICER_ROLES
        )
        ctx["is_reviewer"] = self._is_reviewer()
        return ctx


class DataPointVerifyView(RoleRequiredMixin, View):
    """POST-only endpoint that transitions a PENDING DataPoint to VERIFIED."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, pk: int):
        dp = get_object_or_404(DataPoint, pk=pk)
        try:
            verify_datapoint(dp.pk, verified_by=request.user)
        except ValidationError as exc:
            messages.error(request, "; ".join(exc.messages))
        else:
            messages.success(request, "Data point verified.")
        return _datapoint_redirect(request, dp)


class DataPointRejectView(RoleRequiredMixin, View):
    """POST-only endpoint that marks a DataPoint as REJECTED with a reason."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, pk: int):
        dp = get_object_or_404(DataPoint, pk=pk)
        reason = request.POST.get("reason", "").strip()
        if not reason:
            messages.error(request, "A rejection reason is required.")
            return HttpResponseRedirect(
                reverse("mel_indicators:indicator_detail", kwargs={"code": dp.indicator.code})
            )
        try:
            reject_datapoint(dp.pk, rejected_by=request.user, reason=reason)
        except ValidationError as exc:
            messages.error(request, "; ".join(exc.messages))
        else:
            messages.info(request, "Data point rejected.")
        return _datapoint_redirect(request, dp)


class DataPointClarifyView(RoleRequiredMixin, View):
    """M&E SRS Table 65 — request clarification/correction from the reporter."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, pk: int):
        from apps.mel.indicators.services import request_datapoint_clarification

        dp = get_object_or_404(DataPoint, pk=pk)
        note = request.POST.get("note", "").strip()
        if not note:
            messages.error(request, "Describe what needs clarifying.")
            return HttpResponseRedirect(
                reverse("mel_indicators:indicator_detail", kwargs={"code": dp.indicator.code})
            )
        request_datapoint_clarification(dp.pk, requested_by=request.user, note=note)
        messages.info(request, "Clarification requested — the reporter has been notified.")
        return _datapoint_redirect(request, dp)


class DataPointResubmitView(RoleRequiredMixin, View):
    """M&E SRS — the reporter corrects a flagged point; returns it to PENDING.

    Any authenticated user may reach the endpoint; the object-level check
    below restricts the action to the original reporter (or an officer /
    programme manager), so Program Implementers can complete the
    clarification loop on their own submissions.
    """

    allowed_roles = ()  # object-level check below

    def post(self, request, pk: int):
        from decimal import Decimal, InvalidOperation

        from apps.mel.indicators.services import resubmit_datapoint

        dp = get_object_or_404(DataPoint, pk=pk)
        # Only the reporter (or an officer / programme manager) may resubmit.
        is_officer = request.user.is_superuser or getattr(request.user, "role", "") in (
            OFFICER_ROLES + (UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR)
        )
        if not is_officer and dp.reported_by_id != request.user.pk:
            messages.error(request, "Only the original reporter can resubmit this data point.")
            return _datapoint_redirect(request, dp)
        value = None
        raw_value = request.POST.get("value", "").strip()
        if raw_value:
            try:
                value = Decimal(raw_value)
            except (InvalidOperation, ValueError):
                messages.error(request, "Enter a valid corrected value.")
                return _datapoint_redirect(request, dp)
        resubmit_datapoint(
            dp.pk,
            resubmitted_by=request.user,
            value=value,
            note=request.POST.get("note", "").strip(),
        )
        messages.success(request, "Data point resubmitted for validation.")
        return _datapoint_redirect(request, dp)


# ---------------------------------------------------------------------------
# Variance (FRMFL028–029)
# ---------------------------------------------------------------------------


class IndicatorVarianceView(RoleRequiredMixin, DetailView):
    allowed_roles = OFFICER_ROLES + (
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.GRANTS_MANAGER,
    )
    model = Indicator
    slug_field = "code"
    slug_url_kwarg = "code"
    context_object_name = "indicator"
    template_name = "indicators/variance.html"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        indicator: Indicator = ctx["indicator"]
        # Materialise once: band stats below need the full set, the table only a
        # page. paginate_qs slices the same list, so this stays a single query.
        variances = list(indicator.variances.order_by("-computed_at"))
        ctx["variances"] = paginate_qs(
            self.request, variances, per_page=25, param="var_page"
        )
        ctx["period_options"] = distinct_period_labels(indicator)
        ctx["default_period"] = current_period_label(indicator.frequency)
        # Variance-band triage — critical / significant / within tolerance /
        # above target. Bands mirror the project-wide RAG semantics in
        # ``apps.mel.indicators.services.compute_variance``. Counts span every
        # snapshot, not just the current page.
        stats = {"critical": 0, "significant": 0, "within": 0, "above": 0}
        for v in variances:
            pct = v.variance_pct
            if pct is None:
                continue
            pct = float(pct)
            if pct <= -50:
                stats["critical"] += 1
            elif pct <= -15:
                stats["significant"] += 1
            elif pct >= 15:
                stats["above"] += 1
            else:
                stats["within"] += 1
        ctx["variance_stats"] = stats
        return ctx


class IndicatorVarianceRefreshView(RoleRequiredMixin, View):
    """POST — recompute the variance snapshot for (indicator, current period)."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, code: str):
        from decimal import Decimal as _D

        from apps.mel.indicators.models import VarianceRating
        from apps.mel.indicators.services import variance_rating_for_pct

        indicator = get_object_or_404(Indicator, code=code)
        period = request.POST.get("period") or current_period_label(indicator.frequency)
        cause = request.POST.get("cause_analysis", "").strip()
        adjustment = request.POST.get("adjustment", "").strip()
        # M&E SRS Table 65 (A3) — "Every below-target indicator shall have an
        # explanation and proposed corrective action before reporting."
        # Pre-compute the rating so a blank explanation never overwrites a
        # previously recorded one on a below-target snapshot.
        progress = calculate_progress(indicator, period)
        pct = None
        target, actual = progress.get("target"), progress.get("actual")
        if target not in (None, 0) and _D(str(target)) != 0:
            pct = (_D(str(actual)) - _D(str(target))) / _D(str(target)) * 100
        rating = variance_rating_for_pct(pct)
        if rating in (VarianceRating.NEEDS_ATTENTION, VarianceRating.CRITICAL) and not (
            cause and adjustment
        ):
            messages.error(
                request,
                f"“{indicator.code}” is below target for {period} — record a "
                "variance explanation and a proposed corrective action before "
                "recalculating.",
            )
            return HttpResponseRedirect(
                reverse(
                    "mel_indicators:indicator_variance", kwargs={"code": indicator.code}
                )
            )
        compute_variance(indicator, period, cause_analysis=cause, adjustment=adjustment)
        messages.success(request, f"Variance recalculated for {period}.")
        return HttpResponseRedirect(
            reverse("mel_indicators:indicator_variance", kwargs={"code": indicator.code})
        )


# ---------------------------------------------------------------------------
# Logframe-wide dashboard (FRMFL032–033)
# ---------------------------------------------------------------------------


class LogFrameDashboardView(RoleRequiredMixin, DetailView):
    """Single-page dashboard consolidating indicator RAG, activity summary,
    output deliverables, variance, and recent feedback for one logframe."""

    allowed_roles = OFFICER_ROLES + (
        UserRole.PROGRAM_MANAGER,
        UserRole.PROGRAM_DIRECTOR,
        UserRole.GRANTS_MANAGER,
    )
    model = LogFrame
    slug_field = "slug"
    slug_url_kwarg = "slug"
    context_object_name = "logframe"
    template_name = "indicators/logframe_dashboard.html"

    def get_context_data(self, **kwargs):
        from apps.mel.reports.builders import (
            build_activity_summary,
            build_impact_section,
            build_indicator_rollup,
            build_outcome_section,
            build_output_snapshot,
            build_variance_table,
        )

        ctx = super().get_context_data(**kwargs)
        lf: LogFrame = ctx["logframe"]

        # Build period window from GET params or the current month.
        from datetime import date, timedelta
        period = self.request.GET.get("period") or current_period_label("monthly")
        today = timezone.now().date()
        start = date(today.year, today.month, 1)
        if today.month == 12:
            end = date(today.year, 12, 31)
        else:
            end = date(today.year, today.month + 1, 1) - timedelta(days=1)

        ctx["period"] = period
        ctx["rollup"] = build_indicator_rollup(logframe=lf, period_label=period)
        # M&E SRS Table 65 — aggregate performance following the Results
        # Framework hierarchy: verified Output data rolls into Outcomes and
        # Outcomes into Impacts.
        from apps.mel.indicators.services import aggregate_results_by_level

        level_rollup = aggregate_results_by_level(lf, period)
        for data in level_rollup.values():
            target = data.get("target")
            data["percent"] = (
                round(float(data["actual"]) / float(target) * 100, 1)
                if target
                else None
            )
        ctx["level_rollup"] = level_rollup
        ctx["variance"] = build_variance_table(logframe=lf, period_label=period)
        ctx["activity"] = build_activity_summary(
            logframe=lf, period_start=start, period_end=end,
        )
        ctx["outputs"] = build_output_snapshot(logframe=lf)
        ctx["outcomes"] = build_outcome_section(logframe=lf, period_label=period)
        ctx["impacts"] = build_impact_section(logframe=lf)

        # Recent feedback touching this logframe's reports.
        from apps.mel.feedback.models import FeedbackSubmission
        ctx["recent_feedback"] = (
            FeedbackSubmission.objects.select_related("channel")
            .order_by("-created_at")[:10]
        )

        # Period dropdown — union of existing labels + the current selection.
        options = distinct_period_labels()
        if period and period not in options:
            options = [period, *options]
        ctx["period_options"] = options

        return ctx


# ---------------------------------------------------------------------------
# Outcome assessment (FRMFL020–023)
# ---------------------------------------------------------------------------


VIEWER_ROLES = OFFICER_ROLES + (
    UserRole.PROGRAM_MANAGER,
    UserRole.PROGRAM_DIRECTOR,
    UserRole.GRANTS_MANAGER,
)


class OutcomeAssessmentListView(RoleRequiredMixin, ListView):
    allowed_roles = VIEWER_ROLES
    model = OutcomeAssessment
    context_object_name = "assessments"
    paginate_by = 10
    template_name = "indicators/outcome_assessment_list.html"

    def get_queryset(self):
        qs = (
            OutcomeAssessment.objects.select_related(
                "logframe_row__logframe", "assessor",
            )
            .order_by("-assessed_at")
        )
        logframe = self.request.GET.get("logframe", "").strip()
        if logframe:
            qs = qs.filter(logframe_row__logframe__slug=logframe)
        period = self.request.GET.get("period", "").strip()
        if period:
            qs = qs.filter(period_label=period)
        return qs

    def get_context_data(self, **kwargs):
        from datetime import timedelta

        ctx = super().get_context_data(**kwargs)
        ctx["logframe_options"] = LogFrame.objects.order_by("name")
        ctx["selected_logframe"] = self.request.GET.get("logframe", "")
        ctx["selected_period"] = self.request.GET.get("period", "")
        # Freshness flag — anything assessed in the last 90 days counts as
        # "recent" for the row-accent treatment. ``assessed_at`` is a DateField
        # so the cutoff must be a date, not a datetime.
        cutoff = (timezone.now() - timedelta(days=90)).date()
        for oa in ctx["assessments"]:
            oa.is_recent = bool(oa.assessed_at and oa.assessed_at >= cutoff)
        return ctx


class OutcomeAssessmentDetailView(RoleRequiredMixin, DetailView):
    allowed_roles = VIEWER_ROLES
    model = OutcomeAssessment
    context_object_name = "assessment"
    template_name = "indicators/outcome_assessment_detail.html"

    def get_queryset(self):
        return OutcomeAssessment.objects.select_related(
            "logframe_row__logframe", "assessor",
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        oa: OutcomeAssessment = ctx["assessment"]
        # Indicator rollup for the same outcome row + period — gives the
        # evaluator an at-a-glance read on the data feeding their narrative.
        indicators = list(oa.logframe_row.indicators.all())
        progress_rows = []
        for ind in indicators:
            progress_rows.append(
                {"indicator": ind, "progress": calculate_progress(ind, oa.period_label)},
            )
        ctx["progress_rows"] = progress_rows
        # Audit who reads narrative analyses — outcome data feeds donor reports.
        AuditLog.objects.create(
            actor=self.request.user if self.request.user.is_authenticated else None,
            action=AuditLog.Action.ACCESS,
            target_app="mel_indicators",
            target_model="OutcomeAssessment",
            object_id=str(oa.pk),
            object_repr=f"{oa.logframe_row.title} — {oa.period_label}",
        )
        return ctx


class OutcomeAssessmentCreateView(RoleRequiredMixin, CreateView):
    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER,)
    model = OutcomeAssessment
    form_class = OutcomeAssessmentForm
    template_name = "indicators/outcome_assessment_form.html"

    def get_initial(self):
        initial = super().get_initial()
        initial["assessor"] = self.request.user.pk
        initial["period_label"] = current_period_label("quarterly")
        row_id = self.request.GET.get("logframe_row")
        if row_id:
            initial["logframe_row"] = row_id
        return initial

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

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:outcome_detail", kwargs={"pk": self.object.pk},
        )


class OutcomeAssessmentUpdateView(RoleRequiredMixin, UpdateView):
    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER,)
    model = OutcomeAssessment
    form_class = OutcomeAssessmentForm
    template_name = "indicators/outcome_assessment_form.html"

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

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:outcome_detail", kwargs={"pk": self.object.pk},
        )


# ---------------------------------------------------------------------------
# Impact evaluation (FRMFL024–027)
# ---------------------------------------------------------------------------


class ImpactEvaluationListView(RoleRequiredMixin, ListView):
    allowed_roles = VIEWER_ROLES
    model = ImpactEvaluation
    context_object_name = "evaluations"
    paginate_by = 10
    template_name = "indicators/impact_evaluation_list.html"

    def get_queryset(self):
        qs = (
            ImpactEvaluation.objects.select_related("logframe", "evaluator")
            .order_by("-completed_on", "-created_at")
        )
        logframe = self.request.GET.get("logframe", "").strip()
        if logframe:
            qs = qs.filter(logframe__slug=logframe)
        status = self.request.GET.get("status", "").strip()
        if status:
            qs = qs.filter(peer_review_status=status)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["logframe_options"] = LogFrame.objects.order_by("name")
        ctx["selected_logframe"] = self.request.GET.get("logframe", "")
        ctx["selected_status"] = self.request.GET.get("status", "")
        return ctx


class ImpactEvaluationDetailView(RoleRequiredMixin, DetailView):
    allowed_roles = VIEWER_ROLES
    model = ImpactEvaluation
    context_object_name = "evaluation"
    template_name = "indicators/impact_evaluation_detail.html"

    def get_queryset(self):
        return ImpactEvaluation.objects.select_related("logframe", "evaluator")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ev: ImpactEvaluation = ctx["evaluation"]
        impact_rows = ev.logframe.rows.filter(level="impact").prefetch_related("indicators")
        ind_codes = [
            ind for row in impact_rows for ind in row.indicators.all()
        ]
        ctx["impact_indicators"] = ind_codes
        ctx["datapoints"] = list(
            ev.datapoints.select_related("recorded_by").order_by("group", "recorded_at")
        )
        ctx["cohens_d"] = calculate_cohens_d(ev)
        ctx["datapoint_form"] = ImpactDatapointForm(
            initial={"recorded_at": timezone.now().strftime("%Y-%m-%dT%H:%M")},
        )
        ctx["can_edit_datapoints"] = (
            self.request.user.is_authenticated
            and self.request.user.role in {r.value for r in OFFICER_ROLES}
        )
        AuditLog.objects.create(
            actor=self.request.user if self.request.user.is_authenticated else None,
            action=AuditLog.Action.ACCESS,
            target_app="mel_indicators",
            target_model="ImpactEvaluation",
            object_id=str(ev.pk),
            object_repr=ev.title,
        )
        return ctx


class ImpactDatapointCreateView(RoleRequiredMixin, View):
    allowed_roles = OFFICER_ROLES

    def post(self, request, evaluation_pk):
        evaluation = get_object_or_404(ImpactEvaluation, pk=evaluation_pk)
        form = ImpactDatapointForm(request.POST)
        if not form.is_valid():
            messages.error(
                request,
                "Could not save the data point: " + "; ".join(
                    f"{field}: {', '.join(errs)}" for field, errs in form.errors.items()
                ),
            )
        else:
            dp: ImpactDatapoint = form.save(commit=False)
            dp.evaluation = evaluation
            dp.recorded_by = request.user
            dp.save()
            messages.success(
                request,
                f"Recorded {dp.get_group_display().lower()} value {dp.value}.",
            )
        return HttpResponseRedirect(
            reverse("mel_indicators:impact_detail", kwargs={"pk": evaluation.pk})
        )


class ImpactDatapointDeleteView(RoleRequiredMixin, View):
    allowed_roles = OFFICER_ROLES

    def post(self, request, evaluation_pk, pk):
        dp = get_object_or_404(
            ImpactDatapoint, pk=pk, evaluation_id=evaluation_pk
        )
        dp.delete()
        messages.success(request, "Data point removed.")
        return HttpResponseRedirect(
            reverse("mel_indicators:impact_detail", kwargs={"pk": evaluation_pk})
        )


_IMPACT_EVAL_WIZARD_STEPS = [
    {"n": 1, "title": "Identity"},
    {"n": 2, "title": "Design"},
    {"n": 3, "title": "Results"},
    {"n": 4, "title": "Schedule & ownership"},
]


class ImpactEvaluationCreateView(RoleRequiredMixin, CreateView):
    allowed_roles = OFFICER_ROLES
    model = ImpactEvaluation
    form_class = ImpactEvaluationForm
    template_name = "indicators/impact_evaluation_form.html"

    def get_initial(self):
        initial = super().get_initial()
        initial["evaluator"] = self.request.user.pk
        return initial

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:impact_detail", kwargs={"pk": self.object.pk},
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _IMPACT_EVAL_WIZARD_STEPS
        ctx["cancel_url"] = reverse("mel_indicators:impact_list")
        return ctx


class ImpactEvaluationUpdateView(RoleRequiredMixin, UpdateView):
    allowed_roles = OFFICER_ROLES
    model = ImpactEvaluation
    form_class = ImpactEvaluationForm
    template_name = "indicators/impact_evaluation_form.html"

    def get_success_url(self):
        return reverse_lazy(
            "mel_indicators:impact_detail", kwargs={"pk": self.object.pk},
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["wizard_steps"] = _IMPACT_EVAL_WIZARD_STEPS
        ctx["cancel_url"] = reverse(
            "mel_indicators:impact_detail", kwargs={"pk": self.object.pk}
        )
        return ctx


# ---------------------------------------------------------------------------
# Revision history (django-simple-history)
# ---------------------------------------------------------------------------


class _RevisionHistoryMixin:
    """Shared logic for outcome/impact revision-history views.

    Loads the subject + its HistoricalRecords queryset and renders the diff
    metadata (history_user, history_date, history_type) in chronological order.
    """

    template_name = "indicators/revision_history.html"
    subject_kind: str = ""  # "Outcome assessment" or "Impact evaluation"

    def get_subject(self):
        raise NotImplementedError

    def get_subject_url(self):
        raise NotImplementedError

    def get_subject_label(self) -> str:
        raise NotImplementedError

    def get(self, request, *args, **kwargs):
        from django.shortcuts import render

        subject = self.get_subject()
        history = subject.history.select_related("history_user").order_by(
            "-history_date",
        )
        return render(
            request,
            self.template_name,
            {
                "subject_kind": self.subject_kind,
                "subject_label": self.get_subject_label(),
                "subject_url": self.get_subject_url(),
                "history_records": history,
            },
        )


class OutcomeAssessmentHistoryView(RoleRequiredMixin, _RevisionHistoryMixin, View):
    allowed_roles = VIEWER_ROLES
    subject_kind = "Outcome assessment"

    def get_subject(self):
        self._oa = get_object_or_404(
            OutcomeAssessment.objects.select_related("logframe_row"),
            pk=self.kwargs["pk"],
        )
        return self._oa

    def get_subject_url(self):
        return reverse("mel_indicators:outcome_detail", kwargs={"pk": self._oa.pk})

    def get_subject_label(self) -> str:
        return f"{self._oa.logframe_row.title} — {self._oa.period_label}"


class ImpactEvaluationHistoryView(RoleRequiredMixin, _RevisionHistoryMixin, View):
    allowed_roles = VIEWER_ROLES
    subject_kind = "Impact evaluation"

    def get_subject(self):
        self._ev = get_object_or_404(
            ImpactEvaluation.objects.select_related("logframe"),
            pk=self.kwargs["pk"],
        )
        return self._ev

    def get_subject_url(self):
        return reverse("mel_indicators:impact_detail", kwargs={"pk": self._ev.pk})

    def get_subject_label(self) -> str:
        return self._ev.title


# ---------------------------------------------------------------------------
# G11 — Consolidated audit-log view
# ---------------------------------------------------------------------------


class ConsolidatedAuditLogView(RoleRequiredMixin, View):
    """Admin-only union of every MEL ``HistoricalRecords`` table (G11).

    Streams the cross-model revision history for LogFrame, Indicator,
    IndicatorTarget, DataPoint, Report, and CorrectiveAction in one chronological
    list, with filters for actor / model / action / date range. Reads only.
    """

    # Read-only consolidated log (FRSME-MEI016 / MEI022): the MEL Officer is an
    # authorised MEL user — they author the audit entries (target revisions,
    # manual data entries) — so they can view the trail alongside admins.
    allowed_roles = (UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.MEL_OFFICER)
    template_name = "indicators/audit_log.html"
    per_page = 50
    # Merged window across all six history tables. The union is sorted in
    # memory, so we bound how much we pull per source and overall — paging walks
    # this window. Refine the date range to reach entries beyond it.
    max_rows = 1000

    _HISTORY_TYPE_LABEL = {"+": "create", "~": "update", "-": "delete"}
    # auto_now / auto_now_add churn every save — noise in a field-level diff.
    _DIFF_EXCLUDED_FIELDS = ("updated_at", "created_at")

    @staticmethod
    def _fmt_value(val) -> str:
        if val is None or val == "":
            return "—"
        text = str(val)
        return text if len(text) <= 80 else text[:77] + "…"

    def _changes_for(self, entry) -> list[dict]:
        """Return [{field, old, new}] for an UPDATE history record via simple_history's diff."""
        if entry is None:
            return []
        prev = entry.prev_record
        if prev is None:
            return []
        try:
            delta = entry.diff_against(
                prev, excluded_fields=self._DIFF_EXCLUDED_FIELDS
            )
        except Exception:  # pragma: no cover - defensive against schema drift
            return []
        changes = []
        for change in delta.changes:
            changes.append(
                {
                    "field": change.field,
                    "old": self._fmt_value(change.old),
                    "new": self._fmt_value(change.new),
                }
            )
        return changes

    def _sources(self):
        from apps.mel.indicators.models import (
            DataPoint as _DataPoint,
            DisaggregationCategory as _DisaggCategory,
            DisaggregationValue as _DisaggValue,
            EvidenceAttachment as _EvidenceAttachment,
            Indicator as _Indicator,
            IndicatorTarget as _IndicatorTarget,
            IndicatorVariance as _IndicatorVariance,
            LogFrame as _LogFrame,
            LogFrameRow as _LogFrameRow,
        )
        from apps.mel.reports.models import Report as _Report
        from apps.mel.tracking.models import CorrectiveAction as _CorrectiveAction

        return (
            ("Strategic Objective", _LogFrame.history),
            ("Strategic Objective Row", _LogFrameRow.history),
            ("Indicator", _Indicator.history),
            ("IndicatorTarget", _IndicatorTarget.history),
            ("DataPoint", _DataPoint.history),
            ("IndicatorVariance", _IndicatorVariance.history),
            ("EvidenceAttachment", _EvidenceAttachment.history),
            ("DisaggregationCategory", _DisaggCategory.history),
            ("DisaggregationValue", _DisaggValue.history),
            ("Report", _Report.history),
            ("CorrectiveAction", _CorrectiveAction.history),
        )

    def get(self, request):
        from datetime import datetime, time as _time

        from django.shortcuts import render

        user_filter = request.GET.get("user", "").strip()
        model_filter = request.GET.get("model", "").strip()
        action_filter = request.GET.get("action", "").strip()
        date_from = request.GET.get("from", "").strip()
        date_to = request.GET.get("to", "").strip()

        from_dt = None
        to_dt = None
        try:
            if date_from:
                from_dt = timezone.make_aware(
                    datetime.combine(datetime.fromisoformat(date_from).date(), _time.min)
                )
            if date_to:
                to_dt = timezone.make_aware(
                    datetime.combine(datetime.fromisoformat(date_to).date(), _time.max)
                )
        except ValueError:
            messages.error(request, "Invalid date filter — use YYYY-MM-DD.")

        action_codes = {v: k for k, v in self._HISTORY_TYPE_LABEL.items()}

        rows = []
        for label, manager in self._sources():
            if model_filter and model_filter != label:
                continue
            qs = manager.select_related("history_user").all()
            if from_dt:
                qs = qs.filter(history_date__gte=from_dt)
            if to_dt:
                qs = qs.filter(history_date__lte=to_dt)
            if user_filter:
                qs = qs.filter(history_user__email__icontains=user_filter)
            if action_filter and action_filter in action_codes:
                qs = qs.filter(history_type=action_codes[action_filter])
            # Cap each source per draw — the merged window below is the final
            # word for how deep paging can reach.
            for entry in qs.order_by("-history_date")[: self.max_rows]:
                rows.append(
                    {
                        "model": label,
                        "object_id": entry.pk,
                        # Strip simple-history's " as of <microsecond timestamp>"
                        # suffix so the OBJECT cell shows a clean model label
                        # (defect 17/E-2).
                        "object_repr": str(entry).rsplit(" as of ", 1)[0][:160],
                        "action": self._HISTORY_TYPE_LABEL.get(
                            entry.history_type, entry.history_type
                        ),
                        "actor": entry.history_user,
                        "when": entry.history_date,
                        "history_id": entry.history_id,
                        "_entry": entry,
                    }
                )

        rows.sort(key=lambda r: r["when"], reverse=True)
        rows = rows[: self.max_rows]

        rows_page = paginate_qs(request, rows, per_page=self.per_page)
        # MEI022 — surface changed field deltas on UPDATE rows. Compute only for
        # the current page to keep the per-source history queries bounded.
        for row in rows_page.object_list:
            entry = row.pop("_entry", None)
            row["changes"] = self._changes_for(entry) if row["action"] == "update" else []

        ctx = {
            "rows": rows_page,
            "user_filter": user_filter,
            "model_filter": model_filter,
            "action_filter": action_filter,
            "date_from": date_from,
            "date_to": date_to,
            "model_choices": [label for label, _ in self._sources()],
            "action_choices": ["create", "update", "delete"],
            "max_rows": self.max_rows,
        }
        return render(request, self.template_name, ctx)


# ---------------------------------------------------------------------------
# G10 — Bulk CSV import for DataPoints (officer-only)
# ---------------------------------------------------------------------------


class DataPointImportView(RoleRequiredMixin, View):
    """Form + preview workflow for bulk CSV uploads.

    GET renders the empty form. POST parses the file: dry-run reports
    successes + errors without writing; un-dried run commits via
    :func:`record_data_point`. Replays of the same file are idempotent.
    """

    allowed_roles = OFFICER_ROLES
    template_name = "indicators/data_import.html"

    def get(self, request):
        return render(request, self.template_name, {"form": DataPointImportForm()})

    def post(self, request):
        form = DataPointImportForm(request.POST, request.FILES)
        if not form.is_valid():
            return render(request, self.template_name, {"form": form})
        csv_file = form.cleaned_data["csv_file"]
        dry_run = bool(form.cleaned_data.get("dry_run"))
        raw_bytes = csv_file.read()
        try:
            csv_text = raw_bytes.decode("utf-8")
        except UnicodeDecodeError:
            csv_text = raw_bytes.decode("latin-1")

        result = import_data_points_csv(
            csv_text=csv_text,
            csv_bytes=raw_bytes,
            user=request.user,
            dry_run=dry_run,
        )
        # Stash the errors in the session so the "download errors CSV" link
        # can stream them by batch_id without re-parsing the upload.
        if result["errors"]:
            request.session[f"mel_csv_errors_{result['batch_id']}"] = result["errors"]
        if not dry_run:
            if result["successes"]:
                messages.success(
                    request,
                    f"Imported {len(result['successes'])} data point{'' if len(result['successes']) == 1 else 's'}.",
                )
            if result["errors"]:
                messages.warning(
                    request,
                    f"{len(result['errors'])} row{'' if len(result['errors']) == 1 else 's'} rejected — see preview.",
                )
        return render(
            request,
            self.template_name,
            {"form": form, "result": result, "dry_run": dry_run},
        )


class EvidenceAttachmentUploadView(RoleRequiredMixin, View):
    """G21 — POST handler for adding evidence to any MEL host model.

    URL takes a content_type_id + object_id; the host is loaded via the
    contenttypes framework. A successful upload redirects back to the
    Referer so any host detail page can embed the same form.
    """

    allowed_roles = OFFICER_ROLES

    def post(self, request, ct_id: int, obj_id: int):
        from django.contrib.contenttypes.models import ContentType

        ct = get_object_or_404(ContentType, pk=ct_id)
        host = get_object_or_404(ct.model_class(), pk=obj_id)
        form = EvidenceAttachmentForm(request.POST, request.FILES)
        if not form.is_valid():
            messages.error(request, form.errors.as_text())
            return HttpResponseRedirect(request.META.get("HTTP_REFERER") or "/")
        _, created = attach_evidence(
            host=host,
            file=form.cleaned_data["file"],
            caption=form.cleaned_data.get("caption", ""),
            uploaded_by=request.user,
        )
        messages.success(
            request,
            "Evidence attached." if created else "File already attached — no duplicate created.",
        )
        return HttpResponseRedirect(request.META.get("HTTP_REFERER") or "/")


class EvidenceAttachmentDeleteView(RoleRequiredMixin, View):
    """G21 — POST handler removing an EvidenceAttachment."""

    allowed_roles = OFFICER_ROLES

    def post(self, request, att_pk: int):
        attachment = get_object_or_404(EvidenceAttachment, pk=att_pk)
        attachment.delete()
        messages.success(request, "Evidence removed.")
        return HttpResponseRedirect(request.META.get("HTTP_REFERER") or "/")


class DataPointImportErrorsCSVView(RoleRequiredMixin, View):
    """Stream the latest import's errors as a CSV download keyed by batch_id."""

    allowed_roles = OFFICER_ROLES

    def get(self, request, batch_id: str):
        import csv as _csv
        import io as _io

        errors = request.session.get(f"mel_csv_errors_{batch_id}", [])
        buf = _io.StringIO()
        writer = _csv.writer(buf)
        writer.writerow(["row", "indicator_code", "error"])
        for row in errors:
            writer.writerow([row.get("row"), row.get("indicator_code"), row.get("error")])
        resp = HttpResponse(buf.getvalue(), content_type="text/csv")
        resp["Content-Disposition"] = f'attachment; filename="datapoint-import-errors-{batch_id}.csv"'
        return resp


class DataPointImportTemplateView(RoleRequiredMixin, View):
    """G10 — stream a ready-to-fill CSV template with example rows.

    Uses the first two real Indicator codes (when available) so officers see
    exactly which codes to type. Falls back to placeholders when the DB is
    empty.
    """

    allowed_roles = OFFICER_ROLES

    def get(self, request):
        import csv as _csv
        import io as _io
        from datetime import date as _date

        sample_codes = list(
            Indicator.objects.filter(is_active=True)
            .values_list("code", flat=True)[:2]
        ) or ["INDICATOR-CODE-1", "INDICATOR-CODE-2"]
        today = _date.today()
        period_q = f"{today.year}-Q{(today.month - 1) // 3 + 1}"

        buf = _io.StringIO()
        writer = _csv.writer(buf)
        writer.writerow([
            "indicator_code", "period_label", "value",
            "evidence_url", "reported_at", "notes",
        ])
        writer.writerow([
            sample_codes[0], period_q, "42",
            "https://example.org/evidence.pdf",
            today.isoformat(),
            "Example row — replace with your data.",
        ])
        writer.writerow([
            sample_codes[-1], period_q, "65",
            "", "", "Evidence + reported_at + notes are optional.",
        ])
        resp = HttpResponse(buf.getvalue(), content_type="text/csv")
        resp["Content-Disposition"] = 'attachment; filename="datapoint-import-template.csv"'
        return resp


# ---------------------------------------------------------------------------
# G13 — Alert escalation views
# ---------------------------------------------------------------------------


class AlertEscalationRuleUpdateView(RoleRequiredMixin, View):
    """GET — render the rule form. POST — save rule + tier rosters."""

    allowed_roles = OFFICER_ROLES

    template_name = "indicators/escalation_rule_form.html"

    def _get_or_init_rule(self, slug: str):
        from apps.mel.indicators.models import AlertEscalationRule

        logframe = get_object_or_404(LogFrame, slug=slug)
        rule, _ = AlertEscalationRule.objects.get_or_create(logframe=logframe)
        return logframe, rule

    def get(self, request, slug: str):
        from apps.mel.indicators.forms import AlertEscalationRuleForm

        logframe, rule = self._get_or_init_rule(slug)
        return render(
            request,
            self.template_name,
            {
                "logframe": logframe,
                "rule": rule,
                "form": AlertEscalationRuleForm(instance=rule),
            },
        )

    def post(self, request, slug: str):
        from apps.mel.indicators.forms import AlertEscalationRuleForm

        logframe, rule = self._get_or_init_rule(slug)
        form = AlertEscalationRuleForm(request.POST, instance=rule)
        if not form.is_valid():
            return render(
                request,
                self.template_name,
                {"logframe": logframe, "rule": rule, "form": form},
            )
        form.save()
        messages.success(request, "Escalation rule updated.")
        return HttpResponseRedirect(
            reverse("mel_indicators:logframe_detail", kwargs={"slug": logframe.slug})
        )


class AlertEscalationListView(RoleRequiredMixin, ListView):
    """Cross-system list of in-flight + recently resolved escalations."""

    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR)
    template_name = "indicators/escalation_list.html"
    context_object_name = "escalations"
    paginate_by = 10

    def get_queryset(self):
        from apps.mel.indicators.models import AlertEscalation

        qs = (
            AlertEscalation.objects.select_related(
                "indicator__logframe_row__logframe", "acknowledged_by",
            )
            .order_by("acknowledged_at", "-created_at")
        )
        scope = self.request.GET.get("scope", "open")
        if scope == "open":
            qs = qs.filter(acknowledged_at__isnull=True)
        elif scope == "ack":
            qs = qs.filter(acknowledged_at__isnull=False)
        return qs

    def get_context_data(self, **kwargs):
        from apps.mel.indicators.models import AlertEscalation

        ctx = super().get_context_data(**kwargs)
        ctx["scope"] = self.request.GET.get("scope", "open")
        ctx["open_count"] = AlertEscalation.objects.filter(acknowledged_at__isnull=True).count()
        ctx["ack_count"] = AlertEscalation.objects.filter(acknowledged_at__isnull=False).count()
        return ctx


class AlertEscalationAcknowledgeView(RoleRequiredMixin, View):
    """POST — acknowledge an escalation; halts further tier transitions."""

    allowed_roles = OFFICER_ROLES + (UserRole.PROGRAM_MANAGER, UserRole.PROGRAM_DIRECTOR)

    def post(self, request, pk: int):
        from apps.mel.indicators.models import AlertEscalation
        from apps.mel.indicators.services import acknowledge_escalation

        esc = get_object_or_404(AlertEscalation, pk=pk)
        acknowledge_escalation(esc, user=request.user)
        messages.success(
            request,
            f"Escalation acknowledged for {esc.indicator.code} ({esc.period_label}).",
        )
        next_url = request.POST.get("next") or reverse("mel_indicators:escalation_list")
        return HttpResponseRedirect(next_url)
