"""Invariant domain models for CleverAgents.

Invariants are natural-language constraints on plan execution, scoped at
global, project, action, or plan level.  When an action is used, its
invariants are promoted to plan-level.  The runtime precedence chain is:

    plan > project > global

They are reconciled by the Invariant Reconciliation Actor at the start
of Strategize and recorded as ``invariant_enforced`` decisions.

## Scope Hierarchy

| Scope      | Description                                        |
|------------|----------------------------------------------------|
| ``GLOBAL`` | Applies to every plan in the system                |
| ``PROJECT``| Applies to plans targeting a specific project      |
| ``ACTION`` | Defined in an action template; promoted on ``use`` |
| ``PLAN``   | Attached directly to a specific plan               |

## Merge Precedence

When computing the effective set of invariants for a plan, the merge
order is **plan > project > global**.  Duplicate texts (case-insensitive)
are de-duplicated, keeping the highest-precedence copy.

Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""

from __future__ import annotations

from datetime import datetime
from enum import StrEnum

from pydantic import BaseModel, ConfigDict, Field, field_validator
from ulid import ULID


class InvariantScope(StrEnum):
    """Scope at which an invariant applies.

    Precedence (highest to lowest): PLAN > PROJECT > GLOBAL.
    ACTION invariants are promoted to PLAN scope at ``plan use`` time.
    """

    GLOBAL = "global"
    PROJECT = "project"
    ACTION = "action"
    PLAN = "plan"


class Invariant(BaseModel):
    """A single invariant constraint.

    Invariants carry a unique ULID, the constraint text, scope, source
    name (identifying the project/action/plan that owns it), creation
    timestamp, and an active flag for soft-delete.
    """

    id: str = Field(
        default_factory=lambda: str(ULID()),
        description="Unique ULID identifier for this invariant",
    )
    text: str = Field(
        ...,
        min_length=1,
        description="The natural-language constraint text",
    )
    scope: InvariantScope = Field(
        ...,
        description="Scope at which this invariant applies",
    )
    source_name: str = Field(
        ...,
        min_length=1,
        description="Name of the project/action/plan that owns this invariant",
    )
    created_at: datetime = Field(
        default_factory=datetime.now,
        description="When this invariant was created",
    )
    active: bool = Field(
        default=True,
        description="Whether this invariant is active (False = soft-deleted)",
    )
    non_overridable: bool = Field(
        default=False,
        description=(
            "When True and scope is GLOBAL, this invariant cannot be "
            "overridden by lower-scope invariants during reconciliation"
        ),
    )

    @field_validator("text")
    @classmethod
    def validate_text_not_blank(cls: type[Invariant], v: str) -> str:
        """Reject whitespace-only invariant text."""
        stripped = v.strip()
        if not stripped:
            raise ValueError("Invariant text must not be blank")
        return stripped

    @field_validator("source_name")
    @classmethod
    def validate_source_name(cls: type[Invariant], v: str) -> str:
        """Reject whitespace-only source names."""
        stripped = v.strip()
        if not stripped:
            raise ValueError("Source name must not be blank")
        return stripped

    model_config = ConfigDict(
        str_strip_whitespace=True,
        frozen=True,
    )


class InvariantSet(BaseModel):
    """An ordered collection of invariants with merge logic.

    Provides a ``merge`` class method that combines invariant lists from
    different scopes respecting precedence and de-duplication.

    Uses ``tuple[Invariant, ...]`` rather than ``list[Invariant]`` so that
    the collection is deeply immutable: ``frozen=True`` prevents field
    reassignment, and the tuple prevents in-place mutation (e.g. ``append``).
    This also makes ``InvariantSet`` hashable, enabling use in sets and as
    dict keys.
    """

    invariants: tuple[Invariant, ...] = Field(
        default_factory=tuple,
        description="Ordered tuple of invariants",
    )

    @classmethod
    def merge(
        cls,
        plan_invariants: list[Invariant],
        project_invariants: list[Invariant],
        global_invariants: list[Invariant],
    ) -> InvariantSet:
        """Merge invariants respecting plan > project > global precedence.

        De-duplicates by text (case-insensitive), keeping the copy from
        the highest-precedence tier.  Within each tier, source ordering
        is preserved.

        Args:
            plan_invariants: Plan-level invariants (highest precedence).
            project_invariants: Project-level invariants.
            global_invariants: Global-level invariants (lowest precedence).

        Returns:
            An ``InvariantSet`` with the merged, de-duplicated invariants.
        """
        return cls(
            invariants=tuple(
                merge_invariants(plan_invariants, project_invariants, global_invariants)
            )
        )

    model_config = ConfigDict(frozen=True)


def merge_invariants(
    plan_invariants: list[Invariant],
    project_invariants: list[Invariant],
    global_invariants: list[Invariant],
) -> list[Invariant]:
    """Merge invariants implementing plan > project > global precedence.

    De-duplicates by text (case-insensitive).  The first occurrence
    (from the highest-precedence tier) wins.  Within each tier, the
    original ordering is preserved.

    Args:
        plan_invariants: Plan-level invariants (highest precedence).
        project_invariants: Project-level invariants.
        global_invariants: Global-level invariants (lowest precedence).

    Returns:
        A de-duplicated list of invariants in precedence order.
    """
    seen: set[str] = set()
    result: list[Invariant] = []

    for inv_list in (plan_invariants, project_invariants, global_invariants):
        for inv in inv_list:
            if not inv.active:
                continue
            key = inv.text.strip().lower()
            if key not in seen:
                seen.add(key)
                result.append(inv)

    return result


class InvariantViolation(BaseModel):
    """Record of an invariant being violated during plan execution.

    Captures which invariant was violated, the violating text/action,
    severity level, and optional details.
    """

    invariant_id: str = Field(
        ...,
        description="ULID of the invariant that was violated",
    )
    violated_text: str = Field(
        ...,
        min_length=1,
        description="The invariant text that was violated",
    )
    severity: str = Field(
        default="error",
        description="Severity level: error, warning, info",
    )
    details: str = Field(
        default="",
        description="Additional details about the violation",
    )

    @field_validator("severity")
    @classmethod
    def validate_severity(cls: type[InvariantViolation], v: str) -> str:
        """Validate severity is one of the allowed values."""
        allowed = {"error", "warning", "info"}
        v_lower = v.lower()
        if v_lower not in allowed:
            raise ValueError(f"Severity must be one of {sorted(allowed)}, got '{v}'")
        return v_lower

    model_config = ConfigDict(
        str_strip_whitespace=True,
        frozen=True,
    )


class InvariantEnforcementRecord(BaseModel):
    """Record of invariant enforcement at Strategize start.

    Created by the Invariant Reconciliation Actor to document which
    invariants were enforced, whether enforcement succeeded, and any
    actor response or decision reference.
    """

    invariant_id: str = Field(
        ...,
        description="ULID of the invariant being enforced",
    )
    enforced: bool = Field(
        ...,
        description="Whether the invariant was successfully enforced",
    )
    actor_response: str = Field(
        default="",
        description="Response from the reconciliation actor",
    )
    decision_id: str = Field(
        default="",
        description="ULID of the associated decision node",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        frozen=True,
    )
