"""Step definitions for repositories_coverage_r2.feature.

Targets uncovered lines in repositories.py for:
  - SkillRepository flattened-tool cache methods (lines 4656-4821)
  - DecisionRepository full CRUD (lines 4841-5255)
  - CheckpointRepository full CRUD (lines 5279-5453)
  - ToolRepository legacy wrapper error paths (lines 3568-3595)
"""

from __future__ import annotations

import hashlib
import json
from datetime import UTC, datetime
from unittest.mock import MagicMock

from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, scoped_session, sessionmaker

from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.checkpoint import Checkpoint, CheckpointMetadata
from cleveragents.domain.models.core.decision import (
    ContextSnapshot,
    Decision,
    DecisionType,
)
from cleveragents.infrastructure.database.models import (
    Base,
    DecisionModel,
    LifecycleActionModel,
    LifecyclePlanModel,
    SkillModel,
)
from cleveragents.infrastructure.database.repositories import (
    CheckpointNotFoundError,
    CheckpointRepository,
    DecisionNotFoundError,
    DecisionRepository,
    DuplicateDecisionError,
    LifecyclePlanRepository,
    SkillNotFoundError,
    SkillRepository,
    ToolNotFoundError,
    ToolRepository,
)

# -- Helpers ----------------------------------------------------------------

_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_COUNTER = 0


def _next_ulid() -> str:
    """Return a unique, valid ULID string for each call."""
    global _COUNTER
    _COUNTER += 1
    n = _COUNTER
    suffix = ""
    for _ in range(8):
        suffix = _CB32[n % 32] + suffix
        n //= 32
    return f"01HGZ6FE0AQDYTR4BX{suffix}"


def _make_decision(
    plan_id: str,
    decision_id: str | None = None,
    parent_decision_id: str | None = None,
    sequence_number: int = 0,
    decision_type: DecisionType = DecisionType.STRATEGY_CHOICE,
    superseded_by: str | None = None,
) -> Decision:
    """Create a minimal valid Decision domain object."""
    return Decision(
        decision_id=decision_id or _next_ulid(),
        plan_id=plan_id,
        parent_decision_id=parent_decision_id,
        sequence_number=sequence_number,
        decision_type=decision_type,
        question="What approach?",
        chosen_option="Option A",
        context_snapshot=ContextSnapshot(),
        superseded_by=superseded_by,
    )


def _insert_decision_model(
    session: Session,
    plan_id: str,
    decision_id: str,
    sequence_number: int = 0,
    decision_type: str = "strategy_choice",
    parent_decision_id: str | None = None,
    superseded_by: str | None = None,
) -> None:
    """Insert a decision directly via the ORM model (bypasses retry decorator)."""
    now_iso = datetime.now(UTC).isoformat()
    session.add(
        DecisionModel(
            decision_id=decision_id,
            plan_id=plan_id,
            parent_decision_id=parent_decision_id,
            sequence_number=sequence_number,
            decision_type=decision_type,
            question="What approach?",
            chosen_option="Option A",
            context_snapshot_json=json.dumps(
                {
                    "hot_context_hash": "",
                    "hot_context_ref": "",
                    "relevant_resources": [],
                    "actor_state_ref": "",
                }
            ),
            rationale="",
            created_at=now_iso,
            is_correction=False,
            superseded_by=superseded_by,
        )
    )
    session.flush()


def _make_checkpoint(
    plan_id: str,
    checkpoint_id: str | None = None,
    created_at: datetime | None = None,
) -> Checkpoint:
    """Create a minimal valid Checkpoint domain object."""
    return Checkpoint(
        checkpoint_id=checkpoint_id or _next_ulid(),
        plan_id=plan_id,
        sandbox_ref="abc123commit",
        checkpoint_type="manual",
        created_at=created_at or datetime.now(UTC),
        metadata=CheckpointMetadata(reason="test"),
    )


_ACTION_INSERTED = False


