from django import forms

from apps.mel.indicators.models import (
    CHILD_LEVELS,
    EXPECTED_PARENT_LEVEL,
    AlertEscalationRule,
    DataPoint,
    DisaggregationCategory,
    DisaggregationValue,
    ImpactDatapoint,
    ImpactEvaluation,
    Indicator,
    IndicatorTarget,
    LogFrame,
    LogFrameLevel,
    LogFrameRow,
    OutcomeAssessment,
    ValidationRule,
)


COMPARISON_OPS = [
    (">", "Greater than (>)"),
    (">=", "At least (≥)"),
    ("<", "Less than (<)"),
    ("<=", "At most (≤)"),
    ("==", "Equal to (=)"),
    ("!=", "Not equal to (≠)"),
]


# M&E SRS Table 64 / 4.4.2 — "Output Indicator Management": when a project
# (Results Framework) is chosen, its rows are offered grouped by level with the
# Outputs (University then Secretariat) surfaced first, so the officer selects
# the output an indicator measures rather than scanning a flat cross-programme list.
_ROW_GROUP_LABELS = {
    "output_university": "University Outputs",
    "output_secretariat": "Secretariat Outputs",
    "output": "Outputs",
    "outcome": "Intermediate Outcomes",
    "impact": "Impacts",
    "activity": "Activities",
    "input": "Inputs",
}
_ROW_GROUP_ORDER = {
    "output_university": 0, "output_secretariat": 1, "output": 2,
    "outcome": 3, "impact": 4,
    "activity": 5, "input": 6,
}


def _row_group_key(row) -> str:
    if row.level == LogFrameLevel.OUTPUT:
        if row.output_type == "university":
            return "output_university"
        if row.output_type == "secretariat":
            return "output_secretariat"
        return "output"
    return row.level


class GroupedRowModelChoiceIterator(forms.models.ModelChoiceIterator):
    """Yield <optgroup> tuples grouping Results-Framework rows by level."""

    def __iter__(self):
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        rows = list(self.queryset)
        rows.sort(key=lambda r: (_ROW_GROUP_ORDER.get(_row_group_key(r), 9), r.order, r.pk))
        from itertools import groupby

        for key, group in groupby(rows, key=_row_group_key):
            label = _ROW_GROUP_LABELS.get(key, key.replace("_", " ").title())
            yield (label, [self.choice(obj) for obj in group])


class GroupedRowModelChoiceField(forms.ModelChoiceField):
    """ModelChoiceField whose options are grouped into <optgroup>s by level."""

    iterator = GroupedRowModelChoiceIterator

    def label_from_instance(self, obj):
        return obj.title


