"""M&EL indicators domain (PRD §5.3).

Models the causal chain Inputs → Activities → Outputs → Outcomes → Impact
(via :class:`LogFrame` + :class:`LogFrameRow`), SMART indicators with targets
and baselines, and the data points that feed them from upstream modules or
manual entry.
"""
from __future__ import annotations

from decimal import Decimal

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.functions import Lower
from django.utils import timezone
from simple_history.models import HistoricalRecords

from apps.core.storage import FileValidator
from apps.core.storage.paths import upload_to_mel_evidence, upload_to_mel_reports

_evidence_validator = FileValidator(
    module="mel",
    allowed_categories=["document", "data", "image"],
)


class LogFrameLevel(models.TextChoices):
    """Levels of the results hierarchy beneath a Strategic Objective.

    Per the M&E framework, the **Strategic Objective** IS the framework
    container itself (:class:`LogFrame`) — code, title, description, planning
    period. Its results chain is rooted at **Impact** (Long-Term Outcome), then
    **Intermediate Outcome**, then **Output** (University / Secretariat — see
    :class:`OutputType`). Activity / Input remain available for programmes that
    plan below Output level.
    """

    IMPACT = "impact", "Impact"
    OUTCOME = "outcome", "Intermediate Outcome"
    OUTPUT = "output", "Output"
    ACTIVITY = "activity", "Activity"
    INPUT = "input", "Input"


LEVEL_ORDER = {
    LogFrameLevel.IMPACT: 0,
    LogFrameLevel.OUTCOME: 1,
    LogFrameLevel.OUTPUT: 2,
    LogFrameLevel.ACTIVITY: 3,
    LogFrameLevel.INPUT: 4,
}

# Levels that must always have a parent (M&E framework exception handling: an
# Intermediate Outcome cannot exist without an Impact (E1), an Output cannot
# exist without an Intermediate Outcome (E2)). Impact is the chain root and
# hangs directly off the Strategic Objective container.
CHILD_LEVELS = {
    LogFrameLevel.OUTCOME,
    LogFrameLevel.OUTPUT,
    LogFrameLevel.ACTIVITY,
    LogFrameLevel.INPUT,
}

# M&E framework — the strict causal chain. When a parent is set it must sit
# exactly one level above (an Intermediate Outcome under an Impact, an Output
# under an Intermediate Outcome, …). Impact is a root and has no expected
# parent. Enforced at the form layer; the monotonic model check is the backstop.
EXPECTED_PARENT_LEVEL = {
    LogFrameLevel.OUTCOME: LogFrameLevel.IMPACT,
    LogFrameLevel.OUTPUT: LogFrameLevel.OUTCOME,
    LogFrameLevel.ACTIVITY: LogFrameLevel.OUTPUT,
    LogFrameLevel.INPUT: LogFrameLevel.ACTIVITY,
}


class OutputType(models.TextChoices):
    """M&E SRS Table 62 — Outputs split into University vs Secretariat streams."""

    UNSPECIFIED = "", "—"
    UNIVERSITY = "university", "University Output"
    SECRETARIAT = "secretariat", "Secretariat Output"


class LogFrameStatus(models.TextChoices):
    """G6 — programme lifecycle states for post-project monitoring."""

    ACTIVE = "active", "Active"
    CLOSED = "closed", "Closed"
    ARCHIVED = "archived", "Archived"


class LogFrameApproval(models.TextChoices):
    """M&E SRS Table 62 — Results Framework draft → submit → approve workflow.

    Distinct from :class:`LogFrameStatus` (which is the post-project *closure*
    lifecycle). A framework is authored as a DRAFT, SUBMITTED for approval once
    complete, then APPROVED (or bounced back to DRAFT).
    """

    DRAFT = "draft", "Draft"
    SUBMITTED = "submitted", "Submitted for approval"
    APPROVED = "approved", "Approved"


