"""Step definitions for decision_recording.feature.

All ``Then`` step texts are prefixed with ``dsvc`` (decision-service) to
avoid collisions with the existing ``decision_model_steps.py`` which
defines similar assertion patterns for the domain model layer.
"""

from __future__ import annotations

import contextlib
import os
import re
import tempfile

from behave import given, then, when
from behave.runner import Context
from ulid import ULID

from cleveragents.application.services.decision_service import (
    DecisionNotFoundError,
    DecisionService,
    DuplicateDecisionError,
    SequenceConflictError,
    SnapshotStore,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.decision import (
    ArtifactRef,
    ContextSnapshot,
    Decision,
    DecisionType,
)


def _resolve_plan_id(context: Context, symbolic_id: str) -> str:
    """Map a human-readable plan name to a stable ULID.

    The Decision model requires plan_id to be a valid 26-char ULID.
    Feature files use friendly names like ``"P1"``; this helper
    lazily generates and caches a ULID for each symbolic name.
    """
    registry: dict[str, str] = getattr(context, "_plan_id_registry", {})
    if symbolic_id not in registry:
        registry[symbolic_id] = str(ULID())
    context._plan_id_registry = registry
    return registry[symbolic_id]


# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a decision service")
def step_decision_service(context: Context) -> None:
    context.decision_service = DecisionService()
    context.recorded_decisions = []
    context.decision_error = None
    context.decision_result = None
    context.decision_list = None
    context.tree_result = None
    context.path_result = None
    context.superseded_result = None
    context.snapshot_result = None
    context.snapshots_dict = None
    context.hash_query_result = None
    context.delete_result = None
    context.explicit_snapshot = None
    context._plan_id_registry = {}


# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------


@given('a context snapshot with hash "{ctx_hash}" and ref "{ctx_ref}"')
def step_explicit_snapshot(context: Context, ctx_hash: str, ctx_ref: str) -> None:
    context.explicit_snapshot = ContextSnapshot(
        hot_context_hash=ctx_hash,
        hot_context_ref=ctx_ref,
    )


@given('a DuplicateDecisionError for decision "{decision_id}"')
def step_duplicate_error(context: Context, decision_id: str) -> None:
    context.decision_error = DuplicateDecisionError(decision_id)


@given('a SequenceConflictError for plan "{plan_id}" sequence {seq:d}')
def step_sequence_error(context: Context, plan_id: str, seq: int) -> None:
    context.decision_error = SequenceConflictError(plan_id, seq)


@given("a standalone snapshot store")
def step_standalone_snapshot_store(context: Context) -> None:
    context.standalone_store = SnapshotStore()


# ---------------------------------------------------------------------------
# When — Recording
# ---------------------------------------------------------------------------


@when(
    'I record a prompt_definition decision for plan "{plan_id}" '
    'with question "{question}"'
)
def step_record_root(context: Context, plan_id: str, question: str) -> None:
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.PROMPT_DEFINITION,
        question=question,
        chosen_option=question,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record a strategy_choice decision for plan "{plan_id}" '
    'with question "{question}"'
)
def step_record_strategy(context: Context, plan_id: str, question: str) -> None:
    svc = context.decision_service
    parent_id = None
    if context.recorded_decisions:
        parent_id = context.recorded_decisions[0].decision_id
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question=question,
        chosen_option=f"Chosen: {question}",
        parent_decision_id=parent_id,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record an implementation_choice decision for plan "{plan_id}" '
    'with question "{question}"'
)
def step_record_impl(context: Context, plan_id: str, question: str) -> None:
    svc = context.decision_service
    parent_id = None
    if context.recorded_decisions:
        parent_id = context.recorded_decisions[0].decision_id
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.IMPLEMENTATION_CHOICE,
        question=question,
        chosen_option=f"Chosen: {question}",
        parent_decision_id=parent_id,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record a strategy_choice decision for plan "{plan_id}" '
    "with the explicit snapshot"
)
def step_record_with_snapshot(context: Context, plan_id: str) -> None:
    svc = context.decision_service
    parent_id = None
    if context.recorded_decisions:
        parent_id = context.recorded_decisions[0].decision_id
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Snapshot question",
        chosen_option="Snapshot choice",
        parent_decision_id=parent_id,
        context_snapshot=context.explicit_snapshot,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record a strategy_choice decision for plan "{plan_id}" '
    "with the same explicit snapshot"
)
def step_record_with_same_snapshot(context: Context, plan_id: str) -> None:
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Another snapshot question",
        chosen_option="Another choice",
        context_snapshot=context.explicit_snapshot,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record a strategy_choice decision for plan "{plan_id}" with confidence {score:g}'
)
def step_record_with_confidence(context: Context, plan_id: str, score: float) -> None:
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Confidence test",
        chosen_option="Chosen",
        confidence_score=score,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record a correction decision for plan "{plan_id}" correcting the first decision'
)
def step_record_correction(context: Context, plan_id: str) -> None:
    svc = context.decision_service
    first = context.recorded_decisions[0]
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Correction question",
        chosen_option="Corrected choice",
        parent_decision_id=first.parent_decision_id,
        is_correction=True,
        corrects_decision_id=first.decision_id,
        correction_reason="Fixed mistake",
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record a strategy_choice decision for plan "{plan_id}" '
    'with alternatives "{a1}" "{a2}" "{a3}"'
)
def step_record_with_alternatives(
    context: Context, plan_id: str, a1: str, a2: str, a3: str
) -> None:
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Alternatives test",
        chosen_option="Chosen",
        alternatives_considered=[a1, a2, a3],
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when('I record an implementation_choice decision for plan "{plan_id}" with artifacts')
def step_record_with_artifacts(context: Context, plan_id: str) -> None:
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.IMPLEMENTATION_CHOICE,
        question="Artifact test",
        chosen_option="Chosen",
        artifacts_produced=[
            ArtifactRef(artifact_path="src/main.py", artifact_type="file"),
            ArtifactRef(artifact_path="tests/test_main.py", artifact_type="file"),
        ],
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when('I record a decision for plan "{plan_id}" with string type "{dtype}"')
def step_record_string_type(context: Context, plan_id: str, dtype: str) -> None:
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=dtype,
        question="String type test",
        chosen_option="Chosen",
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