def _ensure_action(session: Session) -> None:
    """Insert the shared test action if it doesn't exist yet."""
    global _ACTION_INSERTED
    if _ACTION_INSERTED:
        return
    existing = (
        session.query(LifecycleActionModel)
        .filter_by(namespaced_name="local/test-action")
        .first()
    )
    if existing is not None:
        _ACTION_INSERTED = True
        return
    now_iso = datetime.now(UTC).isoformat()
    action_row = LifecycleActionModel(
        namespaced_name="local/test-action",
        namespace="local",
        name="test-action",
        description="Test action for r2cov",
        definition_of_done="All tests pass",
        strategy_actor="default",
        execution_actor="default",
        state="available",
        reusable=True,
        read_only=False,
        created_by="test",
        tags_json="[]",
        created_at=now_iso,
        updated_at=now_iso,
    )
    session.add(action_row)
    session.flush()
    _ACTION_INSERTED = True


def _insert_v3_plan(session: Session, plan_id: str) -> None:
    """Insert a minimal v3_plans row so FKs are satisfied."""
    _ensure_action(session)
    now_iso = datetime.now(UTC).isoformat()
    plan_row = LifecyclePlanModel(
        plan_id=plan_id,
        root_plan_id=plan_id,
        namespaced_name=f"local/test-plan-{plan_id[:8]}",
        namespace="local",
        action_name="local/test-action",
        description="Test plan",
        definition_of_done="Done",
        phase="action",
        processing_state="queued",
        attempt=1,
        reusable=False,
        read_only=False,
        created_by="test",
        tags_json="[]",
        effective_profile_snapshot="{}",
        created_at=now_iso,
        updated_at=now_iso,
    )
    session.add(plan_row)
    session.flush()


def _insert_skill_row(session: Session, name: str) -> None:
    """Insert a minimal skill row."""
    now_iso = datetime.now(UTC).isoformat()
    parts = name.split("/", 1)
    namespace = parts[0]
    short_name = parts[1] if len(parts) > 1 else parts[0]
    row = SkillModel(
        name=name,
        namespace=namespace,
        short_name=short_name,
        description="Test skill",
        created_at=now_iso,
        updated_at=now_iso,
    )
    session.add(row)
    session.flush()


# ===========================================================================
# Background
# ===========================================================================


@given("r2cov a fresh in-memory database with all tables")
def step_fresh_db(context: Context) -> None:
    global _ACTION_INSERTED
    _ACTION_INSERTED = False

    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)
    context.r2_engine = engine
    # Use scoped_session so that every ``factory()`` call within the
    # same thread returns the *same* Session instance.  With plain
    # ``sessionmaker``, each ``factory()`` call creates a new Session.
    # SQLite in-memory uses ``SingletonThreadPool`` (one connection per
    # thread), so all sessions share the same connection.  When a
    # session created inside a repository method goes out of scope,
    # Python's garbage collector may close it, issuing an implicit
    # ROLLBACK on the shared connection — wiping flushed-but-uncommitted
    # rows written by *other* sessions.  Under high memory pressure
    # (e.g. 32 parallel worker processes) GC fires often enough to
    # cause intermittent data loss between ``flush()`` and ``commit()``.
    # ``scoped_session`` avoids the problem entirely: one Session lives
    # for the whole scenario, so there is no premature close/rollback.
    context.r2_session_factory = scoped_session(sessionmaker(bind=engine))

    # Pre-create repos used by multiple scenarios
    context.r2_skill_repo = SkillRepository(
        session_factory=context.r2_session_factory,
    )
    context.r2_decision_repo = DecisionRepository(
        session_factory=context.r2_session_factory,
    )
    context.r2_checkpoint_repo = CheckpointRepository(
        session_factory=context.r2_session_factory,
    )
    context.r2_plan_repo = LifecyclePlanRepository(
        session_factory=context.r2_session_factory,
    )
    context.r2_error = None
    context.r2_result = None


# ===========================================================================
# SkillRepository - setup steps
# ===========================================================================


@given('r2cov a skill "{name}" exists in the database')
def step_skill_exists(context: Context, name: str) -> None:
    session = context.r2_session_factory()
    _insert_skill_row(session, name)
    session.commit()


@given('r2cov the skill "{name}" has flattened tools cached')
def step_skill_has_cache(context: Context, name: str) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name=name).first()
    assert row is not None, f"Skill {name} not found"
    row.flattened_tools_json = json.dumps([{"tool": "echo"}])
    row.includes_json = json.dumps(["local/base"])
    row.capability_summary_json = json.dumps({"read": True})
    row.yaml_text = "name: cached"
    row.flattening_hash = hashlib.sha256(b"name: cached").hexdigest()
    session.commit()