class LogFrame(models.Model):
    """A **Strategic Objective** — the root of a results framework.

    Per the M&E framework the Strategic Objective is the framework container
    itself: it carries the code (unique), title (``name``), description and
    planning period, and roots the causal chain
    Impact → Intermediate Outcome → Output (:class:`LogFrameRow`)."""

    name = models.CharField(max_length=255, unique=True)
    slug = models.SlugField(max_length=120, unique=True)
    description = models.TextField(blank=True)
    programme = models.CharField(max_length=120, blank=True)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="owned_logframes",
    )
    finance_budget = models.ForeignKey(
        "rims_finance.Budget",
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name="mel_logframes",
        help_text=(
            "G3 — bind this log frame to a finance Budget to enable budget-vs-"
            "expenditure variance reporting in the report builder."
        ),
    )
    period_start = models.DateField(
        null=True,
        blank=True,
        help_text="G6 — programme start date. Drives current-period anchoring.",
    )
    period_end = models.DateField(
        null=True,
        blank=True,
        help_text="G6 — planned programme end date. Hits trigger close eligibility.",
    )
    status = models.CharField(
        max_length=16,
        choices=LogFrameStatus.choices,
        default=LogFrameStatus.ACTIVE,
        db_index=True,
        help_text="G6 — ACTIVE accepts writes; CLOSED is read-only; ARCHIVED hides from default lists.",
    )
    closed_at = models.DateTimeField(null=True, blank=True)
    closed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="closed_logframes",
    )
    close_reason = models.TextField(blank=True)
    post_project_window_months = models.PositiveSmallIntegerField(
        default=3,
        help_text=(
            "G6 — months after closure before tracer follow-up surveys auto-dispatch."
        ),
    )
    # M&E framework — Results Framework approval workflow (draft→submit→approve).
    code = models.CharField(
        max_length=40,
        blank=True,
        help_text="M&E framework — Strategic Objective code (unique when set).",
    )
    planning_period = models.CharField(
        max_length=60,
        blank=True,
        help_text="M&E framework — planning period this Strategic Objective covers (e.g. 2024–2028).",
    )
    approval_status = models.CharField(
        max_length=16,
        choices=LogFrameApproval.choices,
        default=LogFrameApproval.DRAFT,
        db_index=True,
        help_text="M&E SRS — DRAFT may be incomplete; only complete frameworks may be SUBMITTED.",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="submitted_logframes",
    )
    approved_at = models.DateTimeField(null=True, blank=True)
    approved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="approved_logframes",
    )
    active = models.BooleanField(default=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["name"]
        constraints = [
            # M&E framework — Strategic Objective code must be unique (when supplied).
            models.UniqueConstraint(
                fields=["code"],
                condition=models.Q(code__gt=""),
                name="uniq_logframe_code",
                violation_error_message="A Strategic Objective with this code already exists.",
            ),
        ]

    def __str__(self):
        return self.name

    @property
    def is_closed(self) -> bool:
        return self.status in {LogFrameStatus.CLOSED, LogFrameStatus.ARCHIVED}

    @property
    def is_editable(self) -> bool:
        """G6 — single canonical gate. Writes are rejected when not editable."""
        return self.status == LogFrameStatus.ACTIVE

    @property
    def is_approved(self) -> bool:
        return self.approval_status == LogFrameApproval.APPROVED

    @property
    def is_draft(self) -> bool:
        return self.approval_status == LogFrameApproval.DRAFT


class LogFrameRow(models.Model):
    """A node in the results hierarchy under a Strategic Objective.

    The chain is a causal tree rooted at Impact (which hangs directly off the
    Strategic Objective container, :class:`LogFrame`), then Intermediate
    Outcome, then Output.

    PRD §5.3.4: deleting a parent requires explicit reassignment of children —
    enforced via ``on_delete=PROTECT`` on the self-FK so cascades cannot silently
    erase descendants.
    """

    logframe = models.ForeignKey(LogFrame, on_delete=models.CASCADE, related_name="rows")
    parent = models.ForeignKey(
        "self",
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name="children",
    )
    level = models.CharField(max_length=24, choices=LogFrameLevel.choices)
    output_type = models.CharField(
        max_length=16,
        choices=OutputType.choices,
        blank=True,
        default=OutputType.UNSPECIFIED,
        help_text="M&E SRS — for Output rows, whether this is a University or Secretariat output.",
    )
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    assumptions = models.TextField(blank=True)
    order = models.PositiveSmallIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["logframe", "order", "pk"]
        indexes = [
            models.Index(fields=["logframe", "level"]),
            models.Index(fields=["parent"]),
        ]

    def __str__(self):
        return f"[{self.get_level_display()}] {self.title}"

    def clean(self):
        # M&E framework — Impact is the chain root: it hangs directly off the
        # Strategic Objective container and therefore never has a parent row.
        if self.level == LogFrameLevel.IMPACT and self.parent:
            raise ValidationError(
                {"parent": "Impact rows are the framework root and cannot have a parent."}
            )
        # PRD §5.3.4 / M&E framework — hierarchy must be acyclic + monotonic
        # (parent must be strictly one or more levels higher).
        if self.parent:
            if self.parent_id == self.pk:
                raise ValidationError({"parent": "A row cannot be its own parent."})
            # Defect 6 (MEI009/MFL002) — the parent must live in the SAME log
            # frame, or the causal chain spans programmes and the roll-up breaks.
            if self.parent.logframe_id != self.logframe_id:
                raise ValidationError(
                    {"parent": "Parent must belong to the same Strategic Objective."}
                )
            if LEVEL_ORDER[self.parent.level] >= LEVEL_ORDER[self.level]:
                raise ValidationError(
                    {
                        "level": (
                            f"A {self.get_level_display()} cannot be a child of "
                            f"{self.parent.get_level_display()}."
                        )
                    }
                )
            # M&E SRS Table 62 — the parent must sit EXACTLY one level up (an
            # Intermediate Outcome directly under an Impact, an Output directly
            # under an Intermediate Outcome). This is the model backstop for the
            # same rule the form enforces, so non-form writers (importers, the
            # admin, future services) cannot persist a skip-level chain that
            # breaks the Output→Outcome→Impact roll-up.
            expected_parent = EXPECTED_PARENT_LEVEL.get(self.level)
            if expected_parent is not None and self.parent.level != expected_parent:
                raise ValidationError(
                    {
                        "parent": (
                            f"A {self.get_level_display()} must sit directly under "
                            f"{LogFrameLevel(expected_parent).label}, not "
                            f"{self.parent.get_level_display()}."
                        )
                    }
                )
        # M&E framework exception handling (E1/E2): Intermediate Outcomes /
        # Outputs / Activities / Inputs cannot exist without a parent (no orphan
        # Outcome/Output).
        if self.level in CHILD_LEVELS and self.parent is None:
            expected = {
                LogFrameLevel.OUTCOME: "an Impact",
                LogFrameLevel.OUTPUT: "an Intermediate Outcome",
                LogFrameLevel.ACTIVITY: "an Output",
                LogFrameLevel.INPUT: "an Activity",
            }[self.level]
            raise ValidationError(
                {"parent": f"A {self.get_level_display()} must be linked to {expected}."}
            )
        # Output type only applies to Output-level rows.
        if self.output_type and self.level != LogFrameLevel.OUTPUT:
            raise ValidationError(
                {"output_type": "Output type applies only to Output-level rows."}
            )

    @property
    def is_university_output(self) -> bool:
        return self.output_type == OutputType.UNIVERSITY

    @property
    def is_secretariat_output(self) -> bool:
        return self.output_type == OutputType.SECRETARIAT

    def delete(self, *args, **kwargs):
        if self.children.exists():
            raise ValidationError(
                "Cannot delete a log-frame row with children — reassign or delete them first."
            )
        return super().delete(*args, **kwargs)


class IndicatorType(models.TextChoices):
    QUANTITATIVE = "quantitative", "Quantitative"
    QUALITATIVE = "qualitative", "Qualitative"


class IndicatorState(models.TextChoices):
    """M&E SRS — an indicator is authored as a DRAFT, then published to ACTIVE."""

    DRAFT = "draft", "Draft"
    ACTIVE = "active", "Active"


class IndicatorFrequency(models.TextChoices):
    DAILY = "daily", "Daily"
    WEEKLY = "weekly", "Weekly"
    MONTHLY = "monthly", "Monthly"
    QUARTERLY = "quarterly", "Quarterly"
    SEMI_ANNUAL = "semi_annual", "Semi-annual"
    ANNUAL = "annual", "Annual"
    AD_HOC = "ad_hoc", "Ad hoc"


class RagStatus(models.TextChoices):
    """Traffic-light progress status derived from target vs actual."""

    UNKNOWN = "unknown", "Unknown"
    RED = "red", "Red"
    AMBER = "amber", "Amber"
    GREEN = "green", "Green"


class Indicator(models.Model):
    """A SMART indicator attached to a log frame row.

    PRD §5.3.4: every indicator must specify frequency, data source, and unit.
    """

    logframe_row = models.ForeignKey(
        LogFrameRow,
        on_delete=models.PROTECT,
        related_name="indicators",
    )
    code = models.SlugField(max_length=60, unique=True)
    name = models.CharField(max_length=255)
    definition = models.TextField(blank=True)
    indicator_type = models.CharField(
        max_length=16,
        choices=IndicatorType.choices,
        default=IndicatorType.QUANTITATIVE,
    )
    unit = models.CharField(
        max_length=60,
        help_text="e.g. people, %, USD — required by PRD §5.3.4.",
    )
    calculation_method = models.TextField(
        help_text="How the value is computed or aggregated — required for SMART compliance.",
    )
    data_source = models.CharField(
        max_length=255,
        help_text="Surveys, upstream module, admin records, etc.",
    )
    frequency = models.CharField(
        max_length=16,
        choices=IndicatorFrequency.choices,
    )
    source_module = models.CharField(
        max_length=32,
        blank=True,
        help_text="Upstream module name if this indicator receives automated data points (e.g. 'rims', 'rep').",
    )
    auto_event_type = models.CharField(
        max_length=64,
        blank=True,
        help_text="Event type key matched when draining the integration outbox.",
    )
    responsible = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_indicators",
    )
    workflow_status = models.CharField(
        max_length=12,
        choices=IndicatorState.choices,
        default=IndicatorState.ACTIVE,
        db_index=True,
        help_text=(
            "M&E SRS — DRAFT indicators are still being defined (name, definition, "
            "disaggregation); ACTIVE indicators are available for monitoring."
        ),
    )
    is_active = models.BooleanField(default=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["code"]
        indexes = [
            models.Index(fields=["source_module", "auto_event_type"]),
        ]
        constraints = [
            # M&E SRS Table 64 — "Indicator names should be unique within an
            # output." Enforced case-insensitively so "Farmers trained" and
            # "farmers trained" cannot both attach to the same output, matching
            # the ``name__iexact`` guard in ``IndicatorForm.clean``.
            models.UniqueConstraint(
                Lower("name"),
                "logframe_row",
                name="uniq_indicator_name_per_output",
                violation_error_message=(
                    "Another indicator on this output already uses this name."
                ),
            ),
        ]

    def __str__(self):
        return f"{self.code} — {self.name}"

    @property
    def is_draft(self) -> bool:
        return self.workflow_status == IndicatorState.DRAFT


class DisaggregationCategory(models.Model):
    """M&E SRS Table 64 — a dimension an indicator is broken down along.

    Examples: Gender, Programme, Campus, Year of Study, Age Group, Disability
    Status, Region, Institution. An indicator may have many categories.
    """

    indicator = models.ForeignKey(
        Indicator,
        on_delete=models.CASCADE,
        related_name="disaggregation_categories",
    )
    name = models.CharField(max_length=120)
    order = models.PositiveSmallIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["indicator", "order", "name"]
        constraints = [
            # M&E SRS Table 64 — case-insensitive so "Gender"/"gender" cannot
            # both attach to one indicator.
            models.UniqueConstraint(
                "indicator",
                Lower("name"),
                name="uniq_disagg_category_per_indicator",
                violation_error_message="This indicator already has a disaggregation category with that name.",
            ),
        ]

    def __str__(self):
        return f"{self.indicator.code} · {self.name}"


class DisaggregationValue(models.Model):
    """M&E SRS Table 64 — a value within a disaggregation category.

    Each value carries its OWN baseline, target, data source, collection
    method, measurement frequency, and responsible officer (SRS business rule:
    these are mandatory per value — enforced in the form/submission gate).
    """

    category = models.ForeignKey(
        DisaggregationCategory,
        on_delete=models.CASCADE,
        related_name="values",
    )
    name = models.CharField(max_length=120)
    baseline_value = models.DecimalField(
        max_digits=14, decimal_places=2, null=True, blank=True,
    )
    target_value = models.DecimalField(
        max_digits=14, decimal_places=2, null=True, blank=True,
    )
    data_source = models.CharField(max_length=255, blank=True)
    collection_method = models.CharField(max_length=255, blank=True)
    frequency = models.CharField(
        max_length=16, choices=IndicatorFrequency.choices, blank=True,
    )
    responsible = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_disaggregation_values",
    )
    order = models.PositiveSmallIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["category", "order", "name"]
        constraints = [
            # M&E SRS Table 64 — "Duplicate values within the same category shall
            # not be allowed." Case-insensitive so "Male"/"male" cannot coexist.
            models.UniqueConstraint(
                "category",
                Lower("name"),
                name="uniq_disagg_value_per_category",
                violation_error_message="This category already has a value with that name.",
            ),
        ]

    def __str__(self):
        return f"{self.category.name}: {self.name}"

    @property
    def is_complete(self) -> bool:
        """M&E SRS — a value is complete when all mandatory fields are captured."""
        return all(
            [
                self.baseline_value is not None,
                self.target_value is not None,
                bool(self.data_source),
                bool(self.collection_method),
                bool(self.frequency),
                self.responsible_id is not None,
            ]
        )