# --- Recording errors ---


@when("I try to record a decision with empty plan_id")
def step_try_empty_plan_id(context: Context) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="Q",
            chosen_option="A",
        )
        context.decision_error = None
    except ValidationError as exc:
        context.decision_error = exc


@when("I try to record a decision with empty question")
def step_try_empty_question(context: Context) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="",
            chosen_option="A",
        )
        context.decision_error = None
    except ValidationError as exc:
        context.decision_error = exc


@when("I try to record a decision with empty chosen_option")
def step_try_empty_chosen(context: Context) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="Q",
            chosen_option="",
        )
        context.decision_error = None
    except ValidationError as exc:
        context.decision_error = exc


@when('I try to record a decision with invalid type "{dtype}"')
def step_try_invalid_type(context: Context, dtype: str) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            decision_type=dtype,
            question="Q",
            chosen_option="A",
        )
        context.decision_error = None
    except (ValueError, ValidationError) as exc:
        context.decision_error = exc


@when("I try to record a decision with confidence {score:g}")
def step_try_invalid_confidence(context: Context, score: float) -> None:
    import pydantic

    try:
        context.decision_service.record_decision(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="Q",
            chosen_option="A",
            confidence_score=score,
        )
        context.decision_error = None
    except (ValidationError, pydantic.ValidationError) as exc:
        context.decision_error = exc


# ---------------------------------------------------------------------------
# When — Retrieval
# ---------------------------------------------------------------------------


@when("I get the decision by its ID")
def step_get_by_id(context: Context) -> None:
    d = context.decision_result
    context.decision_result = context.decision_service.get_decision(d.decision_id)


@when('I try to get decision "{decision_id}"')
def step_try_get(context: Context, decision_id: str) -> None:
    try:
        context.decision_service.get_decision(decision_id)
        context.decision_error = None
    except DecisionNotFoundError as exc:
        context.decision_error = exc


@when('I list decisions for plan "{plan_id}"')
def step_list_decisions(context: Context, plan_id: str) -> None:
    context.decision_list = context.decision_service.list_decisions(
        _resolve_plan_id(context, plan_id)
    )


