"""SQLAlchemy database models for CleverAgents.

Defines the ORM models that map to the database tables. Managed by
Alembic migrations.

## Tables

| Table                  | Model                      | Description            |
|------------------------|----------------------------|------------------------|
| ``actions``            | ``ActionModel``            | Action templates       |
| ``action_invariants``  | ``ActionInvariantModel``   | Invariant rules        |
| ``action_arguments``   | ``ActionArgumentModel``    | Typed arguments        |
| ``v3_plans``           | ``V3PlanModel``            | Plan lifecycle         |
| ``plan_projects``      | ``PlanProjectModel``       | Plan-project links     |
| ``plan_arguments``     | ``PlanArgumentModel``      | Plan argument values   |
| ``plan_invariants``    | ``PlanInvariantModel``     | Plan invariant rules   |
| ``resource_types``     | ``ResourceTypeModel``      | Resource type defs     |
| ``resources``          | ``ResourceModel``          | Resource instances     |
| ``resource_edges``     | ``ResourceEdgeModel``      | Resource DAG edges     |
| ``ns_projects``        | ``NamespacedProjectModel`` | Namespaced projects    |
| ``project_resource_links`` | ``ProjectResourceLinkModel`` | Project-resource |
| ``decisions``          | ``DecisionModel``          | Decision tree nodes    |
| ``decision_dependencies`` | ``DecisionDependencyModel`` | Decision DAG edges  |
| ``checkpoint_metadata``     | ``CheckpointModel``          | Plan checkpoints   |
| ``repo_indexes``            | ``RepoIndexModel``           | Repo index metadata|
| ``indexed_files``           | ``IndexedFileModel``         | Per-file records   |
| ``correction_attempts``     | ``CorrectionAttemptModel``   | Correction records |

Based on ADR-007 (Repository Pattern) and Phase 0 discovery.
Includes spec-aligned lifecycle models per Stage A5
(actions, v3_plans, and child tables).
"""

from __future__ import annotations

import json
import logging
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast

if TYPE_CHECKING:
    from cleveragents.domain.models.core.checkpoint import Checkpoint
    from cleveragents.domain.models.core.correction import CorrectionAttemptRecord

from pydantic import ValidationError
from sqlalchemy import (
    JSON,
    Boolean,
    CheckConstraint,
    Column,
    DateTime,
    Enum,
    Float,
    ForeignKey,
    Index,
    Integer,
    String,
    Text,
    UniqueConstraint,
    create_engine,
    text,
)
from sqlalchemy.orm import (
    Mapped,
    declarative_base,
    mapped_column,
    relationship,
    sessionmaker,
    synonym,
)

from cleveragents.domain.models.core import (
    ContextType,
    OperationType,
    PlanStatus,
)

_logger = logging.getLogger(__name__)

# Default automation profile name.  Used as the NOT NULL server_default
# for the ``automation_profile`` column and as the sentinel value that
# ``to_domain()`` treats as "no structured profile set" (returns None).
DEFAULT_AUTOMATION_PROFILE = "balanced"

# Create base class for all models
Base = declarative_base()


class ProjectModel(Base):
    """Legacy database model for projects.

    .. deprecated::
        Superseded by ``NamespacedProjectModel`` (table ``ns_projects``)
        which uses ``namespaced_name`` as PK instead of an integer id.
        The legacy ``projects`` table is retained for backward compatibility.
    """

    __tablename__ = "projects"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(255), nullable=False, unique=True)
    path = Column(String(1024), nullable=False)
    created_at = Column(DateTime, nullable=False, default=datetime.now)
    updated_at = Column(
        DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
    )
    settings = Column(JSON, nullable=False, default=dict)
    current_plan_id = Column(Integer, nullable=True)

    # Relationships
    plans = relationship(
        "PlanModel", back_populates="project", cascade="all, delete-orphan"
    )


class PlanModel(Base):
    """Database model for plans."""

    __tablename__ = "plans"

    id = Column(Integer, primary_key=True, autoincrement=True)
    project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
    name = Column(String(255), nullable=False)
    prompt = Column(Text, nullable=False)
    status = Column(Enum(PlanStatus), nullable=False, default=PlanStatus.PENDING)
    current = Column(Boolean, nullable=False, default=False)
    created_at = Column(DateTime, nullable=False, default=datetime.now)
    updated_at = Column(
        DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
    )

    # Build information
    build_started_at = Column(DateTime, nullable=True)
    build_completed_at = Column(DateTime, nullable=True)
    model_used = Column(String(255), nullable=True)
    token_count = Column(Integer, nullable=True, default=0)
    error_message = Column(Text, nullable=True)

    # Application result
    applied_at = Column(DateTime, nullable=True)
    files_created = Column(Integer, nullable=True, default=0)
    files_modified = Column(Integer, nullable=True, default=0)
    files_deleted = Column(Integer, nullable=True, default=0)

    # Relationships
    project = relationship("ProjectModel", back_populates="plans")
    contexts = relationship(
        "ContextModel", back_populates="plan", cascade="all, delete-orphan"
    )
    changes = relationship(
        "ChangeModel", back_populates="plan", cascade="all, delete-orphan"
    )
    debug_attempts = relationship(
        "DebugAttemptModel", back_populates="plan", cascade="all, delete-orphan"
    )


class ContextModel(Base):
    """Database model for context items."""

    __tablename__ = "contexts"

    id = Column(Integer, primary_key=True, autoincrement=True)
    plan_id = Column(Integer, ForeignKey("plans.id"), nullable=False)
    type = Column(Enum(ContextType), nullable=False, default=ContextType.FILE)
    path = Column(String(1024), nullable=False)
    content = Column(Text, nullable=True)
    file_hash = Column(String(64), nullable=True)
    size = Column(Integer, nullable=False, default=0)
    added_at = Column(DateTime, nullable=False, default=datetime.now)

    # Relationships
    plan = relationship("PlanModel", back_populates="contexts")


class ChangeModel(Base):
    """Database model for changes."""

    __tablename__ = "changes"

    id = Column(Integer, primary_key=True, autoincrement=True)
    plan_id = Column(Integer, ForeignKey("plans.id"), nullable=False)
    file_path = Column(String(1024), nullable=False)
    operation = Column(Enum(OperationType), nullable=False)
    original_content = Column(Text, nullable=True)
    new_content = Column(Text, nullable=True)
    new_path = Column(String(1024), nullable=True)  # For move operations
    applied = Column(Boolean, nullable=False, default=False)
    created_at = Column(DateTime, nullable=False, default=datetime.now)
    applied_at = Column(DateTime, nullable=True)

    # Relationships
    plan = relationship("PlanModel", back_populates="changes")


class DebugAttemptModel(Base):
    """Database model for debug attempts."""

    __tablename__ = "debug_attempts"

    id = Column(Integer, primary_key=True, autoincrement=True)
    plan_id = Column(Integer, ForeignKey("plans.id"), nullable=False)
    error_message = Column(Text, nullable=False)
    attempted_fix = Column(Text, nullable=True)
    success = Column(Boolean, nullable=False, default=False)
    attempt_number = Column(Integer, nullable=False)
    created_at = Column(DateTime, nullable=False, default=datetime.now)

    # Relationships
    plan = relationship("PlanModel", back_populates="debug_attempts")


class ActorModel(Base):
    """Database model for actor configurations.

    Stores the canonical actor config alongside the original YAML source
    text, a ``schema_version`` indicator, and optional ``compiled_metadata``
    produced by the actor compiler.
    """

    __tablename__ = "actors"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(255), nullable=False, unique=True)
    provider = Column(String(255), nullable=False)
    model = Column(String(255), nullable=False)
    config_blob = Column(JSON, nullable=False, default=dict)
    config_hash = Column(String(128), nullable=False)
    graph_descriptor = Column(JSON, nullable=True)
    yaml_text = Column(Text, nullable=True)
    schema_version = Column(
        String(20), nullable=False, default="1.0", server_default="1.0"
    )
    compiled_metadata = Column(JSON, nullable=True)
    unsafe = Column(Boolean, nullable=False, default=False)
    is_built_in = Column(Boolean, nullable=False, default=False)
    is_default = Column(Boolean, nullable=False, default=False)
    created_at = Column(DateTime, nullable=False, default=datetime.now)
    updated_at = Column(
        DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
    )


# ---------------------------------------------------------------------------
# Spec-aligned Lifecycle Models (Stage A5 - migrations a5_003 / a5_004)
# ---------------------------------------------------------------------------


