"""Decision domain models for the decision tree subsystem.

A :class:`Decision` represents a persisted choice point in a plan's
decision tree, created during the Strategize or Execute phases.  Each
decision records the question being answered, the chosen option,
alternatives considered, a confidence score, rationale, a
:class:`ContextSnapshot` for replay, and downstream dependency links.

Decision types
--------------

.. list-table::
   :header-rows: 1

   * - Type
     - Phase
     - Description
   * - ``prompt_definition``
     - Strategize
     - Root decision — the plan prompt
   * - ``invariant_enforced``
     - Strategize
     - An invariant constraint was applied
   * - ``strategy_choice``
     - Strategize
     - High-level approach chosen
   * - ``implementation_choice``
     - Execute
     - How to implement a specific task
   * - ``resource_selection``
     - Strategize or Execute
     - Which resources to read / modify
   * - ``subplan_spawn``
     - Strategize or Execute
     - Decision to create a child plan
   * - ``subplan_parallel_spawn``
     - Strategize or Execute
     - Spawn a group of child plans in parallel
   * - ``tool_invocation``
     - Execute
     - Which skill / tool to use
   * - ``error_recovery``
     - Execute
     - How to handle a failure
   * - ``validation_response``
     - Execute
     - Response to a validation failure
   * - ``user_intervention``
     - Any
     - User-provided guidance / correction

Context snapshots
-----------------

Every decision captures a :class:`ContextSnapshot` sufficient to
replay the decision from the same informational starting point.  The
snapshot includes a hash of the hot context window, a storage
reference for the full context, a list of :class:`ResourceRef`
entries, and the LangGraph actor checkpoint reference.

Based on:
    - docs/specification.md L18390-L18521
    - docs/adr/ADR-007-decision-tree-and-correction.md
    - docs/adr/ADR-033-decision-recording-protocol.md
    - docs/adr/ADR-034-decision-tree-versioning-and-history.md
"""

from __future__ import annotations

import re
from datetime import UTC, datetime
from enum import StrEnum

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

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
_ULID_RE = re.compile(ULID_PATTERN)


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class DecisionType(StrEnum):
    """Classification of a decision node in the plan decision tree.

    Each type is associated with one or more plan phases and constrains
    what downstream decisions are permitted.
    """

    PROMPT_DEFINITION = "prompt_definition"
    INVARIANT_ENFORCED = "invariant_enforced"
    STRATEGY_CHOICE = "strategy_choice"
    IMPLEMENTATION_CHOICE = "implementation_choice"
    RESOURCE_SELECTION = "resource_selection"
    SUBPLAN_SPAWN = "subplan_spawn"
    SUBPLAN_PARALLEL_SPAWN = "subplan_parallel_spawn"
    TOOL_INVOCATION = "tool_invocation"
    ERROR_RECOVERY = "error_recovery"
    VALIDATION_RESPONSE = "validation_response"
    USER_INTERVENTION = "user_intervention"


#: Decision types permitted during the Strategize phase.
#:
#: Phase-agnostic types (``resource_selection``, ``subplan_spawn``,
#: ``subplan_parallel_spawn``, ``user_intervention``) appear in **both**
#: sets so that the phase-gating check in
#: :meth:`DecisionService.record_decision` accepts them in either
#: Strategize or Execute.
#:
#: .. note::
#:
#:    ``subplan_spawn`` and ``subplan_parallel_spawn`` are classified as
#:    Strategize-only in ADR-007 and ADR-033.  They are included in
#:    ``EXECUTE_TYPES`` here because M4's subplan model requires
#:    recording spawn decisions during Execute when the execution actor
#:    discovers additional decomposition needs at runtime.  See ticket
#:    #931 for context.
STRATEGIZE_TYPES: frozenset[DecisionType] = frozenset(
    {
        DecisionType.PROMPT_DEFINITION,
        DecisionType.INVARIANT_ENFORCED,
        DecisionType.STRATEGY_CHOICE,
        DecisionType.RESOURCE_SELECTION,
        DecisionType.SUBPLAN_SPAWN,
        DecisionType.SUBPLAN_PARALLEL_SPAWN,
        DecisionType.USER_INTERVENTION,
    }
)

#: Decision types permitted during the Execute phase.
EXECUTE_TYPES: frozenset[DecisionType] = frozenset(
    {
        DecisionType.IMPLEMENTATION_CHOICE,
        DecisionType.RESOURCE_SELECTION,
        DecisionType.TOOL_INVOCATION,
        DecisionType.ERROR_RECOVERY,
        DecisionType.VALIDATION_RESPONSE,
        DecisionType.SUBPLAN_SPAWN,
        DecisionType.SUBPLAN_PARALLEL_SPAWN,
        DecisionType.USER_INTERVENTION,
    }
)