@when('I filter decisions for plan "{plan_id}" by type "{dtype}"')
def step_list_by_type(context: Context, plan_id: str, dtype: str) -> None:
    context.decision_list = context.decision_service.list_by_type(
        _resolve_plan_id(context, plan_id), dtype
    )


# ---------------------------------------------------------------------------
# When — Tree operations
# ---------------------------------------------------------------------------


@when(
    'I record a strategy_choice decision for plan "{plan_id}" '
    'with parent as child "{question}"'
)
def step_record_child(context: Context, plan_id: str, question: str) -> None:
    svc = context.decision_service
    parent = context.recorded_decisions[0]
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question=question,
        chosen_option=f"Chosen: {question}",
        parent_decision_id=parent.decision_id,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when(
    'I record an implementation_choice decision for plan "{plan_id}" '
    'with second parent as child "{question}"'
)
def step_record_grandchild(context: Context, plan_id: str, question: str) -> None:
    svc = context.decision_service
    # Use the second recorded decision (index 1) as parent
    parent = context.recorded_decisions[1]
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.IMPLEMENTATION_CHOICE,
        question=question,
        chosen_option=f"Chosen: {question}",
        parent_decision_id=parent.decision_id,
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when('I get the tree for plan "{plan_id}"')
def step_get_tree(context: Context, plan_id: str) -> None:
    context.tree_result = context.decision_service.get_tree(
        _resolve_plan_id(context, plan_id)
    )


@when("I get the path to root from the last decision")
def step_get_path(context: Context) -> None:
    last = context.recorded_decisions[-1]
    context.path_result = context.decision_service.get_path_to_root(last.decision_id)


@when('I try to get path to root for decision "{decision_id}"')
def step_try_path(context: Context, decision_id: str) -> None:
    try:
        context.decision_service.get_path_to_root(decision_id)
        context.decision_error = None
    except DecisionNotFoundError as exc:
        context.decision_error = exc


# ---------------------------------------------------------------------------
# When — Superseded
# ---------------------------------------------------------------------------


@when("I mark the first decision as superseded by the second")
def step_mark_superseded(context: Context) -> None:
    first = context.recorded_decisions[0]
    second = context.recorded_decisions[1]
    context.decision_result = context.decision_service.mark_superseded(
        first.decision_id, second.decision_id
    )
    # Update stored reference
    context.recorded_decisions[0] = context.decision_result


@when('I get superseded decisions for plan "{plan_id}"')
def step_get_superseded(context: Context, plan_id: str) -> None:
    context.superseded_result = context.decision_service.get_superseded(
        _resolve_plan_id(context, plan_id)
    )


@when('I try to mark decision "{decision_id}" as superseded')
def step_try_supersede(context: Context, decision_id: str) -> None:
    try:
        context.decision_service.mark_superseded(
            decision_id, "01FAKE0000000000000000000"
        )
        context.decision_error = None
    except DecisionNotFoundError as exc:
        context.decision_error = exc


# ---------------------------------------------------------------------------
# When — Delete
# ---------------------------------------------------------------------------


@when("I delete the recorded decision")
def step_delete(context: Context) -> None:
    d = context.decision_result
    context.delete_result = context.decision_service.delete_decision(d.decision_id)
    context.deleted_decision_id = d.decision_id


@when('I try to delete decision "{decision_id}"')
def step_try_delete(context: Context, decision_id: str) -> None:
    try:
        context.decision_service.delete_decision(decision_id)
        context.decision_error = None
    except DecisionNotFoundError as exc:
        context.decision_error = exc


# ---------------------------------------------------------------------------
# When — Snapshots
# ---------------------------------------------------------------------------


@when("I get the snapshot for the recorded decision")
def step_get_snapshot(context: Context) -> None:
    d = context.decision_result
    context.snapshot_result = context.decision_service.get_snapshot(d.decision_id)


@when("I get the snapshot for the deleted decision")
def step_get_snapshot_deleted(context: Context) -> None:
    context.snapshot_result = context.decision_service.get_snapshot(
        context.deleted_decision_id
    )


@when('I get snapshots for plan "{plan_id}"')
def step_get_plan_snapshots(context: Context, plan_id: str) -> None:
    context.snapshots_dict = context.decision_service.get_snapshots_for_plan(
        _resolve_plan_id(context, plan_id)
    )


