"""Plan domain model for CleverAgents.

This module implements the four-phase plan lifecycle:

```
Action -> Strategize -> Execute -> Apply
```

Plans are instantiated from Action templates. The Action phase is the
non-processing template phase. When a plan is *used* (via
``agents plan use``) it transitions to Strategize.

Terminal processing states are ``applied`` (success, Apply only),
``constrained`` (cannot proceed, Apply only), ``errored`` (failed,
any phase), and ``cancelled`` (user/system cancelled, any phase).

## Lifecycle Phases

| Phase       | Description                                         |
|-------------|-----------------------------------------------------|
| ``ACTION``      | Template phase (non-processing)                 |
| ``STRATEGIZE``  | Generate a strategy and step plan               |
| ``EXECUTE``     | Carry out the planned steps                     |
| ``APPLY``       | Commit or reject the execution results          |

## Processing States

| State          | Description                                       |
|----------------|---------------------------------------------------|
| ``QUEUED``     | Phase entered, waiting to start                   |
| ``PROCESSING`` | Actively working                                  |
| ``ERRORED``    | Failed (terminal; recovery via revert/resume)     |
| ``COMPLETE``   | Phase work finished (terminal within phase)       |
| ``APPLIED``    | Terminal success (Apply only)                     |
| ``CONSTRAINED``| Cannot proceed within constraints (Apply only)    |
| ``CANCELLED``  | User/system cancelled (any phase)                 |

## Automation Profiles

Automation behavior is controlled exclusively by ``AutomationProfileRef``,
resolved at ``plan use`` time using plan > action > project > global precedence.
See ``AutomationProfile`` for threshold semantics.

## Key Classes

- ``NamespacedName`` -- validated ``namespace/name`` identifier
- ``PlanIdentity`` -- plan_id (ULID) + optional name
- ``Plan`` -- the core plan model with lifecycle state
- ``PlanPhase`` / ``ProcessingState`` -- lifecycle enums
- ``PlanInvariant`` -- invariant with source provenance
- ``ProjectLink`` -- project binding with alias
- ``SubplanConfig`` / ``SubplanAttempt`` -- subplan hierarchy

Based on ``docs/specification.md`` and ADR-004 (Pydantic Validation).
"""

from __future__ import annotations

import json
import re
from datetime import datetime
from enum import StrEnum
from typing import Any, ClassVar

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

from cleveragents.domain.models.core.cost_metadata import CostMetadata
from cleveragents.domain.models.core.estimation import EstimationResult
from cleveragents.domain.models.core.multi_project import (
    MultiProjectMetadata,
    ProjectScope,
)
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata

# ULID is 26 characters, all uppercase alphanumeric (Crockford's base32)
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"


class PlanPhase(StrEnum):
    """Phase of a plan in the v3 lifecycle.

    Plans progress through phases in order:
    Action -> Strategize -> Execute -> Apply

    Action is the non-processing template phase.  Terminal outcomes
    (applied, constrained, errored, cancelled) are modeled as
    ``ProcessingState`` values within the Apply phase, **not** as
    separate phases.
    """

    ACTION = "action"
    STRATEGIZE = "strategize"
    EXECUTE = "execute"
    APPLY = "apply"


class ProcessingState(StrEnum):
    """States for plans in Strategize/Execute/Apply phases.

    These states indicate what is happening during processing.

    Strategize / Execute phases use: queued, processing, errored,
    complete, cancelled.

    Apply phase uses: queued, processing, errored, applied,
    constrained, cancelled (but NOT complete — terminal success in
    Apply is ``applied``).
    """

    QUEUED = "queued"  # Waiting for compute/worker
    PROCESSING = "processing"  # Currently running
    ERRORED = "errored"  # Failed; includes error metadata
    COMPLETE = "complete"  # Finished successfully (Strategize/Execute)
    CANCELLED = "cancelled"  # User/system cancelled
    APPLIED = "applied"  # Apply terminal success — changes committed
    CONSTRAINED = "constrained"  # Apply cannot proceed within constraints


class ExecutionEnvironment(StrEnum):
    """Where tool execution takes place.

    Controls whether tools run on the host machine or inside a
    provisioned container resource.  Resolution priority:
    tool-level > plan-level > project-level > default (``HOST``).

    ``CONTAINER_REF`` is used when a specific container resource is
    targeted (e.g. ``container://local/my-devcontainer``).  The
    resolver strips the ``container://`` prefix and stores the
    resource name in the plan's execution environment metadata.
    """

    HOST = "host"
    CONTAINER = "container"
    CONTAINER_REF = "container_ref"


class ExecutionEnvPriority(StrEnum):
    """Priority semantics for the plan-level execution environment.

    ``FALLBACK`` (default): defers to auto-detected devcontainers or
    project-level overrides.  ``OVERRIDE``: always uses the specified
    environment, bypassing all other resolution.
    """

    FALLBACK = "fallback"
    OVERRIDE = "override"