class IndicatorTarget(models.Model):
    """Baseline and target for a reporting period."""

    indicator = models.ForeignKey(
        Indicator,
        on_delete=models.CASCADE,
        related_name="targets",
    )
    period_label = models.CharField(
        max_length=40,
        help_text="e.g. 'FY2026', '2026-Q1', '2026-03'",
    )
    period_start = models.DateField()
    period_end = models.DateField()
    baseline_value = models.DecimalField(
        max_digits=18,
        decimal_places=4,
        null=True,
        blank=True,
    )
    target_value = models.DecimalField(
        max_digits=18,
        decimal_places=4,
        null=True,
        blank=True,
    )
    # FRSME-MEI010 — mid-cycle revisions must preserve the original target.
    # ``original_target_value`` is set once on the first save where
    # ``target_value`` is non-null, and frozen thereafter.
    original_target_value = models.DecimalField(
        max_digits=18,
        decimal_places=4,
        null=True,
        blank=True,
        help_text="Locked-in target at first definition; never changes after creation.",
    )
    revision_reason = models.TextField(
        blank=True,
        help_text="Why was the target revised mid-cycle?",
    )
    revised_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name="+",
    )
    revised_at = models.DateTimeField(null=True, blank=True, db_index=True)
    notes = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["indicator", "period_start"]
        constraints = [
            models.UniqueConstraint(
                fields=["indicator", "period_label"],
                name="uniq_indicator_target_period",
            ),
            models.CheckConstraint(
                check=models.Q(period_end__gte=models.F("period_start")),
                name="indicator_target_period_end_after_start",
            ),
        ]

    def __str__(self):
        return f"{self.indicator.code} — {self.period_label}"

    def clean(self):
        # PRD §5.3.4 — baseline must exist before a target can finalise.
        if self.target_value is not None and self.baseline_value is None:
            raise ValidationError(
                {"target_value": "Record a baseline before setting a target."},
            )

    def save(self, *args, **kwargs):
        # FRSME-MEI010 — freeze the original target the first time we record one.
        if self.original_target_value is None and self.target_value is not None:
            self.original_target_value = self.target_value
        super().save(*args, **kwargs)


