"""Step definitions for changeset repository coverage feature tests."""

from __future__ import annotations

import json
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any

from behave import given, then, when  # type: ignore[import-untyped]
from behave.runner import Context  # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker

from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    ToolInvocation,
)
from cleveragents.infrastructure.database.changeset_repository import (
    ChangeSetEntryRepository,
    SqliteChangeSetStore,
    ToolInvocationRepository,
)
from cleveragents.infrastructure.database.models import (
    Base,
    ChangeSetEntryModel,
    ToolInvocationModel,
)

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


def _make_session_factory() -> tuple[Callable[[], Session], Session, Engine]:
    """Create an in-memory SQLite engine + shared session factory with schema.

    Uses a single shared session so that flush() data is visible across
    all repository calls within the same scenario.  Without this,
    short-lived sessions returned by sessionmaker() may be garbage-
    collected, causing the StaticPool's reset_on_return to discard
    uncommitted INSERTs.
    """
    engine = create_engine(
        "sqlite:///:memory:",
        echo=False,
        connect_args={"check_same_thread": False},
    )
    Base.metadata.create_all(engine)
    session: Session = sessionmaker(bind=engine)()
    return lambda: session, session, engine


def _make_change_entry(
    plan_id: str = "plan-cov-1",
    operation: ChangeOperation = ChangeOperation.CREATE,
    path: str = "src/cov.py",
) -> ChangeEntry:
    """Create a ChangeEntry for testing."""
    before_hash = "aaa111" if operation != ChangeOperation.CREATE else None
    after_hash = "bbb222" if operation != ChangeOperation.DELETE else None
    return ChangeEntry(
        plan_id=plan_id,
        resource_id="res-cov-001",
        tool_name="file-write",
        operation=operation,
        path=path,
        before_hash=before_hash,
        after_hash=after_hash,
    )


class _BrokenSession:
    """Stub session that raises OperationalError on mutating ops."""

    def add(self, _obj: Any) -> None:
        """Raise OperationalError."""
        raise OperationalError("simulated", {}, None)

    def flush(self) -> None:
        """Raise OperationalError."""
        raise OperationalError("simulated", {}, None)

    def rollback(self) -> None:
        """No-op rollback."""


class _BrokenQuerySession:
    """Stub session that raises OperationalError on query."""

    def query(self, _model: Any) -> _BrokenQuerySession:
        """Return self to chain."""
        raise OperationalError("simulated", {}, None)


class _BrokenDeleteSession:
    """Stub session that raises OperationalError on query-based delete."""

    class _FakeQuery:
        """Fake query that raises on delete."""

        def filter_by(self, **_kwargs: Any) -> _BrokenDeleteSession._FakeQuery:
            """Return self."""
            return self

        def delete(self) -> None:
            """Raise OperationalError."""
            raise OperationalError("simulated", {}, None)

    def query(self, _model: Any) -> _BrokenDeleteSession._FakeQuery:
        """Return a fake query."""
        return self._FakeQuery()

    def rollback(self) -> None:
        """No-op rollback."""


# ------ ChangeSetEntryRepository validation steps ------


