"""Step definitions for actor add CLI v3 schema validation.

Tests for features/actor_add_v3_schema_validation.feature — validates that
the `agents actor add --config` command validates v3 YAML files using
ActorConfigSchema, detecting cycles, missing fields, and invalid enums.
"""

from __future__ import annotations

import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pydantic
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner

from cleveragents.actor.registry import ActorRegistry
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.actor import Actor

# ── Background Steps ──────────────────────────────────────────────────────────


@given("a temporary directory for test actor configs")
def step_temp_dir(context: Context) -> None:
    """Ensure temporary directory exists and initialize v3 test context."""
    if not hasattr(context, "temp_dir"):
        context.temp_dir = tempfile.TemporaryDirectory()
        context.add_cleanup(context.temp_dir.cleanup)
    if not hasattr(context, "config_file"):
        context.config_file = None
    if not hasattr(context, "command_result"):
        context.command_result = None
    if not hasattr(context, "command_error"):
        context.command_error = None
    if not hasattr(context, "actor_registered"):
        context.actor_registered = False


# ── v3 LLM Actor YAML Fixtures ────────────────────────────────────────────────


@given('a v3 LLM actor YAML file with name "{name}"')
def step_v3_llm_actor(context: Context, name: str) -> None:
    """Create a valid v3 LLM actor YAML file."""
    yaml_content = f"""\
name: {name}
type: llm
description: Test LLM actor
provider: openai
model: gpt-4
system_prompt: "You are a helpful assistant"
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given("a v3 LLM actor YAML file without the model field")
def step_v3_llm_actor_no_model(context: Context) -> None:
    """Create a v3 LLM actor YAML without model field."""
    yaml_content = """\
name: local/no-model-llm
type: llm
description: LLM without model
provider: openai
system_prompt: "You are helpful"
"""
    context.config_file = _write_yaml_file(context, "no-model-llm", yaml_content)


@given('a v3 LLM actor YAML file with invalid context_view "{invalid_view}"')
def step_v3_llm_actor_invalid_context_view(context: Context, invalid_view: str) -> None:
    """Create a v3 LLM actor with invalid context_view."""
    yaml_content = f"""\
name: local/invalid-view
type: llm
description: LLM with invalid context_view
provider: openai
model: gpt-4
context_view: {invalid_view}
"""
    context.config_file = _write_yaml_file(context, "invalid-view", yaml_content)


# ── v3 TOOL Actor YAML Fixtures ───────────────────────────────────────────────


@given('a v3 TOOL actor YAML file with name "{name}"')
def step_v3_tool_actor(context: Context, name: str) -> None:
    """Create a valid v3 TOOL actor YAML file."""
    yaml_content = f"""\
name: {name}
type: tool
description: Test TOOL actor
provider: openai
model: gpt-4
tools:
  - files/read_file
  - files/write_file
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given("a v3 TOOL actor YAML file without the tools field")
def step_v3_tool_actor_no_tools(context: Context) -> None:
    """Create a v3 TOOL actor YAML without tools field."""
    yaml_content = """\
name: local/no-tools-tool
type: tool
description: TOOL without tools
provider: openai
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "no-tools-tool", yaml_content)


# ── v3 GRAPH Actor YAML Fixtures ──────────────────────────────────────────────


@given('a v3 GRAPH actor YAML file with name "{name}"')
def step_v3_graph_actor(context: Context, name: str) -> None:
    """Create a valid v3 GRAPH actor YAML file."""
    yaml_content = f"""\
name: {name}
type: graph
description: Test GRAPH actor
provider: openai
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A
      description: First node
      config: {{}}
    - id: node_b
      type: agent
      name: Node B
      description: Second node
      config: {{}}
  edges:
    - from_node: node_a
      to_node: node_b
  entry_node: node_a
  exit_nodes:
    - node_b
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given("a v3 GRAPH actor YAML file with a cycle in the route")
def step_v3_graph_actor_with_cycle(context: Context) -> None:
    """Create a v3 GRAPH actor YAML with a cycle."""
    yaml_content = """\
name: local/broken-graph
type: graph
description: Graph with cycle
provider: openai
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A
      description: First node
      config: {}
    - id: node_b
      type: agent
      name: Node B
      description: Second node
      config: {}
  edges:
    - from_node: node_a
      to_node: node_b
    - from_node: node_b
      to_node: node_a
  entry_node: node_a
  exit_nodes:
    - node_b
"""
    context.config_file = _write_yaml_file(context, "broken-graph", yaml_content)


@given("a v3 GRAPH actor YAML file without the route field")
def step_v3_graph_actor_no_route(context: Context) -> None:
    """Create a v3 GRAPH actor YAML without route field."""
    yaml_content = """\
name: local/no-route-graph
type: graph
description: GRAPH without route
provider: openai
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "no-route-graph", yaml_content)


@given("a v3 GRAPH actor YAML file without the model field")
def step_v3_graph_actor_no_model(context: Context) -> None:
    """Create a v3 GRAPH actor YAML without model field."""
    yaml_content = """\