class LifecycleActionModel(Base):  # type: ignore[misc]
    """Database model for spec-aligned actions (reusable plan templates).

    Actions define strategy/execution actors, arguments, invariants, and
    completion criteria.  They are created via CLI commands and stored in the
    ``actions`` table using the namespaced name as the primary key per spec.

    See: ``src/cleveragents/domain/models/core/action.py``
    """

    __allow_unmapped__ = True
    __tablename__ = "actions"

    # PK: namespaced name (e.g., "local/code-review")
    namespaced_name = Column(String(255), primary_key=True)
    namespace = Column(String(100), nullable=False)
    name = Column(String(150), nullable=False)

    # Descriptions
    description = Column(Text, nullable=False)
    long_description = Column(Text, nullable=True)
    definition_of_done = Column(Text, nullable=False)

    # Actor references
    strategy_actor = Column(String(255), nullable=False)
    execution_actor = Column(String(255), nullable=False)
    review_actor = Column(String(255), nullable=True)
    apply_actor = Column(String(255), nullable=True)
    estimation_actor = Column(String(255), nullable=True)
    invariant_actor = Column(String(255), nullable=True)

    # Behavior / policy
    automation_profile = Column(String(255), nullable=True)
    reusable = Column(Boolean, nullable=False, default=True)
    read_only = Column(Boolean, nullable=False, default=False)

    # Safety profile - JSON serialized SafetyProfile
    safety_profile_json = Column(Text, nullable=True)

    # Inputs schema - JSON Schema dict for validation
    inputs_schema_json = Column(Text, nullable=True)

    # State management (available | archived)
    state = Column(String(20), nullable=False, default="available")

    # Metadata
    created_by = Column(String(255), nullable=True)
    tags_json = Column(Text, nullable=False, default="[]")

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    # Relationships
    plans = relationship(
        "LifecyclePlanModel",
        back_populates="action",
        foreign_keys="LifecyclePlanModel.action_name",
    )
    invariants_rel = relationship(
        "ActionInvariantModel",
        back_populates="action",
        cascade="all, delete-orphan",
        order_by="ActionInvariantModel.position",
    )
    arguments_rel = relationship(
        "ActionArgumentModel",
        back_populates="action",
        cascade="all, delete-orphan",
        order_by="ActionArgumentModel.position",
    )

    __table_args__ = (
        Index("ix_actions_namespace", "namespace"),
        Index("ix_actions_state", "state"),
        Index("ix_actions_name", "name"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Any:
        """Convert to ``Action`` domain model.

        Returns:
            Action domain object.
        """
        from cleveragents.domain.models.core.action import (
            Action,
            ActionArgument,
            ActionState,
            ArgumentRequirement,
            ArgumentType,
        )
        from cleveragents.domain.models.core.plan import NamespacedName
        from cleveragents.domain.models.core.safety_profile import SafetyProfile

        # Build arguments from the child table relationship
        arguments: list[ActionArgument] = []
        for arg_model in self.arguments_rel or []:
            default_value: Any = None
            if arg_model.default_value_json is not None:
                default_value = json.loads(cast(str, arg_model.default_value_json))
            arguments.append(
                ActionArgument(
                    name=cast(str, arg_model.name),
                    arg_type=ArgumentType(cast(str, arg_model.arg_type)),
                    requirement=ArgumentRequirement(cast(str, arg_model.requirement)),
                    description=cast("str | None", arg_model.description) or "",
                    default_value=default_value,
                    min_value=(
                        cast(float, arg_model.min_value)
                        if arg_model.min_value is not None
                        else None
                    ),
                    max_value=(
                        cast(float, arg_model.max_value)
                        if arg_model.max_value is not None
                        else None
                    ),
                    validation_pattern=cast("str | None", arg_model.validation_pattern),
                )
            )

        # Build invariants from the child table relationship
        invariants: list[str] = [
            cast(str, inv.invariant_text) for inv in (self.invariants_rel or [])
        ]

        tags_list: list[str] = json.loads(cast(str, self.tags_json or "[]"))

        # Parse inputs_schema JSON
        inputs_schema: dict[str, Any] | None = None
        raw_schema = cast("str | None", self.inputs_schema_json)
        if raw_schema:
            inputs_schema = json.loads(raw_schema)

        # Parse safety_profile JSON
        safety_profile_obj: SafetyProfile | None = None
        raw_safety = cast("str | None", self.safety_profile_json)
        if raw_safety:
            safety_profile_obj = SafetyProfile.model_validate(json.loads(raw_safety))

        return Action(
            namespaced_name=NamespacedName(
                server=None,
                namespace=cast(str, self.namespace),
                name=cast(str, self.name),
            ),
            description=cast(str, self.description),
            long_description=cast("str | None", self.long_description),
            definition_of_done=cast(str, self.definition_of_done),
            strategy_actor=cast(str, self.strategy_actor),
            execution_actor=cast(str, self.execution_actor),
            review_actor=cast("str | None", self.review_actor),
            apply_actor=cast("str | None", self.apply_actor),
            estimation_actor=cast("str | None", self.estimation_actor),
            invariant_actor=cast("str | None", self.invariant_actor),
            arguments=arguments,
            inputs_schema=inputs_schema,
            automation_profile=cast("str | None", self.automation_profile),
            safety_profile=safety_profile_obj,
            invariants=invariants,
            reusable=cast(bool, self.reusable),
            read_only=cast(bool, self.read_only),
            state=ActionState(cast(str, self.state)),
            created_at=datetime.fromisoformat(cast(str, self.created_at)),
            updated_at=datetime.fromisoformat(cast(str, self.updated_at)),
            created_by=cast("str | None", self.created_by),
            tags=tags_list,
        )

    @classmethod
    def from_domain(cls, action: Any) -> LifecycleActionModel:
        """Create from ``Action`` domain model.

        Args:
            action: An ``Action`` domain instance.

        Returns:
            A ``LifecycleActionModel`` ready for persistence.
        """
        from cleveragents.domain.models.core.plan import NamespacedName

        ns: NamespacedName = action.namespaced_name
        tags_json = json.dumps(action.tags)
        inputs_schema_json: str | None = None
        if action.inputs_schema is not None:
            inputs_schema_json = json.dumps(action.inputs_schema)

        safety_profile_json: str | None = None
        if getattr(action, "safety_profile", None) is not None:
            safety_profile_json = action.safety_profile.model_dump_json()

        model = cls(
            namespaced_name=str(ns),
            namespace=ns.namespace,
            name=ns.name,
            description=action.description,
            long_description=action.long_description,
            definition_of_done=action.definition_of_done,
            strategy_actor=action.strategy_actor,
            execution_actor=action.execution_actor,
            review_actor=action.review_actor,
            apply_actor=getattr(action, "apply_actor", None),
            estimation_actor=action.estimation_actor,
            invariant_actor=getattr(action, "invariant_actor", None),
            automation_profile=getattr(action, "automation_profile", None),
            safety_profile_json=safety_profile_json,
            reusable=action.reusable,
            read_only=action.read_only,
            inputs_schema_json=inputs_schema_json,
            state=action.state.value
            if hasattr(action.state, "value")
            else action.state,
            created_by=action.created_by,
            tags_json=tags_json,
            created_at=action.created_at.isoformat(),
            updated_at=action.updated_at.isoformat(),
        )

        # Populate child table models for arguments
        for idx, arg in enumerate(action.arguments):
            default_json: str | None = None
            if arg.default_value is not None:
                default_json = json.dumps(arg.default_value)
            model.arguments_rel.append(
                ActionArgumentModel(
                    name=arg.name,
                    arg_type=arg.arg_type.value
                    if hasattr(arg.arg_type, "value")
                    else arg.arg_type,
                    requirement=arg.requirement.value
                    if hasattr(arg.requirement, "value")
                    else arg.requirement,
                    description=arg.description,
                    default_value_json=default_json,
                    min_value=arg.min_value,
                    max_value=arg.max_value,
                    validation_pattern=arg.validation_pattern,
                    position=idx,
                )
            )

        # Populate child table models for invariants
        now_iso = action.created_at.isoformat()
        for idx, inv_text in enumerate(getattr(action, "invariants", []) or []):
            model.invariants_rel.append(
                ActionInvariantModel(
                    invariant_text=inv_text,
                    position=idx,
                    created_at=now_iso,
                )
            )

        return model


class ActionInvariantModel(Base):  # type: ignore[misc]
    """Database model for action invariants (child of actions).

    Stores ordered invariant constraint text attached to an action.
    """

    __allow_unmapped__ = True
    __tablename__ = "action_invariants"
    __table_args__ = (
        UniqueConstraint(
            "action_name",
            "position",
            name="uq_action_invariants_position",
        ),
    )

    id = Column(Integer, primary_key=True, autoincrement=True)
    action_name = Column(
        String(255),
        ForeignKey("actions.namespaced_name", ondelete="CASCADE"),
        nullable=False,
    )
    invariant_text = Column(Text, nullable=False)
    position = Column(Integer, nullable=False)
    created_at = Column(String(30), nullable=False)

    # Relationships
    action = relationship(
        "LifecycleActionModel",
        back_populates="invariants_rel",
    )


class ActionArgumentModel(Base):  # type: ignore[misc]
    """Database model for action arguments (child of actions).

    Stores ordered typed argument definitions attached to an action.
    """

    __allow_unmapped__ = True
    __tablename__ = "action_arguments"
    __table_args__ = (
        UniqueConstraint("action_name", "name", name="uq_action_arguments_name"),
        CheckConstraint(
            "arg_type IN ('string', 'integer', 'float', 'boolean', 'list')",
            name="ck_action_arguments_type",
        ),
        CheckConstraint(
            "requirement IN ('required', 'optional')",
            name="ck_action_arguments_requirement",
        ),
    )

    id = Column(Integer, primary_key=True, autoincrement=True)
    action_name = Column(
        String(255),
        ForeignKey("actions.namespaced_name", ondelete="CASCADE"),
        nullable=False,
    )
    name = Column(String(100), nullable=False)
    arg_type = Column(String(20), nullable=False, default="string")
    requirement = Column(String(20), nullable=False, default="required")
    description = Column(Text, nullable=True)
    default_value_json = Column(Text, nullable=True)
    min_value = Column(Float, nullable=True)
    max_value = Column(Float, nullable=True)
    validation_pattern = Column(String(500), nullable=True)
    position = Column(Integer, nullable=False)

    # Relationships
    action = relationship(
        "LifecycleActionModel",
        back_populates="arguments_rel",
    )


class LifecyclePlanModel(Base):  # type: ignore[misc]
    """Database model for spec-aligned lifecycle plans.

    A lifecycle plan follows the four-phase lifecycle:
    ``Action -> Strategize -> Execute -> Apply``

    Terminal outcomes are modeled as processing states within Apply:
    ``applied``, ``constrained``, ``errored``, ``cancelled``.

    Plans are created from Action templates via ``agents plan use``.
    Each plan references the action it was created from (by namespaced name),
    links to projects via the plan_projects child table, and tracks
    phase/state, execution context, and sandbox references.

    See: ``src/cleveragents/domain/models/core/plan.py``

    .. note:: **Column naming conventions (intentional deviations from spec DDL)**

       The spec DDL uses ``automation_profile_name``, ``strategy_actor_name``,
       ``execution_actor_name``, ``estimation_actor_name``, ``invariant_actor_name``,
       ``state``, and ``error_traceback``.  The code uses shorter forms without the
       ``_name`` suffix (``automation_profile``, ``strategy_actor``, etc.),
       ``processing_state`` (to avoid collision with SQLAlchemy internals), and
       ``error_details_json`` (richer structured data than a plain traceback).
       The table is named ``v3_plans`` (evolution artifact) vs spec's ``plans``.
       These are intentional conventions established before the spec DDL was
       formalised and are kept for backward compatibility.

       **Semantic note on ``automation_profile``**: The spec's
       ``automation_profile_name`` stores a plain profile name string.
       The code's ``automation_profile`` stores either a bare name
       (``"balanced"``, the NOT NULL default) or a structured JSON object
       (``{"profile_name": "...", "provenance": "..."}``).  The dual
       format allows tracking provenance (how the profile was resolved)
       alongside the name, at the cost of requiring format-aware
       deserialization in ``to_domain()``.
    """

    __allow_unmapped__ = True
    __tablename__ = "v3_plans"

    # Identity
    plan_id = Column(String(26), primary_key=True)  # ULID
    parent_plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="SET NULL"),
        nullable=True,
    )
    root_plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="RESTRICT"),
        nullable=False,
    )
    action_name = Column(
        String(255),
        ForeignKey("actions.namespaced_name", ondelete="RESTRICT"),
        nullable=False,
    )

    # Plan naming
    namespaced_name = Column(String(255), nullable=False)
    namespace = Column(String(100), nullable=False)

    # Lifecycle phase and processing state (spec-aligned, rebaselined in a5_005)
    # NOTE: Spec DDL defaults phase to 'strategize', but the code default is
    # 'action'.  This is intentional — the Action phase was added as a
    # pre-Strategize setup step (template instantiation) that did not exist
    # in the original specification.  Plans begin in Action and transition to
    # Strategize via ``agents plan use``.
    phase = Column(
        String(20), nullable=False, default="action", server_default="action"
    )
    processing_state = Column(
        String(20), nullable=False, default="queued", server_default="queued"
    )
    attempt = Column(Integer, nullable=False, default=1)

    # Resume tracking fields
    reversion_count = Column(Integer, nullable=False, default=0, server_default="0")
    last_completed_step = Column(
        Integer, nullable=False, default=-1, server_default="-1"
    )
    last_checkpoint_id = Column(String(26), nullable=True)

    # Rendered description and DoD
    description = Column(Text, nullable=False)
    definition_of_done = Column(Text, nullable=True)

    # Actor references
    strategy_actor = Column(String(255), nullable=True)
    execution_actor = Column(String(255), nullable=True)
    review_actor = Column(String(255), nullable=True)
    apply_actor = Column(String(255), nullable=True)
    estimation_actor = Column(String(255), nullable=True)
    invariant_actor = Column(String(255), nullable=True)

    # Policy / profile
    automation_profile = Column(
        Text,
        nullable=False,
        default=DEFAULT_AUTOMATION_PROFILE,
        server_default=DEFAULT_AUTOMATION_PROFILE,
    )
    effective_profile_snapshot = Column(
        Text, nullable=False, default="{}", server_default="{}"
    )

    # Behavior
    reusable = Column(Boolean, nullable=False, default=True)
    read_only = Column(Boolean, nullable=False, default=False)

    # Inputs schema JSON (optional; copied from action)
    inputs_schema_json = Column(Text, nullable=True)

    # Execution environment override (ADR-043)
    execution_environment = Column(String(255), nullable=True)
    execution_env_priority = Column(String(20), nullable=True)

    # Execution placeholders
    changeset_id = Column(String(26), nullable=True)
    sandbox_refs_json = Column(Text, nullable=True)
    validation_summary_json = Column(Text, nullable=True)
    decision_root_id = Column(String(26), nullable=True)

    # Error tracking
    error_message = Column(Text, nullable=True)
    error_details_json = Column(Text, nullable=True)

    # Cost/token tracking
    cost_estimate_usd = Column(Float, nullable=True)
    cost_actual_usd = Column(Float, nullable=True)
    estimation_report_json = Column(Text, nullable=True)
    token_count_input = Column(Integer, nullable=True, default=0)
    token_count_output = Column(Integer, nullable=True, default=0)

    # Metadata
    created_by = Column(String(255), nullable=True)
    tags_json = Column(Text, nullable=False, default="[]")

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)
    completed_at = Column(String(30), nullable=True)
    strategize_started_at = Column(String(30), nullable=True)
    strategize_completed_at = Column(String(30), nullable=True)
    execute_started_at = Column(String(30), nullable=True)
    execute_completed_at = Column(String(30), nullable=True)
    apply_started_at = Column(String(30), nullable=True)
    applied_at = Column(String(30), nullable=True)

    # Relationships
    parent_plan = relationship(
        "LifecyclePlanModel",
        remote_side="LifecyclePlanModel.plan_id",
        backref="children",
        foreign_keys=[parent_plan_id],
    )
    action = relationship(
        "LifecycleActionModel",
        back_populates="plans",
        foreign_keys=[action_name],
    )
    project_links_rel = relationship(
        "PlanProjectModel",
        back_populates="plan",
        cascade="all, delete-orphan",
    )
    arguments_rel = relationship(
        "PlanArgumentModel",
        back_populates="plan",
        cascade="all, delete-orphan",
        order_by="PlanArgumentModel.position",
    )
    invariants_rel = relationship(
        "PlanInvariantModel",
        back_populates="plan",
        cascade="all, delete-orphan",
        order_by="PlanInvariantModel.position",
    )
    correction_attempts_rel = relationship(
        "CorrectionAttemptModel",
        back_populates="plan",
        cascade="all, delete-orphan",
        order_by="CorrectionAttemptModel.created_at",
    )

    __table_args__ = (
        CheckConstraint(
            "phase IN ('action', 'strategize', 'execute', 'apply')",
            name="ck_v3_plans_phase",
        ),
        CheckConstraint(
            "processing_state IN "
            "('queued', 'processing', 'errored', 'complete', 'cancelled', "
            "'applied', 'constrained')",
            name="ck_v3_plans_state",
        ),
        Index("ix_v3_plans_phase", "phase"),
        Index("ix_v3_plans_state", "processing_state"),
        Index("ix_v3_plans_parent", "parent_plan_id"),
        Index("ix_v3_plans_root", "root_plan_id"),
        Index("ix_v3_plans_created", "created_at"),
        Index("ix_v3_plans_action", "action_name"),
        Index("ix_v3_plans_namespace", "namespace"),
        Index("ix_v3_plans_phase_state", "phase", "processing_state"),
    )

    # -- Domain conversion helpers ------------------------------------------

    @staticmethod
    def _serialize_automation_profile(profile_ref: Any) -> str:
        """Serialize an ``AutomationProfileRef`` to a JSON column value.

        Returns the JSON representation when *profile_ref* is truthy,
        otherwise the module-level default ``DEFAULT_AUTOMATION_PROFILE``.
        Centralises the serialisation logic used by both ``from_domain()``
        and ``LifecyclePlanRepository.update()``.
        """
        if profile_ref is not None:
            return json.dumps(profile_ref.model_dump(mode="json"))
        return DEFAULT_AUTOMATION_PROFILE

    @staticmethod
    def _parse_iso(value: str | None) -> datetime | None:
        """Parse an ISO-8601 string to ``datetime``, returning ``None`` for empty."""
        if value is None:
            return None
        return datetime.fromisoformat(value)

    @staticmethod
    def _to_iso(value: datetime | None) -> str | None:
        """Convert ``datetime`` to ISO-8601 string, or ``None``."""
        if value is None:
            return None
        return value.isoformat()

    def to_domain(self) -> Any:
        """Convert to ``Plan`` domain model (spec-aligned lifecycle).

        Returns:
            A ``Plan`` domain instance.
        """
        from cleveragents.domain.models.core.plan import (
            AutomationProfileProvenance,
            AutomationProfileRef,
            ExecutionEnvPriority,
            InvariantSource,
            NamespacedName,
            Plan,
            PlanIdentity,
            PlanInvariant,
            PlanPhase,
            PlanTimestamps,
            ProcessingState,
            ProjectLink,
        )

        phase_enum = PlanPhase(cast(str, self.phase))
        state_enum = ProcessingState(cast(str, self.processing_state))

        # Defensive coercion: the Plan model validator now rejects
        # certain phase/state combinations (e.g. COMPLETE in APPLY).
        # Legacy DB rows written before the validator was added may
        # contain these combinations.  Coerce to the closest valid
        # state to avoid crashing deserialization.
        if state_enum == ProcessingState.COMPLETE and phase_enum == PlanPhase.APPLY:
            _logger.warning(
                "Coercing legacy phase/state combo: %s/%s -> %s/%s (plan_id=%s)",
                phase_enum.value,
                state_enum.value,
                phase_enum.value,
                ProcessingState.APPLIED.value,
                self.plan_id,
            )
            state_enum = ProcessingState.APPLIED
        elif (
            state_enum in (ProcessingState.APPLIED, ProcessingState.CONSTRAINED)
            and phase_enum != PlanPhase.APPLY
        ):
            _logger.warning(
                "Coercing legacy phase/state combo: %s/%s -> %s/%s (plan_id=%s)",
                phase_enum.value,
                state_enum.value,
                phase_enum.value,
                ProcessingState.ERRORED.value,
                self.plan_id,
            )
            state_enum = ProcessingState.ERRORED
        elif state_enum == ProcessingState.COMPLETE and phase_enum == PlanPhase.ACTION:
            _logger.warning(
                "Coercing legacy phase/state combo: %s/%s -> %s/%s (plan_id=%s)",
                phase_enum.value,
                state_enum.value,
                phase_enum.value,
                ProcessingState.QUEUED.value,
                self.plan_id,
            )
            state_enum = ProcessingState.QUEUED

        # Build project links from child table
        project_links_list = [
            ProjectLink(
                project_name=cast(str, pl.project_name),
                alias=cast("str | None", pl.alias),
                read_only=cast(bool, pl.read_only),
            )
            for pl in (self.project_links_rel or [])
        ]

        # Build invariants from child table
        invariants_list = [
            PlanInvariant(
                text=cast(str, inv.invariant_text),
                source=InvariantSource(cast(str, inv.source_scope)),
            )
            for inv in (self.invariants_rel or [])
        ]

        # Build arguments from child table
        arguments_dict: dict[str, Any] = {}
        arguments_order_list: list[str] = []
        for arg_model in self.arguments_rel or []:
            arg_name = cast(str, arg_model.name)
            arguments_order_list.append(arg_name)
            if arg_model.value_json is not None:
                arguments_dict[arg_name] = json.loads(cast(str, arg_model.value_json))
            else:
                arguments_dict[arg_name] = None

        # Deserialize automation profile.  The column is NOT NULL with
        # server_default 'balanced'.  Values are either a JSON object
        # (``{"profile_name": "...", "provenance": "..."}``), or a bare
        # profile-name string (e.g. ``"balanced"``).  Bare strings and
        # the default ``"balanced"`` value are treated as unparseable
        # and result in ``automation_profile_ref = None``.
        automation_profile_ref: AutomationProfileRef | None = None
        raw_ap = cast(str, self.automation_profile)
        if raw_ap and raw_ap != DEFAULT_AUTOMATION_PROFILE:
            try:
                profile_raw: dict[str, str] = json.loads(raw_ap)
                automation_profile_ref = AutomationProfileRef(
                    profile_name=profile_raw["profile_name"],
                    provenance=AutomationProfileProvenance(profile_raw["provenance"]),
                )
            except (
                json.JSONDecodeError,
                KeyError,
                ValueError,
                ValidationError,
                RecursionError,
            ):
                # Bare profile name string, invalid provenance enum,
                # Pydantic validation failure, or deeply nested JSON
                # — treat as unparseable.
                _logger.warning(
                    "Unparseable automation_profile for plan %s (length=%d)",
                    self.plan_id,
                    len(raw_ap),
                )
                automation_profile_ref = None

        # Validate effective_profile_snapshot.  The column is NOT NULL
        # with server_default '{}', but corrupted rows may contain invalid
        # JSON.  Mirror the defensive pattern used for automation_profile
        # above to prevent a single corrupted row from crashing reads.
        raw_snapshot = (
            cast(str, self.effective_profile_snapshot)
            if self.effective_profile_snapshot is not None
            else "{}"
        )
        try:
            json.loads(raw_snapshot)
        except (json.JSONDecodeError, TypeError, RecursionError):
            _logger.warning(
                "Unparseable effective_profile_snapshot for plan %s (length=%d); "
                "falling back to '{}'",
                self.plan_id,
                len(raw_snapshot),
            )
            raw_snapshot = "{}"

        # Deserialize sandbox refs
        sandbox_refs_list: list[str] = json.loads(
            cast(str, self.sandbox_refs_json or "[]")
        )

        # Deserialize validation summary
        validation_summary_dict: dict[str, Any] | None = None
        if self.validation_summary_json is not None:
            validation_summary_dict = json.loads(
                cast(str, self.validation_summary_json)
            )

        tags_list: list[str] = json.loads(cast(str, self.tags_json or "[]"))

        # Action name from the FK column
        raw_action = cast("str | None", self.action_name)
        action_name_str = raw_action if raw_action else ""

        # Parse error_details JSON string to dict
        raw_err_details = cast("str | None", self.error_details_json)
        parsed_error_details: dict[str, Any] | None = (
            json.loads(raw_err_details) if raw_err_details else None
        )

        return Plan(
            identity=PlanIdentity(
                plan_id=cast(str, self.plan_id),
                parent_plan_id=cast("str | None", self.parent_plan_id),
                root_plan_id=cast(str, self.root_plan_id),
                attempt=cast(int, self.attempt),
            ),
            namespaced_name=NamespacedName.parse(cast(str, self.namespaced_name)),
            action_name=action_name_str,
            description=cast(str, self.description),
            definition_of_done=cast("str | None", self.definition_of_done),
            phase=phase_enum,
            processing_state=state_enum,
            automation_profile=automation_profile_ref,
            effective_profile_snapshot=raw_snapshot,
            strategy_actor=cast("str | None", self.strategy_actor),
            execution_actor=cast("str | None", self.execution_actor),
            review_actor=cast("str | None", self.review_actor),
            apply_actor=cast("str | None", self.apply_actor),
            estimation_actor=cast("str | None", self.estimation_actor),
            invariant_actor=cast("str | None", self.invariant_actor),
            project_links=project_links_list,
            invariants=invariants_list,
            arguments=arguments_dict,
            arguments_order=arguments_order_list,
            changeset_id=cast("str | None", self.changeset_id),
            sandbox_refs=sandbox_refs_list,
            validation_summary=validation_summary_dict,
            decision_root_id=cast("str | None", self.decision_root_id),
            timestamps=PlanTimestamps(
                created_at=datetime.fromisoformat(cast(str, self.created_at)),
                updated_at=datetime.fromisoformat(cast(str, self.updated_at)),
                strategize_started_at=self._parse_iso(
                    cast("str | None", self.strategize_started_at)
                ),
                strategize_completed_at=self._parse_iso(
                    cast("str | None", self.strategize_completed_at)
                ),
                execute_started_at=self._parse_iso(
                    cast("str | None", self.execute_started_at)
                ),
                execute_completed_at=self._parse_iso(
                    cast("str | None", self.execute_completed_at)
                ),
                apply_started_at=self._parse_iso(
                    cast("str | None", self.apply_started_at)
                ),
                applied_at=self._parse_iso(cast("str | None", self.applied_at)),
            ),
            error_message=cast("str | None", self.error_message),
            error_details=parsed_error_details,
            created_by=cast("str | None", self.created_by),
            tags=tags_list,
            reusable=cast(bool, self.reusable),
            read_only=cast(bool, self.read_only),
            reversion_count=cast(int, self.reversion_count),
            last_completed_step=cast(int, self.last_completed_step),
            last_checkpoint_id=cast("str | None", self.last_checkpoint_id),
            execution_environment=cast("str | None", self.execution_environment),
            execution_env_priority=(
                ExecutionEnvPriority(cast(str, self.execution_env_priority))
                if self.execution_env_priority is not None
                else None
            ),
            cost_estimate_usd=cast("float | None", self.cost_estimate_usd),
            estimation_report=(
                json.loads(cast(str, self.estimation_report_json))
                if self.estimation_report_json is not None
                else None
            ),
        )

    @classmethod
    def from_domain(
        cls, plan: Any, *, action_name: str | None = None
    ) -> LifecyclePlanModel:
        """Create from ``Plan`` domain model.

        Args:
            plan: A v3 ``Plan`` domain instance.
            action_name: The namespaced name string of the parent action.
                When *None*, falls back to ``plan.action_name``.

        Returns:
            A ``LifecyclePlanModel`` ready for persistence.
        """
        state_str = (
            (
                plan.processing_state.value
                if hasattr(plan.processing_state, "value")
                else str(plan.processing_state)
            )
            if plan.processing_state is not None
            else "queued"
        )

        # Serialize automation profile
        automation_profile_json = cls._serialize_automation_profile(
            plan.automation_profile,
        )

        # root_plan_id is resolved by PlanIdentity's model_validator
        # (None → plan_id for root plans); always non-None here.

        tags_json = json.dumps(plan.tags)
        resolved_action = action_name or plan.action_name or ""

        model = cls(
            plan_id=plan.identity.plan_id,
            parent_plan_id=plan.identity.parent_plan_id,
            root_plan_id=plan.identity.root_plan_id,
            action_name=resolved_action,
            namespaced_name=str(plan.namespaced_name),
            namespace=plan.namespaced_name.namespace
            if hasattr(plan.namespaced_name, "namespace")
            else "local",
            phase=plan.phase.value if hasattr(plan.phase, "value") else plan.phase,
            processing_state=state_str,
            attempt=plan.identity.attempt,
            reversion_count=plan.reversion_count,
            last_completed_step=plan.last_completed_step,
            last_checkpoint_id=plan.last_checkpoint_id,
            description=plan.description,
            definition_of_done=plan.definition_of_done,
            strategy_actor=plan.strategy_actor,
            execution_actor=plan.execution_actor,
            review_actor=getattr(plan, "review_actor", None),
            apply_actor=getattr(plan, "apply_actor", None),
            estimation_actor=getattr(plan, "estimation_actor", None),
            invariant_actor=getattr(plan, "invariant_actor", None),
            automation_profile=automation_profile_json,
            effective_profile_snapshot=plan.effective_profile_snapshot,
            reusable=plan.reusable,
            read_only=plan.read_only,
            execution_environment=plan.execution_environment,
            execution_env_priority=(
                plan.execution_env_priority.value
                if plan.execution_env_priority is not None
                else None
            ),
            error_message=plan.error_message,
            error_details_json=(
                json.dumps(plan.error_details)
                if getattr(plan, "error_details", None) is not None
                else None
            ),
            cost_estimate_usd=getattr(plan, "cost_estimate_usd", None),
            estimation_report_json=(
                json.dumps(getattr(plan, "estimation_report", None))
                if getattr(plan, "estimation_report", None) is not None
                else None
            ),
            changeset_id=getattr(plan, "changeset_id", None),
            created_at=plan.timestamps.created_at.isoformat(),
            updated_at=plan.timestamps.updated_at.isoformat(),
            completed_at=cls._to_iso(plan.timestamps.applied_at),
            strategize_started_at=cls._to_iso(plan.timestamps.strategize_started_at),
            strategize_completed_at=cls._to_iso(
                plan.timestamps.strategize_completed_at,
            ),
            execute_started_at=cls._to_iso(plan.timestamps.execute_started_at),
            execute_completed_at=cls._to_iso(plan.timestamps.execute_completed_at),
            apply_started_at=cls._to_iso(plan.timestamps.apply_started_at),
            applied_at=cls._to_iso(plan.timestamps.applied_at),
            created_by=plan.created_by,
            tags_json=tags_json,
        )

        # Populate project links from domain model
        now_iso = plan.timestamps.created_at.isoformat()
        for pl in getattr(plan, "project_links", []) or []:
            model.project_links_rel.append(
                PlanProjectModel(
                    project_name=pl.project_name,
                    alias=pl.alias,
                    read_only=pl.read_only,
                    created_at=now_iso,
                )
            )

        # Populate plan invariants from domain model
        for idx, inv in enumerate(getattr(plan, "invariants", []) or []):
            inv_text = inv.text if hasattr(inv, "text") else str(inv)
            raw_source: Any = inv.source if hasattr(inv, "source") else "plan"
            inv_source_str: str = (
                raw_source.value if hasattr(raw_source, "value") else str(raw_source)
            )
            model.invariants_rel.append(
                PlanInvariantModel(
                    invariant_text=inv_text,
                    source_scope=inv_source_str,
                    source_name=None,
                    position=idx,
                    created_at=now_iso,
                )
            )

        # Populate plan arguments from domain model
        arguments_dict: dict[str, Any] = getattr(plan, "arguments", {}) or {}
        arguments_order: list[str] = getattr(plan, "arguments_order", []) or list(
            arguments_dict.keys()
        )
        for idx, arg_name in enumerate(arguments_order):
            if arg_name in arguments_dict:
                model.arguments_rel.append(
                    PlanArgumentModel(
                        name=arg_name,
                        value_json=json.dumps(arguments_dict[arg_name]),
                        value_type="string",
                        position=idx,
                    )
                )

        return model