class ValidationRuleForm(forms.ModelForm):
    """G9 — declarative validation rule form, structured (non-technical) UI.

    Each rule type renders its own labelled fields. The form's ``clean``
    assembles a ``params`` dict from those fields and stashes it on the
    instance so the runtime validator (``services.validate_value``) reads
    the same shape that the legacy JSON-textarea version produced.
    """

    # RANGE
    range_min = forms.DecimalField(
        required=False, max_digits=20, decimal_places=4,
        label="Minimum value",
        help_text="Reject values below this. Leave blank for no lower bound.",
    )
    range_max = forms.DecimalField(
        required=False, max_digits=20, decimal_places=4,
        label="Maximum value",
        help_text="Reject values above this. Leave blank for no upper bound.",
    )

    # REGEX
    regex_pattern = forms.CharField(
        required=False, max_length=255,
        label="Pattern",
        help_text="Regular expression. The value must match — e.g. ^[A-Z]{3}$ requires three uppercase letters.",
    )

    # COMPARISON
    comparison_op = forms.ChoiceField(
        required=False, choices=[("", "—")] + COMPARISON_OPS,
        label="Operator",
    )
    comparison_other = forms.DecimalField(
        required=False, max_digits=20, decimal_places=4,
        label="Compare against",
        help_text="The fixed number every new value is compared to.",
    )

    # CUSTOM_EXPRESSION
    custom_expression = forms.CharField(
        required=False, max_length=500,
        widget=forms.Textarea(attrs={"rows": 2, "spellcheck": "false"}),
        label="Expression",
        help_text=(
            "Boolean expression evaluated for every new value. "
            "Use value for the current measurement and previous for the last data point. "
            "Functions min, max, abs are available. Example: value > 0 and value < previous."
        ),
    )

    class Meta:
        model = ValidationRule
        fields = ["rule_type", "message", "is_active"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        instance = kwargs.get("instance")
        if instance and isinstance(instance.params, dict):
            params = instance.params
            self.initial.setdefault("range_min", params.get("min"))
            self.initial.setdefault("range_max", params.get("max"))
            self.initial.setdefault("regex_pattern", params.get("pattern", ""))
            self.initial.setdefault("comparison_op", params.get("op", ""))
            self.initial.setdefault("comparison_other", params.get("other"))
            self.initial.setdefault("custom_expression", params.get("expression", ""))

    def clean(self):
        cleaned = super().clean()
        rule_type = cleaned.get("rule_type")
        if not rule_type:
            self.add_error("rule_type", "Pick a rule type to continue.")
            return cleaned

        params: dict = {}
        if rule_type == ValidationRule.RuleType.RANGE:
            lo = cleaned.get("range_min")
            hi = cleaned.get("range_max")
            if lo is None and hi is None:
                self.add_error("range_min", "Range rule needs at least one of minimum or maximum.")
                return cleaned
            if lo is not None and hi is not None and lo > hi:
                self.add_error("range_max", "Maximum must be greater than or equal to minimum.")
                return cleaned
            if lo is not None:
                params["min"] = float(lo)
            if hi is not None:
                params["max"] = float(hi)
        elif rule_type == ValidationRule.RuleType.REGEX:
            pattern = (cleaned.get("regex_pattern") or "").strip()
            if not pattern:
                self.add_error("regex_pattern", "Regex rule needs a pattern.")
                return cleaned
            import re as _re
            try:
                _re.compile(pattern)
            except _re.error as exc:
                self.add_error("regex_pattern", f"Pattern is not a valid regular expression: {exc}")
                return cleaned
            params["pattern"] = pattern
        elif rule_type == ValidationRule.RuleType.COMPARISON:
            op = (cleaned.get("comparison_op") or "").strip()
            other = cleaned.get("comparison_other")
            if not op:
                self.add_error("comparison_op", "Comparison rule needs an operator.")
            if other is None:
                self.add_error("comparison_other", "Comparison rule needs a number to compare against.")
            if not op or other is None:
                return cleaned
            params["op"] = op
            params["other"] = float(other)
        elif rule_type == ValidationRule.RuleType.CUSTOM_EXPRESSION:
            expr = (cleaned.get("custom_expression") or "").strip()
            if not expr:
                self.add_error("custom_expression", "Custom expression rule needs an expression.")
                return cleaned
            params["expression"] = expr

        # Stash on the instance so ModelForm.save() persists it to the JSONField.
        self.instance.params = params
        return cleaned


class EvidenceAttachmentForm(forms.Form):
    """G21 — single-file evidence upload form.

    Used as both the model-bound creation form and the inline upload widget
    in the gallery. Caption is optional.
    """

    file = forms.FileField(label="Evidence file")
    caption = forms.CharField(
        required=False, max_length=255,
        widget=forms.TextInput(attrs={"placeholder": "Caption (optional)"}),
    )


class DataPointImportForm(forms.Form):
    """G10 — officer-only bulk CSV upload form.

    Required CSV columns: ``indicator_code, period_label, value``. Optional:
    ``evidence_url, reported_at, notes``. Dry-run by default — operator must
    untick to commit. Each row routes through :func:`record_data_point` with
    ``source_module="csv_import"`` and a SHA-256-derived ``source_object_id``
    so a replay of the same file is a perfect no-op.
    """

    csv_file = forms.FileField(
        label="CSV file",
        help_text="Columns: indicator_code, period_label, value (required); evidence_url, reported_at, notes (optional).",
    )
    dry_run = forms.BooleanField(
        label="Dry run (preview only — do not commit)",
        required=False,
        initial=True,
    )


class LogFrameForm(forms.ModelForm):
    class Meta:
        model = LogFrame
        fields = [
            "name",
            "code",
            "slug",
            "description",
            "programme",
            "planning_period",
            "finance_budget",
            "period_start",
            "period_end",
            # post_project_window_months is intentionally omitted: it is never
            # rendered on the create form, yet the model field is required
            # (default=3, no blank=True). Including it made every submit fail
            # validation on a field the user can't see — a silent create
            # failure. The model default now applies on save instead.
            "active",
        ]
        widgets = {
            "period_start": forms.DateInput(attrs={"type": "date"}),
            "period_end": forms.DateInput(attrs={"type": "date"}),
            "description": forms.Textarea(attrs={"rows": 3}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # M&E framework — the Strategic Objective's own attributes.
        self.fields["name"].label = "Title"
        self.fields["code"].label = "Code"
        self.fields["planning_period"].label = "Planning period"
        # M&E framework — "Code, Title, and Planning Period are mandatory."
        # The model keeps blank=True so legacy frames load, but every Strategic
        # Objective authored or edited through this form must carry both.
        self.fields["code"].required = True
        self.fields["planning_period"].required = True
        # G3 — limit the dropdown to budgets the user can plausibly bind.
        # Approved and finalized budgets are eligible; draft budgets are not
        # yet committed and would muddy variance reporting.
        try:  # pragma: no cover - upstream is optional in some test envs
            from apps.rims.finance.models import Budget

            self.fields["finance_budget"].queryset = (
                Budget.objects.select_related("award")
                .filter(status__in=[Budget.Status.APPROVED, Budget.Status.FINALIZED])
                .order_by("-created_at")
            )
            self.fields["finance_budget"].help_text = (
                "Optional. Bind a finance Budget so the report builder can "
                "render a Budget Variance section. Only approved / finalized "
                "budgets are eligible."
            )
        except Exception:
            self.fields["finance_budget"].disabled = True
        # Searchable Select2 — the budget list grows over a programme's life.
        self.fields["finance_budget"].widget.attrs.update(
            {"data-s2": True, "data-s2-placeholder": "Search budgets…"}
        )

    def clean(self):
        cleaned = super().clean()
        start = cleaned.get("period_start")
        end = cleaned.get("period_end")
        if start and end and end < start:
            self.add_error("period_end", "Period end must be on or after the start date.")
        return cleaned


class LogFrameCloseForm(forms.Form):
    """G6 — close-out confirmation form.

    Closing a log frame freezes writes and arms the post-project tracer
    dispatch window. The reason is stored on the LogFrame and visible in the
    audit log.
    """

    reason = forms.CharField(
        widget=forms.Textarea(attrs={"rows": 4, "placeholder": "Why is this programme being closed?"}),
        help_text="Visible to auditors. Be specific — this becomes the closure record.",
        max_length=1000,
    )
    confirm = forms.BooleanField(
        label="I understand that closing this programme makes it read-only.",
        required=True,
    )


class LogFrameRowForm(forms.ModelForm):
    class Meta:
        model = LogFrameRow
        fields = [
            "logframe",
            "parent",
            "level",
            "output_type",
            "title",
            "description",
            "assumptions",
            "order",
        ]

    def __init__(self, *args, logframe_scope=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["logframe"].label = "Strategic Objective"
        self.fields["output_type"].help_text = (
            "Required for Output rows — pick University or Secretariat "
            "(M&E framework). Ignored for other levels."
        )
        # Defect 6 — the parent dropdown must not offer rows from OTHER log
        # frames (a cross-logframe parent breaks the results chain). Scope the
        # queryset to the row's own log frame when we know it (passed by the
        # create view via ?logframe=, or resolved from the edited instance).
        scope = logframe_scope
        if (
            scope is None
            and getattr(self.instance, "pk", None)
            and getattr(self.instance, "logframe_id", None)
        ):
            scope = self.instance.logframe

        parent_qs = LogFrameRow.objects.select_related("logframe").order_by(
            "logframe__name", "order", "pk",
        )
        if scope is not None:
            parent_qs = parent_qs.filter(logframe=scope)
            self.fields["logframe"].initial = scope.pk
            # LogFrameRow.__str__ is "[Level] Title" — exactly the label we want
            # once the log frame is fixed.
            self.fields["parent"].label_from_instance = (
                lambda obj: f"[{obj.get_level_display()}] {obj.title}"
            )
        else:
            # Unscoped fallback — still label each option with its log frame so
            # the operator can tell candidates apart.
            self.fields["parent"].label_from_instance = (
                lambda obj: f"{obj.logframe.name} · [{obj.get_level_display()}] {obj.title}"
            )
        self.fields["parent"].queryset = parent_qs
        # Searchable Select2 — framework + parent-row lists get long.
        self.fields["logframe"].widget.attrs.update(
            {"data-s2": True, "data-s2-placeholder": "Search frameworks…"}
        )
        self.fields["parent"].widget.attrs.update(
            {"data-s2": True, "data-s2-placeholder": "Search parent rows…"}
        )

    def clean(self):
        cleaned = super().clean()
        level = cleaned.get("level")
        parent = cleaned.get("parent")
        output_type = cleaned.get("output_type")
        # M&E SRS exception handling — surface orphan errors at the form layer
        # (defence-in-depth; the model.clean() is the backstop).
        if level in CHILD_LEVELS and not parent:
            expected = {
                LogFrameLevel.OUTCOME: "an Impact",
                LogFrameLevel.OUTPUT: "an Intermediate Outcome",
                LogFrameLevel.ACTIVITY: "an Output",
                LogFrameLevel.INPUT: "an Activity",
            }.get(level, "a parent row")
            self.add_error("parent", f"This level must be linked to {expected}.")
        if level == LogFrameLevel.IMPACT and parent:
            self.add_error(
                "parent",
                "Impact rows are the framework root and cannot have a parent.",
            )
        # M&E framework — strict causal chain: the parent must sit exactly
        # one level above (Outcome under Impact, Output under Outcome, …), not
        # merely anywhere higher up.
        if level and parent and level in EXPECTED_PARENT_LEVEL:
            expected_level = EXPECTED_PARENT_LEVEL[level]
            if parent.level != expected_level:
                self.add_error(
                    "parent",
                    f"A {LogFrameLevel(level).label} must sit under "
                    f"{LogFrameLevel(expected_level).label} — not under "
                    f"{parent.get_level_display()}.",
                )
        # M&E SRS — Outputs are split into University vs Secretariat streams.
        if level == LogFrameLevel.OUTPUT and not output_type:
            self.add_error(
                "output_type",
                "Choose whether this is a University or Secretariat output.",
            )
        if level and level != LogFrameLevel.OUTPUT and output_type:
            cleaned["output_type"] = ""
        return cleaned


class IndicatorForm(forms.ModelForm):
    class Meta:
        model = Indicator
        fields = [
            "logframe_row",
            "code",
            "name",
            "definition",
            "indicator_type",
            "unit",
            "calculation_method",
            "data_source",
            "frequency",
            "source_module",
            "auto_event_type",
            "responsible",
            "is_active",
        ]

    # M&E SRS 4.4.2 — the module is "Output Indicator Management": pick the
    # project first, then the output it measures. Not a model field — used only
    # to scope the row picker (ignored on save).
    results_framework = forms.ChoiceField(
        label="Project / Strategic Objective",
        required=True,
        help_text="Choose the programme first — its outputs load below.",
    )

    def __init__(self, *args, framework=None, **kwargs):
        super().__init__(*args, **kwargs)
        # M&E SRS Table 64 — "provides a clear definition" is mandatory.
        self.fields["definition"].required = True
        self.fields["definition"].help_text = (
            "Describe exactly what this indicator measures (M&E SRS — mandatory)."
        )

        # Resolve the scoping framework: explicit arg wins, else the value being
        # POSTed, else (edit) the instance's own framework.
        if framework is None:
            slug = (self.data.get("results_framework") or "").strip() if self.data else ""
            if slug:
                framework = LogFrame.objects.filter(slug=slug).first()
            elif getattr(self.instance, "pk", None) and self.instance.logframe_row_id:
                framework = self.instance.logframe_row.logframe

        # Project selector — only open (ACTIVE) frameworks are selectable;
        # indicators cannot be authored against a CLOSED/ARCHIVED framework
        # (M&E SRS Table 64, req 1 — "user only sees authorized projects").
        from apps.mel.indicators.models import LogFrameStatus

        frameworks = list(
            LogFrame.objects.exclude(
                status__in=[LogFrameStatus.CLOSED, LogFrameStatus.ARCHIVED]
            ).order_by("name")
        )
        # Keep the in-scope framework selectable when editing an indicator whose
        # framework has since been closed, so the edit form still renders.
        if framework is not None and framework not in frameworks:
            frameworks.append(framework)
        self.fields["results_framework"].choices = [("", "— Select a project —")] + [
            (lf.slug, lf.name) for lf in frameworks
        ]
        if framework is not None:
            self.fields["results_framework"].initial = framework.slug

        # Swap the flat row field for a project-scoped, level-grouped picker.
        row_qs = (
            LogFrameRow.objects.filter(logframe=framework).select_related("logframe")
            if framework is not None
            else LogFrameRow.objects.none()
        )
        self.fields["logframe_row"] = GroupedRowModelChoiceField(
            queryset=row_qs,
            required=True,
            label="Output",
            empty_label="— Select an output —",
            help_text=(
                "The output this indicator measures. Intermediate Outcomes and "
                "Impacts are also available lower in the list."
            ),
        )
        # Long, level-grouped output list — make it a searchable Select2.
        self.fields["logframe_row"].widget.attrs.update(
            {"data-s2": True, "data-s2-placeholder": "Search outputs…"}
        )
        # The responsible-officer picker lists every staff user — searchable.
        self.fields["responsible"].widget.attrs.update(
            {"data-s2": True, "data-s2-placeholder": "Search staff…"}
        )
        # Preserve the pre-selected row (edit / ?logframe_row=) when in-scope.
        if getattr(self.instance, "logframe_row_id", None):
            self.fields["logframe_row"].initial = self.instance.logframe_row_id

    def clean(self):
        cleaned = super().clean()
        # M&E SRS — indicator names should be unique within an output.
        row = cleaned.get("logframe_row")
        name = (cleaned.get("name") or "").strip()
        if row and name:
            clash = Indicator.objects.filter(logframe_row=row, name__iexact=name)
            if self.instance.pk:
                clash = clash.exclude(pk=self.instance.pk)
            if clash.exists():
                self.add_error(
                    "name",
                    "Another indicator on this output already uses this name.",
                )
        return cleaned


class DisaggregationCategoryForm(forms.ModelForm):
    """M&E SRS Table 64 — a disaggregation dimension (Gender, Programme, …)."""

    class Meta:
        model = DisaggregationCategory
        # "order" is deliberately excluded: the disaggregation page renders only
        # the name input, and an unrendered required field fails validation
        # invisibly (the model default of 0 applies on save).
        fields = ["name"]
        widgets = {
            "name": forms.TextInput(
                attrs={"placeholder": "e.g. Gender, Programme, Campus, Age Group"}
            ),
        }

    def clean_name(self):
        # Normalise so trailing/leading whitespace can't smuggle a duplicate
        # past the case-insensitive uniqueness constraint.
        return (self.cleaned_data.get("name") or "").strip()


class DisaggregationValueForm(forms.ModelForm):
    """M&E SRS Table 64 — a value within a category, with its own baseline,
    target, data source, collection method, frequency and responsible officer.

    All of those are mandatory per the SRS business rule. They are enforced here
    (not at the DB layer) so partially-entered drafts can still be saved while a
    category is being built up, but a value cannot be *submitted* incomplete.
    """

    class Meta:
        model = DisaggregationValue
        fields = [
            "name",
            "baseline_value",
            "target_value",
            "data_source",
            "collection_method",
            "frequency",
            "responsible",
            # "order" is deliberately excluded: the values page has no ordering
            # control, and an unrendered required field fails validation
            # invisibly (the model default of 0 applies on save).
        ]
        widgets = {
            "name": forms.TextInput(attrs={"placeholder": "e.g. Male, Female"}),
            "data_source": forms.TextInput(attrs={"placeholder": "Survey, admin records…"}),
            "collection_method": forms.TextInput(
                attrs={"placeholder": "Interview, field visit…"}
            ),
            "responsible": forms.Select(
                attrs={"data-s2": True, "data-s2-placeholder": "Search staff…"}
            ),
        }

    _REQUIRED_FOR_COMPLETE = (
        "baseline_value",
        "target_value",
        "data_source",
        "collection_method",
        "frequency",
        "responsible",
    )

    def clean_name(self):
        return (self.cleaned_data.get("name") or "").strip()

    def clean(self):
        cleaned = super().clean()
        # A blank row in the formset (no name) is simply skipped.
        if not (cleaned.get("name") or "").strip():
            return cleaned
        # M&E SRS — every named value must carry all monitoring attributes.
        for field in self._REQUIRED_FOR_COMPLETE:
            value = cleaned.get(field)
            if value in (None, ""):
                self.add_error(
                    field,
                    "Required — every disaggregation value needs baseline, target, "
                    "data source, collection method, frequency and a responsible officer.",
                )
        return cleaned


class BaseDisaggregationValueFormSet(forms.BaseInlineFormSet):
    """M&E SRS Table 64 — "Duplicate values within the same category shall not be
    allowed." The DB constraint is case-insensitive (``Lower("name")``), but an
    inline formset excludes the parent FK during per-form validation, so the
    ``(category, Lower(name))`` constraint is skipped and a duplicate would only
    surface as an IntegrityError (HTTP 500) at save time. Catch it here so the
    officer gets a friendly field error instead. All existing values render in
    the formset, so a cross-form scan covers both new and edited rows.
    """

    def clean(self):
        super().clean()
        seen: dict[str, bool] = {}
        for form in self.forms:
            if not hasattr(form, "cleaned_data"):
                continue
            cd = form.cleaned_data
            if cd.get("DELETE"):
                continue
            name = (cd.get("name") or "").strip()
            if not name:
                continue
            key = name.casefold()
            if key in seen and "name" not in form.errors:
                form.add_error(
                    "name", "This category already has a value with that name."
                )
            seen[key] = True


def disaggregation_value_formset(*, data=None, instance=None):
    """Inline formset binding values to a category.

    ``extra`` only affects the unbound (GET) render — the values page adds rows
    client-side, so a category that already has values starts with no blanks,
    while an empty category is seeded with two (the typical Male/Female split).
    """
    has_values = bool(instance and instance.pk and instance.values.exists())
    Factory = forms.inlineformset_factory(
        DisaggregationCategory,
        DisaggregationValue,
        form=DisaggregationValueForm,
        formset=BaseDisaggregationValueFormSet,
        extra=0 if has_values else 2,
        can_delete=True,
    )
    return Factory(data=data, instance=instance)


class IndicatorTargetForm(forms.ModelForm):
    class Meta:
        model = IndicatorTarget
        fields = [
            "indicator",
            "period_label",
            "period_start",
            "period_end",
            "baseline_value",
            "target_value",
            "notes",
        ]
        widgets = {
            "indicator": forms.Select(
                attrs={"data-s2": True, "data-s2-placeholder": "Search indicators…"}
            ),
            "period_label": forms.TextInput(attrs={"list": "mel-period-options", "autocomplete": "off"}),
            "period_start": forms.DateInput(attrs={"type": "date"}),
            "period_end": forms.DateInput(attrs={"type": "date"}),
        }


class IndicatorTargetRevisionForm(forms.ModelForm):
    """FRSME-MEI010 — mid-cycle target revision.

    Edits only fields that may shift between baseline collection and end-of-period.
    ``revision_reason`` is mandatory when ``target_value`` changes from the
    persisted value.
    """

    class Meta:
        model = IndicatorTarget
        fields = [
            "baseline_value",
            "target_value",
            "notes",
            "revision_reason",
        ]
        widgets = {
            "revision_reason": forms.Textarea(
                attrs={
                    "rows": 4,
                    "placeholder": "Why is this target being revised mid-cycle?",
                },
            ),
            "notes": forms.Textarea(attrs={"rows": 3}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # period_label is immutable once a target exists — surface as context, not input.
        self.fields["revision_reason"].help_text = (
            "Required when changing the target value. Auditors read this; be specific."
        )

    def clean(self):
        cleaned = super().clean()
        new_target = cleaned.get("target_value")
        original = self.instance.target_value
        if (
            self.instance.pk
            and original is not None
            and new_target is not None
            and new_target != original
            and not cleaned.get("revision_reason")
        ):
            self.add_error(
                "revision_reason",
                "Provide a reason — the target value has changed.",
            )
        return cleaned


class DataPointForm(forms.ModelForm):
    class Meta:
        model = DataPoint
        fields = [
            "indicator",
            "period_label",
            "reported_at",
            "value",
            "qualitative_note",
            "evidence",
        ]
        widgets = {
            "indicator": forms.Select(
                attrs={"data-s2": True, "data-s2-placeholder": "Search indicators…"}
            ),
            "period_label": forms.TextInput(attrs={"list": "mel-period-options", "autocomplete": "off"}),
            # BUG-1 — a native <input type="datetime-local"> only accepts
            # "YYYY-MM-DDTHH:MM" (no seconds). Django's default DateTimeInput
            # renders "YYYY-MM-DD HH:MM:SS", whose seconds set the control's
            # step base and silently reject minute-precision entries. Pin the
            # render + parse formats to the minute.
            "reported_at": forms.DateTimeInput(
                attrs={"type": "datetime-local"},
                format="%Y-%m-%dT%H:%M",
            ),
            "qualitative_note": forms.Textarea(attrs={"rows": 3}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # M&E SRS Table 64/65 — only published indicators on an open (ACTIVE)
        # framework are available for monitoring; DRAFT indicators and indicators
        # on CLOSED/ARCHIVED frameworks must not appear in the data-entry picker.
        from apps.mel.indicators.models import IndicatorState, LogFrameStatus

        self.fields["indicator"].queryset = (
            Indicator.objects.filter(
                is_active=True,
                logframe_row__logframe__status=LogFrameStatus.ACTIVE,
            )
            .exclude(workflow_status=IndicatorState.DRAFT)
            .select_related("logframe_row__logframe")
            .order_by("code")
        )
        # Accept the datetime-local minute format on submit, while still
        # tolerating a seconds-bearing value for programmatic/legacy callers.
        self.fields["reported_at"].input_formats = [
            "%Y-%m-%dT%H:%M",
            "%Y-%m-%dT%H:%M:%S",
            "%Y-%m-%d %H:%M:%S",
        ]
        # BUG-1 — suppress the hidden ``initial-`` change-detection input; it
        # renders with Django's default seconds-bearing format and would
        # otherwise reintroduce a "HH:MM:SS" string into the page.
        self.fields["reported_at"].show_hidden_initial = False


class OutcomeAssessmentForm(forms.ModelForm):
    class Meta:
        model = OutcomeAssessment
        fields = [
            "logframe_row",
            "period_label",
            "metrics",
            "contribution_analysis",
            "recommendations",
            "assessor",
            "assessed_at",
        ]
        widgets = {
            "period_label": forms.TextInput(attrs={"list": "mel-period-options", "autocomplete": "off"}),
            "assessed_at": forms.DateInput(attrs={"type": "date"}),
            "contribution_analysis": forms.Textarea(attrs={"rows": 6}),
            "recommendations": forms.Textarea(attrs={"rows": 4}),
            "metrics": forms.Textarea(
                attrs={
                    "rows": 4,
                    "placeholder": '{"reach": 320, "adoption_rate": 0.42}',
                    "data-mel-json": "1",
                },
            ),
            "assessor": forms.Select(attrs={"data-select2-user": "1"}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # PRD §5.3.4: outcome assessments must reference an OUTCOME row.
        self.fields["logframe_row"].queryset = LogFrameRow.objects.filter(
            level=LogFrameLevel.OUTCOME,
        ).select_related("logframe").order_by("logframe__name", "order", "pk")
        self.fields["logframe_row"].label = "Strategic Objective row"
        self.fields["logframe_row"].help_text = (
            "Pick the Intermediate Outcome row this assessment evaluates."
        )
        # Searchable Select2 (jQuery/Select2 loaded via the form's core scripts).
        self.fields["logframe_row"].widget.attrs.update(
            {"data-s2": True, "data-s2-placeholder": "Search outcome rows…"}
        )

    def clean_metrics(self):
        # JSONField already enforces parseability, but Django's default error is
        # generic — surface a friendlier message and reject non-object payloads.
        value = self.cleaned_data.get("metrics")
        if value in (None, ""):
            return {}
        if not isinstance(value, dict):
            raise forms.ValidationError(
                "Metrics must be a JSON object (e.g. {\"reach\": 320}).",
            )
        return value


class ImpactEvaluationForm(forms.ModelForm):
    class Meta:
        model = ImpactEvaluation
        fields = [
            "logframe",
            "title",
            "objectives",
            "counterfactual",
            "comparison_group",
            "findings",
            "recommendations",
            "evaluator",
            "started_on",
            "completed_on",
            "peer_review_status",
        ]
        labels = {"logframe": "Strategic Objective"}
        widgets = {
            "logframe": forms.Select(
                attrs={"data-s2": True, "data-s2-placeholder": "Search frameworks…"}
            ),
            "started_on": forms.DateInput(attrs={"type": "date"}),
            "completed_on": forms.DateInput(attrs={"type": "date"}),
            "objectives": forms.Textarea(attrs={"rows": 4}),
            "counterfactual": forms.Textarea(attrs={"rows": 4}),
            "comparison_group": forms.Textarea(attrs={"rows": 3}),
            "findings": forms.Textarea(attrs={"rows": 8}),
            "recommendations": forms.Textarea(attrs={"rows": 5}),
            "evaluator": forms.Select(attrs={"data-select2-user": "1"}),
        }


class ImpactDatapointForm(forms.ModelForm):
    class Meta:
        model = ImpactDatapoint
        fields = ["group", "value", "recorded_at", "notes"]
        widgets = {
            "recorded_at": forms.DateTimeInput(
                attrs={"type": "datetime-local"},
                format="%Y-%m-%dT%H:%M",
            ),
            "notes": forms.Textarea(attrs={"rows": 2}),
        }

    _INPUT_CLS = (
        "mt-1 block w-full rounded-md border border-ruforum-border bg-white "
        "px-3 py-1.5 text-sm text-ruforum-text font-meta tabular-nums "
        "focus:border-ruforum-primary focus:outline-none focus:ring-1 focus:ring-ruforum-primary"
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["recorded_at"].input_formats = ["%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"]
        for name in self.fields:
            self.fields[name].widget.attrs.setdefault("class", self._INPUT_CLS)


class AlertEscalationRuleForm(forms.ModelForm):
    """G13 — per-log-frame escalation policy.

    Tier-1 recipients receive the immediate alert; tier-2 fires after
    ``hours_until_tier_2`` of unacknowledged silence; tier-3 after another
    ``hours_until_tier_3``.
    """

    class Meta:
        model = AlertEscalationRule
        fields = [
            "tier_1_recipients",
            "tier_2_recipients",
            "tier_3_recipients",
            "hours_until_tier_2",
            "hours_until_tier_3",
            "is_active",
        ]
        widgets = {
            # Select2 enhances these into searchable chip-multi-selects via
            # the select2_multiselect_* component pair on the template.
            "tier_1_recipients": forms.SelectMultiple(),
            "tier_2_recipients": forms.SelectMultiple(),
            "tier_3_recipients": forms.SelectMultiple(),
            "hours_until_tier_2": forms.NumberInput(attrs={"min": 1, "max": 720}),
            "hours_until_tier_3": forms.NumberInput(attrs={"min": 1, "max": 720}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        from django.contrib.auth import get_user_model

        User = get_user_model()
        active_qs = User.objects.filter(is_active=True).order_by("email")
        for tier in ("tier_1_recipients", "tier_2_recipients", "tier_3_recipients"):
            self.fields[tier].queryset = active_qs

    def clean(self):
        cleaned = super().clean()
        if not cleaned.get("tier_1_recipients") and cleaned.get("is_active"):
            self.add_error(
                "tier_1_recipients",
                "Active escalation rules need at least one tier-1 recipient.",
            )
        return cleaned