# ---------------------------------------------------------------------------
# Embedded value objects
# ---------------------------------------------------------------------------


class ResourceRef(BaseModel):
    """Reference to a resource that influenced a decision.

    Captures the resource identifier and an optional path / symbol
    within that resource for fine-grained provenance tracking.
    """

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

    resource_id: str = Field(
        ...,
        description="ULID of the referenced resource.",
    )
    path: str = Field(
        default="",
        description="Optional sub-path or symbol within the resource.",
    )

    @field_validator("resource_id")
    @classmethod
    def _resource_id_not_empty(cls, v: str) -> str:
        if not v or not v.strip():
            raise ValueError("resource_id must not be empty")
        return v


class ArtifactRef(BaseModel):
    """Reference to an artifact produced by a decision.

    Artifacts are files, patches, or outputs created as a side-effect
    of executing a decision.
    """

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

    artifact_path: str = Field(
        ...,
        description="Path to the artifact (relative to workspace root).",
    )
    artifact_type: str = Field(
        default="file",
        description="Kind of artifact: file, patch, log, etc.",
    )

    @field_validator("artifact_path")
    @classmethod
    def _artifact_path_not_empty(cls, v: str) -> str:
        if not v or not v.strip():
            raise ValueError("artifact_path must not be empty")
        return v


class ContextSnapshot(BaseModel):
    """Snapshot of the informational context at decision time.

    Captures enough state to replay the decision from the same
    starting point.  The ``hot_context_hash`` is a cryptographic hash
    of the exact context window; ``hot_context_ref`` is a storage
    pointer to the full serialised context.

    See ADR-033 §Context Snapshot Auto-Capture for the recording
    protocol.
    """

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

    hot_context_hash: str = Field(
        default="",
        description="Cryptographic hash of the hot context window.",
    )
    hot_context_ref: str = Field(
        default="",
        description="Storage pointer to the full context snapshot.",
    )
    relevant_resources: list[ResourceRef] = Field(
        default_factory=list,
        description="Resources that influenced this decision.",
    )
    actor_state_ref: str = Field(
        default="",
        description="LangGraph actor checkpoint reference.",
    )


# ---------------------------------------------------------------------------
# Decision model
# ---------------------------------------------------------------------------