class PlanProjectModel(Base):  # type: ignore[misc]
    """Database model for plan-to-project links (child of v3_plans).

    Stores the many-to-many relationship between plans and projects with
    optional alias and read_only override.
    """

    __allow_unmapped__ = True
    __tablename__ = "plan_projects"

    plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
        primary_key=True,
    )
    project_name = Column(String(255), primary_key=True)
    alias = Column(String(100), nullable=True)
    read_only = Column(Boolean, nullable=False, default=False)
    created_at = Column(String(30), nullable=False)

    # Relationships
    plan = relationship(
        "LifecyclePlanModel",
        back_populates="project_links_rel",
    )


class PlanArgumentModel(Base):  # type: ignore[misc]
    """Database model for plan arguments (child of v3_plans).

    Stores the concrete argument values provided at ``plan use`` time.
    """

    __allow_unmapped__ = True
    __tablename__ = "plan_arguments"

    id = Column(Integer, primary_key=True, autoincrement=True)
    plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
        nullable=False,
    )
    name = Column(String(100), nullable=False)
    value_json = Column(Text, nullable=True)
    value_type = Column(String(20), nullable=False, default="string")
    position = Column(Integer, nullable=False)

    # Relationships
    plan = relationship(
        "LifecyclePlanModel",
        back_populates="arguments_rel",
    )


class PlanInvariantModel(Base):  # type: ignore[misc]
    """Database model for plan invariants (child of v3_plans).

    Stores invariants with their source scope provenance.
    """

    __allow_unmapped__ = True
    __tablename__ = "plan_invariants"

    id = Column(Integer, primary_key=True, autoincrement=True)
    plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
        nullable=False,
    )
    invariant_text = Column(Text, nullable=False)
    source_scope = Column(String(20), nullable=False, default="plan")
    source_name = Column(String(255), nullable=True)
    position = Column(Integer, nullable=False)
    created_at = Column(String(30), nullable=False)

    # Relationships
    plan = relationship(
        "LifecyclePlanModel",
        back_populates="invariants_rel",
    )


# ---------------------------------------------------------------------------
# Project Models (Stage B0 - migration b0_001_projects)
# ---------------------------------------------------------------------------