name: local/no-model-graph
type: graph
description: GRAPH without model
provider: openai
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A
      description: First node
      config: {}
  edges: []
  entry_node: node_a
  exit_nodes:
    - node_a
"""
    context.config_file = _write_yaml_file(context, "no-model-graph", yaml_content)


# ── v3 Detection and Invalid Type Fixtures ────────────────────────────────────


@given('a v3 actor YAML file with invalid type "{invalid_type}"')
def step_v3_actor_invalid_type(context: Context, invalid_type: str) -> None:
    """Create a v3 actor YAML with invalid type."""
    yaml_content = f"""\
name: local/invalid-type
type: {invalid_type}
description: Actor with invalid type
provider: openai
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "invalid-type", yaml_content)


@given('a v3 actor YAML file with version "{version}"')
def step_v3_actor_with_version(context: Context, version: str) -> None:
    """Create a v3 actor YAML with explicit version field (and type for schema validity).

    Includes both version and type so that ActorConfigSchema.model_validate()
    succeeds.  The version-branch of is_v3_yaml() is covered separately by
    direct unit scenarios that call is_v3_yaml() without a type field.
    """
    yaml_content = f"""\
name: local/detect-version
type: llm
description: Actor with version field
provider: openai
version: "{version}"
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "detect-version", yaml_content)


@given("a v3 GRAPH actor YAML file with an invalid node ID")
def step_v3_graph_actor_invalid_node_id(context: Context) -> None:
    """Create a v3 GRAPH actor YAML with an invalid node ID format.

    Node IDs must only contain alphanumeric characters, underscores, and
    hyphens.  A node ID with special characters such as ``"bad!node"``
    triggers the schema validator's node ID format check.
    """
    yaml_content = """\
name: local/bad-node-id
type: graph
description: Graph with invalid node ID
provider: openai
model: gpt-4
route:
  nodes:
    - id: "bad!node"
      type: agent
      name: Bad ID Node
      description: Node with non-alphanumeric ID
      config: {}
  edges: []
  entry_node: "bad!node"
  exit_nodes:
    - "bad!node"
"""
    context.config_file = _write_yaml_file(context, "bad-node-id", yaml_content)


@given("a v3 TOOL actor YAML file with an unnamespaced tool name")
def step_v3_tool_actor_unnamespaced_tool(context: Context) -> None:
    """Create a v3 TOOL actor YAML with an improperly namespaced tool name.

    Tool names must follow the ``namespace/tool_name`` format.  A bare tool
    name without a namespace prefix violates the convention and must be
    rejected by schema validation.
    """
    yaml_content = """\
name: local/bad-tool-name
type: tool
description: TOOL with unnamespaced tool name
provider: openai
model: gpt-4
tools:
  - read_file
"""
    context.config_file = _write_yaml_file(context, "bad-tool-name", yaml_content)


@given("a v3 GRAPH actor YAML file with an unreachable node")
def step_v3_graph_actor_unreachable_node(context: Context) -> None:
    """Create a v3 GRAPH actor YAML with an unreachable node.

    A node that has no incoming edges and is not the entry_node is unreachable
    from the graph's entry point and must be rejected.
    """
    yaml_content = """\
name: local/unreachable-node
type: graph
description: Graph with unreachable node
provider: openai
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Entry Node
      description: Entry node
      config: {}
    - id: node_b
      type: agent
      name: Exit Node
      description: Exit node
      config: {}
    - id: node_orphan
      type: agent
      name: Orphan Node
      description: Unreachable node (no incoming edges)
      config: {}
  edges:
    - from_node: node_a
      to_node: node_b
  entry_node: node_a
  exit_nodes:
    - node_b
"""
    context.config_file = _write_yaml_file(context, "unreachable-node", yaml_content)


# ── v2 Backward-Compatibility Fixtures ───────────────────────────────────────


@given("a v2 actor YAML file with only name, provider, and model fields")
def step_v2_actor_yaml(context: Context) -> None:
    """Create a v2-style actor YAML (no 'type' field).

    v2 actors do not have a 'type' field — they use only 'name', 'provider',
    and 'model'. The CLI must NOT invoke ActorConfigSchema.model_validate()
    for such configs (is_v3_yaml() returns False when there is no 'type'
    field and no 'version' field).
    """
    yaml_content = """\
name: local/v2-actor
description: A classic v2-style actor
provider: openai
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "v2-actor", yaml_content)


# ── Update Command Fixtures ───────────────────────────────────────────────────


@given('a registered actor "{name}" already exists in the registry')
def step_actor_exists_in_registry(context: Context, name: str) -> None:
    """Record that a pre-existing actor should be returned by the mock registry."""
    context.existing_actor_name = name


# ── When Steps (Command Execution via CLI runner) ─────────────────────────────


