"""Step definitions for repositories_uncovered_lines.feature.

Targets the remaining ~84 uncovered lines in repositories.py.
"""

from __future__ import annotations

import json
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock

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

from cleveragents.infrastructure.database.models import (
    Base,
    ResourceLinkModel,
    ResourceModel,
    ResourceTypeModel,
    ToolBindingModel,
    ToolModel,
)
from cleveragents.infrastructure.database.repositories import (
    AutomationProfileNotFoundError,
    AutomationProfileRepository,
    AutomationProfileSchemaVersionError,
    DatabaseError,
    DuplicateAutomationProfileError,
    DuplicateResourceTypeError,
    DuplicateValidationAttachmentError,
    InvalidToolTypeError,
    LinkNotFoundError,
    ResourceNotFoundRepoError,
    ResourceRepository,
    ResourceTypeRepository,
    ToolRepository,
    ValidationAttachmentRepository,
)

# ── helpers ────────────────────────────────────────────────────────────────


def _make_engine_and_session():
    """Create an in-memory SQLite engine + session factory."""
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    factory = sessionmaker(bind=engine)
    return engine, factory


def _now_iso() -> str:
    return datetime.now(tz=UTC).isoformat()


def _make_rt_model_row(
    name: str,
    description: str = "test type",
    resource_kind: str = "physical",
    sandbox_strategy: str = "none",
    user_addable: bool = True,
    allowed_child_types_json: str = "[]",
    auto_discover_json: str | None = None,
    equivalence_json: str | None = None,
    capabilities_json: str = '{"read": true, "write": true, "sandbox": true, "checkpoint": false}',
) -> ResourceTypeModel:
    """Create a ResourceTypeModel with correct column names."""
    now = _now_iso()
    ns = name.split("/")[0] if "/" in name else "builtin"
    return ResourceTypeModel(
        name=name,
        namespace=ns,
        description=description,
        resource_kind=resource_kind,
        sandbox_strategy=sandbox_strategy,
        user_addable=user_addable,
        handler_ref=None,
        args_schema_json=None,
        allowed_parent_types_json=None,
        allowed_child_types_json=allowed_child_types_json,
        auto_discover_json=auto_discover_json,
        equivalence_json=equivalence_json,
        capabilities_json=capabilities_json,
        source=None,
        created_at=now,
        updated_at=now,
    )


def _make_automation_profile(
    name: str,
    description: str = "test",
    schema_version: str = "1.0",
    **overrides: Any,
) -> Any:
    """Create an AutomationProfile domain object."""
    from cleveragents.domain.models.core.automation_profile import AutomationProfile
    from cleveragents.domain.models.core.safety_profile import SafetyProfile

    # Extract safety-related overrides into a SafetyProfile.
    safety_keys = {
        "require_sandbox",
        "require_checkpoints",
        "allow_unsafe_tools",
    }
    safety_defaults: dict[str, Any] = {
        "require_sandbox": True,
        "require_checkpoints": True,
        "allow_unsafe_tools": False,
        "require_human_approval": False,
        "max_retries_per_step": 3,
    }
    for key in safety_keys:
        if key in overrides:
            safety_defaults[key] = overrides.pop(key)

    defaults = dict(
        name=name,
        description=description,
        schema_version=schema_version,
        decompose_task=0.0,
        create_tool=0.0,
        select_tool=0.0,
        edit_code=0.0,
        execute_command=0.0,
        create_file=0.0,
        delete_content=0.0,
        access_network=0.0,
        install_dependency=0.0,
        modify_config=0.0,
        approve_plan=0.0,
        safety=SafetyProfile(**safety_defaults),
    )
    defaults.update(overrides)
    return AutomationProfile(**defaults)


# ── LinkNotFoundError ──────────────────────────────────────────────────────


@given('a LinkNotFoundError for parent "{pid}" and child "{cid}"')
def step_given_link_not_found_error(context, pid, cid):
    context.link_error = LinkNotFoundError(pid, cid)


@then('the LinkNotFoundError message should contain "{pid}" and "{cid}"')
def step_then_link_error_message(context, pid, cid):
    msg = str(context.link_error)
    assert pid in msg, f"Expected '{pid}' in '{msg}'"
    assert cid in msg, f"Expected '{cid}' in '{msg}'"


@then('the LinkNotFoundError parent_id should be "{pid}"')
def step_then_link_error_parent(context, pid):
    assert context.link_error.parent_id == pid


@then('the LinkNotFoundError child_id should be "{cid}"')
def step_then_link_error_child(context, cid):
    assert context.link_error.child_id == cid


# ── ResourceTypeRepository helpers ─────────────────────────────────────────


@given("a resource type repository backed by an in-memory database for uncovered lines")
def step_given_rt_repo_inmem(context):
    engine, factory = _make_engine_and_session()
    context._ucl_engine = engine
    context._ucl_session_factory = factory
    context._ucl_rt_repo = ResourceTypeRepository(factory)
    context._ucl_res_repo = ResourceRepository(factory)


def _make_resource_type_spec(name: str, **kw: Any) -> Any:
    """Create a ResourceTypeSpec domain object."""
    from cleveragents.domain.models.core.resource_type import (
        ResourceKind,
        ResourceTypeSpec,
        SandboxStrategy,
    )

    defaults = dict(
        name=name,
        description="test type",
        resource_kind=ResourceKind.PHYSICAL,
        sandbox_strategy=SandboxStrategy.NONE,
        user_addable=True,
        cli_args=[],
        parent_types=[],
        child_types=[],
        auto_discovery=None,
        equivalence=None,
        handler=None,
        capabilities={
            "read": True,
            "write": True,
            "sandbox": True,
            "checkpoint": False,
        },
        built_in=False,
    )
    defaults.update(kw)
    return ResourceTypeSpec(**defaults)