class NamespacedProjectModel(Base):  # type: ignore[misc]
    """Database model for spec-aligned namespaced projects.

    Projects are identified solely by their namespaced name (e.g.,
    ``local/my-project``).  They store invariants, automation profile,
    context policy, and tags as JSON text fields.

    Table: ``ns_projects``
    """

    __allow_unmapped__ = True
    __tablename__ = "ns_projects"

    # PK: namespaced name (e.g., "local/my-project")
    namespaced_name = Column(String(255), primary_key=True)
    namespace = Column(String(100), nullable=False)

    # Description
    description = Column(Text, nullable=True)

    # JSON list of invariant strings
    invariants_json = Column(Text, nullable=True)

    # Default automation profile name
    automation_profile = Column(String(255), nullable=True)

    # Actor for invariant reconciliation
    invariant_actor = Column(String(255), nullable=True)

    # JSON context policy configuration
    context_policy_json = Column(Text, nullable=True)

    # Tags (JSON array of strings)
    tags_json = Column(Text, nullable=False, default="[]")

    # Creator identifier
    created_by = Column(String(255), nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    # Relationships
    resource_links = relationship(
        "ProjectResourceLinkModel",
        back_populates="project",
        cascade="all, delete-orphan",
    )

    __table_args__ = (Index("ix_ns_projects_namespace", "namespace"),)

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Any:
        """Convert to ``NamespacedProject`` domain model.

        Returns:
            NamespacedProject domain object.
        """
        from cleveragents.domain.models.core.project import (
            ContextConfig,
            LinkedResource,
            NamespacedProject,
        )

        # Parse context policy from JSON
        context_config: ContextConfig = ContextConfig()
        raw_policy = cast("str | None", self.context_policy_json)
        if raw_policy:
            policy_dict: dict[str, Any] = json.loads(raw_policy)
            context_config = ContextConfig(**policy_dict)

        # Build linked resources from child relationship
        linked_resources: list[LinkedResource] = []
        for link in self.resource_links or []:
            linked_resources.append(
                LinkedResource(
                    resource_id=cast(str, link.resource_id),
                    project_read_only=cast(bool, link.read_only),
                    alias=cast("str | None", link.alias),
                    linked_at=datetime.fromisoformat(cast(str, link.created_at)),
                )
            )

        # Extract name from namespaced_name (namespace/name format)
        ns_name = cast(str, self.namespaced_name)
        parts = ns_name.split("/", 1)
        short_name = parts[1] if len(parts) > 1 else parts[0]

        return NamespacedProject(
            name=short_name,
            namespace=cast(str, self.namespace),
            description=cast("str | None", self.description),
            linked_resources=linked_resources,
            context_config=context_config,
            created_at=datetime.fromisoformat(cast(str, self.created_at)),
            updated_at=datetime.fromisoformat(cast(str, self.updated_at)),
        )

    @classmethod
    def from_domain(cls, project: Any) -> NamespacedProjectModel:
        """Create from ``NamespacedProject`` domain model.

        Args:
            project: A ``NamespacedProject`` domain instance.

        Returns:
            A ``NamespacedProjectModel`` ready for persistence.
        """
        # Serialize context_config to JSON
        context_policy_json: str | None = None
        if project.context_config is not None:
            config_dict = project.context_config.model_dump(mode="json")
            context_policy_json = json.dumps(config_dict)

        tags_json_str = json.dumps(getattr(project, "tags", []) or [])

        model = cls(
            namespaced_name=project.namespaced_name,
            namespace=project.namespace,
            description=project.description,
            invariants_json=json.dumps([]),
            automation_profile=None,
            invariant_actor=None,
            context_policy_json=context_policy_json,
            tags_json=tags_json_str,
            created_by=None,
            created_at=project.created_at.isoformat(),
            updated_at=project.updated_at.isoformat(),
        )

        return model


class ProjectResourceLinkModel(Base):  # type: ignore[misc]
    """Database model for project-resource links.

    Links projects to resources from the Resource Registry with optional
    alias and read-only override.

    Table: ``project_resource_links``
    """

    __allow_unmapped__ = True
    __tablename__ = "project_resource_links"

    # PK: ULID (26-char string)
    link_id = Column(String(26), primary_key=True)

    # FK to ns_projects
    project_name = Column(
        String(255),
        ForeignKey("ns_projects.namespaced_name", ondelete="CASCADE"),
        nullable=False,
    )

    # FK to resources
    resource_id = Column(
        String(26),
        ForeignKey("resources.resource_id", ondelete="RESTRICT"),
        nullable=False,
    )

    # Optional alias
    alias = Column(String(255), nullable=True)

    # Read-only override
    read_only = Column(Boolean, nullable=False, default=False)

    # Timestamp (ISO-8601 string)
    created_at = Column(String(30), nullable=False)

    # Relationships
    project = relationship(
        "NamespacedProjectModel",
        back_populates="resource_links",
    )
    resource = relationship(
        "ResourceModel",
        foreign_keys=[resource_id],
    )

    __table_args__ = (
        UniqueConstraint(
            "project_name",
            "resource_id",
            name="uq_project_resource_links_project_resource",
        ),
        Index("ix_project_resource_links_project_name", "project_name"),
        Index("ix_project_resource_links_resource_id", "resource_id"),
        Index(
            "ix_project_resource_links_project_alias",
            "project_name",
            "alias",
        ),
    )


# ---------------------------------------------------------------------------
# Resource Registry Models (Stage B1 - migration b1_001)
# ---------------------------------------------------------------------------


class ResourceTypeModel(Base):  # type: ignore[misc]
    """Database model for resource type definitions.

    Resource types are schema-level definitions that constrain categories of
    resources.  They define CLI arguments, parent/child rules, sandbox strategy,
    and handler implementations.  Built-in types (``git-checkout``,
    ``fs-mount``, etc.) are registered at startup; custom types are loaded from
    YAML configuration files.

    Table: ``resource_types``
    """

    __allow_unmapped__ = True
    __tablename__ = "resource_types"

    # PK: namespaced type name (e.g., "builtin/git-checkout")
    name = Column(String(255), primary_key=True)
    namespace = Column(String(100), nullable=False)
    description = Column(Text, nullable=True)

    # Physical or virtual classification
    resource_kind = Column(String(20), nullable=False)

    # Default sandbox strategy for instances of this type
    sandbox_strategy = Column(String(50), nullable=True)

    # Whether users can add instances via CLI
    user_addable = Column(Boolean, nullable=False, default=False)

    # Handler implementation reference
    handler_ref = Column(String(500), nullable=True)

    # JSON fields for complex structures
    args_schema_json = Column(Text, nullable=True)
    allowed_parent_types_json = Column(Text, nullable=True)
    allowed_child_types_json = Column(Text, nullable=True)
    auto_discover_json = Column(Text, nullable=True)
    capabilities_json = Column(Text, nullable=True)
    equivalence_json = Column(Text, nullable=True)

    # Single-inheritance parent type name (ADR-042)
    inherits = Column(String(255), nullable=True)

    # Source: "builtin" or path to YAML config file
    source = Column(String(500), nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    # Relationships
    resources = relationship(
        "ResourceModel",
        back_populates="resource_type",
        foreign_keys="ResourceModel.type_name",
    )

    __table_args__ = (
        CheckConstraint(
            "resource_kind IN ('physical', 'virtual')",
            name="ck_resource_types_kind",
        ),
        Index("ix_resource_types_namespace", "namespace"),
        Index("ix_resource_types_kind", "resource_kind"),
        Index("ix_resource_types_user_addable", "user_addable"),
        Index("ix_resource_types_inherits", "inherits"),
    )


class ResourceModel(Base):  # type: ignore[misc]
    """Database model for registered resources.

    Resources represent anything that can be read, written, or queried -- git
    repositories, filesystems, databases, APIs, and more.  Each resource has a
    ULID primary key and an optional namespaced name (auto-discovered children
    have no name).

    Table: ``resources``
    """

    __allow_unmapped__ = True
    __tablename__ = "resources"

    # PK: ULID (26-char string)
    resource_id = Column(String(26), primary_key=True)

    # Optional namespaced name (NULL for auto-discovered children)
    namespaced_name = Column(String(255), nullable=True, unique=True)
    namespace = Column(String(100), nullable=True)

    # Type reference
    type_name = Column(
        String(255),
        ForeignKey("resource_types.name", ondelete="RESTRICT"),
        nullable=False,
    )

    # Classification (derived from type, stored for query efficiency)
    resource_kind = Column(String(20), nullable=False)

    # Location (physical resources only; NULL for virtual)
    location = Column(Text, nullable=True)
    description = Column(Text, nullable=True)

    # Flags
    read_only = Column(Boolean, nullable=False, default=False)
    auto_discovered = Column(Boolean, nullable=False, default=False)

    # Per-resource sandbox strategy override
    sandbox_strategy = Column(String(50), nullable=True)

    # Content hash for equivalence tracking
    content_hash = Column(String(128), nullable=True)

    # JSON fields
    properties_json = Column(Text, nullable=True)
    metadata_json = Column(Text, nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    # Relationships
    resource_type = relationship(
        "ResourceTypeModel",
        back_populates="resources",
        foreign_keys=[type_name],
    )
    parent_edges = relationship(
        "ResourceEdgeModel",
        back_populates="child",
        foreign_keys="ResourceEdgeModel.child_id",
        cascade="all, delete-orphan",
    )
    child_edges = relationship(
        "ResourceEdgeModel",
        back_populates="parent",
        foreign_keys="ResourceEdgeModel.parent_id",
        cascade="all, delete-orphan",
    )

    __table_args__ = (
        CheckConstraint(
            "resource_kind IN ('physical', 'virtual')",
            name="ck_resources_kind",
        ),
        Index("ix_resources_namespace", "namespace"),
        Index("ix_resources_type", "type_name"),
        Index("ix_resources_kind", "resource_kind"),
        Index("ix_resources_content_hash", "content_hash"),
    )


class ResourceEdgeModel(Base):  # type: ignore[misc]
    """Database model for resource DAG edges (parent -> child).

    Resources form a directed acyclic graph.  A resource can have multiple
    parents and multiple children, subject to type constraints.  Cycles are
    validated at the application layer.

    Table: ``resource_edges``
    """

    __allow_unmapped__ = True
    __tablename__ = "resource_edges"

    # Composite PK: (parent_id, child_id)
    parent_id = Column(
        String(26),
        ForeignKey("resources.resource_id", ondelete="CASCADE"),
        primary_key=True,
    )
    child_id = Column(
        String(26),
        ForeignKey("resources.resource_id", ondelete="CASCADE"),
        primary_key=True,
    )

    # Link type: contains | references | derived_from
    link_type = Column(String(30), nullable=False, default="contains")

    # Whether auto-discovered (vs manually linked)
    auto_discovered = Column(Boolean, nullable=False, default=False)

    # Timestamp
    created_at = Column(String(30), nullable=False)

    # Relationships
    parent = relationship(
        "ResourceModel",
        back_populates="child_edges",
        foreign_keys=[parent_id],
    )
    child = relationship(
        "ResourceModel",
        back_populates="parent_edges",
        foreign_keys=[child_id],
    )

    __table_args__ = (
        CheckConstraint(
            "parent_id != child_id",
            name="ck_resource_edges_no_self_loop",
        ),
        CheckConstraint(
            "link_type IN ('contains', 'references', 'derived_from')",
            name="ck_resource_edges_link_type",
        ),
        Index("ix_resource_edges_child", "child_id"),
        Index("ix_resource_edges_link_type", "link_type"),
    )


# ---------------------------------------------------------------------------
# Resource Link Models (Stage B1 - migration b1_001_resource_links)
# ---------------------------------------------------------------------------


class ResourceLinkModel(Base):  # type: ignore[misc]
    """Database model for validated resource DAG links.

    Stores parent-child links between resources after validation
    (cycle detection, type compatibility).  Unlike ``resource_edges``
    which stores raw DAG edges with link-type metadata, this table
    records validated DAG relationships managed by ``link_child`` /
    ``unlink_child``.

    Table: ``resource_links``
    """

    __allow_unmapped__ = True
    __tablename__ = "resource_links"

    # Composite PK: (parent_id, child_id)
    parent_id = Column(
        String(26),
        ForeignKey("resources.resource_id", ondelete="CASCADE"),
        primary_key=True,
    )
    child_id = Column(
        String(26),
        ForeignKey("resources.resource_id", ondelete="CASCADE"),
        primary_key=True,
    )

    # Timestamp (ISO-8601 string)
    created_at = Column(String(30), nullable=False)

    __table_args__ = (
        CheckConstraint(
            "parent_id != child_id",
            name="ck_resource_links_no_self_loop",
        ),
        Index("ix_resource_links_child", "child_id"),
        Index("ix_resource_links_parent", "parent_id"),
    )


# ---------------------------------------------------------------------------
# Tool Registry Models (Stage C1 - migration c1_001)
# ---------------------------------------------------------------------------


class ToolModel(Base):  # type: ignore[misc]
    """Database model for tool and validation definitions.

    Tools are the atomic unit of execution in CleverAgents.  They share
    a single naming namespace with Validations (which are just tools
    with pass/fail semantics).  The ``tool_type`` discriminator
    distinguishes them for filtering.

    Table: ``tools``
    """

    __allow_unmapped__ = True
    __tablename__ = "tools"

    # PK: namespaced name (e.g. "local/lint-check")
    name = Column(String(255), primary_key=True)
    tool_id = synonym("name")
    namespace = Column(String(100), nullable=False)
    short_name = Column(String(150), nullable=False)

    # Core fields
    description = Column(Text, nullable=False)
    tool_type = Column(String(20), nullable=False)
    source = Column(String(20), nullable=False)

    # JSON Schema for inputs/outputs
    input_schema_json = Column(Text, nullable=True)
    output_schema_json = Column(Text, nullable=True)

    # Capability and lifecycle metadata (JSON blobs)
    capability_json = Column(Text, nullable=True)
    lifecycle_json = Column(Text, nullable=True)

    # Source-specific fields
    code = Column(Text, nullable=True)
    mcp_server = Column(String(255), nullable=True)
    mcp_tool_name = Column(String(255), nullable=True)
    agent_skill_path = Column(String(1024), nullable=True)

    # Execution
    timeout = Column(Integer, nullable=False, default=300)

    # Validation-specific fields
    wraps = Column(
        String(255),
        ForeignKey("tools.name", ondelete="SET NULL"),
        nullable=True,
    )
    transform = Column(Text, nullable=True)
    mode = Column(String(20), nullable=True)
    argument_mapping_json = Column(Text, nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    @property
    def input_schema(self) -> dict[str, Any] | None:
        raw = cast("str | None", self.input_schema_json)
        return json.loads(raw) if raw else None

    @property
    def output_schema(self) -> dict[str, Any] | None:
        raw = cast("str | None", self.output_schema_json)
        return json.loads(raw) if raw else None

    # Relationships
    resource_bindings_rel = relationship(
        "ToolResourceBindingModel",
        back_populates="tool",
        cascade="all, delete-orphan",
    )
    validation_attachments_rel = relationship(
        "ValidationAttachmentModel",
        back_populates="tool",
        cascade="all, delete-orphan",
    )

    __table_args__ = (
        CheckConstraint(
            "tool_type IN ('tool', 'validation')",
            name="ck_tools_type",
        ),
        CheckConstraint(
            "source IN ('mcp', 'agent_skill', 'builtin', 'custom', 'wrapped')",
            name="ck_tools_source",
        ),
        Index("ix_tools_namespace", "namespace"),
        Index("ix_tools_tool_type", "tool_type"),
        Index("ix_tools_source", "source"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Any:
        """Convert to domain model (stub returning dict).

        Returns:
            A dictionary representation suitable for domain reconstruction.
        """
        result: dict[str, Any] = {
            "name": cast(str, self.name),
            "namespace": cast(str, self.namespace),
            "short_name": cast(str, self.short_name),
            "description": cast(str, self.description),
            "tool_type": cast(str, self.tool_type),
            "source": cast(str, self.source),
            "input_schema_json": cast("str | None", self.input_schema_json),
            "output_schema_json": cast("str | None", self.output_schema_json),
            "capability_json": cast("str | None", self.capability_json),
            "lifecycle_json": cast("str | None", self.lifecycle_json),
            "code": cast("str | None", self.code),
            "mcp_server": cast("str | None", self.mcp_server),
            "mcp_tool_name": cast("str | None", self.mcp_tool_name),
            "agent_skill_path": cast("str | None", self.agent_skill_path),
            "timeout": cast(int, self.timeout),
            "wraps": cast("str | None", self.wraps),
            "transform": cast("str | None", self.transform),
            "mode": cast("str | None", self.mode),
            "argument_mapping_json": cast("str | None", self.argument_mapping_json),
            "created_at": cast(str, self.created_at),
            "updated_at": cast(str, self.updated_at),
        }
        # Include resource bindings
        bindings: list[dict[str, Any]] = []
        for binding in self.resource_bindings_rel or []:
            bindings.append(
                {
                    "id": cast(int, binding.id),
                    "slot_name": cast(str, binding.slot_name),
                    "resource_type": cast(str, binding.resource_type),
                    "access_mode": cast(str, binding.access_mode),
                    "binding_mode": cast(str, binding.binding_mode),
                    "static_resource": cast("str | None", binding.static_resource),
                    "required": cast(bool, binding.required),
                    "description": cast("str | None", binding.description),
                }
            )
        result["resource_bindings"] = bindings
        return result

    @classmethod
    def from_domain(cls, tool: Any) -> ToolModel:
        """Create from a domain tool dict or object.

        Args:
            tool: A dict-like or domain object with tool fields.

        Returns:
            A ``ToolModel`` ready for persistence.
        """

        # Support both dict and object access
        def _get(obj: Any, key: str, default: Any = None) -> Any:
            if isinstance(obj, dict):
                return obj.get(key, default)
            return getattr(obj, key, default)

        now_iso = datetime.now().isoformat()
        name_str: str = _get(tool, "name", "")

        model = cls(
            name=name_str,
            namespace=name_str.split("/", 1)[0] if "/" in name_str else "",
            short_name=name_str.split("/", 1)[1] if "/" in name_str else name_str,
            description=_get(tool, "description", ""),
            tool_type=_get(tool, "tool_type", "tool"),
            source=_get(tool, "source", "builtin"),
            input_schema_json=_get(tool, "input_schema_json"),
            output_schema_json=_get(tool, "output_schema_json"),
            capability_json=_get(tool, "capability_json"),
            lifecycle_json=_get(tool, "lifecycle_json"),
            code=_get(tool, "code"),
            mcp_server=_get(tool, "mcp_server"),
            mcp_tool_name=_get(tool, "mcp_tool_name"),
            agent_skill_path=_get(tool, "agent_skill_path"),
            timeout=_get(tool, "timeout", 300),
            wraps=_get(tool, "wraps"),
            transform=_get(tool, "transform"),
            mode=_get(tool, "mode"),
            argument_mapping_json=_get(tool, "argument_mapping_json"),
            created_at=_get(tool, "created_at", now_iso),
            updated_at=_get(tool, "updated_at", now_iso),
        )

        # Populate resource bindings from domain
        for binding_data in _get(tool, "resource_bindings", []) or []:
            model.resource_bindings_rel.append(
                ToolResourceBindingModel(
                    slot_name=_get(binding_data, "slot_name", ""),
                    resource_type=_get(binding_data, "resource_type", ""),
                    access_mode=_get(binding_data, "access_mode", "read_only"),
                    binding_mode=_get(binding_data, "binding_mode", "contextual"),
                    static_resource=_get(binding_data, "static_resource"),
                    required=_get(binding_data, "required", True),
                    description=_get(binding_data, "description"),
                    created_at=now_iso,
                )
            )

        return model


class ToolResourceBindingModel(Base):  # type: ignore[misc]
    """Database model for tool resource bindings (child of tools).

    Stores the typed resource slot declarations for each tool.

    Table: ``tool_resource_bindings``
    """

    __allow_unmapped__ = True
    __tablename__ = "tool_resource_bindings"

    id = Column(Integer, primary_key=True, autoincrement=True)
    tool_name = Column(
        String(255),
        ForeignKey("tools.name", ondelete="CASCADE"),
        nullable=False,
    )
    tool_id = synonym("tool_name")
    slot_name = Column(String(100), nullable=False)
    resource_type = Column(String(255), nullable=False)
    access_mode = Column(String(20), nullable=False)
    binding_mode = Column(String(20), nullable=False)
    static_resource = Column(String(255), nullable=True)
    required = Column(Boolean, nullable=False, default=True)
    description = Column(Text, nullable=True)
    created_at = Column(String(30), nullable=False)

    # Relationships
    tool = relationship(
        "ToolModel",
        back_populates="resource_bindings_rel",
    )

    __table_args__ = (Index("ix_tool_resource_bindings_tool_name", "tool_name"),)


# Compatibility alias for legacy test code that imports ToolBindingModel.
ToolBindingModel = ToolResourceBindingModel


class ValidationAttachmentModel(Base):  # type: ignore[misc]
    """Database model for validation attachments.

    Runtime attachments linking validations to resources with optional
    project/plan scoping.

    Table: ``validation_attachments``
    """

    __allow_unmapped__ = True
    __tablename__ = "validation_attachments"

    # PK: ULID (26-char string)
    attachment_id = Column(String(26), primary_key=True)

    # FK to tools.name (must be a validation)
    validation_name = Column(
        String(255),
        ForeignKey("tools.name", ondelete="CASCADE"),
        nullable=False,
    )

    # Resource reference
    resource_id = Column(String(255), nullable=False)

    # Mode: required or informational
    mode = Column(String(20), nullable=False)

    # Scoping fields
    project_name = Column(String(255), nullable=True)
    plan_id = Column(String(26), nullable=True)

    # Optional arguments
    args_json = Column(Text, nullable=True)

    # Timestamp
    created_at = Column(String(30), nullable=False)

    # Relationships
    tool = relationship(
        "ToolModel",
        back_populates="validation_attachments_rel",
    )

    __table_args__ = (
        CheckConstraint(
            "mode IN ('required', 'informational')",
            name="ck_validation_attachments_mode",
        ),
        Index("ix_validation_attachments_validation_name", "validation_name"),
        Index("ix_validation_attachments_resource_id", "resource_id"),
        Index("ix_validation_attachments_project_name", "project_name"),
    )


# ---------------------------------------------------------------------------
# Session Persistence Models (Stage A7 - migration a7_001)
# ---------------------------------------------------------------------------


class SessionModel(Base):  # type: ignore[misc]
    """Database model for persistent sessions.

    A session is a persistent conversation thread tied to an orchestrator
    actor.  Each session is identified by a ULID and stores message history,
    token usage, linked plan references, and arbitrary metadata.

    Table: ``sessions``
    """

    __allow_unmapped__ = True
    __tablename__ = "sessions"

    session_id = Column(String(26), primary_key=True)
    actor_name = Column(String(255), nullable=True)
    name = Column(String(255), nullable=True)
    namespace = Column(String(100), nullable=False, server_default="local")
    linked_plan_ids_json = Column(Text, nullable=True)
    token_usage_json = Column(Text, nullable=True)
    metadata_json = Column(Text, nullable=True)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    # Relationships
    messages_rel = relationship(
        "SessionMessageModel",
        back_populates="session",
        cascade="all, delete-orphan",
        order_by="SessionMessageModel.sequence",
    )

    __table_args__ = (
        Index("ix_sessions_created_at", "created_at"),
        Index("ix_sessions_actor_name", "actor_name"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Any:
        """Convert to ``Session`` domain model.

        Returns:
            A ``Session`` domain instance with messages loaded from the
            ``messages_rel`` relationship.
        """
        from cleveragents.domain.models.core.session import (
            Session,
            SessionTokenUsage,
        )

        linked_plan_ids: list[str] = json.loads(
            cast(str, self.linked_plan_ids_json or "[]")
        )
        token_data: dict[str, Any] = json.loads(
            cast(str, self.token_usage_json or "{}")
        )
        metadata: dict[str, Any] = json.loads(cast(str, self.metadata_json or "{}"))

        token_usage = SessionTokenUsage(
            input_tokens=token_data.get("input_tokens", 0),
            output_tokens=token_data.get("output_tokens", 0),
            estimated_cost=token_data.get("estimated_cost", 0.0),
        )

        messages = [msg_model.to_domain() for msg_model in (self.messages_rel or [])]

        return Session(
            session_id=cast(str, self.session_id),
            name=cast("str | None", self.name),
            actor_name=cast("str | None", self.actor_name),
            namespace=cast(str, self.namespace),
            linked_plan_ids=linked_plan_ids,
            token_usage=token_usage,
            metadata=metadata,
            messages=messages,
            created_at=datetime.fromisoformat(cast(str, self.created_at)),
            updated_at=datetime.fromisoformat(cast(str, self.updated_at)),
        )

    @classmethod
    def from_domain(cls, session: Any) -> SessionModel:
        """Create from ``Session`` domain model.

        Args:
            session: A ``Session`` domain instance.

        Returns:
            A ``SessionModel`` ready for persistence.
        """
        token_usage_json = json.dumps(
            {
                "input_tokens": session.token_usage.input_tokens,
                "output_tokens": session.token_usage.output_tokens,
                "estimated_cost": session.token_usage.estimated_cost,
            }
        )

        model = cls(
            session_id=session.session_id,
            name=session.name,
            actor_name=session.actor_name,
            namespace=session.namespace,
            linked_plan_ids_json=json.dumps(session.linked_plan_ids),
            token_usage_json=token_usage_json,
            metadata_json=json.dumps(session.metadata),
            created_at=session.created_at.isoformat(),
            updated_at=session.updated_at.isoformat(),
        )

        # Populate child message models
        for msg in session.messages:
            model.messages_rel.append(SessionMessageModel.from_domain(msg))

        return model


class SessionMessageModel(Base):  # type: ignore[misc]
    """Database model for session messages (child of sessions).

    Each message is a single turn in the session conversation, identified
    by a ULID and ordered by a ``sequence`` integer.

    Table: ``session_messages``
    """

    __allow_unmapped__ = True
    __tablename__ = "session_messages"

    message_id = Column(String(26), primary_key=True)
    session_id = Column(
        String(26),
        ForeignKey("sessions.session_id", ondelete="CASCADE"),
        nullable=False,
    )
    role = Column(String(20), nullable=False)
    content = Column(Text, nullable=False)
    sequence = Column(Integer, nullable=False)
    timestamp = Column(String(30), nullable=False)
    metadata_json = Column(Text, nullable=True)
    tool_call_id = Column(String(255), nullable=True)

    # Relationships
    session = relationship(
        "SessionModel",
        back_populates="messages_rel",
    )

    __table_args__ = (
        Index("ix_session_messages_session_id", "session_id"),
        Index("ix_session_messages_sequence", "session_id", "sequence"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Any:
        """Convert to ``SessionMessage`` domain model.

        Returns:
            A ``SessionMessage`` domain instance.
        """
        from cleveragents.domain.models.core.session import (
            MessageRole,
            SessionMessage,
        )

        metadata: dict[str, Any] = json.loads(cast(str, self.metadata_json or "{}"))

        return SessionMessage(
            message_id=cast(str, self.message_id),
            role=MessageRole(cast(str, self.role)),
            content=cast(str, self.content),
            sequence=cast(int, self.sequence),
            timestamp=datetime.fromisoformat(cast(str, self.timestamp)),
            metadata=metadata,
            tool_call_id=cast("str | None", self.tool_call_id),
        )

    @classmethod
    def from_domain(cls, message: Any) -> SessionMessageModel:
        """Create from ``SessionMessage`` domain model.

        Args:
            message: A ``SessionMessage`` domain instance.

        Returns:
            A ``SessionMessageModel`` ready for persistence.
        """
        return cls(
            message_id=message.message_id,
            session_id=getattr(message, "session_id", None),
            role=message.role.value if hasattr(message.role, "value") else message.role,
            content=message.content,
            sequence=message.sequence,
            timestamp=message.timestamp.isoformat(),
            metadata_json=json.dumps(message.metadata),
            tool_call_id=message.tool_call_id,
        )


# Automation Profile Model (Stage A6 - migration a6_001_automation_profiles)
# ---------------------------------------------------------------------------


class AutomationProfileModel(Base):  # type: ignore[misc]
    """Database model for persisted custom automation profiles.

    Built-in profiles are resolved in-memory and are NOT stored here.
    The primary key is the namespaced ``name`` (e.g. ``acme/strict``).

    Table: ``automation_profiles``
    """

    __allow_unmapped__ = True
    __tablename__ = "automation_profiles"

    # PK: namespaced name
    name = Column(Text, primary_key=True)
    description = Column(Text, nullable=True)
    schema_version = Column(Text, nullable=False, server_default="1.0")

    # Task-type confidence thresholds
    decompose_task = Column(Float, nullable=False, default=0.0)
    create_tool = Column(Float, nullable=False, default=0.0)
    select_tool = Column(Float, nullable=False, default=0.0)
    edit_code = Column(Float, nullable=False, default=0.0)
    execute_command = Column(Float, nullable=False, default=0.0)
    create_file = Column(Float, nullable=False, default=0.0)
    delete_content = Column(Float, nullable=False, default=0.0)
    access_network = Column(Float, nullable=False, default=0.0)
    install_dependency = Column(Float, nullable=False, default=0.0)
    modify_config = Column(Float, nullable=False, default=0.0)
    approve_plan = Column(Float, nullable=False, default=0.0)

    # Safety requirements (legacy scalar columns kept for queries)
    require_sandbox = Column(Boolean, nullable=False, default=True)
    require_checkpoints = Column(Boolean, nullable=False, default=True)
    allow_unsafe_tools = Column(Boolean, nullable=False, default=False)

    # Full-fidelity JSON for SafetyProfile and AutomationGuard sub-models
    safety_json = Column(Text, nullable=True)
    guards_json = Column(Text, nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(Text, nullable=False)
    updated_at = Column(Text, nullable=False)

    __table_args__ = (Index("ix_automation_profiles_name", "name"),)


class AuditLogModel(Base):  # type: ignore[misc]
    """Database model for audit log entries (SEC7).

    Records security-relevant operations: apply events, cancellations,
    resource modifications, corrections, config changes, and entity
    deletions.  Used for compliance, post-hoc analysis, and the
    ``agents audit`` CLI commands.

    Table: ``audit_log``
    """

    __allow_unmapped__ = True
    __tablename__ = "audit_log"

    id = Column(Integer, primary_key=True, autoincrement=True)
    event_type = Column(
        Text,
        nullable=False,
        doc=(
            "Event category: plan_applied, plan_cancelled, "
            "resource_modified, correction_applied, config_changed, "
            "entity_deleted, session_created"
        ),
    )
    plan_id = Column(Text, nullable=True)
    project_name = Column(Text, nullable=True)
    actor_name = Column(Text, nullable=True)
    user_identity = Column(Text, nullable=True)
    details = Column(Text, nullable=False, doc="JSON-encoded event-specific payload")
    created_at = Column(
        Text,
        nullable=False,
        doc="ISO-8601 timestamp, auto-populated on insert",
    )

    __table_args__ = (
        Index("idx_audit_event", "event_type"),
        Index(
            "idx_audit_plan",
            "plan_id",
            sqlite_where=Column("plan_id").isnot(None),
        ),
        Index("idx_audit_created", "created_at"),
    )


# ---------------------------------------------------------------------------
# Skill Registry Models (Stage C0 - migration c0_001_skill_registry_tables)
# ---------------------------------------------------------------------------

_SKILL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$")


class SkillModel(Base):  # type: ignore[misc]
    """Database model for registered skills.

    Skills are namespaced, reusable collections of tools. The
    ``name`` column (``namespace/short_name``) is the primary key.

    Table: ``skills``
    """

    __allow_unmapped__ = True
    __tablename__ = "skills"

    # PK: namespaced name (e.g. "local/code-tools")
    name = Column(String(255), primary_key=True)
    namespace = Column(String(100), nullable=False)
    short_name = Column(String(150), nullable=False)

    # Core fields
    description = Column(Text, nullable=False)
    version = Column(String(50), nullable=True)
    metadata_json = Column(Text, nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    updated_at = Column(String(30), nullable=False)

    # Flattened tool cache columns (m4_002)
    flattened_tools_json = Column(Text, nullable=True)
    includes_json = Column(Text, nullable=True)
    capability_summary_json = Column(Text, nullable=True)
    yaml_text = Column(Text, nullable=True)
    flattening_hash = Column(String(64), nullable=True)

    # Relationships (lazy="joined" prevents N+1 queries on list_all)
    items_rel = relationship(
        "SkillItemModel",
        back_populates="skill",
        cascade="all, delete-orphan",
        order_by="SkillItemModel.item_order",
        lazy="joined",
    )

    __table_args__ = (
        Index("ix_skills_namespace", "namespace"),
        UniqueConstraint("name", name="uq_skills_name"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Any:
        """Reconstruct a ``Skill`` domain object from the persisted model.

        Reverse-maps persisted ``skill_items`` rows back to the appropriate
        domain sub-objects (tool_refs, includes, anonymous_tools, etc.) and
        reconstructs ``overrides`` from ``metadata_json``.

        Returns:
            A ``Skill`` domain model instance.
        """
        from cleveragents.domain.models.core.skill import (
            Skill,
            SkillAgentSource,
            SkillInclude,
            SkillInlineTool,
            SkillMcpSource,
        )

        tool_refs: list[str] = []
        includes: list[SkillInclude] = []
        anonymous_tools: list[SkillInlineTool] = []
        mcp_servers: list[SkillMcpSource] = []
        agent_skills: list[SkillAgentSource] = []

        for item in self.items_rel or []:
            item_type = cast(str, item.item_type)
            item_name = cast(str, item.item_name)
            item_config_str = cast("str | None", item.item_config)

            if item_type == "tool_ref":
                tool_refs.append(item_name)
            elif item_type == "include":
                inc_overrides: dict[str, Any] | None = None
                if item_config_str:
                    config_data = json.loads(item_config_str)
                    inc_overrides = config_data.get("overrides")
                includes.append(SkillInclude(name=item_name, overrides=inc_overrides))
            elif item_type == "inline_tool":
                if item_config_str:
                    config_data = json.loads(item_config_str)
                    anonymous_tools.append(SkillInlineTool.model_validate(config_data))
            elif item_type == "mcp_source":
                if item_config_str:
                    config_data = json.loads(item_config_str)
                    mcp_servers.append(SkillMcpSource.model_validate(config_data))
            elif item_type == "agent_source":
                agent_skills.append(SkillAgentSource(path=item_name))

        overrides: dict[str, dict[str, Any]] = {}
        metadata_str = cast("str | None", self.metadata_json)
        if metadata_str:
            meta = json.loads(metadata_str)
            overrides = meta.get("overrides", {})

        return Skill(
            name=cast(str, self.name),
            description=cast(str, self.description),
            tool_refs=tool_refs,
            includes=includes,
            anonymous_tools=anonymous_tools,
            mcp_servers=mcp_servers,
            agent_skills=agent_skills,
            overrides=overrides,
        )

    @classmethod
    def from_domain(cls, skill: Any) -> SkillModel:
        """Create from a ``Skill`` domain model or dict.

        Args:
            skill: A ``Skill`` domain instance or dict-like object.

        Returns:
            A ``SkillModel`` ready for persistence.

        Raises:
            ValueError: If the skill name is empty or does not match
                the required ``namespace/short_name`` pattern.
        """

        def _get(obj: Any, key: str, default: Any = None) -> Any:
            if isinstance(obj, dict):
                return obj.get(key, default)
            return getattr(obj, key, default)

        now_iso = datetime.now(tz=UTC).isoformat()
        name_str: str = _get(skill, "name", "")

        # SEC-1: Validate name format regardless of input type
        if not _SKILL_NAME_RE.match(name_str):
            raise ValueError(
                f"Skill name must match 'namespace/short_name' pattern "
                f"(alphanumeric, hyphens, underscores): got '{name_str}'"
            )

        # Build metadata JSON from overrides
        overrides = _get(skill, "overrides", {})
        metadata_json_str: str | None = None
        if overrides:
            metadata_json_str = json.dumps({"overrides": overrides})

        model = cls(
            name=name_str,
            namespace=name_str.split("/", 1)[0] if "/" in name_str else "",
            short_name=name_str.split("/", 1)[1] if "/" in name_str else name_str,
            description=_get(skill, "description", ""),
            version=_get(skill, "version"),
            metadata_json=metadata_json_str,
            created_at=_get(skill, "created_at", now_iso),
            updated_at=_get(skill, "updated_at", now_iso),
        )

        # Populate items from domain model
        idx = 0

        # tool_refs
        for ref in _get(skill, "tool_refs", []) or []:
            model.items_rel.append(
                SkillItemModel(
                    item_type="tool_ref",
                    item_name=ref,
                    item_config=None,
                    item_order=idx,
                    created_at=now_iso,
                )
            )
            idx += 1

        # includes
        for inc in _get(skill, "includes", []) or []:
            inc_name = _get(inc, "name", "") if not isinstance(inc, str) else inc
            inc_overrides = _get(inc, "overrides") if not isinstance(inc, str) else None
            config: str | None = None
            if inc_overrides:
                config = json.dumps({"overrides": inc_overrides})
            model.items_rel.append(
                SkillItemModel(
                    item_type="include",
                    item_name=inc_name,
                    item_config=config,
                    item_order=idx,
                    created_at=now_iso,
                )
            )
            idx += 1

        # anonymous (inline) tools
        for anon in _get(skill, "anonymous_tools", []) or []:
            anon_dict: dict[str, Any] = {}
            if hasattr(anon, "model_dump"):
                anon_dict = anon.model_dump(mode="json")
            elif isinstance(anon, dict):
                anon_dict = anon
            else:
                anon_dict = {
                    "description": _get(anon, "description", ""),
                    "source": _get(anon, "source", "custom"),
                }
            model.items_rel.append(
                SkillItemModel(
                    item_type="inline_tool",
                    item_name=_get(anon, "description", f"_anon_{idx}"),
                    item_config=json.dumps(anon_dict),
                    item_order=idx,
                    created_at=now_iso,
                )
            )
            idx += 1

        # mcp_servers
        for mcp in _get(skill, "mcp_servers", []) or []:
            mcp_dict: dict[str, Any] = {}
            if hasattr(mcp, "model_dump"):
                mcp_dict = mcp.model_dump(mode="json")
            elif isinstance(mcp, dict):
                mcp_dict = mcp
            else:
                mcp_dict = {"server": _get(mcp, "server", "")}
            model.items_rel.append(
                SkillItemModel(
                    item_type="mcp_source",
                    item_name=_get(mcp, "server", ""),
                    item_config=json.dumps(mcp_dict),
                    item_order=idx,
                    created_at=now_iso,
                )
            )
            idx += 1

        # agent_skills
        for agent in _get(skill, "agent_skills", []) or []:
            agent_path = (
                _get(agent, "path", "") if not isinstance(agent, str) else agent
            )
            model.items_rel.append(
                SkillItemModel(
                    item_type="agent_source",
                    item_name=agent_path,
                    item_config=None,
                    item_order=idx,
                    created_at=now_iso,
                )
            )
            idx += 1

        return model


class SkillItemModel(Base):  # type: ignore[misc]
    """Database model for skill items (child of skills).

    Stores individual skill components: tool refs, includes, inline tools,
    MCP sources, and agent sources with stable ordering.

    Table: ``skill_items``
    """

    __allow_unmapped__ = True
    __tablename__ = "skill_items"

    id = Column(Integer, primary_key=True, autoincrement=True)
    skill_name = Column(
        String(255),
        ForeignKey("skills.name", ondelete="CASCADE"),
        nullable=False,
    )
    item_type = Column(String(30), nullable=False)
    item_name = Column(String(500), nullable=False)
    item_config = Column(Text, nullable=True)
    item_order = Column(Integer, nullable=False)
    created_at = Column(String(30), nullable=False)

    # Relationships
    skill = relationship(
        "SkillModel",
        back_populates="items_rel",
    )

    __table_args__ = (
        Index("ix_skill_items_skill_name", "skill_name"),
        Index("ix_skill_items_item_type", "item_type"),
    )


# ---------------------------------------------------------------------------
# Concurrency Lock Models (Stage M4 - migration m4_001_concurrency_locks)
# ---------------------------------------------------------------------------


class LockModel(Base):  # type: ignore[misc]
    """Database model for concurrency locks.

    Provides plan-level and project-level mutual exclusion via
    owner-scoped advisory locks with configurable TTL.  Only one
    active lock per ``(resource_type, resource_id)`` is allowed.

    Table: ``locks``
    """

    __tablename__ = "locks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
    resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
    resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
    acquired_at: Mapped[str] = mapped_column(String(30), nullable=False)
    expires_at: Mapped[str] = mapped_column(String(30), nullable=False)

    __table_args__ = (
        UniqueConstraint(
            "resource_type",
            "resource_id",
            name="uq_locks_resource",
        ),
        Index("ix_locks_owner_id", "owner_id"),
        Index("ix_locks_expires_at", "expires_at"),
        Index("ix_locks_resource_type", "resource_type"),
    )


# ---------------------------------------------------------------------------
# Decision models (Stage M3.2)
# ---------------------------------------------------------------------------


class DecisionModel(Base):  # type: ignore[misc]
    """Database model for decision tree nodes.

    Maps to the ``decisions`` table.  Each row represents a single
    choice point recorded during the Strategize or Execute phase of a
    plan's lifecycle.

    JSON-as-Text columns
    --------------------
    ``alternatives_considered_json``, ``context_snapshot_json``,
    ``downstream_decision_ids_json``, ``downstream_plan_ids_json``, and
    ``artifacts_produced_json`` are stored as ``Text`` and parsed with
    :func:`json.loads` / :func:`json.dumps`.
    """

    __allow_unmapped__ = True
    __tablename__ = "decisions"

    # --- Identity ---
    decision_id = Column(String(26), primary_key=True)
    plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
        nullable=False,
    )
    parent_decision_id = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="SET NULL"),
        nullable=True,
    )
    sequence_number = Column(Integer, nullable=False)

    # --- Classification ---
    decision_type = Column(String(30), nullable=False)

    # --- The decision itself ---
    question = Column(Text, nullable=False)
    chosen_option = Column(Text, nullable=False)
    alternatives_considered_json = Column(Text, nullable=True)
    confidence_score = Column(Float, nullable=True)

    # --- Context snapshot (JSON blob) ---
    context_snapshot_json = Column(Text, nullable=False)

    # --- Rationale ---
    rationale = Column(Text, nullable=True, default="")
    actor_reasoning = Column(Text, nullable=True)

    # --- Downstream impact (JSON arrays) ---
    downstream_decision_ids_json = Column(Text, nullable=True)
    downstream_plan_ids_json = Column(Text, nullable=True)
    artifacts_produced_json = Column(Text, nullable=True)

    # --- Timestamps ---
    created_at = Column(String(30), nullable=False)

    # --- Correction metadata ---
    is_correction = Column(Boolean, nullable=False, default=False)
    corrects_decision_id = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="SET NULL"),
        nullable=True,
    )
    correction_reason = Column(Text, nullable=True)
    superseded_by = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="SET NULL"),
        nullable=True,
    )

    # --- Relationships ---
    parent_decision = relationship(
        "DecisionModel",
        remote_side="DecisionModel.decision_id",
        foreign_keys=[parent_decision_id],
    )

    __table_args__ = (
        CheckConstraint(
            "decision_type IN ("
            "'prompt_definition', 'invariant_enforced', "
            "'strategy_choice', 'implementation_choice', "
            "'resource_selection', 'subplan_spawn', "
            "'subplan_parallel_spawn', 'tool_invocation', "
            "'error_recovery', 'validation_response', "
            "'user_intervention')",
            name="ck_decisions_type",
        ),
        CheckConstraint(
            "confidence_score IS NULL OR "
            "(confidence_score >= 0.0 AND confidence_score <= 1.0)",
            name="ck_decisions_confidence",
        ),
        Index("ix_decisions_plan", "plan_id"),
        Index("ix_decisions_parent", "parent_decision_id"),
        Index("ix_decisions_type", "decision_type"),
        Index("ix_decisions_created", "created_at"),
        Index("ix_decisions_plan_seq", "plan_id", "sequence_number"),
    )

    # --- Domain conversion ---

    def to_domain(self) -> Any:
        """Convert to a domain :class:`Decision` instance."""
        from cleveragents.domain.models.core.decision import (
            ArtifactRef,
            ContextSnapshot,
            Decision,
            DecisionType,
            ResourceRef,
        )

        # Deserialise JSON-as-text columns
        alt_raw = cast(str, self.alternatives_considered_json or "[]")
        alternatives: list[str] = json.loads(alt_raw)

        snapshot_raw: dict[str, Any] = json.loads(
            cast(str, self.context_snapshot_json),
        )
        relevant_resources = [
            ResourceRef(**r) for r in snapshot_raw.get("relevant_resources", [])
        ]
        context_snapshot = ContextSnapshot(
            hot_context_hash=snapshot_raw.get("hot_context_hash", ""),
            hot_context_ref=snapshot_raw.get("hot_context_ref", ""),
            relevant_resources=relevant_resources,
            actor_state_ref=snapshot_raw.get("actor_state_ref", ""),
        )

        dd_raw = cast(str, self.downstream_decision_ids_json or "[]")
        downstream_decision_ids: list[str] = json.loads(dd_raw)

        dp_raw = cast(str, self.downstream_plan_ids_json or "[]")
        downstream_plan_ids: list[str] = json.loads(dp_raw)

        artifacts_produced: list[ArtifactRef] = []
        ap_raw = cast(str, self.artifacts_produced_json or "[]")
        for a in json.loads(ap_raw):
            artifacts_produced.append(ArtifactRef(**a))

        # Parse created_at
        created_at = datetime.fromisoformat(cast(str, self.created_at))

        return Decision(
            decision_id=cast(str, self.decision_id),
            plan_id=cast(str, self.plan_id),
            parent_decision_id=cast(str | None, self.parent_decision_id),
            sequence_number=cast(int, self.sequence_number),
            decision_type=DecisionType(cast(str, self.decision_type)),
            question=cast(str, self.question),
            chosen_option=cast(str, self.chosen_option),
            alternatives_considered=alternatives,
            confidence_score=cast(float | None, self.confidence_score),
            context_snapshot=context_snapshot,
            rationale=cast(str, self.rationale or ""),
            actor_reasoning=cast(str | None, self.actor_reasoning),
            downstream_decision_ids=downstream_decision_ids,
            downstream_plan_ids=downstream_plan_ids,
            artifacts_produced=artifacts_produced,
            created_at=created_at,
            is_correction=bool(self.is_correction),
            corrects_decision_id=cast(str | None, self.corrects_decision_id),
            correction_reason=cast(str | None, self.correction_reason),
            superseded_by=cast(str | None, self.superseded_by),
        )

    @classmethod
    def from_domain(cls, decision: Any) -> DecisionModel:
        """Create a model instance from a domain :class:`Decision`."""
        # Serialise JSON-as-text columns
        alternatives_json: str | None = None
        if decision.alternatives_considered:
            alternatives_json = json.dumps(decision.alternatives_considered)

        # Serialise context snapshot
        snapshot = decision.context_snapshot
        snapshot_dict: dict[str, Any] = {
            "hot_context_hash": snapshot.hot_context_hash,
            "hot_context_ref": snapshot.hot_context_ref,
            "relevant_resources": [
                {"resource_id": r.resource_id, "path": r.path}
                for r in snapshot.relevant_resources
            ],
            "actor_state_ref": snapshot.actor_state_ref,
        }
        context_snapshot_json = json.dumps(snapshot_dict)

        downstream_decision_ids_json: str | None = None
        if decision.downstream_decision_ids:
            downstream_decision_ids_json = json.dumps(
                decision.downstream_decision_ids,
            )

        downstream_plan_ids_json: str | None = None
        if decision.downstream_plan_ids:
            downstream_plan_ids_json = json.dumps(decision.downstream_plan_ids)

        artifacts_produced_json: str | None = None
        if decision.artifacts_produced:
            artifacts_produced_json = json.dumps(
                [
                    {
                        "artifact_path": a.artifact_path,
                        "artifact_type": a.artifact_type,
                    }
                    for a in decision.artifacts_produced
                ],
            )

        # Format created_at as ISO-8601
        created_at_str = decision.created_at.isoformat()

        return cls(
            decision_id=decision.decision_id,
            plan_id=decision.plan_id,
            parent_decision_id=decision.parent_decision_id,
            sequence_number=decision.sequence_number,
            decision_type=(
                decision.decision_type.value
                if hasattr(decision.decision_type, "value")
                else str(decision.decision_type)
            ),
            question=decision.question,
            chosen_option=decision.chosen_option,
            alternatives_considered_json=alternatives_json,
            confidence_score=decision.confidence_score,
            context_snapshot_json=context_snapshot_json,
            rationale=decision.rationale,
            actor_reasoning=decision.actor_reasoning,
            downstream_decision_ids_json=downstream_decision_ids_json,
            downstream_plan_ids_json=downstream_plan_ids_json,
            artifacts_produced_json=artifacts_produced_json,
            created_at=created_at_str,
            is_correction=decision.is_correction,
            corrects_decision_id=decision.corrects_decision_id,
            correction_reason=decision.correction_reason,
            superseded_by=decision.superseded_by,
        )


class DecisionDependencyModel(Base):  # type: ignore[misc]
    """Database model for the decision dependency DAG.

    Maps to the ``decision_dependencies`` table.  Each row represents
    an influence edge from one decision to another (the *source*
    decision influenced or produced the *target* decision).
    """

    __allow_unmapped__ = True
    __tablename__ = "decision_dependencies"

    source_decision_id = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="CASCADE"),
        primary_key=True,
    )
    target_decision_id = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="CASCADE"),
        primary_key=True,
    )
    relationship_type = Column(
        String(30),
        nullable=False,
        default="influences",
    )
    created_at = Column(String(30), nullable=False)

    __table_args__ = (
        CheckConstraint(
            "source_decision_id != target_decision_id",
            name="ck_decision_deps_no_self_loop",
        ),
        Index("ix_decision_deps_source", "source_decision_id"),
        Index("ix_decision_deps_target", "target_decision_id"),
    )


# ---------------------------------------------------------------------------
# Checkpoint Models (Stage M6 - checkpointing and rollback)
# ---------------------------------------------------------------------------


class CheckpointModel(Base):  # type: ignore[misc]
    """Database model for plan checkpoints.

    Stores checkpoint metadata aligned to the specification's
    ``checkpoint_metadata`` table schema: a ULID primary key, the owning
    plan ID, optional decision and resource references, checkpoint type,
    filesystem path, size, creation timestamp, and a JSON blob of audit
    metadata (reason, source tool, phase).

    Table: ``checkpoint_metadata``
    """

    __allow_unmapped__ = True
    __tablename__ = "checkpoint_metadata"

    # PK: ULID (26-char string)
    checkpoint_id = Column(String(26), primary_key=True)

    # FK to v3_plans
    plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
        nullable=False,
    )

    # Optional FK to decisions (decision-aligned checkpoints)
    decision_id = Column(String(26), nullable=True)

    # Checkpoint type: pre_write, post_step, or manual
    checkpoint_type = Column(Text, nullable=False, default="manual")

    # Optional resource association
    resource_id = Column(String(26), nullable=True)

    # Sandbox reference (e.g. git commit hash)
    sandbox_ref = Column(Text, nullable=False)

    # Relative path within checkpoint directory
    filesystem_path = Column(Text, nullable=False, default="")

    # Size of the checkpoint data in bytes
    size_bytes = Column(Integer, nullable=True)

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)

    # JSON blob for audit metadata (reason, source_tool, phase, extra)
    metadata_json = Column(Text, nullable=True)

    __table_args__ = (
        Index("idx_checkpoints_plan", "plan_id"),
        Index("ix_checkpoint_metadata_created_at", "created_at"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> Checkpoint:
        """Convert to ``Checkpoint`` domain model.

        Returns:
            A ``Checkpoint`` domain instance.
        """
        from cleveragents.domain.models.core.checkpoint import (
            Checkpoint,
            CheckpointMetadata,
        )

        metadata_dict: dict[str, Any] = {}
        raw_meta = cast("str | None", self.metadata_json)
        if raw_meta:
            try:
                parsed = json.loads(raw_meta)
                if isinstance(parsed, dict):
                    # Filter to only known fields to handle future schema changes
                    known_fields = {"reason", "source_tool", "phase", "extra"}
                    metadata_dict = {
                        k: v for k, v in parsed.items() if k in known_fields
                    }
            except (json.JSONDecodeError, TypeError):
                metadata_dict = {}

        return Checkpoint(
            checkpoint_id=cast(str, self.checkpoint_id),
            plan_id=cast(str, self.plan_id),
            sandbox_ref=cast(str, self.sandbox_ref),
            decision_id=cast("str | None", self.decision_id),
            checkpoint_type=cast(str, self.checkpoint_type) or "manual",
            resource_id=cast("str | None", self.resource_id),
            filesystem_path=cast(str, self.filesystem_path) or "",
            size_bytes=cast("int | None", self.size_bytes),
            created_at=datetime.fromisoformat(cast(str, self.created_at)),
            metadata=CheckpointMetadata(**metadata_dict),
        )

    @classmethod
    def from_domain(cls, checkpoint: Checkpoint) -> CheckpointModel:
        """Create from ``Checkpoint`` domain model.

        Args:
            checkpoint: A ``Checkpoint`` domain instance.

        Returns:
            A ``CheckpointModel`` ready for persistence.
        """
        metadata_json: str | None = None
        if checkpoint.metadata is not None:
            metadata_json = json.dumps(checkpoint.metadata.model_dump(mode="json"))

        return cls(
            checkpoint_id=checkpoint.checkpoint_id,
            plan_id=checkpoint.plan_id,
            sandbox_ref=checkpoint.sandbox_ref,
            decision_id=checkpoint.decision_id,
            checkpoint_type=checkpoint.checkpoint_type,
            resource_id=checkpoint.resource_id,
            filesystem_path=checkpoint.filesystem_path,
            size_bytes=checkpoint.size_bytes,
            created_at=checkpoint.created_at.isoformat(),
            metadata_json=metadata_json,
        )


# ---------------------------------------------------------------------------
# Correction Attempt Models (spec DDL — correction_attempts table)
# ---------------------------------------------------------------------------

#: Length of a SQLite-compatible ISO-8601 timestamp truncated to millisecond
#: Length of the millisecond-precision ISO-8601 timestamp string:
#: ``YYYY-MM-DDTHH:MM:SS.mmm`` (23 characters).  Matches the output
#: of SQLite ``strftime('%Y-%m-%dT%H:%M:%f', 'now')``.
SQLITE_TIMESTAMP_MS_LEN: int = 23


def format_sqlite_timestamp(dt: datetime) -> str:
    """Format a datetime to millisecond-precision ISO-8601 for SQLite.

    Normalises to UTC via ``astimezone(UTC)`` and truncates to the
    ``YYYY-MM-DDTHH:MM:SS.mmm`` format matching the SQLite
    ``server_default`` ``strftime('%Y-%m-%dT%H:%M:%f', 'now')``.

    Args:
        dt: A timezone-aware datetime to format.

    Returns:
        A 23-character ISO-8601 timestamp string in UTC.

    Raises:
        ValueError: If *dt* is a naive datetime (no ``tzinfo``).
    """
    if dt.tzinfo is None:
        raise ValueError(
            "format_sqlite_timestamp requires a timezone-aware datetime, "
            f"got naive datetime: {dt!r}"
        )
    utc_dt = dt.astimezone(UTC)
    return utc_dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:SQLITE_TIMESTAMP_MS_LEN]


class CorrectionAttemptModel(Base):  # type: ignore[misc]
    """Database model for correction attempt records.

    Tracks decision correction workflows (revert/append modes) as defined
    in the specification DDL (``correction_attempts`` table).  Each row records one
    correction attempt against a plan's decision tree, including the
    original decision, optionally the new replacement decision, the mode,
    human guidance, archived artifacts path, lifecycle state, and
    timestamps.

    Table: ``correction_attempts``
    """

    __allow_unmapped__ = True
    __tablename__ = "correction_attempts"

    # PK: ULID (26-char string)
    correction_attempt_id = Column(String(26), primary_key=True)

    # FK to v3_plans.  Uses CASCADE (not the spec DDL default RESTRICT)
    # for consistency with all other v3_plans child tables in the codebase
    # (PlanProjectModel, PlanArgumentModel, PlanInvariantModel, etc.).
    plan_id = Column(
        String(26),
        ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
        nullable=False,
    )

    # FK to decisions — the decision being corrected.
    # Uses RESTRICT (spec DDL default) to preserve correction audit trail
    # when decisions are cleaned up — consistent with non-dependency FK
    # references to decisions elsewhere in the codebase.
    original_decision_id = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="RESTRICT"),
        nullable=False,
    )

    # FK to decisions — the new decision created by the correction (nullable).
    # Uses RESTRICT (spec DDL default: no ON DELETE clause) to preserve
    # the correction audit trail, consistent with original_decision_id.
    new_decision_id = Column(
        String(26),
        ForeignKey("decisions.decision_id", ondelete="RESTRICT"),
        nullable=True,
    )

    # Correction strategy: 'revert' | 'append'
    mode = Column(String(20), nullable=False)

    # Human-supplied guidance text
    guidance = Column(Text, nullable=False)

    # Path to archived artifacts (revert mode)
    archived_artifacts_path = Column(Text, nullable=True)

    # Lifecycle state: 'pending' | 'executing' | 'complete' | 'failed'
    state = Column(
        String(20),
        nullable=False,
        default="pending",
        server_default="pending",
    )

    # Timestamps (ISO-8601 strings)
    created_at = Column(
        String(30),
        nullable=False,
        server_default=text("(strftime('%Y-%m-%dT%H:%M:%f', 'now'))"),
    )
    completed_at = Column(String(30), nullable=True)

    # Relationships
    plan = relationship(
        "LifecyclePlanModel",
        back_populates="correction_attempts_rel",
        foreign_keys=[plan_id],
    )

    __table_args__ = (
        CheckConstraint(
            "mode IN ('revert', 'append')",
            name="ck_correction_attempts_mode",
        ),
        CheckConstraint(
            "state IN ('pending', 'executing', 'complete', 'failed')",
            name="ck_correction_attempts_state",
        ),
        Index("idx_corrections_plan", "plan_id"),
    )

    # -- Domain conversion helpers ------------------------------------------

    def to_domain(self) -> CorrectionAttemptRecord:
        """Convert to ``CorrectionAttemptRecord`` domain model.

        Returns:
            A ``CorrectionAttemptRecord`` domain instance.
        """
        from cleveragents.domain.models.core.correction import (
            CorrectionAttemptRecord,
            CorrectionAttemptState,
            CorrectionMode,
        )

        completed_at_dt = None
        raw_completed = cast("str | None", self.completed_at)
        if raw_completed:
            completed_at_dt = datetime.fromisoformat(raw_completed)
            if completed_at_dt.tzinfo is None:
                completed_at_dt = completed_at_dt.replace(tzinfo=UTC)

        created_at_dt = datetime.fromisoformat(cast(str, self.created_at))
        if created_at_dt.tzinfo is None:
            created_at_dt = created_at_dt.replace(tzinfo=UTC)

        raw_mode = cast(str, self.mode)
        raw_state = cast(str, self.state)
        raw_guidance = cast(str, self.guidance)
        try:
            mode_enum = CorrectionMode(raw_mode)
        except ValueError:
            _logger.warning(
                "Unknown correction mode '%s' for attempt %s; defaulting to 'revert'",
                raw_mode,
                self.correction_attempt_id,
            )
            mode_enum = CorrectionMode.REVERT
        try:
            state_enum = CorrectionAttemptState(raw_state)
        except ValueError:
            _logger.warning(
                "Unknown correction state '%s' for attempt %s; defaulting to 'failed'",
                raw_state,
                self.correction_attempt_id,
            )
            state_enum = CorrectionAttemptState.FAILED
        if not raw_guidance or not raw_guidance.strip():
            _logger.warning(
                "Empty/whitespace guidance for attempt %s; defaulting to '[corrupted]'",
                self.correction_attempt_id,
            )
            raw_guidance = "[corrupted]"

        return CorrectionAttemptRecord(
            correction_attempt_id=cast(str, self.correction_attempt_id),
            plan_id=cast(str, self.plan_id),
            original_decision_id=cast(str, self.original_decision_id),
            new_decision_id=cast("str | None", self.new_decision_id),
            mode=mode_enum,
            guidance=raw_guidance,
            archived_artifacts_path=cast("str | None", self.archived_artifacts_path),
            state=state_enum,
            created_at=created_at_dt,
            completed_at=completed_at_dt,
        )

    @classmethod
    def from_domain(cls, record: CorrectionAttemptRecord) -> CorrectionAttemptModel:
        """Create from ``CorrectionAttemptRecord`` domain model.

        Timestamps are normalised to UTC before formatting to prevent
        silent data loss when non-UTC timezone-aware datetimes are provided.
        The output format ``YYYY-MM-DDTHH:MM:SS.mmm`` (no timezone offset,
        millisecond precision) matches the SQLite ``server_default``
        ``strftime('%Y-%m-%dT%H:%M:%f', 'now')`` for consistent
        string-based ordering.

        Args:
            record: A ``CorrectionAttemptRecord`` domain instance.

        Returns:
            A ``CorrectionAttemptModel`` ready for persistence.
        """
        completed_at_str: str | None = None
        if record.completed_at is not None:
            completed_at_str = format_sqlite_timestamp(record.completed_at)

        return cls(
            correction_attempt_id=record.correction_attempt_id,
            plan_id=record.plan_id,
            original_decision_id=record.original_decision_id,
            new_decision_id=record.new_decision_id,
            mode=record.mode.value,
            guidance=record.guidance,
            archived_artifacts_path=record.archived_artifacts_path,
            state=record.state.value,
            created_at=format_sqlite_timestamp(record.created_at),
            completed_at=completed_at_str,
        )