@given('r2cov the skill "{name}" has flattening_hash "{hash_val}"')
def step_skill_has_hash(context: Context, name: str, hash_val: str) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name=name).first()
    assert row is not None, f"Skill {name} not found"
    row.flattening_hash = hash_val
    session.commit()


# ===========================================================================
# SkillRepository - update_flattened_tools
# ===========================================================================


@when('r2cov update_flattened_tools is called for "{name}" with valid cache data')
def step_update_flattened_tools(context: Context, name: str) -> None:
    context.r2_flat_hash = hashlib.sha256(b"yaml: test").hexdigest()
    try:
        context.r2_skill_repo.update_flattened_tools(
            name=name,
            flattened_tools_json=json.dumps([{"tool": "test"}]),
            includes_json=json.dumps(["local/inc"]),
            capability_summary_json=json.dumps({"write": True}),
            yaml_text="yaml: test",
            flattening_hash=context.r2_flat_hash,
        )
    except Exception as exc:
        context.r2_error = exc


@then("r2cov the skill row should have non-null flattened_tools_json")
def step_check_flat_tools_not_null(context: Context) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name="local/my-skill").first()
    assert row is not None
    assert row.flattened_tools_json is not None


@then("r2cov the skill row should have the expected flattening_hash")
def step_check_flat_hash(context: Context) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name="local/my-skill").first()
    assert row is not None
    assert row.flattening_hash == context.r2_flat_hash


@then("r2cov a SkillNotFoundError should be raised")
def step_skill_not_found_error(context: Context) -> None:
    assert context.r2_error is not None, "Expected SkillNotFoundError but no error"
    assert isinstance(context.r2_error, SkillNotFoundError), (
        f"Expected SkillNotFoundError, got {type(context.r2_error).__name__}: "
        f"{context.r2_error}"
    )


# ===========================================================================
# SkillRepository - get_flattened_tools
# ===========================================================================


@when('r2cov get_flattened_tools is called for "{name}"')
def step_get_flattened_tools(context: Context, name: str) -> None:
    try:
        context.r2_result = context.r2_skill_repo.get_flattened_tools(name)
    except Exception as exc:
        context.r2_error = exc