@when('I query the snapshot store by hash "{ctx_hash}"')
def step_query_hash(context: Context, ctx_hash: str) -> None:
    context.hash_query_result = context.decision_service.snapshots.get_by_hash(ctx_hash)


# --- Standalone snapshot store ---


@when('I remove snapshot for decision "{decision_id}"')
def step_standalone_remove(context: Context, decision_id: str) -> None:
    context.snapshot_remove_result = context.standalone_store.remove(decision_id)


@when('I get snapshot for decision "{decision_id}"')
def step_standalone_get(context: Context, decision_id: str) -> None:
    context.standalone_snapshot = context.standalone_store.get(decision_id)


@when('I query the standalone store by hash "{ctx_hash}"')
def step_standalone_hash(context: Context, ctx_hash: str) -> None:
    context.standalone_hash_result = context.standalone_store.get_by_hash(ctx_hash)


# ---------------------------------------------------------------------------
# Then — Recording assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("the dsvc decision should be recorded successfully")
def step_recorded(context: Context) -> None:
    assert context.decision_result is not None


@then("the dsvc decision sequence number should be {seq:d}")
def step_seq_number(context: Context, seq: int) -> None:
    assert context.decision_result.sequence_number == seq


@then('the dsvc decision type should be "{dtype}"')
def step_decision_type(context: Context, dtype: str) -> None:
    assert str(context.decision_result.decision_type) == dtype


@then("the dsvc decision should be a root decision")
def step_is_root(context: Context) -> None:
    assert context.decision_result.is_root


@then("the dsvc decision should have a valid ULID as decision_id")
def step_valid_ulid(context: Context) -> None:
    ulid_re = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
    assert ulid_re.match(context.decision_result.decision_id)


@then("the dsvc decision context snapshot hash should not be empty")
def step_snapshot_hash_not_empty(context: Context) -> None:
    assert context.decision_result.context_snapshot.hot_context_hash != ""


@then('the dsvc plan "{plan_id}" should have {count:d} decisions')
def step_plan_count(context: Context, plan_id: str, count: int) -> None:
    assert (
        context.decision_service.count_decisions(_resolve_plan_id(context, plan_id))
        == count
    )


@then("the dsvc decisions should have sequence numbers {nums}")
def step_seq_numbers(context: Context, nums: str) -> None:
    expected = [int(n) for n in nums.split()]
    actual = [d.sequence_number for d in context.recorded_decisions]
    assert actual == expected, f"Expected {expected}, got {actual}"


@then('the dsvc decision context snapshot hash should be "{expected}"')
def step_snapshot_hash_exact(context: Context, expected: str) -> None:
    assert context.decision_result.context_snapshot.hot_context_hash == expected


@then('the dsvc decision context snapshot ref should be "{expected}"')
def step_snapshot_ref_exact(context: Context, expected: str) -> None:
    assert context.decision_result.context_snapshot.hot_context_ref == expected


@then("the dsvc decision confidence score should be {score:g}")
def step_confidence(context: Context, score: float) -> None:
    assert context.decision_result.confidence_score == score


@then("the dsvc correction decision is_correction should be true")
def step_is_correction(context: Context) -> None:
    assert context.decision_result.is_correction


@then(
    "the dsvc correction decision corrects_decision_id should match the first decision"
)
def step_corrects_first(context: Context) -> None:
    first = context.recorded_decisions[0]
    assert context.decision_result.corrects_decision_id == first.decision_id


@then("the dsvc decision should have {count:d} alternatives considered")
def step_alternatives_count(context: Context, count: int) -> None:
    assert len(context.decision_result.alternatives_considered) == count


@then("the dsvc decision should have {count:d} artifacts produced")
def step_artifacts_count(context: Context, count: int) -> None:
    assert len(context.decision_result.artifacts_produced) == count


@then('the dsvc decision context snapshot hash should start with "{prefix}"')
def step_snapshot_hash_prefix(context: Context, prefix: str) -> None:
    assert context.decision_result.context_snapshot.hot_context_hash.startswith(prefix)


# ---------------------------------------------------------------------------
# Then — Validation errors (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("a dsvc validation error should be raised")
def step_validation_error(context: Context) -> None:
    import pydantic

    assert context.decision_error is not None
    assert isinstance(
        context.decision_error, (ValidationError, ValueError, pydantic.ValidationError)
    )