# Database initialization functions
def init_database(database_url: str = "sqlite:///.cleveragents/db.sqlite") -> Any:
    """Initialize the database.

    Args:
        database_url: Database connection URL

    Returns:
        SQLAlchemy engine instance
    """
    engine = create_engine(database_url, echo=False)
    Base.metadata.create_all(engine)
    return engine


def get_session(engine: Any) -> Any:
    """Get a database session.

    Args:
        engine: SQLAlchemy engine instance

    Returns:
        SQLAlchemy session instance
    """
    Session = sessionmaker(bind=engine)
    return Session()


# ---------------------------------------------------------------------------
# ChangeSet Artifact Models (Stage D0 - migration d0_001)
# ---------------------------------------------------------------------------


class ChangeSetEntryModel(Base):  # type: ignore[misc]
    """Database model for persisted ChangeEntry records.

    Each row represents a single file mutation captured during the
    Execute phase of a plan.  Rows are keyed by a ULID ``entry_id``
    and grouped by ``changeset_id`` / ``plan_id`` for efficient
    retrieval during diff and artifacts output.

    Table: ``changeset_entries``
    """

    __allow_unmapped__ = True
    __tablename__ = "changeset_entries"

    entry_id = Column(String(26), primary_key=True)
    changeset_id = Column(String(26), nullable=False)
    plan_id = Column(String(26), nullable=False)
    resource_id = Column(String(26), nullable=False)
    tool_name = Column(String(255), nullable=False)
    operation = Column(String(20), nullable=False)
    path = Column(String(1024), nullable=False)
    before_hash = Column(String(64), nullable=True)
    after_hash = Column(String(64), nullable=True)
    before_mode = Column(Integer, nullable=True)
    after_mode = Column(Integer, nullable=True)
    timestamp = Column(String(30), nullable=False)

    __table_args__ = (
        Index("ix_changeset_entries_changeset_id", "changeset_id"),
        Index("ix_changeset_entries_plan_id", "plan_id"),
        Index("ix_changeset_entries_path", "path"),
    )