class ExecutionMode(StrEnum):
    """How subplans should be executed.

    Controls the scheduling and ordering of child plans within a parent plan.
    """

    SEQUENTIAL = "sequential"  # One after another, ordered by sequence
    PARALLEL = "parallel"  # All at once (up to max_parallel)
    DEPENDENCY_ORDERED = "dependency_ordered"  # Respect DAG dependencies


class SubplanMergeStrategy(StrEnum):
    """How to merge results from parallel subplans.

    When multiple subplans modify the same resources concurrently,
    a merge strategy determines how conflicts are resolved.

    Named ``SubplanMergeStrategy`` to avoid collision with the infrastructure-
    level ``MergeStrategy`` protocol in ``infrastructure.sandbox.merge``.
    """

    GIT_THREE_WAY = "git_three_way"  # Use git merge-file for code
    SEQUENTIAL_APPLY = "sequential_apply"  # Apply in completion order
    FAIL_ON_CONFLICT = "fail_on_conflict"  # Error if any conflicts
    LAST_WINS = "last_wins"  # Later changes overwrite earlier


class InvariantSource(StrEnum):
    """Where an invariant originated from in the precedence hierarchy.

    Precedence (highest to lowest): plan > action > project > global.
    """

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


class AutomationProfileProvenance(StrEnum):
    """Where the resolved automation profile came from.

    Resolved at ``plan use`` time using plan > action > project > global
    precedence. Once set, it is locked to the plan.
    """

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


class NamespacedName(BaseModel):
    """A namespaced name following the format ``[server:][namespace/]<name>``.

    Namespace rules:
    - ``local/``: Reserved for local-only items (default if omitted)
    - ``<username>/``: Personal server namespace
    - ``<orgname>/``: Organization namespace
    - ``openai/``, ``anthropic/``, etc.: Built-in provider namespaces
    """

    server: str | None = Field(
        None, description="Optional server qualifier (e.g., 'prod', 'dev')"
    )
    namespace: str = Field(
        "local", description="Namespace (e.g., 'local', 'username', 'orgname')"
    )
    name: str = Field(..., min_length=1, max_length=255, description="The item name")

    @field_validator("namespace")
    @classmethod
    def validate_namespace(cls: type[NamespacedName], v: str) -> str:
        """Validate namespace format."""
        if not v:
            return "local"
        # Namespace should be lowercase alphanumeric with hyphens
        if not all(c.isalnum() or c == "-" for c in v):
            raise ValueError("Namespace must be alphanumeric with hyphens only")
        return v.lower()

    @field_validator("name")
    @classmethod
    def validate_name(cls: type[NamespacedName], v: str) -> str:
        """Validate name format (kebab-case recommended)."""
        if not v.replace("-", "").replace("_", "").isalnum():
            raise ValueError("Name must be alphanumeric with hyphens or underscores")
        return v.lower()

    @classmethod
    def parse(cls, full_name: str) -> NamespacedName:
        """Parse a full namespaced name string.

        Examples:
            - "my-action" -> namespace="local", name="my-action"
            - "local/my-action" -> namespace="local", name="my-action"
            - "myorg/my-action" -> namespace="myorg", name="my-action"
            - "prod:myorg/my-action" -> server="prod", namespace="myorg",
              name="my-action"
        """
        server = None
        namespace = "local"
        name = full_name

        # Check for server qualifier
        if ":" in name:
            server, name = name.split(":", 1)

        # Check for namespace
        if "/" in name:
            namespace, name = name.split("/", 1)

        return cls(server=server, namespace=namespace, name=name)

    def __str__(self) -> str:
        """Return the full namespaced name string."""
        parts = []
        if self.server:
            parts.append(f"{self.server}:")
        parts.append(f"{self.namespace}/{self.name}")
        return "".join(parts)

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


class PlanIdentity(BaseModel):
    """Identity and traceability fields for a plan.

    Every plan has a unique ULID, optional parent/root for hierarchy,
    and an attempt counter for re-runs.
    """

    plan_id: str = Field(
        ...,
        description="Unique ULID identifier",
        pattern=ULID_PATTERN,
    )
    parent_plan_id: str | None = Field(
        default=None,
        description="Parent plan ULID (for subplans)",
        pattern=ULID_PATTERN,
    )
    root_plan_id: str | None = Field(
        default=None,
        description="Root plan ULID (top-most in tree)",
        pattern=ULID_PATTERN,
    )
    attempt: int = Field(
        default=1,
        ge=1,
        description="Attempt counter (increments on re-run)",
    )

    @model_validator(mode="after")
    def resolve_root_plan_id(self) -> PlanIdentity:
        """Resolve ``root_plan_id`` to ``plan_id`` when ``None``.

        Per the specification DDL, ``root_plan_id`` is ``NOT NULL``:
        root plans self-reference their own ``plan_id``; child plans
        carry the root ancestor's ``plan_id``.  Resolving at domain
        construction time keeps the domain model consistent with the
        database constraint regardless of whether the object has been
        persisted yet.
        """
        if self.root_plan_id is None:
            self.root_plan_id = self.plan_id
        return self

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