class DataPoint(models.Model):
    """An actual measurement recorded against an indicator.

    Can be automated (`source_module` / `source_object_id` set from upstream
    signals) or manually entered (``reported_by`` set).
    """

    class Status(models.TextChoices):
        PENDING = "pending", "Pending verification"
        NEEDS_CLARIFICATION = "needs_clarification", "Clarification requested"
        VERIFIED = "verified", "Verified"
        REJECTED = "rejected", "Rejected"

    indicator = models.ForeignKey(
        Indicator,
        on_delete=models.CASCADE,
        related_name="data_points",
    )
    target = models.ForeignKey(
        IndicatorTarget,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="data_points",
        help_text="Optional link to the target period this data point counts toward.",
    )
    period_label = models.CharField(max_length=40)
    reported_at = models.DateTimeField(default=timezone.now)
    value = models.DecimalField(max_digits=18, decimal_places=4)
    qualitative_note = models.TextField(blank=True)
    evidence = models.FileField(
        upload_to=upload_to_mel_reports,
        validators=[_evidence_validator],
        blank=True,
        null=True,
    )
    source_module = models.CharField(max_length=32, blank=True)
    source_event = models.CharField(max_length=64, blank=True)
    source_object_id = models.CharField(max_length=64, blank=True)
    reported_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_data_points_reported",
    )
    status = models.CharField(
        max_length=24,
        choices=Status.choices,
        default=Status.PENDING,
        db_index=True,
    )
    clarification_note = models.TextField(
        blank=True,
        help_text="M&E SRS — the M&E Officer's clarification/correction request to the reporter.",
    )
    verified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_data_points_verified",
    )
    verified_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-reported_at"]
        indexes = [
            models.Index(fields=["indicator", "period_label"]),
            models.Index(fields=["source_module", "source_event"]),
            models.Index(fields=["status"]),
        ]
        constraints = [
            # Auto-generated rows must have source metadata to be idempotent.
            models.CheckConstraint(
                check=(
                    models.Q(source_module="", source_object_id="")
                    | ~models.Q(source_module="")
                ),
                name="datapoint_source_module_presence",
            ),
        ]

    def __str__(self):
        return f"{self.indicator.code} {self.period_label}: {self.value}"