class ToolInvocationModel(Base):  # type: ignore[misc]
    """Database model for persisted ToolInvocation records.

    Each row records a single tool execution for auditability.
    Linked to a plan and optionally to a changeset.  Arguments,
    results, change IDs, resource refs, and provider metadata
    are stored as JSON text fields.

    Table: ``tool_invocations``
    """

    __allow_unmapped__ = True
    __tablename__ = "tool_invocations"

    invocation_id = Column(String(26), primary_key=True)
    changeset_id = Column(String(26), nullable=True)
    plan_id = Column(String(26), nullable=False)
    tool_name = Column(String(255), nullable=False)
    skill_name = Column(String(255), nullable=True)
    arguments_json = Column(Text, nullable=True)
    result_json = Column(Text, nullable=True)
    error = Column(Text, nullable=True)
    success = Column(Boolean, nullable=False, default=True)
    duration_ms = Column(Float, nullable=False, default=0.0)
    started_at = Column(String(30), nullable=False)
    completed_at = Column(String(30), nullable=True)
    change_ids_json = Column(Text, nullable=True)
    sequence_number = Column(Integer, nullable=False, default=0)
    sandbox_path = Column(String(1024), nullable=True)
    resource_refs_json = Column(Text, nullable=True)
    provider_metadata_json = Column(Text, nullable=True)
    container_metadata_json = Column(Text, nullable=True)

    __table_args__ = (
        Index("ix_tool_invocations_changeset_id", "changeset_id"),
        Index("ix_tool_invocations_plan_id", "plan_id"),
        Index("ix_tool_invocations_tool_name", "tool_name"),
    )