class PlanTimestamps(BaseModel):
    """Timestamp tracking for plan lifecycle."""

    created_at: datetime = Field(default_factory=datetime.now)
    updated_at: datetime = Field(default_factory=datetime.now)
    strategize_started_at: datetime | None = Field(default=None)
    strategize_completed_at: datetime | None = Field(default=None)
    execute_started_at: datetime | None = Field(default=None)
    execute_completed_at: datetime | None = Field(default=None)
    apply_started_at: datetime | None = Field(default=None)
    applied_at: datetime | None = Field(default=None)

    model_config = ConfigDict(
        validate_assignment=True,
    )


class ProjectLink(BaseModel):
    """A link between a plan and a project.

    Plans target one or more projects. Each link carries an optional alias
    (for disambiguation in multi-project plans) and a read_only flag.
    """

    project_name: str = Field(
        ...,
        min_length=1,
        description="Namespaced project name (e.g., 'local/api-service')",
    )
    alias: str | None = Field(
        default=None,
        description="Optional alias for this project in the plan context",
    )
    read_only: bool = Field(
        default=False,
        description="Whether this project is read-only in the plan",
    )

    _ALIAS_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")

    @classmethod
    def validate_alias(cls, alias: str) -> bool:
        """Validate a project link alias.

        Aliases must be lowercase, start with a letter, and contain only
        alphanumeric characters, hyphens, and underscores. Max 64 chars.
        """
        return bool(cls._ALIAS_PATTERN.match(alias))

    @field_validator("alias")
    @classmethod
    def _validate_alias_format(cls: type[ProjectLink], v: str | None) -> str | None:
        if v is not None and not cls.validate_alias(v):
            raise ValueError(
                "Alias must be lowercase, start with a letter, "
                "and contain only alphanumeric characters, hyphens, "
                "and underscores (max 64 chars)"
            )
        return v

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


class PlanInvariant(BaseModel):
    """An invariant constraint attached to a plan.

    Invariants are natural-language constraints that flow into the decision
    tree during Strategize. Each invariant carries a source tag for
    precedence resolution (plan > action > project > global).
    """

    text: str = Field(
        ...,
        min_length=1,
        description="The invariant constraint text",
    )
    source: InvariantSource = Field(
        ...,
        description="Where this invariant originated",
    )

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


class AutomationProfileRef(BaseModel):
    """Reference to the resolved automation profile for a plan.

    Resolved at ``plan use`` time using plan > action > project > global
    precedence. Once set, it is locked to the plan.
    """

    profile_name: str = Field(
        ...,
        min_length=1,
        description="Namespaced profile name (e.g., 'trusted', 'local/careful-auto')",
    )
    provenance: AutomationProfileProvenance = Field(
        ...,
        description="Where this profile was resolved from",
    )

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


class SubplanAttempt(BaseModel):
    """Record of a subplan execution attempt.

    Captures timing and error information for each retry of a subplan,
    enabling post-mortem analysis and informed retry decisions.
    """

    attempt_number: int = Field(..., ge=1, description="1-based attempt counter")
    started_at: datetime = Field(..., description="When this attempt started")
    completed_at: datetime | None = Field(
        default=None, description="When this attempt ended (None if still running)"
    )
    error: str | None = Field(
        default=None, description="Error message if attempt failed"
    )
    was_retried: bool = Field(
        default=False,
        description="Whether a subsequent attempt was made after this one",
    )

    model_config = ConfigDict(validate_assignment=True)


class SubplanStatus(BaseModel):
    """Track status of a spawned subplan.

    Stored on the parent plan to track the progress and results of each
    child subplan without requiring a full plan fetch.
    """

    subplan_id: str = Field(
        ..., description="The subplan's plan_id (ULID)", pattern=ULID_PATTERN
    )
    action_name: str = Field(
        ..., description="Namespaced action name used to create the subplan"
    )
    target_resources: list[str] = Field(
        default_factory=list,
        description="Resource IDs this subplan operates on",
    )

    # Status tracking
    status: ProcessingState = Field(
        default=ProcessingState.QUEUED,
        description="Current processing state of the subplan",
    )
    started_at: datetime | None = Field(default=None)
    completed_at: datetime | None = Field(default=None)

    # Results
    error: str | None = Field(
        default=None, description="Error message if subplan failed"
    )
    changeset_summary: str | None = Field(
        default=None, description="Brief summary of changes produced"
    )
    files_changed: int = Field(
        default=0, ge=0, description="Number of files modified by this subplan"
    )

    # Retries
    attempt_number: int = Field(default=1, ge=1, description="Current attempt number")
    previous_attempts: list[SubplanAttempt] = Field(
        default_factory=list,
        description="History of prior execution attempts",
    )

    model_config = ConfigDict(validate_assignment=True)