class RagThreshold(models.Model):
    """Per-logframe override for GREEN / AMBER RAG thresholds.

    Settings default (``MEL_RAG_DEFAULT``) applies when no row exists for a
    logframe. PRD §5.3.4 non-functional: thresholds must be configurable.
    """

    logframe = models.OneToOneField(
        LogFrame,
        on_delete=models.CASCADE,
        related_name="rag_threshold",
    )
    green_min = models.DecimalField(
        max_digits=5,
        decimal_places=2,
        help_text="Minimum % of target for GREEN (default 85).",
    )
    amber_min = models.DecimalField(
        max_digits=5,
        decimal_places=2,
        help_text="Minimum % of target for AMBER (below → RED; default 60).",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"RAG({self.green_min}/{self.amber_min}) — {self.logframe.name}"

    def clean(self):
        if self.green_min is not None and self.amber_min is not None and self.green_min <= self.amber_min:
            raise ValidationError({"green_min": "GREEN minimum must be greater than AMBER minimum."})


class OutcomeAssessment(models.Model):
    """Structured outcome-level assessment (FRMFL020–023).

    Captures whether outputs are translating into expected short- and
    medium-term changes, with room for narrative contribution analysis and
    optional rating scales.
    """

    logframe_row = models.ForeignKey(
        LogFrameRow,
        on_delete=models.PROTECT,
        related_name="outcome_assessments",
        help_text="Must reference a row at OUTCOME level.",
    )
    period_label = models.CharField(max_length=40, db_index=True)
    metrics = models.JSONField(
        default=dict,
        blank=True,
        help_text="Structured metrics (e.g. {'reach': 320, 'adoption_rate': 0.42}).",
    )
    contribution_analysis = models.TextField(
        blank=True,
        help_text="Narrative linking outputs to outcomes.",
    )
    recommendations = models.TextField(blank=True)
    assessor = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_outcome_assessments_authored",
    )
    assessed_at = models.DateField(default=timezone.now)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-assessed_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["logframe_row", "period_label"],
                name="uniq_outcome_assessment_period",
            ),
        ]

    def __str__(self):
        return f"Outcome {self.logframe_row.title} — {self.period_label}"

    def clean(self):
        if self.logframe_row_id and self.logframe_row.level != LogFrameLevel.OUTCOME:
            raise ValidationError(
                {"logframe_row": "Outcome assessments must reference an OUTCOME row."},
            )