# ---------------------------------------------------------------------------
# LLM Trace Models (Observability — Forgejo #500)
# ---------------------------------------------------------------------------


class LLMTraceModel(Base):  # type: ignore[misc]
    """Database model for persisted LLMTrace records.

    Each row captures a single LLM provider call with full
    observability metadata: token usage, cost, latency, tool
    calls, context fingerprint, streaming flag, retry count,
    and error information.

    Table: ``llm_traces``
    """

    __allow_unmapped__ = True
    __tablename__ = "llm_traces"

    trace_id = Column(String(26), primary_key=True)
    plan_id = Column(String(26), nullable=False)
    decision_id = Column(String(26), nullable=True)
    actor = Column(String(255), nullable=False)
    provider = Column(String(128), nullable=False)
    model = Column(String(255), nullable=False)
    prompt_tokens = Column(Integer, nullable=False, default=0)
    completion_tokens = Column(Integer, nullable=False, default=0)
    cost_usd = Column(Float, nullable=False, default=0.0)
    latency_ms = Column(Float, nullable=False, default=0.0)
    tool_calls_json = Column(Text, nullable=True)
    context_hash = Column(String(64), nullable=True)
    streaming = Column(Boolean, nullable=False, default=False)
    retry_count = Column(Integer, nullable=False, default=0)
    error = Column(Text, nullable=True)
    timestamp = Column(String(30), nullable=False)

    __table_args__ = (
        Index("ix_llm_traces_plan_id", "plan_id"),
        Index("ix_llm_traces_decision_id", "decision_id"),
        Index("ix_llm_traces_actor", "actor"),
        Index("ix_llm_traces_provider", "provider"),
    )