@then('the dsvc error should mention "{text}"')
def step_error_mention(context: Context, text: str) -> None:
    assert text in str(context.decision_error)


@then("a dsvc value error should be raised")
def step_value_error(context: Context) -> None:
    assert context.decision_error is not None
    assert isinstance(context.decision_error, ValueError)


@then("a dsvc decision not found error should be raised")
def step_not_found_error(context: Context) -> None:
    assert context.decision_error is not None
    assert isinstance(context.decision_error, DecisionNotFoundError)


# ---------------------------------------------------------------------------
# Then — Retrieval assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("the dsvc retrieved decision should match the recorded decision")
def step_match_recorded(context: Context) -> None:
    recorded = context.recorded_decisions[-1]
    assert context.decision_result.decision_id == recorded.decision_id


@then("the dsvc decision list should have {count:d} entries")
def step_list_count(context: Context, count: int) -> None:
    assert len(context.decision_list) == count


@then("the dsvc decision list should be ordered by sequence number")
def step_list_ordered(context: Context) -> None:
    seqs = [d.sequence_number for d in context.decision_list]
    assert seqs == sorted(seqs)


# ---------------------------------------------------------------------------
# Then — Tree assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("the dsvc tree should have {count:d} decisions")
def step_tree_count(context: Context, count: int) -> None:
    assert len(context.tree_result) == count


@then("the dsvc first tree decision should be a root decision")
def step_tree_root(context: Context) -> None:
    assert context.tree_result[0].is_root


@then("the dsvc tree should be in BFS level order")
def step_tree_bfs_order(context: Context) -> None:
    """Verify that all nodes at depth *d* appear before any node at depth *d+1*.

    We compute each node's depth by walking ``parent_decision_id`` links,
    then assert the depth sequence is non-decreasing.
    """
    tree: list[Decision] = context.tree_result
    if len(tree) <= 1:
        return  # trivially ordered

    # Build a lookup: decision_id -> Decision
    by_id = {d.decision_id: d for d in tree}

    def _depth(node: Decision) -> int:
        depth = 0
        current = node
        while current.parent_decision_id is not None:
            parent = by_id.get(current.parent_decision_id)
            if parent is None:
                break
            depth += 1
            current = parent
        return depth

    depths = [_depth(d) for d in tree]
    for i in range(1, len(depths)):
        assert depths[i] >= depths[i - 1], (
            f"BFS order violated at index {i}: "
            f"depth {depths[i]} follows depth {depths[i - 1]}. "
            f"Depths: {depths}"
        )


@then("the dsvc path should have {count:d} decisions")
def step_path_count(context: Context, count: int) -> None:
    assert len(context.path_result) == count


@then("the dsvc first path decision should be the last recorded decision")
def step_path_first(context: Context) -> None:
    last = context.recorded_decisions[-1]
    assert context.path_result[0].decision_id == last.decision_id


@then("the dsvc last path decision should be the root")
def step_path_last_root(context: Context) -> None:
    assert context.path_result[-1].is_root


# ---------------------------------------------------------------------------
# Then — Superseded assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("the dsvc first decision should be superseded")
def step_first_superseded(context: Context) -> None:
    assert context.recorded_decisions[0].is_superseded


@then("the dsvc first decision superseded_by should match the second decision")
def step_superseded_by(context: Context) -> None:
    first = context.recorded_decisions[0]
    second = context.recorded_decisions[1]
    assert first.superseded_by == second.decision_id


@then("the dsvc superseded list should have {count:d} entry")
def step_superseded_count(context: Context, count: int) -> None:
    assert len(context.superseded_result) == count


# ---------------------------------------------------------------------------
# Then — Delete assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("the dsvc decision should be deleted")
def step_deleted(context: Context) -> None:
    assert context.delete_result is True


# ---------------------------------------------------------------------------
# Then — Snapshot assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then("the dsvc snapshot should not be None")
def step_snapshot_not_none(context: Context) -> None:
    assert context.snapshot_result is not None


@then("the dsvc snapshot should be None")
def step_snapshot_none(context: Context) -> None:
    assert context.snapshot_result is None


@then("the dsvc snapshots dict should have {count:d} entries")
def step_snapshots_dict_count(context: Context, count: int) -> None:
    assert len(context.snapshots_dict) == count