class ImpactEvaluation(models.Model):
    """Long-term impact evaluation (FRMFL024–027).

    One evaluation per logframe impact, capturing counterfactual/comparison
    design, narrative findings, and peer-review status.
    """

    logframe = models.ForeignKey(
        LogFrame,
        on_delete=models.PROTECT,
        related_name="impact_evaluations",
    )
    title = models.CharField(max_length=255)
    objectives = models.TextField(blank=True)
    counterfactual = models.TextField(
        blank=True,
        help_text="Counterfactual design or comparison group.",
    )
    comparison_group = models.TextField(blank=True)
    findings = models.TextField(blank=True)
    recommendations = models.TextField(blank=True)
    evaluator = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_impact_evaluations_led",
    )
    started_on = models.DateField(null=True, blank=True)
    completed_on = models.DateField(null=True, blank=True)
    peer_review_status = models.CharField(
        max_length=32,
        default="pending",
        help_text="e.g. pending, in_review, approved, revisions_requested.",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-completed_on", "-created_at"]
        indexes = [
            models.Index(fields=["logframe", "completed_on"]),
        ]

    def __str__(self):
        return f"Impact: {self.title}"


class ImpactDatapoint(models.Model):
    """Quantitative observation feeding into an :class:`ImpactEvaluation` (FRMFL026).

    Each row represents a single measurement collected from either the
    treatment or control group. Effect-size analysis (Cohen's d) is computed
    on demand from these rows by :func:`apps.mel.indicators.services.calculate_cohens_d`.
    """

    class Group(models.TextChoices):
        TREATMENT = "treatment", "Treatment"
        CONTROL = "control", "Control"

    evaluation = models.ForeignKey(
        ImpactEvaluation,
        on_delete=models.CASCADE,
        related_name="datapoints",
    )
    group = models.CharField(max_length=16, choices=Group.choices)
    value = models.DecimalField(max_digits=14, decimal_places=4)
    recorded_at = models.DateTimeField(default=timezone.now)
    recorded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="mel_impact_datapoints_recorded",
    )
    notes = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["recorded_at"]
        indexes = [models.Index(fields=["evaluation", "group"])]

    def __str__(self):
        return f"{self.evaluation_id}/{self.group}: {self.value}"


class VarianceRating(models.TextChoices):
    """M&E SRS Table 66 — qualitative performance ratings for variance rows."""

    ON_TRACK = "on_track", "On Track"
    NEEDS_ATTENTION = "needs_attention", "Needs Attention"
    CRITICAL = "critical", "Critical"


class IndicatorVariance(models.Model):
    """Planned-vs-actual variance snapshot for a reporting period (FRMFL028–029)."""

    indicator = models.ForeignKey(
        Indicator,
        on_delete=models.CASCADE,
        related_name="variances",
    )
    period_label = models.CharField(max_length=40, db_index=True)
    target_value = models.DecimalField(
        max_digits=18, decimal_places=4, null=True, blank=True,
    )
    actual_value = models.DecimalField(
        max_digits=18, decimal_places=4, null=True, blank=True,
    )
    variance_pct = models.DecimalField(
        max_digits=9,
        decimal_places=4,
        null=True,
        blank=True,
        help_text="(actual − target) / target × 100.",
    )
    variance_abs = models.DecimalField(
        max_digits=18,
        decimal_places=4,
        null=True,
        blank=True,
        help_text="M&E SRS Table 66 — absolute variance (actual − target).",
    )
    rating = models.CharField(
        max_length=16,
        choices=VarianceRating.choices,
        blank=True,
        db_index=True,
        help_text=(
            "M&E SRS Table 66 — qualitative rating derived from variance_pct: "
            "On Track (≥ −15%), Needs Attention (−15% to −50%), Critical (≤ −50%)."
        ),
    )
    cause_analysis = models.TextField(blank=True)
    adjustment = models.TextField(
        blank=True,
        help_text="Corrective action summary (FRMFL031).",
    )
    computed_at = models.DateTimeField(auto_now=True)
    created_at = models.DateTimeField(auto_now_add=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-computed_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["indicator", "period_label"],
                name="uniq_indicator_variance_period",
            ),
        ]

    def __str__(self):
        return f"{self.indicator.code} variance {self.period_label}: {self.variance_pct}%"