@given('a resource type "{name}" exists in the database for uncovered lines')
def step_given_rt_exists(context, name):
    spec = _make_resource_type_spec(name)
    context._ucl_rt_repo.create(spec)


@when('the same resource type "{name}" is created again for uncovered lines')
def step_when_rt_dup_create(context, name):
    spec = _make_resource_type_spec(name)
    try:
        context._ucl_rt_repo.create(spec)
        context._ucl_error = None
    except DuplicateResourceTypeError as exc:
        context._ucl_error = exc
    except Exception as exc:
        context._ucl_error = exc


@then("a DuplicateResourceTypeError should be raised for uncovered lines")
def step_then_dup_rt_error(context):
    assert isinstance(context._ucl_error, DuplicateResourceTypeError), (
        f"Expected DuplicateResourceTypeError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── ResourceTypeRepository._to_domain equivalence_json ──────────────────────


@given("a resource type row with equivalence_json set to '{json_str}'")
def step_given_rt_with_equiv(context, json_str):
    # Insert a resource type row directly with equivalence_json set
    session = context._ucl_session_factory()
    now = _now_iso()
    row = ResourceTypeModel(
        name="test/equiv-type",
        namespace="test",
        description="type with equivalence",
        resource_kind="physical",
        sandbox_strategy="none",
        user_addable=True,
        handler_ref=None,
        args_schema_json=None,
        allowed_parent_types_json=None,
        allowed_child_types_json="[]",
        auto_discover_json=None,
        equivalence_json=json_str,
        capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}',
        source=None,
        created_at=now,
        updated_at=now,
    )
    session.add(row)
    session.commit()


@when('the resource type is retrieved by name "{name}" for uncovered lines')
def step_when_rt_get_by_name(context, name):
    context._ucl_rt_result = context._ucl_rt_repo.get(name)


@then('the resource type equivalence should contain key "{key}"')
def step_then_rt_equiv_key(context, key):
    result = context._ucl_rt_result
    assert result is not None, "Resource type was None"
    equiv = result.equivalence
    assert equiv is not None, "Equivalence was None"
    assert key in equiv, f"Expected key '{key}' in equivalence {equiv}"


# ── ResourceRepository helpers ─────────────────────────────────────────────


@given("a resource repository backed by an in-memory database for uncovered lines")
def step_given_res_repo_inmem(context):
    if not hasattr(context, "_ucl_engine"):
        engine, factory = _make_engine_and_session()
        context._ucl_engine = engine
        context._ucl_session_factory = factory
        context._ucl_rt_repo = ResourceTypeRepository(factory)
    context._ucl_res_repo = ResourceRepository(context._ucl_session_factory)


def _ensure_resource_type(context, type_name: str = "test/default-type"):
    """Ensure a resource type exists."""
    session = context._ucl_session_factory()
    existing = session.query(ResourceTypeModel).filter_by(name=type_name).first()
    if not existing:
        now = _now_iso()
        ns = type_name.split("/")[0] if "/" in type_name else "builtin"
        row = ResourceTypeModel(
            name=type_name,
            namespace=ns,
            description="test",
            resource_kind="physical",
            sandbox_strategy="none",
            user_addable=True,
            handler_ref=None,
            args_schema_json=None,
            allowed_parent_types_json=None,
            allowed_child_types_json=None,
            auto_discover_json=None,
            capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}',
            equivalence_json=None,
            source=None,
            created_at=now,
            updated_at=now,
        )
        session.add(row)
        session.commit()


def _create_resource_row(
    context,
    resource_id: str | None = None,
    type_name: str = "test/default-type",
    namespace: str | None = None,
    name: str | None = None,
    sandbox_strategy: str | None = None,
) -> str:
    """Insert a resource row directly and return its ID."""
    from ulid import ULID as _ULID

    if resource_id is None:
        resource_id = str(_ULID())
    _ensure_resource_type(context, type_name)
    session = context._ucl_session_factory()
    now = _now_iso()
    row = ResourceModel(
        resource_id=resource_id,
        namespaced_name=name,
        namespace=namespace,
        type_name=type_name,
        resource_kind="physical",
        location=None,
        description="test resource",
        read_only=False,
        auto_discovered=False,
        sandbox_strategy=sandbox_strategy,
        content_hash=None,
        properties_json=None,
        metadata_json=None,
        created_at=now,
        updated_at=now,
    )
    session.add(row)
    session.commit()
    return resource_id


# ── list_resources namespace filter ─────────────────────────────────────────


@given('resources exist in namespace "{ns1}" and "{ns2}" for uncovered lines')
def step_given_resources_in_namespaces(context, ns1, ns2):
    from ulid import ULID

    _create_resource_row(context, str(ULID()), namespace=ns1, name=f"{ns1}/res-a")
    _create_resource_row(context, str(ULID()), namespace=ns1, name=f"{ns1}/res-b")
    _create_resource_row(context, str(ULID()), namespace=ns2, name=f"{ns2}/res-a")


@when('resources are listed with namespace "{ns}" for uncovered lines')
def step_when_list_by_namespace(context, ns):
    context._ucl_listed = context._ucl_res_repo.list_resources(namespace=ns)


@then('only resources from namespace "{ns}" should be returned')
def step_then_only_namespace_resources(context, ns):
    resources = context._ucl_listed
    assert len(resources) > 0, "Expected at least one resource"
    for r in resources:
        name = r.name or ""
        assert name.startswith(ns), f"Resource '{name}' not in namespace '{ns}'"


# ── unlink_child - parent not found ────────────────────────────────────────


@when(
    'unlink_child is called with non-existent parent "{pid}" and child "{cid}" for uncovered lines'
)
def step_when_unlink_parent_missing(context, pid, cid):
    try:
        context._ucl_res_repo.unlink_child(pid, cid)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("a ResourceNotFoundRepoError should be raised for the unlink parent")