# ---------------------------------------------------------------------------
# Async Job Models (M6 - async command execution and workers, #312)
# ---------------------------------------------------------------------------


class AsyncJobModel(Base):  # type: ignore[misc]
    """Database model for async jobs.

    Stores background job metadata for asynchronous plan phase execution.
    Jobs follow a strict state machine:
    ``queued -> running -> succeeded / failed / cancelled``

    Table: ``async_jobs``
    """

    __allow_unmapped__ = True
    __tablename__ = "async_jobs"

    # PK: ULID (26-char string)
    job_id = Column(String(26), primary_key=True)

    # Logical reference to v3_plans (no FK constraint — jobs may outlive plans)
    plan_id = Column(String(26), nullable=False)

    # Plan phase this job executes
    phase = Column(String(20), nullable=False)

    # Job status
    status = Column(String(20), nullable=False, default="queued")

    # Serialized job payload (JSON)
    payload_json = Column(Text, nullable=False, default="{}")

    # Timestamps (ISO-8601 strings)
    created_at = Column(String(30), nullable=False)
    started_at = Column(String(30), nullable=True)
    finished_at = Column(String(30), nullable=True)

    # Worker assignment
    worker_id = Column(String(255), nullable=True)
    last_heartbeat = Column(String(30), nullable=True)

    # Payload schema version for forward compatibility
    schema_version = Column(Integer, nullable=False, default=1)

    # Error details for audit trail (NULL when not failed)
    error_message = Column(Text, nullable=True)

    __table_args__ = (
        CheckConstraint(
            "status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')",
            name="ck_async_jobs_status",
        ),
        CheckConstraint(
            "phase IN ('execute', 'apply')",
            name="ck_async_jobs_phase",
        ),
        Index("ix_async_jobs_plan_id", "plan_id"),
        Index("ix_async_jobs_status", "status"),
        Index("ix_async_jobs_worker_id", "worker_id"),
        Index("ix_async_jobs_created_at", "created_at"),
    )


# ---------------------------------------------------------------------------
# Repo Indexing models (#195)
# ---------------------------------------------------------------------------


class RepoIndexModel(Base):
    """Per-resource index metadata.

    Stores summary information about an indexed repository resource:
    file count, total token estimate, primary language, and status.
    One row per indexed resource.
    """

    __tablename__ = "repo_indexes"

    # PK: ULID (26-char string)
    index_id = Column(String(26), primary_key=True)

    # FK-like reference to resources (no hard FK — index may outlive resource)
    resource_id = Column(String(26), nullable=False, unique=True)

    # Index summary
    file_count = Column(Integer, nullable=False, default=0)
    token_estimate = Column(Integer, nullable=False, default=0)
    primary_language = Column(String(50), nullable=False, default="unknown")

    # Lifecycle status
    status = Column(String(20), nullable=False, default="pending")

    # Error details (NULL when not in error state)
    error_message = Column(Text, nullable=True)

    # Timestamps (ISO-8601 strings, UTC — 40 chars accommodates microseconds)
    indexed_at = Column(String(40), nullable=False)
    created_at = Column(String(40), nullable=False)

    __table_args__ = (
        CheckConstraint(
            "status IN ('pending', 'indexing', 'ready', 'stale', 'error')",
            name="ck_repo_indexes_status",
        ),
        # N10: resource_id already has unique=True which creates an implicit
        # unique index — no need for a redundant explicit index.
        Index("ix_repo_indexes_status", "status"),
    )


class IndexedFileModel(Base):
    """Per-file index record within a repository resource.

    Stores content hash for incremental refresh: on re-index, only files
    whose hash differs from the stored value are re-processed.
    """

    __tablename__ = "indexed_files"

    # Composite PK: (index_id, path)
    index_id = Column(
        String(26),
        ForeignKey("repo_indexes.index_id", ondelete="CASCADE"),
        primary_key=True,
    )
    path = Column(String(1024), primary_key=True)

    # Content fingerprint
    content_hash = Column(String(64), nullable=False)

    # Metrics
    token_count = Column(Integer, nullable=False, default=0)
    size_bytes = Column(Integer, nullable=False, default=0)

    # Language detection
    language = Column(String(50), nullable=False, default="unknown")

    # File modification timestamp (ISO-8601 string, UTC — 40 chars for µs)
    last_modified = Column(String(40), nullable=False)

    __table_args__ = (
        # index_id is the first column in the composite PK — SQLite uses
        # it for lookups already; no separate index needed.
        Index("ix_indexed_files_language", "language"),
    )