class Decision(BaseModel):
    """A persisted choice point in a plan's decision tree.

    Decisions form a tree (via ``parent_decision_id``) and an influence
    DAG (via ``downstream_decision_ids``).  The ``prompt_definition``
    type is always the root of the tree and must have no parent.

    Immutable once created — corrections create *new* decisions and
    set ``superseded_by`` on the original.

    .. note::

       Because the model is frozen, ``superseded_by`` cannot be set
       via attribute assignment.  Use :meth:`with_superseded_by` to
       obtain a copy with the field populated.  The persistence layer
       is responsible for writing the updated copy back to storage.
    """

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

    # --- Identity ---

    decision_id: str = Field(
        default_factory=lambda: str(ULID()),
        description="Unique decision identifier (ULID).",
        pattern=ULID_PATTERN,
    )
    plan_id: str = Field(
        ...,
        description="ULID of the parent plan.",
        pattern=ULID_PATTERN,
    )
    parent_decision_id: str | None = Field(
        default=None,
        description="ULID of the parent decision (None for root).",
    )
    sequence_number: int = Field(
        ...,
        ge=0,
        description=(
            "Monotonic order within the plan's decisions.  "
            "Uniqueness within a plan is enforced at the persistence "
            "layer, not the domain model."
        ),
    )

    # --- Classification ---

    decision_type: DecisionType = Field(
        ...,
        description="The kind of decision being recorded.",
    )

    # --- The decision itself ---

    question: str = Field(
        ...,
        min_length=1,
        description="What question was being answered.",
    )
    chosen_option: str = Field(
        ...,
        min_length=1,
        description="The option that was chosen.",
    )
    alternatives_considered: list[str] = Field(
        default_factory=list,
        description="Other options that were evaluated.",
    )
    confidence_score: float | None = Field(
        default=None,
        ge=0.0,
        le=1.0,
        description="Confidence in the decision (0.0-1.0).",
    )

    # --- Context snapshot ---

    context_snapshot: ContextSnapshot = Field(
        default_factory=ContextSnapshot,
        description="Informational context at decision time.",
    )

    # --- Rationale ---

    rationale: str = Field(
        default="",
        description="Human-readable rationale for the decision.",
    )
    actor_reasoning: str | None = Field(
        default=None,
        description="Raw LLM reasoning trace, if available.",
    )

    # --- Downstream impact ---

    downstream_decision_ids: list[str] = Field(
        default_factory=list,
        description="ULIDs of decisions that depend on this one.",
    )
    downstream_plan_ids: list[str] = Field(
        default_factory=list,
        description="ULIDs of child plans spawned from this decision.",
    )
    artifacts_produced: list[ArtifactRef] = Field(
        default_factory=list,
        description="Artifacts created as side-effects.",
    )

    # --- Timestamps ---

    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="When the decision was recorded.",
    )

    # --- Correction metadata ---

    is_correction: bool = Field(
        default=False,
        description="Whether this decision is a correction of another.",
    )
    corrects_decision_id: str | None = Field(
        default=None,
        description="ULID of the decision being corrected.",
    )
    correction_reason: str | None = Field(
        default=None,
        description="Why the correction was made.",
    )
    superseded_by: str | None = Field(
        default=None,
        description="ULID of the decision that supersedes this one.",
    )

    # --- Validators ---

    @field_validator("plan_id")
    @classmethod
    def _plan_id_valid(cls, v: str) -> str:
        if not _ULID_RE.match(v):
            raise ValueError(f"plan_id must be a valid ULID, got '{v}'")
        return v

    @field_validator("parent_decision_id")
    @classmethod
    def _parent_decision_id_valid(cls, v: str | None) -> str | None:
        if v is not None and not _ULID_RE.match(v):
            raise ValueError(
                f"parent_decision_id must be a valid ULID or None, got '{v}'"
            )
        return v

    @field_validator("corrects_decision_id")
    @classmethod
    def _corrects_decision_id_valid(cls, v: str | None) -> str | None:
        if v is not None and not _ULID_RE.match(v):
            raise ValueError(
                f"corrects_decision_id must be a valid ULID or None, got '{v}'"
            )
        return v

    @field_validator("superseded_by")
    @classmethod
    def _superseded_by_valid(cls, v: str | None) -> str | None:
        if v is not None and not _ULID_RE.match(v):
            raise ValueError(f"superseded_by must be a valid ULID or None, got '{v}'")
        return v

    @model_validator(mode="after")
    def _root_decision_constraints(self) -> Decision:
        """Ensure prompt_definition decisions have no parent."""
        if (
            self.decision_type == DecisionType.PROMPT_DEFINITION
            and self.parent_decision_id is not None
        ):
            raise ValueError(
                "prompt_definition decisions must be the tree root "
                "(parent_decision_id must be None)"
            )
        return self

    @model_validator(mode="after")
    def _correction_fields_consistent(self) -> Decision:
        """Ensure correction metadata is internally consistent."""
        if self.is_correction and not self.corrects_decision_id:
            raise ValueError(
                "is_correction is True but corrects_decision_id is not set"
            )
        if self.corrects_decision_id and not self.is_correction:
            raise ValueError("corrects_decision_id is set but is_correction is False")
        return self

    # --- Domain helpers ---

    @property
    def is_root(self) -> bool:
        """Return True if this is the root of the decision tree."""
        return self.parent_decision_id is None

    @property
    def is_superseded(self) -> bool:
        """Return True if this decision has been superseded by a correction."""
        return self.superseded_by is not None

    @property
    def is_strategize_type(self) -> bool:
        """Return True if this is a Strategize-phase decision type."""
        return self.decision_type in STRATEGIZE_TYPES

    @property
    def is_execute_type(self) -> bool:
        """Return True if this is an Execute-phase decision type."""
        return self.decision_type in EXECUTE_TYPES

    @property
    def is_any_phase_type(self) -> bool:
        """Return True if this decision type is valid in any phase."""
        return (
            self.decision_type in STRATEGIZE_TYPES
            and self.decision_type in EXECUTE_TYPES
        )

    def as_cli_dict(self) -> dict[str, object]:
        """Return a stable dict for CLI table rendering."""
        return {
            "decision_id": self.decision_id,
            "plan_id": self.plan_id,
            "type": str(self.decision_type),
            "sequence": self.sequence_number,
            "question": self.question,
            "chosen": self.chosen_option,
            "confidence": self.confidence_score,
            "parent": self.parent_decision_id or "(root)",
            "is_correction": self.is_correction,
            "superseded": self.is_superseded,
        }

    def with_superseded_by(self, new_decision_id: str) -> Decision:
        """Return a copy with ``superseded_by`` set.

        Because the model is frozen, this is the canonical way to mark
        a decision as superseded during the correction lifecycle.

        Args:
            new_decision_id: ULID of the decision that supersedes this one.

        Returns:
            A new :class:`Decision` instance identical to this one
            except with ``superseded_by`` populated.
        """
        return self.model_copy(update={"superseded_by": new_decision_id})


__all__ = [
    "EXECUTE_TYPES",
    "STRATEGIZE_TYPES",
    "ArtifactRef",
    "ContextSnapshot",
    "Decision",
    "DecisionType",
    "ResourceRef",
]