class SubplanConfig(BaseModel):
    """Configuration for subplan execution.

    Set on parent plans that spawn child subplans.  Controls scheduling,
    concurrency, merge behaviour, retries, and timeouts.
    """

    execution_mode: ExecutionMode = Field(
        default=ExecutionMode.SEQUENTIAL,
        description="How to execute subplans",
    )
    merge_strategy: SubplanMergeStrategy = Field(
        default=SubplanMergeStrategy.GIT_THREE_WAY,
        description="How to merge subplan results",
    )
    max_parallel: int = Field(
        default=5,
        ge=1,
        le=50,
        description="Max concurrent subplans (for PARALLEL mode)",
    )
    fail_fast: bool = Field(
        default=False,
        description="Stop all subplans on first failure",
    )
    timeout_per_subplan_seconds: int | None = Field(
        default=None,
        ge=1,
        description="Timeout for each subplan in seconds (None=no timeout)",
    )
    retry_failed: bool = Field(
        default=True,
        description="Automatically retry failed subplans",
    )
    max_retries: int = Field(
        default=2,
        ge=0,
        le=5,
        description="Max retry attempts per subplan",
    )

    model_config = ConfigDict(validate_assignment=True)


class Plan(BaseModel):
    """Domain model for a v3 plan.

    A plan is the fundamental unit of orchestration in CleverAgents v3.
    It follows a four-phase lifecycle: Action -> Strategize -> Execute -> Apply

    Plans are instantiated from Action templates via ``agents plan use``.

    Key concepts:
    - Phase: Which step of the lifecycle (Action, Strategize, Execute, Apply)
    - Processing State: What is happening in that phase (queued, processing, etc.)
    - Identity: ULID-based identification with parent/root for hierarchy
    - Namespace: Scoping mechanism for ownership and discoverability
    - Action linkage: action_name references the source Action template
    - Project links: list of project references with alias and read_only flags
    """

    # Identity
    identity: PlanIdentity = Field(..., description="Plan identity and traceability")

    # Naming - follows [server:][namespace/]<name> format
    namespaced_name: NamespacedName = Field(
        ..., description="Namespaced name of the plan"
    )

    # Description - rendered from the action template at plan creation time
    description: str = Field(
        ...,
        min_length=1,
        description="The rendered prompt/description defining what this plan does",
    )

    # Definition of done - rendered from the action template at plan creation
    definition_of_done: str | None = Field(
        None,
        description="Rendered, testable completion criteria",
    )

    # Action linkage - namespaced name of the Action template this plan was
    # created from (replaces the old action_id pattern)
    action_name: str = Field(
        ...,
        min_length=1,
        description="Namespaced name of the source Action template",
    )

    # Current phase in the lifecycle
    phase: PlanPhase = Field(
        PlanPhase.STRATEGIZE,
        description="Current phase in the plan lifecycle",
    )

    # Processing state within the current phase
    processing_state: ProcessingState = Field(
        ProcessingState.QUEUED,
        description="Processing state in the current phase",
    )

    # Automation profile - resolved at plan use time and locked
    automation_profile: AutomationProfileRef | None = Field(
        default=None,
        description="Resolved automation profile with provenance tag",
    )

    # Actor references
    strategy_actor: str | None = Field(
        None,
        description="Actor to use for Strategize phase (namespaced name)",
    )
    execution_actor: str | None = Field(
        None,
        description="Actor to use for Execute phase (namespaced name)",
    )
    review_actor: str | None = Field(
        default=None,
        description="Optional actor for code review/QA",
    )
    apply_actor: str | None = Field(
        default=None,
        description="Optional actor for release/merge",
    )
    estimation_actor: str | None = Field(
        default=None,
        description="Optional actor for cost/risk estimation",
    )
    estimation_result: EstimationResult | None = Field(
        default=None,
        description="Cost/risk estimation result from the estimation actor",
    )
    cost_estimate_usd: float | None = Field(
        default=None,
        ge=0.0,
        description="Estimated cost in USD populated from estimation_result midpoint",
    )
    estimation_report: dict[str, object] | None = Field(
        default=None,
        description="Full estimation report as a JSON-serialisable dict",
    )
    invariant_actor: str | None = Field(
        default=None,
        description="Optional actor for invariant reconciliation",
    )

    # Project links (populated from ``plan use <action> <project>...``)
    project_links: list[ProjectLink] = Field(
        default_factory=list,
        description="Projects this plan operates on with aliases and flags",
    )

    # Invariants with source tags for precedence resolution
    invariants: list[PlanInvariant] = Field(
        default_factory=list,
        description="Invariant constraints with source provenance",
    )

    # Arguments - the resolved argument values supplied at plan use time
    arguments: dict[str, Any] = Field(
        default_factory=dict,
        description="Resolved argument values (name -> value)",
    )
    arguments_order: list[str] = Field(
        default_factory=list,
        description="Stable ordering of argument names for CLI rendering",
    )

    # Timestamps
    timestamps: PlanTimestamps = Field(
        default_factory=lambda: PlanTimestamps(),
        description="Lifecycle timestamps",
    )

    # Error tracking
    error_message: str | None = Field(
        default=None, description="Error message if errored"
    )
    error_details: dict[str, str] | None = Field(
        default=None, description="Additional error context"
    )

    # Execution placeholders
    changeset_id: str | None = Field(
        default=None,
        description="ID of the ChangeSet produced during Execute",
    )
    sandbox_refs: list[str] = Field(
        default_factory=list,
        description="Sandbox reference IDs for active sandboxes",
    )
    validation_summary: dict[str, Any] | None = Field(
        default=None,
        description="Summary of validation results before Apply",
    )
    decision_root_id: str | None = Field(
        default=None,
        description="ULID of the root decision node in the decision tree",
    )

    # Cost tracking (M4)
    cost_metadata: CostMetadata | None = Field(
        default=None,
        description="Token/cost tracking and budget exhaustion events",
    )

    # Skeleton compression metadata (M6 — ACMS context inheritance)
    skeleton_metadata: SkeletonMetadata | None = Field(
        default=None,
        description=(
            "Metadata from skeleton compression pass used for "
            "subplan context inheritance auditability"
        ),
    )

    # Execution environment override (tool > plan > project > default)
    execution_environment: str | None = Field(
        default=None,
        description="Execution environment override for this plan (host or container)",
    )
    execution_env_priority: ExecutionEnvPriority | None = Field(
        default=None,
        description="Execution environment priority semantics: fallback or override",
    )

    # Multi-project scope tracking (#199)
    multi_project_metadata: MultiProjectMetadata | None = Field(
        default=None,
        description="Multi-project scope tracking and cross-project metadata",
    )

    # Frozen profile snapshot — JSON snapshot of the automation profile
    # resolved at plan-creation time.  Used for audit / reproducibility.
    # NOTE: The default '{}' exists for backward compatibility with code
    # paths that create Plan objects before the snapshot is populated.
    # New plans SHOULD explicitly set this field to the resolved profile
    # JSON at creation time; the empty default does not satisfy the spec
    # intent of capturing a frozen profile for audit purposes.
    effective_profile_snapshot: str = Field(
        default="{}",
        description=(
            "Frozen JSON snapshot of the automation profile at plan creation time"
        ),
    )

    # Metadata
    created_by: str | None = Field(None, description="User/session that created plan")
    tags: list[str] = Field(default_factory=list, description="Tags for organization")
    reusable: bool = Field(
        True,
        description="Whether the source action remains available after use",
    )
    read_only: bool = Field(
        False,
        description="Whether plan only performs read operations",
    )

    # Subplan hierarchy
    subplan_config: SubplanConfig | None = Field(
        default=None,
        description="Config for subplan execution (set on parent plans)",
    )
    subplan_statuses: list[SubplanStatus] = Field(
        default_factory=list,
        description="Status tracking for spawned subplans",
    )

    # Resume metadata (step-level progress for plan resume)
    last_completed_step: int = Field(
        default=-1,
        ge=-1,
        description=(
            "Index of the last successfully completed step. "
            "-1 means no steps completed."
        ),
    )
    last_checkpoint_id: str | None = Field(
        default=None,
        description="ULID of the most recent resume checkpoint",
    )

    # Phase reversion tracking
    reversion_count: int = Field(
        default=0,
        ge=0,
        description="Number of phase reversions performed on this plan",
    )
    decisions: list[dict[str, Any]] = Field(
        default_factory=list,
        description="Decision log entries including reversions",
    )

    MAX_REVERSIONS: ClassVar[int] = 3

    @field_validator("effective_profile_snapshot")
    @classmethod
    def validate_effective_profile_snapshot_json(cls, v: str) -> str:
        """Ensure the snapshot is well-formed JSON."""
        try:
            json.loads(v)
        except (json.JSONDecodeError, TypeError, RecursionError) as exc:
            raise ValueError(
                f"effective_profile_snapshot must be valid JSON (length={len(v)})"
            ) from exc
        return v

    @model_validator(mode="after")
    def validate_env_priority_requires_environment(self) -> Plan:
        """Enforce that execution_env_priority requires execution_environment.

        The business rule is that setting the priority semantics (fallback
        or override) only makes sense when an explicit execution
        environment has been provided.  Any code path that creates or
        mutates a ``Plan`` — CLI, service layer, API, deserialization —
        is guarded by this invariant.
        """
        if self.execution_env_priority and not self.execution_environment:
            raise ValueError(
                "execution_env_priority requires execution_environment to be set"
            )
        return self

    @model_validator(mode="after")
    def validate_phase_state_consistency(self) -> Plan:
        """Ensure phase and state are consistent.

        - ACTION phase defaults to QUEUED if no state supplied.
        - CANCELLED state is terminal for any phase.
        - Apply phase accepts APPLIED / CONSTRAINED as terminal states.
        """
        if self.phase == PlanPhase.ACTION and self.processing_state is None:
            self.processing_state = ProcessingState.QUEUED
        return self

    @model_validator(mode="after")
    def validate_phase_state_constraints(self) -> Plan:
        """Enforce per-phase processing-state constraints.

        The specification restricts certain processing states to
        specific lifecycle phases:

        - ``APPLIED`` and ``CONSTRAINED`` are only valid in the
          ``APPLY`` phase (they are terminal outcomes of the apply
          step).
        - ``COMPLETE`` is only valid in ``STRATEGIZE`` or ``EXECUTE``
          phases (it signals that phase work finished and the plan
          should advance to the next phase; the Apply phase uses
          ``APPLIED`` instead of ``COMPLETE``).
        - ``QUEUED``, ``PROCESSING``, ``ERRORED``, and ``CANCELLED``
          are valid in any phase.

        Raises:
            ValueError: If the phase/state combination violates the
                specification constraints.
        """
        apply_only_states = (ProcessingState.APPLIED, ProcessingState.CONSTRAINED)
        if self.processing_state in apply_only_states and self.phase != PlanPhase.APPLY:
            raise ValueError(
                f"ProcessingState.{self.processing_state.name} is only valid "
                f"in the APPLY phase, but phase is {self.phase.value!r}"
            )

        complete_phases = (PlanPhase.STRATEGIZE, PlanPhase.EXECUTE)
        if (
            self.processing_state == ProcessingState.COMPLETE
            and self.phase not in complete_phases
        ):
            raise ValueError(
                f"ProcessingState.COMPLETE is only valid in STRATEGIZE or "
                f"EXECUTE phases, but phase is {self.phase.value!r}"
            )

        return self

    @model_validator(mode="after")
    def validate_project_link_alias_uniqueness(self) -> Plan:
        """Ensure project link aliases are unique within a plan."""
        aliases = [link.alias for link in self.project_links if link.alias is not None]
        if len(aliases) != len(set(aliases)):
            raise ValueError("Project link aliases must be unique within a plan")
        return self

    @property
    def state(self) -> ProcessingState:
        """Get the current processing state."""
        return self.processing_state

    @property
    def is_terminal(self) -> bool:
        """Check if plan is in a terminal state.

        Per the specification, the following processing states are
        terminal regardless of which phase the plan is in:

        - ``APPLIED``     - Apply-phase success (changes committed).
        - ``CANCELLED``   - User/system cancelled (any phase).
        - ``CONSTRAINED`` - Apply cannot proceed within constraints.
        - ``ERRORED``     - Processing failed (spec marks errored as
          terminal for all processing phases).

        .. note:: **Why COMPLETE is not plan-terminal**

            The spec marks both ``ERRORED`` and ``COMPLETE`` as
            "Terminal? Yes" in the Strategize/Execute phase tables.
            However, the spec uses "terminal" at the *phase* level —
            meaning processing stops for that phase.  ``COMPLETE``
            signals that the phase finished successfully and the plan
            should advance to the next phase (Strategize → Execute,
            Execute → Apply).  Including ``COMPLETE`` in
            ``is_terminal`` would block ``can_transition_to_next_phase``
            and prevent all forward lifecycle progression.  ``ERRORED``
            is plan-terminal because a failed plan must not auto-advance
            — explicit recovery (revert or resume) is required.
            See issue #918 for the design discussion.

        .. note::

            *Terminal* does not mean *permanently irrecoverable*.
            ``ERRORED`` and ``CONSTRAINED`` plans can still be reverted
            to an earlier phase via ``revert_plan`` or resumed through
            ``PlanResumeService``.  ``APPLIED`` and ``CANCELLED`` are
            permanently terminal — no further lifecycle transitions are
            allowed.  ``is_terminal`` is used by service guards to
            prevent automatic progression, cancellation, and pausing,
            but explicit recovery paths remain available.
        """
        return self.processing_state in (
            ProcessingState.APPLIED,
            ProcessingState.CANCELLED,
            ProcessingState.CONSTRAINED,
            ProcessingState.ERRORED,
        )

    @property
    def is_errored(self) -> bool:
        """Check if plan is in an errored state."""
        return self.processing_state == ProcessingState.ERRORED

    @property
    def can_transition_to_next_phase(self) -> bool:
        """Check if plan can transition to the next phase.

        - ACTION phase: can always transition (template -> Strategize).
        - Strategize / Execute: must be COMPLETE.
        - Apply: terminal phase — no forward transition.
        """
        if self.phase == PlanPhase.ACTION:
            return True  # Templates can always be used
        if self.phase == PlanPhase.APPLY:
            return False  # Terminal phase
        return self.processing_state == ProcessingState.COMPLETE

    def get_next_phase(self) -> PlanPhase | None:
        """Get the next phase in the lifecycle, or None if terminal.

        Apply is the last phase; there is no phase after it.
        """
        phase_order = [
            PlanPhase.ACTION,
            PlanPhase.STRATEGIZE,
            PlanPhase.EXECUTE,
            PlanPhase.APPLY,
        ]
        try:
            current_idx = phase_order.index(self.phase)
            if current_idx < len(phase_order) - 1:
                return phase_order[current_idx + 1]
        except ValueError:
            pass
        return None

    # --- Subplan hierarchy computed properties ---

    @property
    def is_subplan(self) -> bool:
        """Check if this plan is a subplan (has a parent)."""
        return self.identity.parent_plan_id is not None

    @property
    def is_root_plan(self) -> bool:
        """Check if this is a root plan (no parent or root == self)."""
        return (
            self.identity.root_plan_id is None
            or self.identity.root_plan_id == self.identity.plan_id
        )

    @property
    def depth(self) -> int:
        """Distance from root plan (0 for root).

        For non-root plans this returns ``-1`` as a placeholder; the actual
        depth must be computed by the service layer traversing the
        ``parent_plan_id`` chain.
        """
        if self.is_root_plan:
            return 0
        return -1  # Must be computed externally via parent chain

    @property
    def has_subplans(self) -> bool:
        """Check if this plan has spawned subplans."""
        return len(self.subplan_statuses) > 0

    @property
    def is_multi_project(self) -> bool:
        """Check if this plan targets more than one project."""
        return len(self.project_links) > 1

    def get_project_scope(self, project_name_or_alias: str) -> ProjectScope | None:
        """Look up a ``ProjectScope`` by project name or alias.

        Searches the ``multi_project_metadata.project_scopes`` list.
        Returns ``None`` if multi-project metadata is not set or no
        matching scope is found.

        Args:
            project_name_or_alias: Project name or alias to look up.

        Returns:
            The matching ``ProjectScope``, or ``None``.
        """
        if self.multi_project_metadata is None:
            return None
        for scope in self.multi_project_metadata.project_scopes:
            if scope.alias is not None and scope.alias == project_name_or_alias:
                return scope
            if scope.project_name == project_name_or_alias:
                return scope
        return None

    def can_revert_to(self, phase: PlanPhase) -> bool:
        """Check if reversion to the given phase is valid.

        Reversion is valid when:
        - The plan is not in a permanently terminal state (APPLIED or
          CANCELLED).  Note: ERRORED and CONSTRAINED are terminal per
          ``is_terminal`` but are still revertable — they represent
          recoverable failures, not permanent outcomes.
        - The target phase is a valid reversion target from the current phase.
        - The reversion count has not exceeded MAX_REVERSIONS.

        Args:
            phase: The target phase to revert to.

        Returns:
            True if reversion is allowed.
        """
        if self.processing_state in (
            ProcessingState.APPLIED,
            ProcessingState.CANCELLED,
        ):
            return False
        if self.reversion_count >= self.MAX_REVERSIONS:
            return False
        return can_transition(self.phase, phase)

    def as_cli_dict(self) -> dict[str, Any]:
        """Return a stable dictionary representation for CLI output.

        Fields are ordered for consistent rendering in table, JSON, and
        YAML output formats.
        """
        result: dict[str, Any] = {
            "plan_id": self.identity.plan_id,
            "name": str(self.namespaced_name),
            "action": self.action_name,
            "phase": self.phase.value,
            "state": self.processing_state.value,
            "description": self.description,
        }
        if self.definition_of_done:
            result["definition_of_done"] = self.definition_of_done
        if self.automation_profile:
            result["automation_profile"] = self.automation_profile.profile_name
            result["automation_profile_source"] = (
                self.automation_profile.provenance.value
            )
        if self.project_links:
            result["projects"] = [
                {
                    "name": link.project_name,
                    **({"alias": link.alias} if link.alias else {}),
                    **({"read_only": True} if link.read_only else {}),
                }
                for link in self.project_links
            ]
        if self.invariants:
            result["invariants"] = [
                {"text": inv.text, "source": inv.source.value}
                for inv in self.invariants
            ]
        if self.arguments:
            result["arguments"] = {
                name: self.arguments[name]
                for name in self.arguments_order
                if name in self.arguments
            }
        if self.strategy_actor:
            result["strategy_actor"] = self.strategy_actor
        if self.execution_actor:
            result["execution_actor"] = self.execution_actor
        if self.execution_environment:
            result["execution_environment"] = self.execution_environment
            result["execution_env_priority"] = (
                self.execution_env_priority.value
                if self.execution_env_priority is not None
                else ExecutionEnvPriority.FALLBACK.value
            )
        if self.error_message:
            result["error"] = self.error_message
        result["created_at"] = self.timestamps.created_at.isoformat()
        result["updated_at"] = self.timestamps.updated_at.isoformat()
        if self.identity.parent_plan_id:
            result["parent_plan_id"] = self.identity.parent_plan_id
        if self.has_subplans:
            result["subplan_count"] = len(self.subplan_statuses)
        if self.estimation_result is not None:
            result["estimation"] = self.estimation_result.as_display_dict()
        if self.cost_metadata is not None:
            result["cost"] = self.cost_metadata.as_display_dict()
        if self.skeleton_metadata is not None:
            result["skeleton"] = {
                "ratio": self.skeleton_metadata.ratio,
                "original_tokens": self.skeleton_metadata.original_tokens,
                "compressed_tokens": self.skeleton_metadata.compressed_tokens,
                "source_decision_ids": list(self.skeleton_metadata.source_decision_ids),
            }
        if self.multi_project_metadata is not None:
            mp = self.multi_project_metadata
            mp_dict: dict[str, Any] = {
                "total_projects": mp.total_projects,
                "allow_mixed_access": mp.allow_mixed_access,
            }
            scope_summaries: list[dict[str, Any]] = []
            for scope in mp.project_scopes:
                entry: dict[str, Any] = {"project_name": scope.project_name}
                if scope.alias:
                    entry["alias"] = scope.alias
                if scope.read_only:
                    entry["read_only"] = True
                if scope.changeset_summary is not None:
                    cs = scope.changeset_summary
                    entry["changeset"] = {
                        "files_changed": cs.files_changed,
                        "files_added": cs.files_added,
                        "files_deleted": cs.files_deleted,
                        "total_lines_changed": cs.total_lines_changed,
                        "validation_passed": cs.validation_passed,
                    }
                scope_summaries.append(entry)
            mp_dict["project_scopes"] = scope_summaries
            if mp.cross_project_dependencies:
                mp_dict["cross_project_dependencies"] = [
                    {
                        "source": dep.source_project,
                        "target": dep.target_project,
                        "type": dep.dependency_type,
                    }
                    for dep in mp.cross_project_dependencies
                ]
            result["multi_project"] = mp_dict
        if self.last_completed_step >= 0:
            result["last_completed_step"] = self.last_completed_step
        if self.last_checkpoint_id:
            result["last_checkpoint_id"] = self.last_checkpoint_id
        return result

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,  # Keep enums as enum objects
    )