@then("the dsvc hash query should return {count:d} decision IDs")
def step_hash_count(context: Context, count: int) -> None:
    assert len(context.hash_query_result) == count


# ---------------------------------------------------------------------------
# Then — Statistics assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then('the dsvc decision count for plan "{plan_id}" should be {count:d}')
def step_count(context: Context, plan_id: str, count: int) -> None:
    assert (
        context.decision_service.count_decisions(_resolve_plan_id(context, plan_id))
        == count
    )


@then('the dsvc next sequence for plan "{plan_id}" should be {seq:d}')
def step_next_seq(context: Context, plan_id: str, seq: int) -> None:
    assert (
        context.decision_service.get_next_sequence(_resolve_plan_id(context, plan_id))
        == seq
    )


# ---------------------------------------------------------------------------
# Then — Error object assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------


@then('the dsvc duplicate error decision_id should be "{decision_id}"')
def step_dup_id(context: Context, decision_id: str) -> None:
    assert context.decision_error.decision_id == decision_id


@then('the dsvc duplicate error message should contain "{text}"')
def step_dup_msg(context: Context, text: str) -> None:
    assert text in str(context.decision_error)


@then('the dsvc sequence error plan_id should be "{plan_id}"')
def step_seq_err_plan(context: Context, plan_id: str) -> None:
    assert context.decision_error.plan_id == plan_id


@then("the dsvc sequence error sequence_number should be {seq:d}")
def step_seq_err_num(context: Context, seq: int) -> None:
    assert context.decision_error.sequence_number == seq


@then('the dsvc sequence error message should contain "{text}"')
def step_seq_err_msg(context: Context, text: str) -> None:
    assert text in str(context.decision_error)


# --- Standalone snapshot store (dsvc-prefixed) ---


@then("the dsvc snapshot remove result should be False")
def step_standalone_remove_false(context: Context) -> None:
    assert context.snapshot_remove_result is False


@then("the dsvc standalone snapshot should be None")
def step_standalone_none(context: Context) -> None:
    assert context.standalone_snapshot is None


@then("the dsvc standalone hash query should return {count:d} decision IDs")
def step_standalone_hash_count(context: Context, count: int) -> None:
    assert len(context.standalone_hash_result) == count


# ---------------------------------------------------------------------------
# Edge-case coverage steps
# ---------------------------------------------------------------------------


@given("a context snapshot with no hash")
def step_snapshot_no_hash(context: Context) -> None:
    context.explicit_snapshot = ContextSnapshot(
        hot_context_hash="",
        hot_context_ref="",
    )


@when('I store the hashless snapshot for decision "{decision_id}"')
def step_store_hashless(context: Context, decision_id: str) -> None:
    context.standalone_store.store(decision_id, context.explicit_snapshot)


@when('I get standalone snapshot for decision "{decision_id}"')
def step_get_standalone_snapshot(context: Context, decision_id: str) -> None:
    context.standalone_retrieved = context.standalone_store.get(decision_id)


@when('I store the explicit snapshot for decision "{decision_id}"')
def step_store_explicit(context: Context, decision_id: str) -> None:
    context.standalone_store.store(decision_id, context.explicit_snapshot)


@then("the dsvc standalone retrieved snapshot should not be None")
def step_standalone_retrieved_not_none(context: Context) -> None:
    assert context.standalone_retrieved is not None


@then("the dsvc standalone retrieved snapshot hash should be empty")
def step_standalone_retrieved_hash_empty(context: Context) -> None:
    assert context.standalone_retrieved.hot_context_hash == ""


@then("the dsvc snapshot remove result should be True")
def step_standalone_remove_true(context: Context) -> None:
    assert context.snapshot_remove_result is True


@then(
    'the dsvc standalone hash query for "{ctx_hash}" '
    "should return {count:d} decision IDs"
)
def step_standalone_hash_query_count(
    context: Context, ctx_hash: str, count: int
) -> None:
    result = context.standalone_store.get_by_hash(ctx_hash)
    assert len(result) == count, f"Expected {count}, got {len(result)}"