@given("I have a coverage ChangeSetEntryRepository")
def step_create_coverage_entry_repo(context: Context) -> None:
    """Create a ChangeSetEntryRepository with real in-memory session."""
    factory, session, engine = _make_session_factory()
    context.cov_engine = engine
    context.cov_entry_repo = ChangeSetEntryRepository(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when("I try to coverage-save an entry with empty changeset_id")
def step_save_entry_empty_changeset_id(context: Context) -> None:
    """Attempt to save entry with empty changeset_id."""
    entry = _make_change_entry()
    try:
        context.cov_entry_repo.save_entry("", entry)
        context.cov_raised = None
    except (ValueError, TypeError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-save a non-ChangeEntry object")
def step_save_entry_non_change_entry(context: Context) -> None:
    """Attempt to save a non-ChangeEntry object."""
    try:
        context.cov_entry_repo.save_entry("cs-valid", "not-an-entry")  # type: ignore[arg-type]
        context.cov_raised = None
    except (ValueError, TypeError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-get entries for empty changeset_id")
def step_get_entries_empty_changeset_id(context: Context) -> None:
    """Attempt to get entries with empty changeset_id."""
    try:
        context.cov_entry_repo.get_entries_for_changeset("")
        context.cov_raised = None
    except (ValueError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-get entries for empty plan_id")
def step_get_entries_empty_plan_id(context: Context) -> None:
    """Attempt to get entries with empty plan_id."""
    try:
        context.cov_entry_repo.get_entries_for_plan("")
        context.cov_raised = None
    except (ValueError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-delete entries for empty changeset_id")
def step_delete_entries_empty_changeset_id(context: Context) -> None:
    """Attempt to delete entries with empty changeset_id."""
    try:
        context.cov_entry_repo.delete_for_changeset("")
        context.cov_raised = None
    except (ValueError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-delete entries for empty plan_id")
def step_delete_entries_empty_plan_id(context: Context) -> None:
    """Attempt to delete entries for empty plan_id."""
    try:
        context.cov_entry_repo.delete_for_plan("")
        context.cov_raised = None
    except (ValueError, DatabaseError) as exc:
        context.cov_raised = exc


# ------ ChangeSetEntryRepository database error steps ------


@given("I have a coverage ChangeSetEntryRepository with a broken session")
def step_create_broken_entry_repo(context: Context) -> None:
    """Create a ChangeSetEntryRepository with a broken session factory."""
    context.cov_entry_repo = ChangeSetEntryRepository(lambda: _BrokenSession())  # type: ignore[return-value]


@when("I try to coverage-save an entry that triggers an OperationalError")
def step_save_entry_operational_error(context: Context) -> None:
    """Attempt to save an entry that triggers OperationalError."""
    entry = _make_change_entry()
    try:
        context.cov_entry_repo.save_entry("cs-broken", entry)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


@given("I have a coverage ChangeSetEntryRepository with a broken query session")
def step_create_broken_query_entry_repo(context: Context) -> None:
    """Create a ChangeSetEntryRepository with broken query session."""
    context.cov_entry_repo = ChangeSetEntryRepository(lambda: _BrokenQuerySession())  # type: ignore[return-value]


@when('I try to coverage-get entries for changeset "{changeset_id}"')
def step_get_entries_broken_changeset(context: Context, changeset_id: str) -> None:
    """Attempt to get entries with broken query session."""
    try:
        context.cov_entry_repo.get_entries_for_changeset(changeset_id)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


@when('I try to coverage-get entries for plan "{plan_id}"')
def step_get_entries_broken_plan(context: Context, plan_id: str) -> None:
    """Attempt to get entries for plan with broken session."""
    try:
        context.cov_entry_repo.get_entries_for_plan(plan_id)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


@given("I have a coverage ChangeSetEntryRepository with a broken delete session")
def step_create_broken_delete_entry_repo(context: Context) -> None:
    """Create a ChangeSetEntryRepository with broken delete session."""
    context.cov_entry_repo = ChangeSetEntryRepository(lambda: _BrokenDeleteSession())  # type: ignore[return-value]


@when('I try to coverage-delete entries for changeset "{changeset_id}"')
def step_delete_entries_broken_changeset(context: Context, changeset_id: str) -> None:
    """Attempt to delete entries for changeset with broken session."""
    try:
        context.cov_entry_repo.delete_for_changeset(changeset_id)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


@when('I try to coverage-delete entries for plan "{plan_id}"')
def step_delete_entries_broken_plan(context: Context, plan_id: str) -> None:
    """Attempt to delete entries for plan with broken session."""
    try:
        context.cov_entry_repo.delete_for_plan(plan_id)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


# ------ ChangeSetEntryRepository _to_domain steps ------


@given("I have a coverage ChangeSetEntryModel row with null hashes and modes")
def step_create_row_null_hashes(context: Context) -> None:
    """Create a ChangeSetEntryModel with null hashes and modes."""
    context.cov_row = ChangeSetEntryModel(
        entry_id="01ENTRY0000000000000000001",
        changeset_id="01CSET00000000000000000001",
        plan_id="01PLAN00000000000000000001",
        resource_id="01RES000000000000000000001",
        tool_name="file-write",
        operation="modify",
        path="src/test.py",
        before_hash=None,
        after_hash=None,
        before_mode=None,
        after_mode=None,
        timestamp=datetime.now(UTC).isoformat(),
    )


@given("I have a coverage ChangeSetEntryModel row with hashes and modes")
def step_create_row_with_hashes(context: Context) -> None:
    """Create a ChangeSetEntryModel with populated hashes and modes."""
    context.cov_row = ChangeSetEntryModel(
        entry_id="01ENTRY0000000000000000002",
        changeset_id="01CSET00000000000000000002",
        plan_id="01PLAN00000000000000000002",
        resource_id="01RES000000000000000000002",
        tool_name="file-write",
        operation="modify",
        path="src/test.py",
        before_hash="abc123",
        after_hash="def456",
        before_mode=33188,
        after_mode=33261,
        timestamp=datetime.now(UTC).isoformat(),
    )


@given("I have a coverage ChangeSetEntryModel row with empty string hashes")
def step_create_row_empty_string_hashes(context: Context) -> None:
    """Create a ChangeSetEntryModel with empty string hashes."""
    context.cov_row = ChangeSetEntryModel(
        entry_id="01ENTRY0000000000000000003",
        changeset_id="01CSET00000000000000000003",
        plan_id="01PLAN00000000000000000003",
        resource_id="01RES000000000000000000003",
        tool_name="file-write",
        operation="modify",
        path="src/test.py",
        before_hash="",
        after_hash="",
        before_mode=None,
        after_mode=None,
        timestamp=datetime.now(UTC).isoformat(),
    )


@when("I convert the coverage row to a domain ChangeEntry")
def step_convert_row_to_domain_entry(context: Context) -> None:
    """Convert the model row to a domain ChangeEntry."""
    context.cov_domain_entry = ChangeSetEntryRepository._to_domain(context.cov_row)


@then("the domain entry before_hash should be None")
def step_assert_before_hash_none(context: Context) -> None:
    """Assert before_hash is None."""
    assert context.cov_domain_entry.before_hash is None, (
        f"Expected before_hash=None, got {context.cov_domain_entry.before_hash!r}"
    )


@then("the domain entry after_hash should be None")
def step_assert_after_hash_none(context: Context) -> None:
    """Assert after_hash is None."""
    assert context.cov_domain_entry.after_hash is None, (
        f"Expected after_hash=None, got {context.cov_domain_entry.after_hash!r}"
    )


@then("the domain entry before_mode should be None")
def step_assert_before_mode_none(context: Context) -> None:
    """Assert before_mode is None."""
    assert context.cov_domain_entry.before_mode is None, (
        f"Expected before_mode=None, got {context.cov_domain_entry.before_mode!r}"
    )


@then("the domain entry after_mode should be None")
def step_assert_after_mode_none(context: Context) -> None:
    """Assert after_mode is None."""
    assert context.cov_domain_entry.after_mode is None, (
        f"Expected after_mode=None, got {context.cov_domain_entry.after_mode!r}"
    )


@then('the domain entry before_hash should be "{expected}"')
def step_assert_before_hash_value(context: Context, expected: str) -> None:
    """Assert before_hash equals expected value."""
    assert context.cov_domain_entry.before_hash == expected, (
        f"Expected before_hash={expected!r}, got {context.cov_domain_entry.before_hash!r}"
    )


@then('the domain entry after_hash should be "{expected}"')
def step_assert_after_hash_value(context: Context, expected: str) -> None:
    """Assert after_hash equals expected value."""
    assert context.cov_domain_entry.after_hash == expected, (
        f"Expected after_hash={expected!r}, got {context.cov_domain_entry.after_hash!r}"
    )


@then("the domain entry before_mode should be {expected:d}")
def step_assert_before_mode_value(context: Context, expected: int) -> None:
    """Assert before_mode equals expected value."""
    assert context.cov_domain_entry.before_mode == expected, (
        f"Expected before_mode={expected}, got {context.cov_domain_entry.before_mode!r}"
    )


@then("the domain entry after_mode should be {expected:d}")
def step_assert_after_mode_value(context: Context, expected: int) -> None:
    """Assert after_mode equals expected value."""
    assert context.cov_domain_entry.after_mode == expected, (
        f"Expected after_mode={expected}, got {context.cov_domain_entry.after_mode!r}"
    )


# ------ ToolInvocationRepository validation steps ------


@given("I have a coverage ToolInvocationRepository")
def step_create_coverage_inv_repo(context: Context) -> None:
    """Create a ToolInvocationRepository with real in-memory session."""
    factory, session, engine = _make_session_factory()
    context.cov_engine = engine
    context.cov_inv_repo = ToolInvocationRepository(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when("I try to coverage-save a non-ToolInvocation object")
def step_save_non_tool_invocation(context: Context) -> None:
    """Attempt to save a non-ToolInvocation object."""
    try:
        context.cov_inv_repo.save_invocation("not-an-invocation")  # type: ignore[arg-type]
        context.cov_raised = None
    except (ValueError, TypeError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-get invocations for empty plan_id")
def step_get_invocations_empty_plan_id(context: Context) -> None:
    """Attempt to get invocations with empty plan_id."""
    try:
        context.cov_inv_repo.get_invocations_for_plan("")
        context.cov_raised = None
    except (ValueError, DatabaseError) as exc:
        context.cov_raised = exc


@when("I try to coverage-delete invocations for empty plan_id")
def step_delete_invocations_empty_plan_id(context: Context) -> None:
    """Attempt to delete invocations for empty plan_id."""
    try:
        context.cov_inv_repo.delete_for_plan("")
        context.cov_raised = None
    except (ValueError, DatabaseError) as exc:
        context.cov_raised = exc


# ------ ToolInvocationRepository database error steps ------


@given("I have a coverage ToolInvocationRepository with a broken session")
def step_create_broken_inv_repo(context: Context) -> None:
    """Create a ToolInvocationRepository with a broken session factory."""
    context.cov_inv_repo = ToolInvocationRepository(lambda: _BrokenSession())  # type: ignore[return-value]


@when("I try to coverage-save an invocation that triggers an OperationalError")
def step_save_invocation_operational_error(context: Context) -> None:
    """Attempt to save invocation that triggers OperationalError."""
    inv = ToolInvocation(
        plan_id="plan-broken",
        tool_name="file-write",
        arguments={"path": "test.py"},
        success=True,
    )
    try:
        context.cov_inv_repo.save_invocation(inv, changeset_id="cs-broken")
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


@given("I have a coverage ToolInvocationRepository with a broken query session")
def step_create_broken_query_inv_repo(context: Context) -> None:
    """Create a ToolInvocationRepository with broken query session."""
    context.cov_inv_repo = ToolInvocationRepository(lambda: _BrokenQuerySession())  # type: ignore[return-value]


@when('I try to coverage-get invocations for plan "{plan_id}"')
def step_get_invocations_broken_plan(context: Context, plan_id: str) -> None:
    """Attempt to get invocations with broken query session."""
    try:
        context.cov_inv_repo.get_invocations_for_plan(plan_id)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


@given("I have a coverage ToolInvocationRepository with a broken delete session")
def step_create_broken_delete_inv_repo(context: Context) -> None:
    """Create a ToolInvocationRepository with broken delete session."""
    context.cov_inv_repo = ToolInvocationRepository(lambda: _BrokenDeleteSession())  # type: ignore[return-value]


@when('I try to coverage-delete invocations for plan "{plan_id}"')
def step_delete_invocations_broken_plan(context: Context, plan_id: str) -> None:
    """Attempt to delete invocations with broken session."""
    try:
        context.cov_inv_repo.delete_for_plan(plan_id)
        context.cov_raised = None
    except (DatabaseError, OperationalError) as exc:
        context.cov_raised = exc


# ------ ToolInvocationRepository save with optional fields ------


@given("I have a coverage ToolInvocationRepository with real session")
def step_create_real_inv_repo(context: Context) -> None:
    """Create a ToolInvocationRepository with real in-memory session."""
    factory, session, engine = _make_session_factory()
    context.cov_engine = engine
    context.cov_session_factory = factory
    context.cov_inv_repo = ToolInvocationRepository(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when("I coverage-save an invocation with result, completed_at, and provider_metadata")
def step_save_invocation_all_optional(context: Context) -> None:
    """Save invocation with all optional fields populated."""
    context.cov_plan_id = "plan-all-opts"
    inv = ToolInvocation(
        plan_id=context.cov_plan_id,
        tool_name="file-write",
        skill_name="code-skill",
        arguments={"path": "test.py", "content": "hello"},
        result={"status": "ok", "bytes_written": 5},
        error=None,
        success=True,
        duration_ms=42.5,
        completed_at=datetime.now(UTC),
        change_ids=["chg-1", "chg-2"],
        sequence_number=3,
        sandbox_path="/tmp/sandbox",
        resource_refs=["res-1"],
        provider_metadata={"model": "gpt-4", "latency_ms": 100},
    )
    context.cov_inv_repo.save_invocation(inv, changeset_id="cs-all-opts")
    # Commit the session
    session = context.cov_session_factory()
    session.commit()


@then("the coverage-saved invocation should round-trip with all fields intact")
def step_assert_roundtrip_all_fields(context: Context) -> None:
    """Assert round-trip preserves all optional fields."""
    invocations = context.cov_inv_repo.get_invocations_for_plan(context.cov_plan_id)
    assert len(invocations) == 1, f"Expected 1 invocation, got {len(invocations)}"
    inv = invocations[0]
    assert inv.result is not None, "Expected result to not be None"
    assert inv.result["status"] == "ok", (
        f"Expected result status 'ok', got {inv.result}"
    )
    assert inv.completed_at is not None, "Expected completed_at to not be None"
    assert inv.provider_metadata is not None, (
        "Expected provider_metadata to not be None"
    )
    assert inv.provider_metadata["model"] == "gpt-4", (
        f"Expected provider_metadata model 'gpt-4', got {inv.provider_metadata}"
    )
    assert inv.skill_name == "code-skill", (
        f"Expected skill_name 'code-skill', got {inv.skill_name}"
    )
    assert inv.duration_ms == 42.5, f"Expected duration_ms 42.5, got {inv.duration_ms}"
    assert inv.sequence_number == 3, (
        f"Expected sequence_number 3, got {inv.sequence_number}"
    )
    assert inv.sandbox_path == "/tmp/sandbox", (
        f"Expected sandbox_path, got {inv.sandbox_path}"
    )
    assert inv.change_ids == ["chg-1", "chg-2"], (
        f"Expected change_ids, got {inv.change_ids}"
    )
    assert inv.resource_refs == ["res-1"], (
        f"Expected resource_refs, got {inv.resource_refs}"
    )


@when(
    "I coverage-save an invocation with no result, no completed_at, and no provider_metadata"
)
def step_save_invocation_none_optional(context: Context) -> None:
    """Save invocation with all optional fields as None."""
    context.cov_plan_id = "plan-no-opts"
    inv = ToolInvocation(
        plan_id=context.cov_plan_id,
        tool_name="file-read",
        arguments={},
        result=None,
        success=True,
        completed_at=None,
        provider_metadata=None,
    )
    context.cov_inv_repo.save_invocation(inv, changeset_id="cs-no-opts")
    session = context.cov_session_factory()
    session.commit()


@then("the coverage-saved invocation should round-trip with None optional fields")
def step_assert_roundtrip_none_fields(context: Context) -> None:
    """Assert round-trip preserves None optional fields."""
    invocations = context.cov_inv_repo.get_invocations_for_plan(context.cov_plan_id)
    assert len(invocations) == 1, f"Expected 1 invocation, got {len(invocations)}"
    inv = invocations[0]
    assert inv.result is None, f"Expected result=None, got {inv.result!r}"
    assert inv.completed_at is None, (
        f"Expected completed_at=None, got {inv.completed_at!r}"
    )
    assert inv.provider_metadata is None, (
        f"Expected provider_metadata=None, got {inv.provider_metadata!r}"
    )


@when("I coverage-save an invocation without changeset_id")
def step_save_invocation_no_changeset(context: Context) -> None:
    """Save invocation without changeset_id."""
    context.cov_plan_id = "plan-no-cs"
    inv = ToolInvocation(
        plan_id=context.cov_plan_id,
        tool_name="file-write",
        arguments={"path": "test.py"},
        success=True,
    )
    context.cov_inv_repo.save_invocation(inv, changeset_id=None)
    session = context.cov_session_factory()
    session.commit()


@then("the coverage-saved invocation for plan should be retrievable")
def step_assert_invocation_retrievable(context: Context) -> None:
    """Assert invocation is retrievable without changeset_id."""
    invocations = context.cov_inv_repo.get_invocations_for_plan(context.cov_plan_id)
    assert len(invocations) == 1, f"Expected 1 invocation, got {len(invocations)}"


# ------ ToolInvocationRepository _to_domain steps ------


@given("I have a coverage ToolInvocationModel row with null JSON fields")
def step_create_inv_row_null_json(context: Context) -> None:
    """Create a ToolInvocationModel with null JSON fields."""
    context.cov_inv_row = ToolInvocationModel(
        invocation_id="01INV000000000000000000001",
        changeset_id=None,
        plan_id="01PLAN00000000000000000001",
        tool_name="file-write",
        skill_name=None,
        arguments_json=None,
        result_json=None,
        error=None,
        success=True,
        duration_ms=10.0,
        started_at=datetime.now(UTC).isoformat(),
        completed_at=None,
        change_ids_json=None,
        sequence_number=1,
        sandbox_path=None,
        resource_refs_json=None,
        provider_metadata_json=None,
    )


@given("I have a coverage ToolInvocationModel row with populated JSON fields")
def step_create_inv_row_populated_json(context: Context) -> None:
    """Create a ToolInvocationModel with populated JSON fields."""
    context.cov_inv_row = ToolInvocationModel(
        invocation_id="01INV000000000000000000002",
        changeset_id="01CSET00000000000000000001",
        plan_id="01PLAN00000000000000000002",
        tool_name="file-write",
        skill_name="code-skill",
        arguments_json=json.dumps({"path": "test.py"}),
        result_json=json.dumps({"status": "ok"}),
        error=None,
        success=True,
        duration_ms=55.5,
        started_at=datetime.now(UTC).isoformat(),
        completed_at=datetime.now(UTC).isoformat(),
        change_ids_json=json.dumps(["chg-1", "chg-2"]),
        sequence_number=5,
        sandbox_path="/tmp/sandbox",
        resource_refs_json=json.dumps(["res-1"]),
        provider_metadata_json=json.dumps({"model": "gpt-4"}),
    )


@given("I have a coverage ToolInvocationModel row with null numeric fields")
def step_create_inv_row_null_numerics(context: Context) -> None:
    """Create a ToolInvocationModel with null numeric fields."""
    context.cov_inv_row = ToolInvocationModel(
        invocation_id="01INV000000000000000000003",
        changeset_id=None,
        plan_id="01PLAN00000000000000000003",
        tool_name="file-read",
        skill_name=None,
        arguments_json=json.dumps({}),
        result_json=None,
        error=None,
        success=True,
        duration_ms=None,
        started_at=datetime.now(UTC).isoformat(),
        completed_at=None,
        change_ids_json=json.dumps([]),
        sequence_number=None,
        sandbox_path=None,
        resource_refs_json=json.dumps([]),
        provider_metadata_json=None,
    )


@given("I have a coverage ToolInvocationModel row with null completed_at")
def step_create_inv_row_null_completed(context: Context) -> None:
    """Create a ToolInvocationModel with null completed_at."""
    context.cov_inv_row = ToolInvocationModel(
        invocation_id="01INV000000000000000000004",
        changeset_id=None,
        plan_id="01PLAN00000000000000000004",
        tool_name="file-write",
        skill_name=None,
        arguments_json=json.dumps({}),
        result_json=None,
        error=None,
        success=True,
        duration_ms=10.0,
        started_at=datetime.now(UTC).isoformat(),
        completed_at=None,
        change_ids_json=json.dumps([]),
        sequence_number=0,
        sandbox_path=None,
        resource_refs_json=json.dumps([]),
        provider_metadata_json=None,
    )


@given("I have a coverage ToolInvocationModel row with populated completed_at")
def step_create_inv_row_populated_completed(context: Context) -> None:
    """Create a ToolInvocationModel with populated completed_at."""
    context.cov_inv_row = ToolInvocationModel(
        invocation_id="01INV000000000000000000005",
        changeset_id=None,
        plan_id="01PLAN00000000000000000005",
        tool_name="file-write",
        skill_name=None,
        arguments_json=json.dumps({}),
        result_json=None,
        error=None,
        success=True,
        duration_ms=10.0,
        started_at=datetime.now(UTC).isoformat(),
        completed_at=datetime.now(UTC).isoformat(),
        change_ids_json=json.dumps([]),
        sequence_number=0,
        sandbox_path=None,
        resource_refs_json=json.dumps([]),
        provider_metadata_json=None,
    )


@when("I convert the coverage row to a domain ToolInvocation")
def step_convert_row_to_domain_invocation(context: Context) -> None:
    """Convert the model row to a domain ToolInvocation."""
    context.cov_domain_inv = ToolInvocationRepository._to_domain(context.cov_inv_row)


@then("the domain invocation arguments should be an empty dict")
def step_assert_inv_args_empty(context: Context) -> None:
    """Assert arguments is empty dict."""
    assert context.cov_domain_inv.arguments == {}, (
        f"Expected empty dict, got {context.cov_domain_inv.arguments!r}"
    )


@then("the domain invocation result should be None")
def step_assert_inv_result_none(context: Context) -> None:
    """Assert result is None."""
    assert context.cov_domain_inv.result is None, (
        f"Expected result=None, got {context.cov_domain_inv.result!r}"
    )


@then("the domain invocation change_ids should be an empty list")
def step_assert_inv_change_ids_empty(context: Context) -> None:
    """Assert change_ids is empty list."""
    assert context.cov_domain_inv.change_ids == [], (
        f"Expected empty list, got {context.cov_domain_inv.change_ids!r}"
    )


@then("the domain invocation resource_refs should be an empty list")
def step_assert_inv_resource_refs_empty(context: Context) -> None:
    """Assert resource_refs is empty list."""
    assert context.cov_domain_inv.resource_refs == [], (
        f"Expected empty list, got {context.cov_domain_inv.resource_refs!r}"
    )


@then("the domain invocation provider_metadata should be None")
def step_assert_inv_provider_meta_none(context: Context) -> None:
    """Assert provider_metadata is None."""
    assert context.cov_domain_inv.provider_metadata is None, (
        f"Expected None, got {context.cov_domain_inv.provider_metadata!r}"
    )


@then('the domain invocation arguments should have key "{key}"')
def step_assert_inv_args_has_key(context: Context, key: str) -> None:
    """Assert arguments dict has given key."""
    assert key in context.cov_domain_inv.arguments, (
        f"Expected key '{key}' in arguments, got {context.cov_domain_inv.arguments!r}"
    )


@then('the domain invocation result should have key "{key}"')
def step_assert_inv_result_has_key(context: Context, key: str) -> None:
    """Assert result dict has given key."""
    assert context.cov_domain_inv.result is not None, "Expected result to not be None"
    assert key in context.cov_domain_inv.result, (
        f"Expected key '{key}' in result, got {context.cov_domain_inv.result!r}"
    )


@then("the domain invocation change_ids should have {count:d} entries")
def step_assert_inv_change_ids_count(context: Context, count: int) -> None:
    """Assert change_ids has expected number of entries."""
    assert len(context.cov_domain_inv.change_ids) == count, (
        f"Expected {count} change_ids, got {len(context.cov_domain_inv.change_ids)}"
    )


@then("the domain invocation resource_refs should have {count:d} entry")
def step_assert_inv_resource_refs_count(context: Context, count: int) -> None:
    """Assert resource_refs has expected count."""
    assert len(context.cov_domain_inv.resource_refs) == count, (
        f"Expected {count} resource_refs, got {len(context.cov_domain_inv.resource_refs)}"
    )


@then('the domain invocation provider_metadata should have key "{key}"')
def step_assert_inv_provider_meta_has_key(context: Context, key: str) -> None:
    """Assert provider_metadata dict has given key."""
    assert context.cov_domain_inv.provider_metadata is not None, (
        "Expected provider_metadata to not be None"
    )
    assert key in context.cov_domain_inv.provider_metadata, (
        f"Expected key '{key}' in provider_metadata, got {context.cov_domain_inv.provider_metadata!r}"
    )


@then("the domain invocation duration_ms should be 0.0")
def step_assert_inv_duration_zero(context: Context) -> None:
    """Assert duration_ms is 0.0."""
    assert context.cov_domain_inv.duration_ms == 0.0, (
        f"Expected duration_ms=0.0, got {context.cov_domain_inv.duration_ms}"
    )


@then("the domain invocation sequence_number should be 0")
def step_assert_inv_seq_zero(context: Context) -> None:
    """Assert sequence_number is 0."""
    assert context.cov_domain_inv.sequence_number == 0, (
        f"Expected sequence_number=0, got {context.cov_domain_inv.sequence_number}"
    )


@then("the domain invocation completed_at should be None")
def step_assert_inv_completed_none(context: Context) -> None:
    """Assert completed_at is None."""
    assert context.cov_domain_inv.completed_at is None, (
        f"Expected completed_at=None, got {context.cov_domain_inv.completed_at!r}"
    )


@then("the domain invocation completed_at should be a datetime")
def step_assert_inv_completed_datetime(context: Context) -> None:
    """Assert completed_at is a datetime."""
    assert isinstance(context.cov_domain_inv.completed_at, datetime), (
        f"Expected datetime, got {type(context.cov_domain_inv.completed_at)}"
    )


# ------ SqliteChangeSetStore steps ------


@given("I have a coverage sqlite changeset store")
def step_create_coverage_store(context: Context) -> None:
    """Create a SqliteChangeSetStore backed by in-memory SQLite."""
    factory, session, engine = _make_session_factory()
    context.cov_engine = engine
    context.cov_session_factory = factory
    context.cov_store = SqliteChangeSetStore(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when("I coverage-get a changeset with empty string")
def step_get_changeset_empty(context: Context) -> None:
    """Get a changeset with empty string ID."""
    context.cov_get_result = context.cov_store.get("")


@when('I coverage-get a changeset with id "{changeset_id}"')
def step_get_changeset_by_id(context: Context, changeset_id: str) -> None:
    """Get a changeset by specific ID."""
    context.cov_get_result = context.cov_store.get(changeset_id)


@when('I coverage-start a changeset for plan "{plan_id}"')
def step_start_changeset_coverage(context: Context, plan_id: str) -> None:
    """Start a changeset for a plan."""
    context.cov_changeset_id = context.cov_store.start(plan_id)
    context.cov_plan_id = plan_id


@when("I coverage-get the started changeset")
def step_get_started_changeset(context: Context) -> None:
    """Get the changeset that was just started."""
    context.cov_get_result = context.cov_store.get(context.cov_changeset_id)


@when("I coverage-record a create entry in the started changeset")
def step_record_entry_coverage(context: Context) -> None:
    """Record a create entry in the started changeset."""
    entry = _make_change_entry(
        plan_id=context.cov_plan_id,
        operation=ChangeOperation.CREATE,
        path="src/new_cov.py",
    )
    context.cov_store.record(context.cov_changeset_id, entry)
    # Commit the session so the entry is visible
    session = context.cov_session_factory()
    session.commit()


@when("I try to coverage-record an entry with empty changeset_id")
def step_record_empty_changeset_id(context: Context) -> None:
    """Attempt to record entry with empty changeset_id."""
    entry = _make_change_entry()
    try:
        context.cov_store.record("", entry)
        context.cov_raised = None
    except ValueError as exc:
        context.cov_raised = exc


@when("I coverage-get_for_plan with empty plan_id")
def step_get_for_plan_empty(context: Context) -> None:
    """Get changesets for empty plan_id."""
    context.cov_for_plan_result = context.cov_store.get_for_plan("")


@when('I coverage-get_for_plan with plan_id "{plan_id}"')
def step_get_for_plan_by_id(context: Context, plan_id: str) -> None:
    """Get changesets for specific plan_id."""
    context.cov_for_plan_result = context.cov_store.get_for_plan(plan_id)


@when("I coverage-summarize changeset with empty string")
def step_summarize_empty(context: Context) -> None:
    """Summarize changeset with empty string ID."""
    context.cov_summarize_result = context.cov_store.summarize("")


@when("I coverage-summarize the started changeset")
def step_summarize_started(context: Context) -> None:
    """Summarize the changeset that was started."""
    context.cov_summarize_result = context.cov_store.summarize(context.cov_changeset_id)


@when("I try to coverage-delete_for_plan with empty plan_id")
def step_delete_for_plan_empty(context: Context) -> None:
    """Attempt to delete for empty plan_id."""
    try:
        context.cov_store.delete_for_plan("")
        context.cov_raised = None
    except ValueError as exc:
        context.cov_raised = exc


@when('I coverage-delete_for_plan "{plan_id}"')
def step_delete_for_plan_coverage(context: Context, plan_id: str) -> None:
    """Delete all entries for a plan."""
    context.cov_delete_count = context.cov_store.delete_for_plan(plan_id)


@then("the coverage-get result should be None")
def step_assert_get_result_none(context: Context) -> None:
    """Assert get result is None."""
    assert context.cov_get_result is None, (
        f"Expected None, got {context.cov_get_result!r}"
    )


@then("the coverage-get result should be a SpecChangeSet with {count:d} entries")
def step_assert_get_result_spec_changeset(context: Context, count: int) -> None:
    """Assert get result is a SpecChangeSet with expected entries."""
    from cleveragents.domain.models.core.change import SpecChangeSet

    assert context.cov_get_result is not None, "Expected SpecChangeSet, got None"
    assert isinstance(context.cov_get_result, SpecChangeSet), (
        f"Expected SpecChangeSet, got {type(context.cov_get_result)}"
    )
    assert len(context.cov_get_result.entries) == count, (
        f"Expected {count} entries, got {len(context.cov_get_result.entries)}"
    )


@then('the coverage-get result plan_id should be "{expected}"')
def step_assert_get_result_plan_id(context: Context, expected: str) -> None:
    """Assert get result plan_id matches expected."""
    assert context.cov_get_result.plan_id == expected, (
        f"Expected plan_id={expected!r}, got {context.cov_get_result.plan_id!r}"
    )


@then("the coverage-get_for_plan result should be an empty list")
def step_assert_for_plan_empty(context: Context) -> None:
    """Assert get_for_plan result is empty list."""
    assert context.cov_for_plan_result == [], (
        f"Expected empty list, got {context.cov_for_plan_result!r}"
    )


@then("the coverage-summarize result should be an empty dict")
def step_assert_summarize_empty(context: Context) -> None:
    """Assert summarize result is empty dict."""
    assert context.cov_summarize_result == {}, (
        f"Expected empty dict, got {context.cov_summarize_result!r}"
    )


@then("the coverage-summarize result total should be {count:d}")
def step_assert_summarize_total(context: Context, count: int) -> None:
    """Assert summarize result total matches expected."""
    assert context.cov_summarize_result.get("total") == count, (
        f"Expected total={count}, got {context.cov_summarize_result!r}"
    )


@then("the coverage-delete count should be {count:d}")
def step_assert_delete_count(context: Context, count: int) -> None:
    """Assert delete count matches expected."""
    assert context.cov_delete_count == count, (
        f"Expected delete count={count}, got {context.cov_delete_count}"
    )


# ------ Shared assertion steps ------


@then('a coverage ValueError should be raised with message "{message}"')
def step_assert_coverage_value_error(context: Context, message: str) -> None:
    """Assert a ValueError was raised with expected message."""
    assert isinstance(context.cov_raised, ValueError), (
        f"Expected ValueError, got {type(context.cov_raised).__name__}: {context.cov_raised!r}"
    )
    assert message in str(context.cov_raised), (
        f"Expected message containing '{message}', got '{context.cov_raised}'"
    )


@then('a coverage TypeError should be raised with message "{message}"')
def step_assert_coverage_type_error(context: Context, message: str) -> None:
    """Assert a TypeError was raised with expected message."""
    assert isinstance(context.cov_raised, TypeError), (
        f"Expected TypeError, got {type(context.cov_raised).__name__}: {context.cov_raised!r}"
    )
    assert message in str(context.cov_raised), (
        f"Expected message containing '{message}', got '{context.cov_raised}'"
    )


@then('a coverage DatabaseError should be raised with message "{message}"')
def step_assert_coverage_db_error(context: Context, message: str) -> None:
    """Assert a DatabaseError was raised with expected message."""
    assert isinstance(context.cov_raised, (DatabaseError, OperationalError)), (
        f"Expected DatabaseError or OperationalError, got {type(context.cov_raised).__name__}: {context.cov_raised!r}"
    )
    assert message in str(context.cov_raised), (
        f"Expected message containing '{message}', got '{context.cov_raised}'"
    )