class ValidationRule(models.Model):
    """G9 — declarative validation attached to an Indicator.

    A DataPoint write through :func:`record_data_point` is checked against
    every active rule for the target Indicator; on first violation a
    :class:`DataPointValidationError` is raised. CUSTOM_EXPRESSION is
    evaluated through ``simpleeval`` with a strict name + function whitelist
    — no ``eval``/``exec``, no attribute access, no imports.

    Param shapes per rule_type:
        RANGE             {"min": float, "max": float}
        REGEX             {"pattern": str}
        COMPARISON        {"op": ">=", "other": float}
        CUSTOM_EXPRESSION {"expression": "value > 0 and value < previous"}
    """

    class RuleType(models.TextChoices):
        RANGE = "range", "Range"
        REGEX = "regex", "Regex"
        COMPARISON = "comparison", "Comparison"
        CUSTOM_EXPRESSION = "custom_expression", "Custom expression"

    indicator = models.ForeignKey(
        Indicator,
        on_delete=models.CASCADE,
        related_name="validation_rules",
    )
    rule_type = models.CharField(max_length=24, choices=RuleType.choices)
    params = models.JSONField(default=dict, blank=True)
    message = models.CharField(
        max_length=255,
        blank=True,
        help_text=(
            "Optional friendlier rejection message shown in the form error. "
            "Falls back to a default per rule_type when blank."
        ),
    )
    is_active = models.BooleanField(default=True, db_index=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="+",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["indicator_id", "pk"]
        indexes = [
            models.Index(fields=["indicator", "is_active"]),
        ]

    def __str__(self):
        return f"{self.indicator_id}/{self.rule_type}"

    _OP_SYMBOLS = {">": ">", ">=": "≥", "<": "<", "<=": "≤", "==": "=", "!=": "≠"}

    def human_readable_params(self) -> str:
        """Plain-English description of this rule for non-engineer surfaces."""
        params = self.params or {}

        def _fmt(value):
            if value is None:
                return "—"
            try:
                f = float(value)
            except (TypeError, ValueError):
                return str(value)
            return str(int(f)) if f.is_integer() else str(f)

        if self.rule_type == self.RuleType.RANGE:
            lo, hi = params.get("min"), params.get("max")
            if lo is not None and hi is not None:
                return f"Between {_fmt(lo)} and {_fmt(hi)}"
            if lo is not None:
                return f"At least {_fmt(lo)}"
            if hi is not None:
                return f"At most {_fmt(hi)}"
            return "—"
        if self.rule_type == self.RuleType.REGEX:
            pattern = params.get("pattern")
            return f"Matches {pattern}" if pattern else "—"
        if self.rule_type == self.RuleType.COMPARISON:
            op, other = params.get("op"), params.get("other")
            if not op or other is None:
                return "—"
            return f"Must be {self._OP_SYMBOLS.get(op, op)} {_fmt(other)}"
        if self.rule_type == self.RuleType.CUSTOM_EXPRESSION:
            expr = params.get("expression")
            return f"Expression: {expr}" if expr else "—"
        return "—"


class IndicatorSilentAlert(models.Model):
    """G7 — dedupe row for the silent-indicator notification beat.

    Idempotent on (indicator, period_label). The Celery scanner only fires a
    notification when no row exists for the indicator's current period; once
    written, the row blocks duplicate alerts until the period rolls.
    """

    indicator = models.ForeignKey(
        Indicator,
        on_delete=models.CASCADE,
        related_name="silent_alerts",
    )
    period_label = models.CharField(max_length=40, db_index=True)
    fired_at = models.DateTimeField(auto_now_add=True)
    recipient = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="+",
        help_text="User the alert was sent to; nulled if the user is later deleted.",
    )

    history = HistoricalRecords()

    class Meta:
        ordering = ["-fired_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["indicator", "period_label"],
                name="uniq_indicator_silent_alert_period",
            ),
        ]

    def __str__(self):
        return f"silent[{self.indicator_id}@{self.period_label}]"