# Phase transition map for validation
VALID_PHASE_TRANSITIONS: dict[PlanPhase, list[PlanPhase]] = {
    PlanPhase.ACTION: [PlanPhase.STRATEGIZE],  # plan use
    PlanPhase.STRATEGIZE: [PlanPhase.EXECUTE],  # execute command
    PlanPhase.EXECUTE: [PlanPhase.APPLY, PlanPhase.STRATEGIZE],  # apply or revert
    PlanPhase.APPLY: [PlanPhase.STRATEGIZE],  # revert via constrained
}


def can_transition(from_phase: PlanPhase, to_phase: PlanPhase) -> bool:
    """Check if a phase transition is valid."""
    return to_phase in VALID_PHASE_TRANSITIONS.get(from_phase, [])


# ---------------------------------------------------------------------------
# Subplan failure classification
# ---------------------------------------------------------------------------

# Error = application/system bug, likely not recoverable
# Failure = task couldn't complete (tests fail, validation fail), may be retryable

RETRIABLE_FAILURES: frozenset[str] = frozenset(
    {
        "ValidationError",
        "TimeoutError",
        "TemporaryResourceError",
        "MergeConflictError",  # May succeed with different merge strategy
    }
)

NON_RETRIABLE_ERRORS: frozenset[str] = frozenset(
    {
        "ConfigurationError",
        "AuthenticationError",
        "MissingResourceError",
        "CircularDependencyError",
    }
)