@when('I record a decision for plan "{plan_id}" with a forced non-root parent')
def step_record_forced_non_root(context: Context, plan_id: str) -> None:
    """Record a decision with a fake parent_decision_id so it's not a root."""
    svc = context.decision_service
    d = svc.record_decision(
        plan_id=_resolve_plan_id(context, plan_id),
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Non-root decision",
        chosen_option="Chosen",
        parent_decision_id=str(ULID()),
    )
    context.decision_result = d
    context.recorded_decisions.append(d)


@when("I try to record a decision with whitespace plan_id")
def step_try_whitespace_plan_id(context: Context) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="   ",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="Q",
            chosen_option="A",
        )
        context.decision_error = None
    except ValidationError as exc:
        context.decision_error = exc


@when("I try to record a decision with whitespace question")
def step_try_whitespace_question(context: Context) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="   ",
            chosen_option="A",
        )
        context.decision_error = None
    except ValidationError as exc:
        context.decision_error = exc


@when("I try to record a decision with whitespace chosen_option")
def step_try_whitespace_chosen(context: Context) -> None:
    try:
        context.decision_service.record_decision(
            plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
            decision_type=DecisionType.STRATEGY_CHOICE,
            question="Q",
            chosen_option="   ",
        )
        context.decision_error = None
    except ValidationError as exc:
        context.decision_error = exc


@when("I list snapshots for decisions with missing entries")
def step_list_snapshots_missing(context: Context) -> None:
    """Call list_for_plan with a Decision whose snapshot is not in the store."""
    fake_decision = Decision(
        plan_id=str(ULID()),
        decision_type=DecisionType.STRATEGY_CHOICE,
        sequence_number=0,
        question="ghost",
        chosen_option="ghost",
    )
    context.standalone_plan_snapshots = context.standalone_store.list_for_plan(
        [fake_decision]
    )


@then("the dsvc standalone plan snapshots should have {count:d} entries")
def step_standalone_plan_snapshots_count(context: Context, count: int) -> None:
    assert len(context.standalone_plan_snapshots) == count


# ---------------------------------------------------------------------------
# Persisted-mode (database-backed) steps
# ---------------------------------------------------------------------------


@given("a persisted decision service")
def step_persisted_decision_service(context: Context) -> None:
    """Create a DecisionService backed by a real SQLite database via UnitOfWork."""
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork

    db_path = tempfile.mktemp(suffix=".db", prefix="dsvc_persisted_")
    context._dsvc_db_path = db_path
    uow = UnitOfWork(f"sqlite:///{db_path}")
    uow.init_database()

    context.decision_service = DecisionService(unit_of_work=uow)
    context.recorded_decisions = []
    context.decision_error = None
    context.decision_result = None
    context.decision_list = None
    context.tree_result = None
    context.path_result = None
    context.superseded_result = None
    context.snapshot_result = None
    context.snapshots_dict = None
    context.hash_query_result = None
    context.delete_result = None
    context.explicit_snapshot = None
    context._plan_id_registry = {}

    # Register cleanup to remove temp DB file
    def _cleanup_db() -> None:
        for suffix in ("", "-journal", "-wal", "-shm"):
            with contextlib.suppress(OSError):
                os.unlink(db_path + suffix)

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_cleanup_db)


@when("I recreate the decision service with the same database")
def step_recreate_service_same_db(context: Context) -> None:
    """Destroy the current DecisionService and create a fresh one against the same DB.

    This simulates a service restart: the in-memory sequence counters
    are lost and must be rehydrated from the database.
    """
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork

    db_path = context._dsvc_db_path
    uow = UnitOfWork(f"sqlite:///{db_path}")
    uow.init_database()

    context.decision_service = DecisionService(unit_of_work=uow)
    # Preserve _plan_id_registry so symbolic IDs ("P1") still resolve
    # to the same ULIDs used before the restart.
    context.recorded_decisions = []
    context.decision_error = None
    context.decision_result = None


@when("I try to store a duplicate of the recorded decision")
def step_try_store_duplicate(context: Context) -> None:
    """Attempt to store a decision with the same ID that already exists."""
    try:
        decision = context.decision_result
        context.decision_service._store_decision(decision)
        context.decision_error = None
    except DuplicateDecisionError as exc:
        context.decision_error = exc


@then("a dsvc duplicate decision error should be raised")
def step_duplicate_error_raised(context: Context) -> None:
    assert context.decision_error is not None
    assert isinstance(context.decision_error, DuplicateDecisionError)