class EvidenceAttachment(models.Model):
    """G21 — polymorphic file attachment for MEL hosts.

    Generic FK targets are: DataPoint, OutputDeliverable, Activity,
    FeedbackSubmission. SHA-256 dedupe via the composite unique constraint
    on (content_type, object_id, content_hash) means re-uploading the same
    file to the same host is a get_or_create no-op.
    """

    content_type = models.ForeignKey(
        "contenttypes.ContentType",
        on_delete=models.CASCADE,
        related_name="+",
    )
    object_id = models.PositiveIntegerField()
    file = models.FileField(
        upload_to=upload_to_mel_evidence,
        validators=[_evidence_validator],
    )
    content_hash = models.CharField(
        max_length=64,
        db_index=True,
        help_text="SHA-256 of the file bytes; used for de-duplication.",
    )
    mime_type = models.CharField(max_length=120, blank=True)
    caption = models.CharField(max_length=255, blank=True)
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name="mel_evidence_uploads",
    )
    uploaded_at = models.DateTimeField(auto_now_add=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-uploaded_at"]
        indexes = [
            models.Index(fields=["content_type", "object_id"]),
        ]
        constraints = [
            models.UniqueConstraint(
                fields=["content_type", "object_id", "content_hash"],
                name="uniq_evidence_attachment_per_host",
            ),
        ]

    def __str__(self):
        return f"evidence[{self.content_type_id}/{self.object_id}]"

    @property
    def host(self):
        """Resolve the host object via the generic FK (read-only convenience)."""
        ct = self.content_type
        model_cls = ct.model_class()
        try:
            return model_cls._default_manager.get(pk=self.object_id) if model_cls else None
        except model_cls.DoesNotExist:  # type: ignore[union-attr]
            return None


# ---------------------------------------------------------------------------
# G4 — Programme-level health score
# ---------------------------------------------------------------------------


class ProgrammeHealthScore(models.Model):
    """Per-period composite health score for a log frame (0–100).

    The score blends four orthogonal signals:

    * **rag_health**       — share of indicators meeting target (40%)
    * **activity_on_time** — share of activities completed on schedule (30%)
    * **budget_adherence** — actual spend vs planned spend (20%)
    * **feedback_satisfaction** — mean beneficiary rating, scaled (10%)

    Terms that cannot be computed (e.g. no finance binding, no feedback for
    the period) are stored as ``NULL`` and their weight is redistributed
    proportionally across the available terms — see
    :func:`compute_programme_health`.
    """

    logframe = models.ForeignKey(
        LogFrame,
        on_delete=models.CASCADE,
        related_name="health_scores",
    )
    period_label = models.CharField(max_length=40, db_index=True)
    period_start = models.DateField(null=True, blank=True)
    period_end = models.DateField(null=True, blank=True)

    rag_health = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
    activity_on_time = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
    budget_adherence = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
    feedback_satisfaction = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)

    score = models.DecimalField(max_digits=6, decimal_places=2)
    weights_applied = models.JSONField(
        default=dict,
        blank=True,
        help_text="Snapshot of per-term weight after redistribution (audit aid).",
    )
    indicator_count = models.PositiveIntegerField(default=0)
    activity_count = models.PositiveIntegerField(default=0)
    feedback_count = models.PositiveIntegerField(default=0)

    computed_at = models.DateTimeField(auto_now=True)
    created_at = models.DateTimeField(auto_now_add=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-period_start", "-period_label", "logframe_id"]
        constraints = [
            models.UniqueConstraint(
                fields=["logframe", "period_label"],
                name="uniq_programme_health_per_period",
            ),
        ]
        indexes = [
            models.Index(fields=["logframe", "period_label"]),
            models.Index(fields=["-period_start"]),
        ]

    def __str__(self):
        return f"{self.logframe.slug}@{self.period_label}={self.score}"


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


class AlertEscalationRule(models.Model):
    """Per-log-frame escalation policy for off-track indicator alerts.

    An indicator that goes RED notifies tier 1 immediately. If no recipient
    acknowledges within ``hours_until_tier_2``, tier 2 is notified; same
    again for ``hours_until_tier_3``. Recipients are explicit user lists per
    tier (no role-name indirection — keeps fan-out predictable).
    """

    logframe = models.OneToOneField(
        LogFrame,
        on_delete=models.CASCADE,
        related_name="escalation_rule",
    )
    tier_1_recipients = models.ManyToManyField(
        settings.AUTH_USER_MODEL, blank=True, related_name="alert_tier1_logframes",
    )
    tier_2_recipients = models.ManyToManyField(
        settings.AUTH_USER_MODEL, blank=True, related_name="alert_tier2_logframes",
    )
    tier_3_recipients = models.ManyToManyField(
        settings.AUTH_USER_MODEL, blank=True, related_name="alert_tier3_logframes",
    )
    hours_until_tier_2 = models.PositiveSmallIntegerField(
        default=24,
        help_text="Hours of silence after tier-1 dispatch before escalating to tier 2.",
    )
    hours_until_tier_3 = models.PositiveSmallIntegerField(
        default=24,
        help_text="Additional hours after tier-2 dispatch before escalating to tier 3.",
    )
    is_active = models.BooleanField(default=True, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    def __str__(self):
        return f"escalation[{self.logframe.slug}]"


class AlertEscalation(models.Model):
    """One escalation chain per (indicator, period). Replays are no-ops."""

    indicator = models.ForeignKey(
        Indicator, on_delete=models.CASCADE, related_name="escalations",
    )
    period_label = models.CharField(max_length=40, db_index=True)
    tier_reached = models.PositiveSmallIntegerField(default=1)
    acknowledged_at = models.DateTimeField(null=True, blank=True)
    acknowledged_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="acknowledged_escalations",
    )
    resolved_at = models.DateTimeField(null=True, blank=True)
    last_notified_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    history = HistoricalRecords()

    class Meta:
        ordering = ["-created_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["indicator", "period_label"],
                name="uniq_escalation_per_indicator_period",
            ),
        ]
        indexes = [
            models.Index(fields=["acknowledged_at"]),
            models.Index(fields=["tier_reached"]),
        ]

    def __str__(self):
        return f"escalation[{self.indicator.code}@{self.period_label} t{self.tier_reached}]"

    @property
    def is_open(self) -> bool:
        return self.acknowledged_at is None and self.resolved_at is None


# ---------------------------------------------------------------------------
# G2 — QR-code attendance & registration
# ---------------------------------------------------------------------------


def _generate_attendance_token() -> str:
    import secrets

    return secrets.token_urlsafe(24)


# Keep the module import side-effect free beyond this line.
Decimal  # re-exported indirectly via Decimal-typed fields; silences unused import in some linters.