class SubplanFailureHandler:
    """Handle subplan failures based on configuration.

    This is a stateless helper that inspects a ``SubplanConfig`` and a
    ``SubplanStatus`` to decide whether to stop other subplans or retry
    the failed one.
    """

    def should_stop_others(
        self,
        config: SubplanConfig,
        failed_status: SubplanStatus,
    ) -> bool:
        """Determine if other running subplans should be stopped.

        Args:
            config: The parent plan's subplan configuration.
            failed_status: The status of the subplan that failed.

        Returns:
            True if remaining subplans should be cancelled.
        """
        if config.fail_fast:
            return True
        # Sequential always stops on failure; parallel: let others continue
        return config.execution_mode == ExecutionMode.SEQUENTIAL

    def should_retry(
        self,
        config: SubplanConfig,
        status: SubplanStatus,
    ) -> bool:
        """Determine if a failed subplan should be retried.

        Args:
            config: The parent plan's subplan configuration.
            status: The status of the subplan that failed.

        Returns:
            True if the subplan should be retried.
        """
        if not config.retry_failed:
            return False
        if status.attempt_number > config.max_retries:
            return False

        # Check error classification
        error = status.error or ""
        # Non-retriable errors should never be retried
        for err_type in NON_RETRIABLE_ERRORS:
            if err_type in error:
                return False
        # Retriable failures are always worth retrying
        # Unknown errors: don't retry by default (conservative)
        return any(fail_type in error for fail_type in RETRIABLE_FAILURES)