def step_then_unlink_parent_error(context):
    assert isinstance(context._ucl_error, ResourceNotFoundRepoError), (
        f"Expected ResourceNotFoundRepoError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── unlink_child - child not found ────────────────────────────────────────


@given('a resource "{rid}" exists for uncovered unlink tests')
def step_given_resource_for_unlink(context, rid):
    _create_resource_row(context, rid)
    context._ucl_unlink_parent_id = rid


@when(
    'unlink_child is called with existing parent and non-existent child "{cid}" for uncovered lines'
)
def step_when_unlink_child_missing(context, cid):
    try:
        context._ucl_res_repo.unlink_child(context._ucl_unlink_parent_id, cid)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("a ResourceNotFoundRepoError should be raised for the unlink child")
def step_then_unlink_child_error(context):
    assert isinstance(context._ucl_error, ResourceNotFoundRepoError), (
        f"Expected ResourceNotFoundRepoError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── unlink_child - link not found ─────────────────────────────────────────


@given('two resources "{pid}" and "{cid}" exist but are not linked for uncovered lines')
def step_given_two_unlinked_resources(context, pid, cid):
    _create_resource_row(context, pid)
    _create_resource_row(context, cid)
    context._ucl_unlink_pid = pid
    context._ucl_unlink_cid = cid


@when('unlink_child is called for "{pid}" and "{cid}" for uncovered lines')
def step_when_unlink_no_link(context, pid, cid):
    try:
        context._ucl_res_repo.unlink_child(pid, cid)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("a LinkNotFoundError should be raised for uncovered lines")
def step_then_link_not_found_error(context):
    assert isinstance(context._ucl_error, LinkNotFoundError), (
        f"Expected LinkNotFoundError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── unlink_child - re-raise domain errors ─────────────────────────────────


@when(
    'unlink_child is called with non-existent parent "{pid}" and child "{cid}" for uncovered lines re-raise'
)
def step_when_unlink_reraise(context, pid, cid):
    try:
        context._ucl_res_repo.unlink_child(pid, cid)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("a ResourceNotFoundRepoError should be raised and re-raised for uncovered lines")
def step_then_unlink_reraise(context):
    assert isinstance(context._ucl_error, ResourceNotFoundRepoError), (
        f"Expected ResourceNotFoundRepoError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── auto_discover_children - type_row is None ─────────────────────────────


@given(
    'a resource with type "missing-type" that has no type row in DB for uncovered lines'
)
def step_given_res_missing_type(context):
    # Insert resource with a type_name that has no matching ResourceTypeModel
    session = context._ucl_session_factory()
    now = _now_iso()
    row = ResourceModel(
        resource_id="auto-disc-no-type",
        namespaced_name=None,
        namespace=None,
        type_name="missing-type",
        resource_kind="physical",
        location=None,
        description="test",
        read_only=False,
        auto_discovered=False,
        sandbox_strategy=None,
        content_hash=None,
        properties_json=None,
        metadata_json=None,
        created_at=now,
        updated_at=now,
    )
    session.add(row)
    session.commit()
    context._ucl_auto_disc_id = "auto-disc-no-type"


@when("auto_discover_children is called for that resource for uncovered lines")
def step_when_auto_disc_no_type(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_id
    )


@then("an empty list should be returned from auto_discover for uncovered lines")
def step_then_auto_disc_empty(context):
    assert context._ucl_auto_disc_result == [], (
        f"Expected empty list, got {context._ucl_auto_disc_result}"
    )


# ── auto_discover_children - null auto_discover_json ──────────────────────


@given("a resource with type that has null auto_discover_json for uncovered lines")
def step_given_res_null_auto_disc(context):
    _ensure_resource_type(context, "test/null-auto-disc")
    _create_resource_row(context, "auto-disc-null", type_name="test/null-auto-disc")
    context._ucl_auto_disc_id_null = "auto-disc-null"


@when(
    "auto_discover_children is called for that typed resource for uncovered lines null"
)
def step_when_auto_disc_null(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_id_null
    )


# ── auto_discover_children - disabled ─────────────────────────────────────


@given("a resource with type that has auto_discover disabled for uncovered lines")
def step_given_res_disabled_auto_disc(context):
    session = context._ucl_session_factory()
    now = _now_iso()
    row = ResourceTypeModel(
        name="test/disabled-auto-disc",
        namespace="test",
        description="disabled auto-disc",
        resource_kind="physical",
        sandbox_strategy="none",
        user_addable=True,
        handler_ref=None,
        args_schema_json=None,
        allowed_parent_types_json=None,
        allowed_child_types_json="[]",
        auto_discover_json=json.dumps({"enabled": False, "rules": []}),
        equivalence_json=None,
        capabilities_json='{"read": true, "write": true, "sandbox": true, "checkpoint": false}',
        source=None,
        created_at=now,
        updated_at=now,
    )
    session.add(row)
    session.commit()
    _create_resource_row(
        context, "auto-disc-disabled", type_name="test/disabled-auto-disc"
    )
    context._ucl_auto_disc_id_disabled = "auto-disc-disabled"


@when(
    "auto_discover_children is called for that typed resource for uncovered lines disabled"
)
def step_when_auto_disc_disabled(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_id_disabled
    )


# ── auto_discover_children - empty rules ──────────────────────────────────


@given(
    "a resource with type that has auto_discover enabled but empty rules for uncovered lines"
)
def step_given_res_empty_rules(context):
    session = context._ucl_session_factory()
    _now_iso()
    row = _make_rt_model_row(
        "test/empty-rules",
        description="empty rules",
        auto_discover_json=json.dumps({"enabled": True, "rules": []}),
    )
    session.add(row)
    session.commit()
    _create_resource_row(context, "auto-disc-empty-rules", type_name="test/empty-rules")
    context._ucl_auto_disc_id_empty = "auto-disc-empty-rules"


@when(
    "auto_discover_children is called for that typed resource for uncovered lines empty rules"
)
def step_when_auto_disc_empty_rules(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_id_empty
    )


# ── auto_discover_children - child type not in DB ─────────────────────────


@given(
    "a resource with auto_discover rules referencing non-existent child type for uncovered lines"
)
def step_given_res_no_child_type(context):
    session = context._ucl_session_factory()
    _now_iso()
    row = _make_rt_model_row(
        "test/no-child-type",
        description="missing child type",
        auto_discover_json=json.dumps(
            {
                "enabled": True,
                "rules": [{"type": "nonexistent/child-type"}],
            }
        ),
    )
    session.add(row)
    session.commit()
    _create_resource_row(context, "auto-disc-no-child", type_name="test/no-child-type")
    context._ucl_auto_disc_id_no_child = "auto-disc-no-child"


@when(
    "auto_discover_children is called for that resource with missing child type for uncovered lines"
)
def step_when_auto_disc_no_child(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_id_no_child
    )


# ── auto_discover_children - child type not in allowed list ───────────────


@given(
    "a resource with auto_discover rules where child type is not in allowed list for uncovered lines"
)
def step_given_res_disallowed_child(context):
    session = context._ucl_session_factory()
    _now_iso()
    # Create the child type in DB
    child_type = _make_rt_model_row("test/disallowed-child", description="child type")
    session.add(child_type)
    session.commit()

    # Create parent type that allows only "test/other-child", not "test/disallowed-child"
    session2 = context._ucl_session_factory()
    parent_type = _make_rt_model_row(
        "test/disallow-parent",
        description="parent type disallowing child",
        allowed_child_types_json=json.dumps(["test/other-child"]),
        auto_discover_json=json.dumps(
            {
                "enabled": True,
                "rules": [{"type": "test/disallowed-child"}],
            }
        ),
    )
    session2.add(parent_type)
    session2.commit()

    _create_resource_row(
        context, "auto-disc-disallowed", type_name="test/disallow-parent"
    )
    context._ucl_auto_disc_id_disallowed = "auto-disc-disallowed"


@when(
    "auto_discover_children is called for that resource with disallowed child type for uncovered lines"
)
def step_when_auto_disc_disallowed(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_id_disallowed
    )


# ── auto_discover_children - successful discovery ─────────────────────────


@given(
    "a resource with auto_discover rules that match an existing child type for uncovered lines"
)
def step_given_res_full_discovery(context):
    session = context._ucl_session_factory()
    _now_iso()
    # Create the child type
    existing_ct = (
        session.query(ResourceTypeModel).filter_by(name="test/valid-child").first()
    )
    if not existing_ct:
        child_type = _make_rt_model_row(
            "test/valid-child", description="valid child type"
        )
        session.add(child_type)
        session.commit()

    session2 = context._ucl_session_factory()
    existing_pt = (
        session2.query(ResourceTypeModel).filter_by(name="test/disc-parent").first()
    )
    if not existing_pt:
        parent_type = _make_rt_model_row(
            "test/disc-parent",
            description="parent for discovery",
            allowed_child_types_json=json.dumps(["test/valid-child"]),
            auto_discover_json=json.dumps(
                {
                    "enabled": True,
                    "rules": [{"type": "test/valid-child"}],
                }
            ),
        )
        session2.add(parent_type)
        session2.commit()

    _create_resource_row(context, "auto-disc-full", type_name="test/disc-parent")
    context._ucl_auto_disc_full_id = "auto-disc-full"


@when(
    "auto_discover_children is called for that resource for full discovery for uncovered lines"
)
def step_when_auto_disc_full(context):
    context._ucl_auto_disc_result = context._ucl_res_repo.auto_discover_children(
        context._ucl_auto_disc_full_id
    )


@then("the discovered children list should not be empty for uncovered lines")
def step_then_discovered_not_empty(context):
    assert len(context._ucl_auto_disc_result) > 0, (
        "Expected non-empty discovered children"
    )


@then("the child resource should be linked to the parent for uncovered lines")
def step_then_child_linked(context):
    session = context._ucl_session_factory()
    links = (
        session.query(ResourceLinkModel)
        .filter_by(parent_id=context._ucl_auto_disc_full_id)
        .all()
    )
    assert len(links) > 0, "Expected at least one link from parent to child"


# ── auto_discover_children - OperationalError ─────────────────────────────


@given(
    "a resource repository with session raising OperationalError on auto_discover for uncovered lines"
)
def step_given_res_repo_op_error_auto_disc(context):
    call_count = 0

    def mock_session_factory():
        nonlocal call_count
        call_count += 1
        mock_session = MagicMock(spec=Session)
        # First query returns the resource row
        parent_row = MagicMock()
        parent_row.type_name = "some-type"
        parent_row.resource_id = "res-op-err"

        type_row = MagicMock()
        type_row.auto_discover_json = json.dumps(
            {"enabled": True, "rules": [{"type": "child-t"}]}
        )
        type_row.allowed_child_types_json = "[]"

        # query().filter_by().first() returns parent_row first, then type_row, then raises
        query_mock = MagicMock()
        filter_mock = MagicMock()
        filter_mock.first.side_effect = [
            parent_row,
            type_row,
            OperationalError("db fail", {}, None),
        ]
        query_mock.filter_by.return_value = filter_mock
        mock_session.query.return_value = query_mock
        mock_session.rollback = MagicMock()
        return mock_session

    context._ucl_res_repo = ResourceRepository(mock_session_factory)
    context._ucl_auto_disc_op_err_id = "res-op-err"


@when(
    "auto_discover_children is called and an OperationalError occurs for uncovered lines"
)
def step_when_auto_disc_op_error(context):
    try:
        context._ucl_res_repo.auto_discover_children(context._ucl_auto_disc_op_err_id)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("a DatabaseError should be raised mentioning auto-discover for uncovered lines")
def step_then_auto_disc_db_error(context):
    assert isinstance(context._ucl_error, DatabaseError), (
        f"Expected DatabaseError, got {type(context._ucl_error)}: {context._ucl_error}"
    )
    assert (
        "auto-discover" in str(context._ucl_error).lower()
        or "auto_discover" in str(context._ucl_error).lower()
    ), f"Expected 'auto-discover' in message: {context._ucl_error}"


# ── _get_ancestors - diamond graph ────────────────────────────────────────


@given("resources forming a diamond graph for ancestor traversal for uncovered lines")
def step_given_diamond_graph(context):
    # Create: A -> B, A -> C, B -> D, C -> D (diamond)
    # _get_ancestors(D) should visit D, B, C, A
    for rid in ["anc-A", "anc-B", "anc-C", "anc-D"]:
        _create_resource_row(context, rid)

    session = context._ucl_session_factory()
    now = _now_iso()
    links = [
        ResourceLinkModel(parent_id="anc-A", child_id="anc-B", created_at=now),
        ResourceLinkModel(parent_id="anc-A", child_id="anc-C", created_at=now),
        ResourceLinkModel(parent_id="anc-B", child_id="anc-D", created_at=now),
        ResourceLinkModel(parent_id="anc-C", child_id="anc-D", created_at=now),
    ]
    for link in links:
        session.add(link)
    session.commit()
    context._ucl_ancestor_id = "anc-D"


@when("_get_ancestors is called on the bottom resource for uncovered lines")
def step_when_get_ancestors(context):
    session = context._ucl_session_factory()
    context._ucl_ancestors = ResourceRepository._get_ancestors(
        session, context._ucl_ancestor_id
    )


@then(
    "all ancestor IDs should be returned including the bottom resource for uncovered lines"
)
def step_then_ancestors_returned(context):
    expected = {"anc-A", "anc-B", "anc-C", "anc-D"}
    assert context._ucl_ancestors == expected, (
        f"Expected {expected}, got {context._ucl_ancestors}"
    )


# ── ResourceRepository._to_domain - sandbox_strategy ─────────────────────


@given('a resource row with sandbox_strategy set to "git_worktree" for uncovered lines')
def step_given_res_with_sandbox(context):
    from ulid import ULID

    rid = str(ULID())
    _create_resource_row(
        context,
        rid,
        sandbox_strategy="git_worktree",
    )
    context._ucl_sandbox_id = rid


@when("the resource is retrieved by ID for uncovered lines sandbox")
def step_when_res_get_sandbox(context):
    context._ucl_sandbox_result = context._ucl_res_repo.get(context._ucl_sandbox_id)


@then("the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE")
def step_then_sandbox_strategy(context):
    from cleveragents.domain.models.core.resource import SandboxStrategy

    result = context._ucl_sandbox_result
    assert result is not None, "Resource was None"
    assert result.sandbox_strategy == SandboxStrategy.GIT_WORKTREE, (
        f"Expected GIT_WORKTREE, got {result.sandbox_strategy}"
    )


# ── ToolRepository helpers ────────────────────────────────────────────────


@given("a tool repository backed by an in-memory database for uncovered lines")
def step_given_tool_repo_inmem(context):
    engine, factory = _make_engine_and_session()
    context._ucl_tool_engine = engine
    context._ucl_tool_session_factory = factory
    context._ucl_tool_repo = ToolRepository(factory)


def _make_tool_domain(
    name: str = "test/my-tool",
    tool_type: str = "tool",
    source: str = "builtin",
    input_schema: dict | None = None,
    output_schema: dict | None = None,
    resource_slots: list | None = None,
) -> Any:
    """Create a mock tool domain object."""
    tool = MagicMock()
    tool.name = name
    tool.description = "A test tool"

    # tool_type enum-like
    tt = MagicMock()
    tt.value = tool_type
    tool.tool_type = tt

    # source enum-like
    src = MagicMock()
    src.value = source
    tool.source = src

    tool.input_schema = input_schema
    tool.output_schema = output_schema
    tool.config_yaml = None
    tool.tool_id = ""

    cap = MagicMock()
    cap.read_only = False
    cap.writes = True
    cap.checkpointable = False
    cap.side_effects = False
    tool.capability = cap

    tool.resource_slots = resource_slots or []
    return tool


# ── ToolRepository.add - invalid tool_type ────────────────────────────────


@given('a tool domain object with tool_type "bogus" for uncovered lines')
def step_given_tool_bad_type(context):
    context._ucl_tool = _make_tool_domain(tool_type="bogus")


@when("the tool is added to the repository for uncovered lines invalid type")
def step_when_tool_add_invalid(context):
    try:
        context._ucl_tool_repo.add(context._ucl_tool)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("an InvalidToolTypeError should be raised for uncovered lines")
def step_then_invalid_tool_type(context):
    assert isinstance(context._ucl_error, InvalidToolTypeError), (
        f"Expected InvalidToolTypeError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── ToolRepository.add - schemas + resource slots ─────────────────────────


@given("a tool domain object with schemas and resource slots for uncovered lines")
def step_given_tool_with_schemas(context):
    slot = MagicMock()
    slot.name = "my_slot"
    slot.resource_type = "test/resource-type"

    binding = MagicMock()
    binding.value = "contextual"
    slot.binding = binding

    access = MagicMock()
    access.value = "read_only"
    slot.access = access

    context._ucl_tool = _make_tool_domain(
        name="test/schema-tool",
        input_schema={"type": "object", "properties": {"x": {"type": "string"}}},
        output_schema={"type": "object", "properties": {"y": {"type": "integer"}}},
        resource_slots=[slot],
    )


@when("the tool is added to the repository for uncovered lines with schemas")
def step_when_tool_add_schemas(context):
    context._ucl_tool_id = context._ucl_tool_repo.add(context._ucl_tool)


@then(
    "the tool should be retrievable and have schemas and bindings for uncovered lines"
)
def step_then_tool_schemas_bindings(context):
    session = context._ucl_tool_session_factory()
    row = session.query(ToolModel).filter_by(name=context._ucl_tool_id).first()
    assert row is not None, "Tool row not found"
    assert row.input_schema_json is not None, "input_schema_json should not be None"
    assert row.output_schema_json is not None, "output_schema_json should not be None"

    bindings = (
        session.query(ToolBindingModel).filter_by(tool_name=context._ucl_tool_id).all()
    )
    assert len(bindings) == 1, f"Expected 1 binding, got {len(bindings)}"
    assert bindings[0].slot_name == "my_slot"
    assert bindings[0].binding_mode == "contextual"


# ── ToolRepository.get - by ULID ──────────────────────────────────────────


@given("a tool has been persisted and its ULID captured for uncovered lines")
def step_given_tool_persisted(context):
    tool = _make_tool_domain(name="test/get-tool")
    context._ucl_persisted_tool_id = context._ucl_tool_repo.add(tool)


@when("the tool is fetched by ULID for uncovered lines")
def step_when_tool_get_by_ulid(context):
    context._ucl_tool_result = context._ucl_tool_repo.get(
        context._ucl_persisted_tool_id
    )


@then("the tool domain object should be returned for uncovered lines")
def step_then_tool_returned(context):
    assert context._ucl_tool_result is not None, "Tool was None"
    result = context._ucl_tool_result
    name = result["name"] if isinstance(result, dict) else result.name
    assert name == "test/get-tool"


@when('a tool is fetched by ULID "{ulid}" for uncovered lines')
def step_when_tool_get_unknown(context, ulid):
    context._ucl_tool_result = context._ucl_tool_repo.get(ulid)


@then("None should be returned for the tool get for uncovered lines")
def step_then_tool_none(context):
    assert context._ucl_tool_result is None, (
        f"Expected None, got {context._ucl_tool_result}"
    )


# ── ToolRepository._to_domain - input/output schema ──────────────────────


@given("a tool with input_schema and output_schema stored as JSON for uncovered lines")
def step_given_tool_with_json_schemas(context):
    tool = _make_tool_domain(
        name="test/json-schema-tool",
        input_schema={"type": "object"},
        output_schema={"type": "array"},
    )
    context._ucl_tool_repo.add(tool)
    context._ucl_schema_tool_name = "test/json-schema-tool"


@when("the tool is retrieved by name for uncovered lines schemas")
def step_when_tool_get_by_name_schemas(context):
    context._ucl_schema_tool = context._ucl_tool_repo.get_by_name(
        context._ucl_schema_tool_name
    )


@then("the tool should have parsed input_schema and output_schema for uncovered lines")
def step_then_tool_parsed_schemas(context):
    import json as _json

    tool = context._ucl_schema_tool
    assert tool is not None, "Tool was None"
    # HEAD's to_domain() returns a dict with JSON strings
    if isinstance(tool, dict):
        input_raw = tool.get("input_schema_json")
        output_raw = tool.get("output_schema_json")
        assert input_raw is not None, "input_schema_json should not be None"
        assert output_raw is not None, "output_schema_json should not be None"
        input_schema = (
            _json.loads(input_raw) if isinstance(input_raw, str) else input_raw
        )
        output_schema = (
            _json.loads(output_raw) if isinstance(output_raw, str) else output_raw
        )
    else:
        input_schema = tool.input_schema
        output_schema = tool.output_schema
    assert isinstance(input_schema, dict), f"Expected dict, got {type(input_schema)}"
    assert isinstance(output_schema, dict), f"Expected dict, got {type(output_schema)}"


# ── ValidationAttachment - duplicate detection ────────────────────────────


@given(
    "a validation attachment repository backed by an in-memory database for uncovered lines"
)
def step_given_va_repo(context):
    engine, factory = _make_engine_and_session()
    context._ucl_va_engine = engine
    context._ucl_va_session_factory = factory
    context._ucl_va_repo = ValidationAttachmentRepository(factory)
    # We need a resource to attach to
    _ensure_va_resource(context, factory)


def _ensure_va_resource(context, factory):
    """Create a resource to use in validation attachment tests."""
    session = factory()
    now = _now_iso()
    # Resource type first
    rt = _make_rt_model_row("test/va-type", description="for VA tests")
    session.add(rt)
    session.flush()
    for rid in ["res-1", "res-2"]:
        res = ResourceModel(
            resource_id=rid,
            namespaced_name=None,
            namespace=None,
            type_name="test/va-type",
            resource_kind="physical",
            location=None,
            description="VA test resource",
            read_only=False,
            auto_discovered=False,
            sandbox_strategy=None,
            content_hash=None,
            properties_json=None,
            metadata_json=None,
            created_at=now,
            updated_at=now,
        )
        session.add(res)
    session.commit()


@given(
    'a validation "{vname}" is already attached to resource "{rid}" for uncovered lines'
)
def step_given_va_attached(context, vname, rid):
    context._ucl_va_repo.attach(vname, rid, "required")
    context._ucl_va_rid = rid
    context._ucl_va_vname = vname


@when(
    'the same validation "{vname}" is attached again to resource "{rid}" for uncovered lines'
)
def step_when_va_dup_attach(context, vname, rid):
    try:
        context._ucl_va_repo.attach(vname, rid, "required")
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then(
    "a DuplicateValidationAttachmentError should be raised for uncovered lines attach"
)
def step_then_va_dup_error(context):
    assert isinstance(context._ucl_error, DuplicateValidationAttachmentError), (
        f"Expected DuplicateValidationAttachmentError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── ValidationAttachment - re-raise ───────────────────────────────────────


@when(
    'the same validation "{vname}" is attached again to resource "{rid}" for uncovered lines re-raise'
)
def step_when_va_dup_reraise(context, vname, rid):
    try:
        context._ucl_va_repo.attach(vname, rid, "required")
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("the DuplicateValidationAttachmentError should propagate for uncovered lines")
def step_then_va_dup_propagate(context):
    assert isinstance(context._ucl_error, DuplicateValidationAttachmentError), (
        f"Expected DuplicateValidationAttachmentError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── AutomationProfileRepository ──────────────────────────────────────────


@given(
    "an automation profile repository backed by an in-memory database for uncovered lines"
)
def step_given_ap_repo(context):
    engine, factory = _make_engine_and_session()
    context._ucl_ap_engine = engine
    context._ucl_ap_session_factory = factory
    context._ucl_ap_repo = AutomationProfileRepository(factory)


@when('a profile is fetched by name "{name}" for uncovered lines')
def step_when_ap_get(context, name):
    context._ucl_ap_result = context._ucl_ap_repo.get_by_name(name)


@then("None should be returned for the profile get for uncovered lines")
def step_then_ap_none(context):
    assert context._ucl_ap_result is None, (
        f"Expected None, got {context._ucl_ap_result}"
    )


# ── get_by_name returns domain ────────────────────────────────────────────


@given('a profile "{name}" has been upserted for uncovered lines')
def step_given_ap_upserted(context, name):
    profile = _make_automation_profile(name)
    context._ucl_ap_repo.upsert(profile)


@then("the profile domain object should be returned for uncovered lines")
def step_then_ap_domain(context):
    assert context._ucl_ap_result is not None, "Profile was None"
    assert context._ucl_ap_result.name == "test-profile"


# ── list_all ──────────────────────────────────────────────────────────────


@given('profiles "{name1}" and "{name2}" have been upserted for uncovered lines')
def step_given_multiple_profiles(context, name1, name2):
    for name in [name1, name2]:
        profile = _make_automation_profile(name)
        context._ucl_ap_repo.upsert(profile)


@when("all profiles are listed for uncovered lines")
def step_when_ap_list(context):
    context._ucl_ap_list = context._ucl_ap_repo.list_all()


@then("two profile domain objects should be returned for uncovered lines")
def step_then_ap_list_count(context):
    assert len(context._ucl_ap_list) == 2, (
        f"Expected 2, got {len(context._ucl_ap_list)}"
    )


# ── upsert update ────────────────────────────────────────────────────────


@when(
    'the profile "{name}" is upserted again with changed description for uncovered lines'
)
def step_when_ap_update(context, name):
    profile = _make_automation_profile(name, description="updated desc")
    context._ucl_ap_repo.upsert(profile)


@then("the profile should have the updated description for uncovered lines")
def step_then_ap_updated_desc(context):
    result = context._ucl_ap_repo.get_by_name("update-prof")
    assert result is not None, "Profile was None"
    assert result.description == "updated desc", (
        f"Expected 'updated desc', got '{result.description}'"
    )


# ── upsert schema version mismatch ──────────────────────────────────────


@given(
    'a profile "{name}" has been upserted with schema_version "{ver}" for uncovered lines'
)
def step_given_ap_versioned(context, name, ver):
    profile = _make_automation_profile(name, schema_version=ver)
    context._ucl_ap_repo.upsert(profile)


@when(
    'the profile "{name}" is upserted with expected_schema_version "{ver}" for uncovered lines'
)
def step_when_ap_version_mismatch(context, name, ver):
    profile = _make_automation_profile(name, schema_version="1.0")
    try:
        context._ucl_ap_repo.upsert(profile, expected_schema_version=ver)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("an AutomationProfileSchemaVersionError should be raised for uncovered lines")
def step_then_ap_version_error(context):
    assert isinstance(context._ucl_error, AutomationProfileSchemaVersionError), (
        f"Expected AutomationProfileSchemaVersionError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── upsert new insert ────────────────────────────────────────────────────


@when('a new profile "{name}" is upserted for uncovered lines')
def step_when_ap_new_insert(context, name):
    profile = _make_automation_profile(name, description="new profile")
    context._ucl_ap_repo.upsert(profile)


@then('the profile "{name}" should be retrievable for uncovered lines')
def step_then_ap_retrievable(context, name):
    result = context._ucl_ap_repo.get_by_name(name)
    assert result is not None, f"Profile '{name}' was None"
    assert result.name == name


# ── upsert IntegrityError ────────────────────────────────────────────────


@given(
    "an automation profile repository with session raising IntegrityError on flush for uncovered lines"
)
def step_given_ap_repo_integrity_error(context):
    def mock_session_factory():
        mock_session = MagicMock(spec=Session)
        query_mock = MagicMock()
        filter_mock = MagicMock()
        filter_mock.first.return_value = None  # No existing row
        query_mock.filter_by.return_value = filter_mock
        mock_session.query.return_value = query_mock
        mock_session.add = MagicMock()
        mock_session.flush.side_effect = IntegrityError("UNIQUE constraint", {}, None)
        mock_session.rollback = MagicMock()
        return mock_session

    context._ucl_ap_repo = AutomationProfileRepository(mock_session_factory)


@when("a profile is upserted and IntegrityError occurs for uncovered lines")
def step_when_ap_integrity_error(context):
    profile = _make_automation_profile("integrity-fail")
    try:
        context._ucl_ap_repo.upsert(profile)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("a DuplicateAutomationProfileError should be raised for uncovered lines")
def step_then_ap_dup_error(context):
    assert isinstance(context._ucl_error, DuplicateAutomationProfileError), (
        f"Expected DuplicateAutomationProfileError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── delete not found ─────────────────────────────────────────────────────


@when('a profile "{name}" is deleted for uncovered lines')
def step_when_ap_delete(context, name):
    try:
        context._ucl_ap_repo.delete(name)
        context._ucl_error = None
    except Exception as exc:
        context._ucl_error = exc


@then("an AutomationProfileNotFoundError should be raised for uncovered lines")
def step_then_ap_not_found(context):
    assert isinstance(context._ucl_error, AutomationProfileNotFoundError), (
        f"Expected AutomationProfileNotFoundError, got {type(context._ucl_error)}: {context._ucl_error}"
    )


# ── delete success ────────────────────────────────────────────────────────


@when('the profile "{name}" is deleted for uncovered lines')
def step_when_ap_delete_success(context, name):
    context._ucl_ap_repo.delete(name)


@then('the profile "{name}" should no longer exist for uncovered lines')
def step_then_ap_deleted(context, name):
    result = context._ucl_ap_repo.get_by_name(name)
    assert result is None, f"Profile '{name}' still exists"


# ── _to_domain full fields ───────────────────────────────────────────────


@given('a profile "{name}" with all fields set has been upserted for uncovered lines')
def step_given_ap_full_fields(context, name):
    profile = _make_automation_profile(
        name,
        description="full fields profile",
        schema_version="2.0",
        decompose_task=0.1,
        create_tool=0.2,
        select_tool=0.3,
        edit_code=0.4,
        execute_command=0.5,
        create_file=0.6,
        delete_content=0.7,
        access_network=0.8,
        install_dependency=0.9,
        modify_config=0.15,
        approve_plan=0.25,
        require_sandbox=False,
        require_checkpoints=False,
        allow_unsafe_tools=True,
    )
    context._ucl_ap_repo.upsert(profile)
    context._ucl_ap_full = profile


@then("all profile fields should match the original values for uncovered lines")
def step_then_ap_all_fields(context):
    result = context._ucl_ap_result
    orig = context._ucl_ap_full
    assert result is not None, "Profile was None"
    assert result.name == orig.name
    assert result.description == orig.description
    assert result.schema_version == orig.schema_version
    assert abs(result.decompose_task - orig.decompose_task) < 0.001
    assert abs(result.create_tool - orig.create_tool) < 0.001
    assert abs(result.select_tool - orig.select_tool) < 0.001
    assert abs(result.edit_code - orig.edit_code) < 0.001
    assert abs(result.execute_command - orig.execute_command) < 0.001
    assert abs(result.create_file - orig.create_file) < 0.001
    assert abs(result.delete_content - orig.delete_content) < 0.001
    assert abs(result.access_network - orig.access_network) < 0.001
    assert abs(result.install_dependency - orig.install_dependency) < 0.001
    assert abs(result.modify_config - orig.modify_config) < 0.001
    assert abs(result.approve_plan - orig.approve_plan) < 0.001
    assert result.safety.require_sandbox == orig.safety.require_sandbox
    assert result.safety.require_checkpoints == orig.safety.require_checkpoints
    assert result.safety.allow_unsafe_tools == orig.safety.allow_unsafe_tools


# ── _from_domain + _update_row roundtrip ──────────────────────────────────


@given(
    'a profile "{name}" with specific field values has been upserted for uncovered lines'
)
def step_given_ap_specific_fields(context, name):
    profile = _make_automation_profile(
        name,
        description="original",
        decompose_task=0.1,
        create_tool=0.2,
        require_sandbox=True,
        allow_unsafe_tools=False,
    )
    context._ucl_ap_repo.upsert(profile)


@when(
    'the profile "{name}" is upserted again with different field values for uncovered lines'
)
def step_when_ap_update_fields(context, name):
    profile = _make_automation_profile(
        name,
        description="updated roundtrip",
        decompose_task=0.9,
        create_tool=0.8,
        select_tool=0.7,
        edit_code=0.6,
        execute_command=0.5,
        create_file=0.4,
        delete_content=0.3,
        access_network=0.2,
        install_dependency=0.1,
        modify_config=0.05,
        approve_plan=0.15,
        require_sandbox=False,
        require_checkpoints=False,
        allow_unsafe_tools=True,
    )
    context._ucl_ap_repo.upsert(profile)
    context._ucl_ap_updated_vals = profile


@then('the profile "{name}" should have the new field values for uncovered lines')
def step_then_ap_new_values(context, name):
    result = context._ucl_ap_repo.get_by_name(name)
    expected = context._ucl_ap_updated_vals
    assert result is not None, f"Profile '{name}' was None"
    assert result.description == expected.description
    assert abs(result.decompose_task - expected.decompose_task) < 0.001
    assert abs(result.create_tool - expected.create_tool) < 0.001
    assert abs(result.select_tool - expected.select_tool) < 0.001
    assert abs(result.edit_code - expected.edit_code) < 0.001
    assert abs(result.execute_command - expected.execute_command) < 0.001
    assert abs(result.create_file - expected.create_file) < 0.001
    assert abs(result.delete_content - expected.delete_content) < 0.001
    assert abs(result.access_network - expected.access_network) < 0.001
    assert abs(result.install_dependency - expected.install_dependency) < 0.001
    assert abs(result.modify_config - expected.modify_config) < 0.001
    assert abs(result.approve_plan - expected.approve_plan) < 0.001
    assert result.safety.require_sandbox == expected.safety.require_sandbox
    assert result.safety.require_checkpoints == expected.safety.require_checkpoints
    assert result.safety.allow_unsafe_tools == expected.safety.allow_unsafe_tools