@when('I run the actor add command for "{name}" with the prepared config file')
def step_run_actor_add(context: Context, name: str) -> None:
    """Run the agents actor add command via the real CLI (Typer CliRunner).

    Uses typer.testing.CliRunner to invoke the actual CLI entrypoint — the
    same code path the user runs.  The registry is mocked so that the test
    does not require a real database, but all CLI-level validation (including
    v3 schema validation) runs for real.

    A spy on ``ActorConfigSchema.model_validate`` is stored on ``context``
    so that assertion steps can verify the schema was (or was not) invoked.
    """
    if context.config_file is None:
        raise ValueError("No config file set")

    runner = getattr(context, "runner", CliRunner())

    mock_actor = Actor(
        id=1,
        name=name,
        provider="openai",
        model="gpt-4",
        config_blob={"name": name, "provider": "openai", "model": "gpt-4"},
        config_hash=Actor.compute_hash(
            {"name": name, "provider": "openai", "model": "gpt-4"}
        ),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    mock_registry = MagicMock()
    mock_registry.upsert_actor.return_value = mock_actor
    mock_registry.get_actor.side_effect = NotFoundError("not found")
    mock_service = MagicMock()

    with (
        patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
        patch.object(
            ActorConfigSchema,
            "model_validate",
            wraps=ActorConfigSchema.model_validate,
        ) as schema_spy,
    ):
        mock_get_services.return_value = (mock_service, mock_registry)
        result = runner.invoke(
            actor_app,
            ["add", name, "--config", str(context.config_file)],
            catch_exceptions=True,
        )

    context.schema_validate_spy = schema_spy
    context.cli_result = result
    success = result.exit_code == 0
    context.command_result = {
        "success": success,
        "exit_code": result.exit_code,
        "output": result.output,
    }
    if success:
        assert result.exception is None, f"Unexpected exception: {result.exception}"
        context.command_error = None
        context.actor_registered = True
        # Verify registry was called and extract actor metadata from the config blob
        if mock_registry.upsert_actor.called:
            call_kwargs = mock_registry.upsert_actor.call_args
            blob = call_kwargs.kwargs.get("config_blob") if call_kwargs.kwargs else None
            if isinstance(blob, dict):
                context.registered_actor_type = blob.get("type")
                context.registered_actor_provider = blob.get("provider")
                context.registered_actor_model = blob.get("model")
            else:
                context.registered_actor_type = None
                context.registered_actor_provider = None
                context.registered_actor_model = None
        else:
            context.registered_actor_type = None
            context.registered_actor_provider = None
            context.registered_actor_model = None
    else:
        assert isinstance(result.exception, SystemExit), (
            f"Expected SystemExit, got: {result.exception}"
        )
        context.command_error = result.output
        context.actor_registered = False
        context.registered_actor_type = None
        context.registered_actor_provider = None
        context.registered_actor_model = None


@when('I run the actor update command for "{name}" with the prepared config file')
def step_run_actor_update(context: Context, name: str) -> None:
    """Run the agents actor update command via the real CLI (Typer CliRunner).

    Uses typer.testing.CliRunner to invoke the actual CLI entrypoint.
    The registry is mocked with a pre-existing actor so that the update
    command can resolve the current actor. v3 schema validation runs for real.
    """
    if context.config_file is None:
        raise ValueError("No config file set")

    runner = getattr(context, "runner", CliRunner())

    existing_name = getattr(context, "existing_actor_name", name)
    existing_actor = Actor(
        id=1,
        name=existing_name,
        provider="openai",
        model="gpt-4",
        config_blob={"name": existing_name, "provider": "openai", "model": "gpt-4"},
        config_hash=Actor.compute_hash(
            {"name": existing_name, "provider": "openai", "model": "gpt-4"}
        ),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    updated_actor = Actor(
        id=1,
        name=name,
        provider="openai",
        model="gpt-4",
        config_blob={"name": name, "provider": "openai", "model": "gpt-4"},
        config_hash=Actor.compute_hash(
            {"name": name, "provider": "openai", "model": "gpt-4"}
        ),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    mock_registry = MagicMock()
    mock_registry.get_actor.return_value = existing_actor
    mock_registry.upsert_actor.return_value = updated_actor
    mock_service = MagicMock()

    with (
        patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
        patch.object(
            ActorConfigSchema,
            "model_validate",
            wraps=ActorConfigSchema.model_validate,
        ) as schema_spy,
    ):
        mock_get_services.return_value = (mock_service, mock_registry)
        result = runner.invoke(
            actor_app,
            ["update", name, "--config", str(context.config_file)],
            catch_exceptions=True,
        )

    context.schema_validate_spy = schema_spy
    context.cli_result = result
    success = result.exit_code == 0
    context.command_result = {
        "success": success,
        "exit_code": result.exit_code,
        "output": result.output,
    }
    if success:
        assert result.exception is None, f"Unexpected exception: {result.exception}"
        context.command_error = None
        context.actor_registered = True
        # Verify registry was called and extract actor metadata from the config blob
        if mock_registry.upsert_actor.called:
            call_kwargs = mock_registry.upsert_actor.call_args
            blob = call_kwargs.kwargs.get("config_blob") if call_kwargs.kwargs else None
            if isinstance(blob, dict):
                context.registered_actor_type = blob.get("type")
                context.registered_actor_provider = blob.get("provider")
                context.registered_actor_model = blob.get("model")
            else:
                context.registered_actor_type = None
                context.registered_actor_provider = None
                context.registered_actor_model = None
        else:
            context.registered_actor_type = None
            context.registered_actor_provider = None
            context.registered_actor_model = None
    else:
        assert isinstance(result.exception, SystemExit), (
            f"Expected SystemExit, got: {result.exception}"
        )
        context.command_error = result.output
        context.actor_registered = False
        context.registered_actor_type = None
        context.registered_actor_provider = None
        context.registered_actor_model = None


# ── Then Steps (Assertions) — prefixed with "v3actor" to avoid conflicts ─────


@then("v3actor the command should succeed")
def step_command_success(context: Context) -> None:
    """Assert that the command succeeded."""
    assert context.command_result is not None, "No command result"
    assert context.command_result.get("success", False), (
        f"Command failed (exit {context.command_result.get('exit_code')}): "
        f"{context.command_result.get('output', '')}"
    )


@then("v3actor the command should fail")
def step_command_fail(context: Context) -> None:
    """Assert that the command failed."""
    assert context.command_result is not None, "No command result"
    assert not context.command_result.get("success", True), (
        f"Command should have failed but succeeded with output: "
        f"{context.command_result.get('output', '')}"
    )


@then('v3actor the error message should contain "{text}"')
def step_error_contains(context: Context, text: str) -> None:
    """Assert that the error output contains specific text."""
    output = context.command_result.get("output", "") if context.command_result else ""
    assert text.lower() in output.lower(), (
        f"Output does not contain '{text}':\n{output}"
    )


@then('the actor should be registered with type "{actor_type}"')
def step_actor_registered_with_type(context: Context, actor_type: str) -> None:
    """Assert that the actor was registered with the correct type.

    Checks the config_blob passed to registry.upsert_actor() to verify the
    type field matches — not the mock return value.
    """
    assert context.actor_registered, (
        "Actor was not registered. Output: "
        f"{context.command_result.get('output', '') if context.command_result else ''}"
    )
    registered_type = getattr(context, "registered_actor_type", None)
    assert registered_type == actor_type, (
        f"Expected actor type '{actor_type}' but got '{registered_type}'"
    )


@then("the actor should be validated via ActorConfigSchema")
def step_actor_validated_via_schema(context: Context) -> None:
    """Assert that the actor was validated via ActorConfigSchema.model_validate.

    The spy installed in the ``@when`` step records whether
    ``ActorConfigSchema.model_validate`` was called. If the command succeeded
    AND the spy was called, v3 schema validation ran as expected.
    """
    assert context.actor_registered, (
        "Actor was not registered. Output: "
        f"{context.command_result.get('output', '') if context.command_result else ''}"
    )
    spy = getattr(context, "schema_validate_spy", None)
    assert spy is not None and spy.called, (
        "ActorConfigSchema.model_validate was not called — "
        "v3 schema validation did not run."
    )


@then("the actor should NOT be validated via ActorConfigSchema")
def step_actor_not_validated_via_schema(context: Context) -> None:
    """Assert that ActorConfigSchema.model_validate was NOT invoked.

    Used to verify that v2-style actors (no 'type' field) bypass v3 schema
    validation entirely, as only v3 configs should pass through the schema.
    """
    spy = getattr(context, "schema_validate_spy", None)
    assert spy is not None, (
        "schema_validate_spy was not set by the @when step — "
        "cannot verify v2 backward compatibility."
    )
    assert not spy.called, (
        "ActorConfigSchema.model_validate was unexpectedly called for a v2 actor."
    )


# ── is_v3_yaml Direct Unit Steps ─────────────────────────────────────────────


@given('a config blob with only version field "{version}"')
def step_config_blob_with_version(context: Context, version: str) -> None:
    """Store a config dict with only a version field (no type) for is_v3_yaml tests."""
    context.config_blob_under_test = {"version": version, "name": "local/test"}


@given("a config blob with a null type field")
def step_config_blob_null_type(context: Context) -> None:
    """Store a config dict with type set to None for is_v3_yaml null-type test."""
    context.config_blob_under_test = {"type": None, "name": "local/test"}


@when("I call is_v3_yaml on the config blob")
def step_call_is_v3_yaml(context: Context) -> None:
    """Call is_v3_yaml() with the config blob stored in context."""
    context.is_v3_yaml_result = is_v3_yaml(context.config_blob_under_test)


@then("is_v3_yaml should return True")
def step_is_v3_yaml_true(context: Context) -> None:
    """Assert is_v3_yaml() returned True."""
    assert context.is_v3_yaml_result is True, (
        f"Expected is_v3_yaml() to return True, got {context.is_v3_yaml_result}"
    )


@then("is_v3_yaml should return False")
def step_is_v3_yaml_false(context: Context) -> None:
    """Assert is_v3_yaml() returned False."""
    assert context.is_v3_yaml_result is False, (
        f"Expected is_v3_yaml() to return False, got {context.is_v3_yaml_result}"
    )


# ── Provider-Required Direct Registry Steps ───────────────────────────────────


@given('a v3 LLM actor YAML file without the provider field named "{name}"')
def step_v3_llm_actor_no_provider(context: Context, name: str) -> None:
    """Create a v3 LLM actor YAML without a provider field."""
    safe = name.replace("/", "_")
    yaml_content = f"""\
name: {name}
type: llm
description: LLM without provider
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, safe, yaml_content)


@given('a v3 GRAPH actor YAML file without the provider field named "{name}"')
def step_v3_graph_actor_no_provider(context: Context, name: str) -> None:
    """Create a v3 GRAPH actor YAML without a provider field."""
    safe = name.replace("/", "_")
    yaml_content = f"""\
name: {name}
type: graph
description: GRAPH without provider
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A
      description: Entry node
      config: {{}}
  edges: []
  entry_node: node_a
  exit_nodes:
    - node_a
"""
    context.config_file = _write_yaml_file(context, safe, yaml_content)


@given('a valid v3 TOOL actor YAML file without the provider field named "{name}"')
def step_v3_tool_actor_no_provider(context: Context, name: str) -> None:
    """Create a valid v3 TOOL actor YAML without a provider field.

    TOOL actors do not require a provider field per the schema.
    """
    safe = name.replace("/", "_")
    yaml_content = f"""\
name: {name}
type: tool
description: TOOL without provider
tools:
  - files/read_file
"""
    context.config_file = _write_yaml_file(context, safe, yaml_content)


@given("a valid v3 TOOL actor YAML without provider for schema validation")
def step_v3_tool_actor_no_provider_schema(context: Context) -> None:
    """Store a v3 TOOL actor blob without a provider for direct schema validation.

    TOOL actors are the only v3 actor type that does not require a provider field.
    This scenario validates the schema behaviour directly without going through
    the legacy CLI canonicalization layer.
    """
    context.schema_validation_blob = {
        "name": "local/tool-no-provider",
        "type": "tool",
        "description": "TOOL without provider",
        "tools": ["files/read_file"],
    }
    context.schema_validation_error: str | None = None
    context.schema_validation_success: bool = False


@when("I validate the TOOL actor YAML blob directly via ActorConfigSchema")
def step_validate_tool_blob_via_schema(context: Context) -> None:
    """Call ActorConfigSchema.model_validate() on the stored blob."""
    try:
        ActorConfigSchema.model_validate(context.schema_validation_blob)
        context.schema_validation_success = True
    except pydantic.ValidationError as exc:
        context.schema_validation_error = str(exc)
        context.schema_validation_success = False


@then("the schema validation should succeed")
def step_schema_validation_succeeds(context: Context) -> None:
    """Assert that ActorConfigSchema.model_validate() succeeded."""
    assert context.schema_validation_success, (
        f"Expected schema validation to succeed, but got error: "
        f"{context.schema_validation_error}"
    )


# ── Registry-Level Direct Test Steps ─────────────────────────────────────────


@given('a v3 GRAPH actor YAML with a cycle for direct registry testing named "{name}"')
def step_v3_graph_cycle_for_registry(context: Context, name: str) -> None:
    """Store a v3 GRAPH YAML string with a cycle for direct registry.add() testing."""
    context.registry_yaml_text = f"""\
name: {name}
type: graph
description: Graph with cycle for direct registry test
provider: openai
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A
      description: First node
      config: {{}}
    - id: node_b
      type: agent
      name: Node B
      description: Second node
      config: {{}}
  edges:
    - from_node: node_a
      to_node: node_b
    - from_node: node_b
      to_node: node_a
  entry_node: node_a
  exit_nodes:
    - node_b
"""


@when("I call ActorRegistry.add directly with the YAML")
def step_call_registry_add_directly(context: Context) -> None:
    """Call ActorRegistry.add() directly (bypassing the CLI).

    The registry is constructed with mocked service and provider dependencies
    so the test exercises the validation layer without a real database.
    """
    mock_service = MagicMock()
    mock_provider_registry = MagicMock()
    mock_provider_registry.get_configured_providers.return_value = []
    mock_settings = MagicMock()
    mock_settings.resolve_provider_defaults.return_value = MagicMock(
        provider=None, model=None
    )

    registry = ActorRegistry(
        actor_service=mock_service,
        provider_registry=mock_provider_registry,
        settings=mock_settings,
    )

    context.registry_add_error: str | None = None
    try:
        registry.add(context.registry_yaml_text)
    except ValidationError as exc:
        context.registry_add_error = str(exc)


@then('the registry should raise a ValidationError containing "{text}"')
def step_registry_raises_validation_error(context: Context, text: str) -> None:
    """Assert that registry.add() raised a ValidationError with the given text."""
    assert context.registry_add_error is not None, (
        "Expected ActorRegistry.add() to raise a ValidationError, but it succeeded."
    )
    assert text.lower() in context.registry_add_error.lower(), (
        f"Expected error to contain '{text}', got: {context.registry_add_error}"
    )


# ── M4: null type field CLI steps ────────────────────────────────────────────


@given("a v3 actor YAML file with null type field")
def step_v3_actor_null_type_cli(context: Context) -> None:
    """Create a YAML file with type explicitly set to null.

    Such a config is NOT detected as v3 by is_v3_yaml() (null type field is
    excluded per spec), so it falls through to v2 handling.  Provider and
    model fields are required by v2 handling.
    """
    yaml_content = """\
name: local/null-type-actor
type: null
description: Actor with null type field (falls back to v2)
provider: openai
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "null-type-actor", yaml_content)
    context.actor_add_name = "local/null-type-actor"


@when("I run the actor add command")
def step_run_actor_add_no_name(context: Context) -> None:
    """Run the actor add command using the name stored in context.actor_add_name."""
    name = getattr(context, "actor_add_name", "local/null-type-actor")

    if context.config_file is None:
        raise ValueError("No config file set")

    runner = getattr(context, "runner", CliRunner())

    mock_actor = Actor(
        id=1,
        name=name,
        provider="openai",
        model="gpt-4",
        config_blob={"name": name, "provider": "openai", "model": "gpt-4"},
        config_hash=Actor.compute_hash(
            {"name": name, "provider": "openai", "model": "gpt-4"}
        ),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    mock_registry = MagicMock()
    mock_registry.upsert_actor.return_value = mock_actor
    mock_registry.get_actor.side_effect = NotFoundError("not found")
    mock_service = MagicMock()

    with (
        patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
        patch.object(
            ActorConfigSchema,
            "model_validate",
            wraps=ActorConfigSchema.model_validate,
        ) as schema_spy,
    ):
        mock_get_services.return_value = (mock_service, mock_registry)
        result = runner.invoke(
            actor_app,
            ["add", name, "--config", str(context.config_file)],
            catch_exceptions=True,
        )

    context.schema_validate_spy = schema_spy
    context.cli_result = result
    success = result.exit_code == 0
    context.command_result = {
        "success": success,
        "exit_code": result.exit_code,
        "output": result.output,
    }
    if success:
        assert result.exception is None, f"Unexpected exception: {result.exception}"
        context.command_error = None
        context.actor_registered = True
        if mock_registry.upsert_actor.called:
            call_kwargs = mock_registry.upsert_actor.call_args
            blob = call_kwargs.kwargs.get("config_blob") if call_kwargs.kwargs else None
            context.registered_actor_type = (
                blob.get("type") if isinstance(blob, dict) else None
            )
        else:
            context.registered_actor_type = None
    else:
        assert isinstance(result.exception, SystemExit), (
            f"Expected SystemExit, got: {result.exception}"
        )
        context.command_error = result.output
        context.actor_registered = False
        context.registered_actor_type = None


# ── M5: Update without --config flag steps ───────────────────────────────────


@given("a registered v3 LLM actor")
def step_registered_v3_llm_actor(context: Context) -> None:
    """Prepare a pre-existing v3 LLM actor for the update-without-config test."""
    context.existing_actor_name = "local/existing-v3-llm"


@when("I run the actor update command without a config file")
def step_run_actor_update_no_config(context: Context) -> None:
    """Run the actor update command without providing a --config flag.

    When no config file is given, the CLI re-uses the existing actor's blob
    as-is and skips v3 schema re-validation.
    """
    name = getattr(context, "existing_actor_name", "local/existing-v3-llm")
    runner = getattr(context, "runner", CliRunner())

    existing_actor = Actor(
        id=1,
        name=name,
        provider="openai",
        model="gpt-4",
        config_blob={
            "name": name,
            "type": "llm",
            "provider": "openai",
            "model": "gpt-4",
        },
        config_hash=Actor.compute_hash(
            {"name": name, "type": "llm", "provider": "openai", "model": "gpt-4"}
        ),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    updated_actor = Actor(
        id=1,
        name=name,
        provider="openai",
        model="gpt-4",
        config_blob={
            "name": name,
            "type": "llm",
            "provider": "openai",
            "model": "gpt-4",
        },
        config_hash=Actor.compute_hash(
            {"name": name, "type": "llm", "provider": "openai", "model": "gpt-4"}
        ),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    mock_registry = MagicMock()
    mock_registry.get_actor.return_value = existing_actor
    mock_registry.upsert_actor.return_value = updated_actor
    mock_service = MagicMock()

    with (
        patch("cleveragents.cli.commands.actor._get_services") as mock_get_services,
        patch.object(
            ActorConfigSchema,
            "model_validate",
            wraps=ActorConfigSchema.model_validate,
        ) as schema_spy,
    ):
        mock_get_services.return_value = (mock_service, mock_registry)
        # Invoke update WITHOUT --config flag
        result = runner.invoke(
            actor_app,
            ["update", name],
            catch_exceptions=True,
        )

    context.schema_validate_spy = schema_spy
    context.cli_result = result
    success = result.exit_code == 0
    context.command_result = {
        "success": success,
        "exit_code": result.exit_code,
        "output": result.output,
    }
    if success:
        assert result.exception is None, f"Unexpected exception: {result.exception}"
        context.command_error = None
        context.actor_registered = True
        if mock_registry.upsert_actor.called:
            call_kwargs = mock_registry.upsert_actor.call_args
            blob = call_kwargs.kwargs.get("config_blob") if call_kwargs.kwargs else None
            context.registered_actor_type = (
                blob.get("type") if isinstance(blob, dict) else None
            )
        else:
            context.registered_actor_type = None
    else:
        assert isinstance(result.exception, SystemExit), (
            f"Expected SystemExit, got: {result.exception}"
        )
        context.command_error = result.output
        context.actor_registered = False
        context.registered_actor_type = None


# ── M6: version "3" without type field CLI steps ──────────────────────────────


@given('a v3 actor YAML file with version "3" but no type field')
def step_v3_actor_version3_no_type(context: Context) -> None:
    """Create a YAML file with version "3" but no type field.

    is_v3_yaml() detects this as v3 via the version branch.  However,
    ActorConfigSchema.model_validate() requires 'type', so validation fails.
    """
    yaml_content = """\
name: local/version3-no-type
version: "3"
description: Actor with version 3 but no type field
provider: openai
model: gpt-4
"""
    context.config_file = _write_yaml_file(context, "version3-no-type", yaml_content)
    context.actor_add_name = "local/version3-no-type"


# ── m5: Duplicate node IDs step ───────────────────────────────────────────────


@given("a v3 GRAPH actor YAML file with duplicate node IDs")
def step_v3_graph_actor_duplicate_node_ids(context: Context) -> None:
    """Create a v3 GRAPH actor YAML with two nodes sharing the same ID."""
    yaml_content = """\
name: local/dup-node-ids
type: graph
description: Graph with duplicate node IDs
provider: openai
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A First
      description: First occurrence of node_a
      config: {}
    - id: node_a
      type: agent
      name: Node A Second
      description: Duplicate node_a
      config: {}
  edges:
    - from_node: node_a
      to_node: node_a
  entry_node: node_a
  exit_nodes:
    - node_a
"""
    context.config_file = _write_yaml_file(context, "dup-node-ids", yaml_content)


# ── m6: Edge referencing non-existent node step ───────────────────────────────


@given("a v3 GRAPH actor YAML file with an edge referencing a non-existent node")
def step_v3_graph_actor_bad_edge_ref(context: Context) -> None:
    """Create a v3 GRAPH actor YAML with an edge pointing to a node that does not exist."""
    yaml_content = """\
name: local/bad-edge-ref
type: graph
description: Graph with edge to non-existent node
provider: openai
model: gpt-4
route:
  nodes:
    - id: node_a
      type: agent
      name: Node A
      description: Valid node
      config: {}
  edges:
    - from_node: node_a
      to_node: ghost_node
  entry_node: node_a
  exit_nodes:
    - node_a
"""
    context.config_file = _write_yaml_file(context, "bad-edge-ref", yaml_content)


# ── Nested actors map: combined config.actor field (bug #11189) ───────────────


@given('a nested LLM actor YAML using combined config.actor field with name "{name}"')
def step_nested_llm_combined_actor(context: Context, name: str) -> None:
    """Create a spec-compliant nested-actors-map YAML using the combined config.actor field.

    The ``actors:`` map format places ``type: llm`` at the actor-entry level
    (``actors.<name>.type``), not inside the ``config:`` block.  The provider
    and model are supplied via the combined ``config.actor: provider/model``
    shorthand rather than separate ``config.provider``/``config.model`` keys.

    This is the exact format described in the spec and reproduced in bug #11189.
    """
    yaml_content = f"""\
name: {name}
cleveragents:
  version: "3.0"
  default_actor: main
actors:
  main:
    type: llm
    config:
      actor: anthropic/claude-sonnet-4-5
      system_prompt: "You are a test actor."
      temperature: 0.3
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given(
    "a nested LLM actor YAML with explicit config.provider overriding config.actor"
    ' with name "{name}"'
)
def step_nested_llm_explicit_provider_overrides_combined(
    context: Context, name: str
) -> None:
    """Create a nested-actors-map YAML with both config.provider and config.actor set.

    When ``config.provider`` is explicitly present it must take precedence over
    the provider parsed from the combined ``config.actor: provider/model``
    shorthand.  The model half of the combined field is still used as the
    model fallback (since no separate ``config.model`` is provided here).
    """
    yaml_content = f"""\
name: {name}
cleveragents:
  version: "3.0"
  default_actor: main
actors:
  main:
    type: llm
    config:
      provider: explicit-provider
      actor: override/gpt-4
      system_prompt: "Precedence test actor."
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given('a nested LLM actor YAML with no provider or model fields with name "{name}"')
def step_nested_llm_no_provider_or_model(context: Context, name: str) -> None:
    """Create a nested-actors-map YAML with type: llm but no provider or model.

    When neither ``config.provider``, ``config.model``, nor ``config.actor``
    are present the extraction must fail gracefully and propagate a validation
    error message so that users receive a clear diagnostic.
    """
    yaml_content = f"""\
name: {name}
cleveragents:
  version: "3.0"
  default_actor: main
actors:
  main:
    type: llm
    config:
      system_prompt: "Actor without provider or model."
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given(
    "a nested LLM actor YAML with explicit config.model overriding config.actor"
    ' with name "{name}"'
)
def step_nested_llm_explicit_model_overrides_combined(
    context: Context, name: str
) -> None:
    """Create a nested-actors-map YAML with both config.model and config.actor set.

    When ``config.model`` is explicitly present it must take precedence over
    the model parsed from the combined ``config.actor: provider/model``
    shorthand.  The provider half of the combined field is still used as the
    provider fallback (since no separate ``config.provider`` is provided here).
    """
    yaml_content = f"""\
name: {name}
cleveragents:
  version: "3.0"
  default_actor: main
actors:
  main:
    type: llm
    config:
      model: explicit-model
      actor: anthropic/claude-sonnet-4-5
      system_prompt: "Model precedence test actor."
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@given(
    'a nested LLM actor YAML with combined config.actor "{combined_value}"'
    ' and name "{name}"'
)
def step_nested_llm_malformed_combined_actor(
    context: Context, combined_value: str, name: str
) -> None:
    """Create a nested-actors-map YAML with a specific config.actor value.

    Used to test edge cases of the combined ``config.actor: provider/model``
    shorthand: empty model half (``provider/``), empty provider half
    (``/model``), no delimiter (``noslash``), empty string, etc.
    """
    yaml_content = f"""\
name: {name}
cleveragents:
  version: "3.0"
  default_actor: main
actors:
  main:
    type: llm
    config:
      actor: {combined_value}
      system_prompt: "Malformed actor test."
"""
    context.config_file = _write_yaml_file(context, name, yaml_content)


@then(
    'v3actor the actor should be registered with provider "{provider}" and model "{model}"'
)
def step_actor_registered_with_provider_and_model(
    context: Context, provider: str, model: str
) -> None:
    """Assert the actor was registered with the expected provider and model.

    Inspects ``registered_actor_provider`` and ``registered_actor_model``
    captured by the ``@when`` step from the ``config_blob`` passed to
    ``registry.upsert_actor()`` after CLI canonicalization.  Canonicalization
    calls ``setdefault`` so the canonical blob always carries the resolved
    provider and model even when they were not present in the original YAML.
    """
    assert context.actor_registered, (
        "Actor was not registered. Output: "
        f"{context.command_result.get('output', '') if context.command_result else ''}"
    )
    reg_provider = getattr(context, "registered_actor_provider", None)
    reg_model = getattr(context, "registered_actor_model", None)
    assert reg_provider == provider, (
        f"Expected provider '{provider}' but got '{reg_provider}'"
    )
    assert reg_model == model, f"Expected model '{model}' but got '{reg_model}'"


@then('v3actor the actor should be registered with provider "{provider}"')
def step_actor_registered_with_provider(context: Context, provider: str) -> None:
    """Assert the actor was registered with the expected provider.

    Checks ``registered_actor_provider`` captured by the ``@when`` step from
    the ``config_blob`` passed to ``registry.upsert_actor()`` after
    CLI canonicalization.
    """
    assert context.actor_registered, (
        "Actor was not registered. Output: "
        f"{context.command_result.get('output', '') if context.command_result else ''}"
    )
    reg_provider = getattr(context, "registered_actor_provider", None)
    assert reg_provider == provider, (
        f"Expected provider '{provider}' but got '{reg_provider}'"
    )


@then('v3actor the actor should be registered with model "{model}"')
def step_actor_registered_with_model(context: Context, model: str) -> None:
    """Assert the actor was registered with the expected model.

    Checks ``registered_actor_model`` captured by the ``@when`` step from
    the ``config_blob`` passed to ``registry.upsert_actor()`` after
    CLI canonicalization.
    """
    assert context.actor_registered, (
        "Actor was not registered. Output: "
        f"{context.command_result.get('output', '') if context.command_result else ''}"
    )
    reg_model = getattr(context, "registered_actor_model", None)
    assert reg_model == model, f"Expected model '{model}' but got '{reg_model}'"


# ── Helper Functions ──────────────────────────────────────────────────────────


def _write_yaml_file(context: Context, name: str, content: str) -> Path:
    """Write a YAML file to the temporary directory."""
    if not hasattr(context, "temp_dir") or context.temp_dir is None:
        context.temp_dir = tempfile.TemporaryDirectory()
        context.add_cleanup(context.temp_dir.cleanup)

    # Use just the basename to avoid creating subdirectories
    safe_name = name.replace("/", "_")
    file_path = Path(context.temp_dir.name) / f"{safe_name}.yaml"
    file_path.write_text(content)
    return file_path