@then("r2cov the result should be a dict with flattened_tools_json key")
def step_result_is_dict_with_key(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert isinstance(context.r2_result, dict)
    assert "flattened_tools_json" in context.r2_result


@then("r2cov the result flattening_hash should not be None")
def step_result_hash_not_none(context: Context) -> None:
    assert context.r2_result["flattening_hash"] is not None


# ===========================================================================
# SkillRepository - needs_refresh
# ===========================================================================


@when('r2cov needs_refresh is called for "{name}" with hash "{hash_val}"')
def step_needs_refresh(context: Context, name: str, hash_val: str) -> None:
    try:
        context.r2_result = context.r2_skill_repo.needs_refresh(name, hash_val)
    except Exception as exc:
        context.r2_error = exc


@then("r2cov the result should be True")
def step_result_true(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is True, f"Expected True, got {context.r2_result}"


@then("r2cov the result should be False")
def step_result_false(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is False, f"Expected False, got {context.r2_result}"


# ===========================================================================
# SkillRepository - recompute_flattening_hash
# ===========================================================================


@when('r2cov recompute_flattening_hash is called for "{name}" with yaml "{yaml_text}"')
def step_recompute_hash(context: Context, name: str, yaml_text: str) -> None:
    try:
        context.r2_result = context.r2_skill_repo.recompute_flattening_hash(
            name, yaml_text
        )
    except Exception as exc:
        context.r2_error = exc


@then('r2cov the returned hash should be the SHA-256 of "{yaml_text}"')
def step_check_sha256(context: Context, yaml_text: str) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    expected = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
    assert context.r2_result == expected, (
        f"Expected {expected}, got {context.r2_result}"
    )


@then('r2cov the skill row "{name}" should have the recomputed hash stored')
def step_check_stored_hash(context: Context, name: str) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name=name).first()
    assert row is not None
    assert row.flattening_hash == context.r2_result


# ===========================================================================
# SkillRepository - invalidate_cached_summaries
# ===========================================================================


@when('r2cov invalidate_cached_summaries is called for "{name}"')
def step_invalidate_cache(context: Context, name: str) -> None:
    try:
        context.r2_skill_repo.invalidate_cached_summaries(name)
    except Exception as exc:
        context.r2_error = exc


@then('r2cov the skill row "{name}" should have null flattened_tools_json')
def step_check_null_flat_tools(context: Context, name: str) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name=name).first()
    assert row is not None
    assert row.flattened_tools_json is None


@then('r2cov the skill row "{name}" should have null flattening_hash')
def step_check_null_hash(context: Context, name: str) -> None:
    session = context.r2_session_factory()
    row = session.query(SkillModel).filter_by(name=name).first()
    assert row is not None
    assert row.flattening_hash is None


# ===========================================================================
# DecisionRepository - setup steps
# ===========================================================================


@given("r2cov a lifecycle plan exists for decisions")
def step_plan_for_decisions(context: Context) -> None:
    context.r2_plan_id = _next_ulid()
    session = context.r2_session_factory()
    _insert_v3_plan(session, context.r2_plan_id)
    session.commit()


@given('r2cov a decision "{decision_id}" exists for that plan')
def step_decision_exists(context: Context, decision_id: str) -> None:
    """Insert a decision directly via the model so it's committed and visible
    to subsequent repo calls (avoids retry-decorator complications)."""
    now_iso = datetime.now(UTC).isoformat()
    session = context.r2_session_factory()
    session.add(
        DecisionModel(
            decision_id=decision_id,
            plan_id=context.r2_plan_id,
            sequence_number=0,
            decision_type="prompt_definition",
            question="What approach?",
            chosen_option="Option A",
            context_snapshot_json=json.dumps(
                {
                    "hot_context_hash": "",
                    "hot_context_ref": "",
                    "relevant_resources": [],
                    "actor_state_ref": "",
                }
            ),
            rationale="",
            created_at=now_iso,
            is_correction=False,
        )
    )
    session.commit()
    context.r2_decision_id = decision_id


@given("r2cov decisions with sequence 0, 1, 2 exist for that plan")
def step_three_decisions(context: Context) -> None:
    context.r2_decision_ids = []
    session = context.r2_session_factory()
    for seq in range(3):
        did = _next_ulid()
        dtype = "prompt_definition" if seq == 0 else "strategy_choice"
        parent = None if seq == 0 else context.r2_decision_ids[0]
        _insert_decision_model(
            session,
            plan_id=context.r2_plan_id,
            decision_id=did,
            sequence_number=seq,
            decision_type=dtype,
            parent_decision_id=parent,
        )
        context.r2_decision_ids.append(did)
    session.commit()


@given("r2cov a decision tree with root and two children exists")
def step_decision_tree(context: Context) -> None:
    root_id = _next_ulid()
    child1_id = _next_ulid()
    child2_id = _next_ulid()

    session = context.r2_session_factory()
    _insert_decision_model(session, context.r2_plan_id, root_id, 0, "prompt_definition")
    _insert_decision_model(
        session,
        context.r2_plan_id,
        child1_id,
        1,
        "strategy_choice",
        parent_decision_id=root_id,
    )
    _insert_decision_model(
        session,
        context.r2_plan_id,
        child2_id,
        2,
        "strategy_choice",
        parent_decision_id=root_id,
    )
    session.commit()

    context.r2_root_decision_id = root_id
    context.r2_tree_ids = [root_id, child1_id, child2_id]


@given("r2cov a decision chain root -> mid -> leaf exists")
def step_decision_chain(context: Context) -> None:
    root_id = _next_ulid()
    mid_id = _next_ulid()
    leaf_id = _next_ulid()

    session = context.r2_session_factory()
    _insert_decision_model(session, context.r2_plan_id, root_id, 0, "prompt_definition")
    _insert_decision_model(
        session,
        context.r2_plan_id,
        mid_id,
        1,
        "strategy_choice",
        parent_decision_id=root_id,
    )
    _insert_decision_model(
        session,
        context.r2_plan_id,
        leaf_id,
        2,
        "strategy_choice",
        parent_decision_id=mid_id,
    )
    session.commit()

    context.r2_chain_root_id = root_id
    context.r2_chain_leaf_id = leaf_id


@given("r2cov a decision that has been superseded exists")
def step_superseded_decision(context: Context) -> None:
    original_id = _next_ulid()
    replacement_id = _next_ulid()

    session = context.r2_session_factory()
    _insert_decision_model(
        session,
        context.r2_plan_id,
        original_id,
        0,
        "prompt_definition",
        superseded_by=replacement_id,
    )
    _insert_decision_model(
        session, context.r2_plan_id, replacement_id, 1, "strategy_choice"
    )
    session.commit()


@given("r2cov an original decision and a replacement decision exist")
def step_original_and_replacement(context: Context) -> None:
    context.r2_original_id = _next_ulid()
    context.r2_replacement_id = _next_ulid()

    session = context.r2_session_factory()
    _insert_decision_model(
        session, context.r2_plan_id, context.r2_original_id, 0, "prompt_definition"
    )
    _insert_decision_model(
        session, context.r2_plan_id, context.r2_replacement_id, 1, "strategy_choice"
    )
    session.commit()


@given('r2cov decisions of type "strategy_choice" and "implementation_choice" exist')
def step_decisions_two_types(context: Context) -> None:
    context.r2_strategy_id = _next_ulid()
    context.r2_impl_id = _next_ulid()
    root_id = _next_ulid()

    session = context.r2_session_factory()
    _insert_decision_model(session, context.r2_plan_id, root_id, 0, "prompt_definition")
    _insert_decision_model(
        session,
        context.r2_plan_id,
        context.r2_strategy_id,
        1,
        "strategy_choice",
        parent_decision_id=root_id,
    )
    _insert_decision_model(
        session,
        context.r2_plan_id,
        context.r2_impl_id,
        2,
        "implementation_choice",
        parent_decision_id=root_id,
    )
    session.commit()


@given("r2cov a single decision exists for deletion")
def step_decision_for_deletion(context: Context) -> None:
    did = _next_ulid()
    session = context.r2_session_factory()
    _insert_decision_model(session, context.r2_plan_id, did, 0, "prompt_definition")
    session.commit()
    context.r2_delete_decision_id = did


# ===========================================================================
# DecisionRepository - action steps
# ===========================================================================


@when("r2cov a decision is created for that plan")
def step_create_decision(context: Context) -> None:
    context.r2_created_decision_id = _next_ulid()
    decision = _make_decision(
        plan_id=context.r2_plan_id,
        decision_id=context.r2_created_decision_id,
        sequence_number=0,
        decision_type=DecisionType.PROMPT_DEFINITION,
    )
    try:
        context.r2_decision_repo.create(decision)
        session = context.r2_session_factory()
        session.commit()
    except Exception as exc:
        context.r2_error = exc


@when("r2cov creating a decision with the same ID is attempted")
def step_create_duplicate_decision(context: Context) -> None:
    """Attempt to create a decision with an ID that already exists.
    Uses PROMPT_DEFINITION (root) type to avoid parent_decision_id constraints."""
    decision = _make_decision(
        plan_id=context.r2_plan_id,
        decision_id=context.r2_decision_id,
        sequence_number=99,
        decision_type=DecisionType.PROMPT_DEFINITION,
    )
    try:
        context.r2_decision_repo.create(decision)
    except Exception as exc:
        context.r2_error = exc


@when('r2cov getting decision "{decision_id}" is attempted')
def step_get_decision(context: Context, decision_id: str) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get(decision_id)
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_by_plan is called for that plan")
def step_get_by_plan(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_by_plan(context.r2_plan_id)
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_tree is called with the root decision ID")
def step_get_tree(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_tree(
            context.r2_root_decision_id
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_tree is called with a nonexistent root ID")
def step_get_tree_missing(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_tree(
            "01HGZ6FE0AQDYTR4BXNOSUCHID"
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_path_to_root is called with the leaf decision ID")
def step_get_path_to_root(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_path_to_root(
            context.r2_chain_leaf_id
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_path_to_root is called with a nonexistent ID")
def step_get_path_to_root_missing(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_path_to_root(
            "01HGZ6FE0AQDYTR4BXNOSUCHID"
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_superseded is called for that plan")
def step_get_superseded(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_superseded(context.r2_plan_id)
    except Exception as exc:
        context.r2_error = exc


@when("r2cov update_superseded_by is called on the original with the replacement ID")
def step_update_superseded(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.update_superseded_by(
            context.r2_original_id,
            context.r2_replacement_id,
        )
        session = context.r2_session_factory()
        session.commit()
    except Exception as exc:
        context.r2_error = exc


@when("r2cov update_superseded_by is called with a nonexistent decision ID")
def step_update_superseded_missing(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.update_superseded_by(
            "01HGZ6FE0AQDYTR4BXNOSUCHID",
            "01HGZ6FE0AQDYTR4BXNOSUCHTG",
        )
    except Exception as exc:
        context.r2_error = exc


@when('r2cov list_by_type is called with "strategy_choice"')
def step_list_by_type(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.list_by_type(
            context.r2_plan_id, "strategy_choice"
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov delete is called on the decision")
def step_delete_decision(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.delete(
            context.r2_delete_decision_id
        )
        session = context.r2_session_factory()
        session.commit()
    except Exception as exc:
        context.r2_error = exc


@when("r2cov delete is called with a nonexistent decision ID")
def step_delete_decision_missing(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.delete(
            "01HGZ6FE0AQDYTR4BXNOSUCHID"
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov get_max_sequence_number is called for that plan")
def step_get_max_seq(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.get_max_sequence_number(
            context.r2_plan_id
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov count is called for that plan")
def step_count_decisions(context: Context) -> None:
    try:
        context.r2_result = context.r2_decision_repo.count(context.r2_plan_id)
    except Exception as exc:
        context.r2_error = exc


# ===========================================================================
# DecisionRepository - assertion steps
# ===========================================================================


@then("r2cov the decision should be retrievable by ID")
def step_decision_retrievable(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    result = context.r2_decision_repo.get(context.r2_created_decision_id)
    assert result is not None, "Decision not found after create"
    assert result.decision_id == context.r2_created_decision_id


@then("r2cov a DuplicateDecisionError should be raised")
def step_duplicate_decision_error(context: Context) -> None:
    assert context.r2_error is not None, "Expected DuplicateDecisionError but no error"
    assert isinstance(context.r2_error, DuplicateDecisionError), (
        f"Expected DuplicateDecisionError, got {type(context.r2_error).__name__}"
    )


@then("r2cov the result should be None")
def step_result_none(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is None, f"Expected None, got {context.r2_result}"


@then("r2cov {count:d} decisions should be returned in sequence order")
def step_decisions_in_order(context: Context, count: int) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert len(context.r2_result) == count, (
        f"Expected {count} decisions, got {len(context.r2_result)}"
    )
    for i in range(len(context.r2_result) - 1):
        assert (
            context.r2_result[i].sequence_number
            <= context.r2_result[i + 1].sequence_number
        ), "Decisions not in sequence order"


@then("r2cov {count:d} decisions should be returned with root first")
def step_decisions_bfs(context: Context, count: int) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert len(context.r2_result) == count
    assert context.r2_result[0].decision_id == context.r2_root_decision_id


@then("r2cov a DecisionNotFoundError should be raised")
def step_decision_not_found_error(context: Context) -> None:
    assert context.r2_error is not None, "Expected DecisionNotFoundError but no error"
    assert isinstance(context.r2_error, DecisionNotFoundError), (
        f"Expected DecisionNotFoundError, got {type(context.r2_error).__name__}: "
        f"{context.r2_error}"
    )


@then("r2cov the path should contain 3 decisions starting with leaf ending with root")
def step_path_leaf_to_root(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    path = context.r2_result
    assert len(path) == 3, f"Expected 3 decisions in path, got {len(path)}"
    assert path[0].decision_id == context.r2_chain_leaf_id
    assert path[-1].decision_id == context.r2_chain_root_id


@then("r2cov exactly 1 superseded decision should be returned")
def step_one_superseded(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert len(context.r2_result) == 1, (
        f"Expected 1 superseded decision, got {len(context.r2_result)}"
    )
    assert context.r2_result[0].superseded_by is not None


@then("r2cov the original decision should have superseded_by set")
def step_superseded_by_set(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    result = context.r2_decision_repo.get(context.r2_original_id)
    assert result is not None
    assert result.superseded_by == context.r2_replacement_id


@then("r2cov only strategy_choice decisions should be returned")
def step_only_strategy(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert len(context.r2_result) >= 1
    for d in context.r2_result:
        assert (
            d.decision_type == DecisionType.STRATEGY_CHOICE
            or str(d.decision_type) == "strategy_choice"
        )


@then("r2cov delete should return True")
def step_delete_true(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is True


@then("r2cov the decision should no longer be retrievable")
def step_decision_gone(context: Context) -> None:
    result = context.r2_decision_repo.get(context.r2_delete_decision_id)
    assert result is None, "Decision still exists after delete"


@then("r2cov delete should return False")
def step_delete_false(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is False


@then("r2cov the result should be {expected:d}")
def step_result_int(context: Context, expected: int) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result == expected, (
        f"Expected {expected}, got {context.r2_result}"
    )


@then("r2cov the count result should be {expected:d}")
def step_count_result(context: Context, expected: int) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result == expected, (
        f"Expected {expected}, got {context.r2_result}"
    )


# ===========================================================================
# CheckpointRepository - setup steps
# ===========================================================================


@given("r2cov a lifecycle plan exists for checkpoints")
def step_plan_for_checkpoints(context: Context) -> None:
    context.r2_ckpt_plan_id = _next_ulid()
    session = context.r2_session_factory()
    _insert_v3_plan(session, context.r2_ckpt_plan_id)
    session.commit()


@given("r2cov {count:d} checkpoints exist for that plan")
def step_n_checkpoints(context: Context, count: int) -> None:
    context.r2_checkpoint_ids = []
    for i in range(count):
        cid = _next_ulid()
        # Stagger creation times slightly
        created = datetime(2024, 1, 1, 0, 0, i, tzinfo=UTC)
        checkpoint = _make_checkpoint(
            plan_id=context.r2_ckpt_plan_id,
            checkpoint_id=cid,
            created_at=created,
        )
        context.r2_checkpoint_repo.create(checkpoint)
        context.r2_checkpoint_ids.append(cid)
    session = context.r2_session_factory()
    session.commit()


@given("r2cov a single checkpoint exists for deletion")
def step_checkpoint_for_deletion(context: Context) -> None:
    cid = _next_ulid()
    checkpoint = _make_checkpoint(
        plan_id=context.r2_ckpt_plan_id,
        checkpoint_id=cid,
    )
    context.r2_checkpoint_repo.create(checkpoint)
    session = context.r2_session_factory()
    session.commit()
    context.r2_delete_checkpoint_id = cid


# ===========================================================================
# CheckpointRepository - action steps
# ===========================================================================


@when("r2cov a checkpoint is created for that plan")
def step_create_checkpoint(context: Context) -> None:
    context.r2_created_ckpt_id = _next_ulid()
    checkpoint = _make_checkpoint(
        plan_id=context.r2_ckpt_plan_id,
        checkpoint_id=context.r2_created_ckpt_id,
    )
    try:
        context.r2_result = context.r2_checkpoint_repo.create(checkpoint)
        session = context.r2_session_factory()
        session.commit()
    except Exception as exc:
        context.r2_error = exc


@when('r2cov getting checkpoint "{checkpoint_id}" is attempted')
def step_get_checkpoint(context: Context, checkpoint_id: str) -> None:
    try:
        context.r2_result = context.r2_checkpoint_repo.get_by_id(checkpoint_id)
    except Exception as exc:
        context.r2_error = exc


@when("r2cov list_by_plan is called for that plan")
def step_list_checkpoints(context: Context) -> None:
    try:
        context.r2_result = context.r2_checkpoint_repo.list_by_plan(
            context.r2_ckpt_plan_id
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov delete_checkpoint is called on the checkpoint")
def step_delete_checkpoint(context: Context) -> None:
    try:
        context.r2_result = context.r2_checkpoint_repo.delete(
            context.r2_delete_checkpoint_id
        )
        session = context.r2_session_factory()
        session.commit()
    except Exception as exc:
        context.r2_error = exc


@when("r2cov delete_checkpoint is called with a nonexistent ID")
def step_delete_checkpoint_missing(context: Context) -> None:
    try:
        context.r2_result = context.r2_checkpoint_repo.delete(
            "01HGZ6FE0AQDYTR4BXCHKNOSUC"
        )
    except Exception as exc:
        context.r2_error = exc


@when("r2cov prune is called with max_checkpoints {max_ckpt:d}")
def step_prune(context: Context, max_ckpt: int) -> None:
    try:
        context.r2_result = context.r2_checkpoint_repo.prune(
            context.r2_ckpt_plan_id, max_ckpt
        )
    except Exception as exc:
        context.r2_error = exc


# ===========================================================================
# CheckpointRepository - assertion steps
# ===========================================================================


@then("r2cov the checkpoint should be retrievable by ID")
def step_checkpoint_retrievable(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    result = context.r2_checkpoint_repo.get_by_id(context.r2_created_ckpt_id)
    assert result is not None, "Checkpoint not found after create"
    assert result.checkpoint_id == context.r2_created_ckpt_id


@then("r2cov a CheckpointNotFoundError should be raised")
def step_checkpoint_not_found_error(context: Context) -> None:
    assert context.r2_error is not None, "Expected CheckpointNotFoundError"
    assert isinstance(context.r2_error, CheckpointNotFoundError), (
        f"Expected CheckpointNotFoundError, got {type(context.r2_error).__name__}"
    )


@then("r2cov {count:d} checkpoints should be returned in creation order")
def step_checkpoints_ordered(context: Context, count: int) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert len(context.r2_result) == count
    for i in range(len(context.r2_result) - 1):
        assert context.r2_result[i].created_at <= context.r2_result[i + 1].created_at


@then("r2cov checkpoint delete should return True")
def step_ckpt_delete_true(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is True


@then("r2cov checkpoint delete should return False")
def step_ckpt_delete_false(context: Context) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert context.r2_result is False


@then("r2cov {count:d} checkpoint IDs should be returned as pruned")
def step_pruned_count(context: Context, count: int) -> None:
    assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
    assert len(context.r2_result) == count, (
        f"Expected {count} pruned IDs, got {len(context.r2_result)}"
    )


@then("r2cov only {count:d} checkpoints should remain for the plan")
def step_remaining_checkpoints(context: Context, count: int) -> None:
    remaining = context.r2_checkpoint_repo.list_by_plan(context.r2_ckpt_plan_id)
    assert len(remaining) == count, (
        f"Expected {count} remaining checkpoints, got {len(remaining)}"
    )


# ===========================================================================
# ToolRepository - error path steps
# ===========================================================================


@given("r2cov a tool repository with a broken session")
def step_tool_repo_broken(context: Context) -> None:
    """Create a ToolRepository whose session raises OperationalError on query."""

    def broken_factory() -> Session:
        mock_session = MagicMock(spec=Session)
        mock_query = MagicMock()
        mock_query.filter_by.return_value.first.side_effect = OperationalError(
            "connection lost", {}, None
        )
        mock_session.query.return_value = mock_query
        return mock_session

    context.r2_tool_repo = ToolRepository(session_factory=broken_factory)


@given("r2cov a tool repository backed by an empty database")
def step_tool_repo_empty(context: Context) -> None:
    context.r2_tool_repo = ToolRepository(
        session_factory=context.r2_session_factory,
    )


@when('r2cov ToolRepository.get_by_name is called with "{name}"')
def step_tool_get_by_name(context: Context, name: str) -> None:
    try:
        context.r2_result = context.r2_tool_repo.get_by_name(name)
    except Exception as exc:
        context.r2_error = exc


@when('r2cov ToolRepository.remove is called with "{name}"')
def step_tool_remove(context: Context, name: str) -> None:
    try:
        context.r2_result = context.r2_tool_repo.remove(name)
    except Exception as exc:
        context.r2_error = exc


@then('r2cov a DatabaseError should be raised containing "{fragment}"')
def step_database_error_fragment(context: Context, fragment: str) -> None:
    assert context.r2_error is not None, "Expected DatabaseError but no error raised"
    assert isinstance(context.r2_error, DatabaseError), (
        f"Expected DatabaseError, got {type(context.r2_error).__name__}: "
        f"{context.r2_error}"
    )
    assert fragment in str(context.r2_error), (
        f"Expected '{fragment}' in error message, got: {context.r2_error}"
    )


@then("r2cov a ToolNotFoundError should be raised")
def step_tool_not_found_error(context: Context) -> None:
    assert context.r2_error is not None, "Expected ToolNotFoundError but no error"
    assert isinstance(context.r2_error, ToolNotFoundError), (
        f"Expected ToolNotFoundError, got {type(context.r2_error).__name__}: "
        f"{context.r2_error}"
    )
