"""Step definitions for plan service coverage tests."""

import asyncio
import contextlib
import os
import tempfile
from collections.abc import Callable, Iterator
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch

from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine

from cleveragents.application.services.memory_service import (
    ConversationBufferMemoryAdapter,
    MemoryService,
)
from cleveragents.application.services.plan_service import PlanService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core import (
    Actor,
    Change,
    OperationType,
    Plan,
    PlanStatus,
    Project,
    ProjectSettings,
)
from cleveragents.domain.models.core import (
    Context as PlanContext,
)
from cleveragents.domain.providers.ai_provider import (
    ActorInvocationContext,
    ProviderResponse,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.providers.registry import ProviderType


class _NoProviderRegistry:
    """Stub provider registry that always raises."""

    def create_ai_provider(self, *args: Any, **kwargs: Any) -> None:
        raise ValueError("No AI provider configured")


@given("I have a temporary test directory for plan service")
def step_create_temp_dir_plan_service(context: Context) -> None:
    """Create a temporary directory for testing."""
    context.temp_dir = Path(tempfile.mkdtemp())


@given("I have a Unit of Work instance for plan testing")
def step_create_unit_of_work_plan(context: Context) -> None:
    """Create a Unit of Work instance for testing."""
    from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
    from cleveragents.infrastructure.database.models import Base

    # Create a unique in-memory database URL for this test
    database_url = "sqlite:///:memory:"

    # Create a shared engine for in-memory database
    if database_url not in MEMORY_ENGINES:
        engine = create_engine(database_url, connect_args={"check_same_thread": False})
        MEMORY_ENGINES[database_url] = engine
    else:
        engine = MEMORY_ENGINES[database_url]

    # Create schema directly — much faster than 25 Alembic migrations
    Base.metadata.create_all(engine)

    # Create the unit of work which will use the cached engine
    context.unit_of_work = UnitOfWork(database_url=database_url)


@given("I have a PlanService instance")
def step_create_plan_service(context: Context) -> None:
    """Create a PlanService instance."""
    os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
    settings = Settings()
    context.plan_service = PlanService(
        settings=settings, unit_of_work=context.unit_of_work, ai_provider=None
    )


@given("I have a lightweight plan service for actor testing")
def step_create_lightweight_plan_service(context: Context) -> None:
    """Create a lightweight PlanService using in-memory DB for actor resolution tests.

    This avoids the overhead of file-based DB creation, temp directories, and
    project initialization — none of which are needed for testing actor/provider
    resolution logic.
    """
    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)
    if not hasattr(context, "temp_dir"):
        context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_actor_"))
    settings = Settings()
    context.plan_service = PlanService(
        settings=settings, unit_of_work=context.unit_of_work, ai_provider=None
    )


@given("I have a plan service with stub provider registry")
def step_plan_service_with_stub_registry(context: Context) -> None:
    """Create a PlanService that uses a stub provider registry for overrides."""

    os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)

    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)
    if not hasattr(context, "temp_dir"):
        context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_service_stub_"))

    settings = Settings()
    context.stub_provider_requests = cast(list[dict[str, Any]], [])

    class _StubProvider:
        def __init__(self, provider_name: str, model_name: str) -> None:
            self.name = provider_name
            self.model_id = model_name

        def generate_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> ProviderResponse:
            change = Change(
                id=None,
                plan_id=plan.id or 0,
                file_path="stub/generated_file.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content="# generated by stub\n",
                new_path=None,
                applied=False,
                created_at=datetime.now(),
                applied_at=None,
            )
            return ProviderResponse(
                changes=[change],
                model_used=self.model_id,
                token_count=42,
                error_message=None,
            )

    class _StubRegistry:
        def create_ai_provider(
            self,
            provider_type: str | ProviderType | None = None,
            model_id: str | None = None,
            max_retries: int = 3,
        ) -> _StubProvider:
            if isinstance(provider_type, ProviderType):
                provider_name = provider_type.value
            else:
                provider_name = (provider_type or "openai").lower()
            model_name = model_id or "gpt-4o"
            context.stub_provider_requests.append(
                {"provider": provider_name, "model": model_name}
            )
            return _StubProvider(provider_name, model_name)

    context.stub_registry = _StubRegistry()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=context.unit_of_work,
        ai_provider=None,
        provider_registry=context.stub_registry,
    )

    project = Project(
        id=None,
        name="stub-project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="stub-gpt",
        ),
    )
    with context.unit_of_work.transaction() as ctx:
        context.project = ctx.projects.create(project)
    context.test_project = context.project


@given("I have a plan service with a streaming stub provider")
def step_plan_service_streaming_stub(context: Context) -> None:
    """Create a PlanService that streams provider events for testing."""

    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"

    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)
    if not hasattr(context, "temp_dir"):
        context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_service_streaming_"))

    settings = Settings()

    class _StreamingStubProvider:
        def __init__(self) -> None:
            self.name = "stub-stream"
            self.model_id = "stub-model"
            self.token_count = 321

        def _build_change(self, plan: Plan) -> Change:
            return Change(
                id=None,
                plan_id=plan.id or 0,
                file_path="stream/generated_file.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content="# streamed change\n",
                new_path=None,
                applied=False,
                created_at=datetime.now(),
                applied_at=None,
            )

        def generate_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> ProviderResponse:
            change = self._build_change(plan)
            return ProviderResponse(
                changes=[change],
                model_used=self.model_id,
                token_count=self.token_count,
                error_message=None,
            )

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            response = self.generate_changes(
                project,
                plan,
                contexts,
                actor_context=actor_context,
                progress_callback=progress_callback,
            )
            for node in (
                "load_context",
                "analyze_requirements",
                "generate_plan",
                "validate",
            ):
                payload: dict[str, object] = {}
                if node == "generate_plan":
                    payload = {
                        "status": "completed",
                        "change_count": len(response.changes),
                    }
                elif node == "validate":
                    payload = {"status": "completed"}
                yield {node: payload}
            yield {"__end__": {"response": response}}

    context.streaming_stub_provider = _StreamingStubProvider()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=context.unit_of_work,
        ai_provider=context.streaming_stub_provider,
    )

    project = Project(
        id=None,
        name="streaming-project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="stub-stream",
        ),
    )
    with context.unit_of_work.transaction() as ctx:
        context.test_project = ctx.projects.create(project)


def _setup_custom_streaming_plan_service(
    context: Context,
    provider_factory: Callable[[], object],
    *,
    project_name: str,
) -> None:
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)
    if not hasattr(context, "temp_dir"):
        context.temp_dir = Path(
            tempfile.mkdtemp(prefix="plan_service_streaming_custom_")
        )
    settings = Settings()
    provider = provider_factory()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=context.unit_of_work,
        ai_provider=provider,
    )
    project = Project(
        id=None,
        name=project_name,
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model=getattr(provider, "model_id", "custom-stream"),
        ),
    )
    with context.unit_of_work.transaction() as ctx:
        context.test_project = ctx.projects.create(project)


@given("I have a plan service with an incomplete streaming provider")
def step_streaming_provider_incomplete(context: Context) -> None:
    class _IncompleteStreamProvider:
        def __init__(self) -> None:
            self.name = "incomplete-stream"
            self.model_id = "stub-incomplete"

        def generate_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> ProviderResponse:
            return ProviderResponse(
                changes=[],
                model_used=self.model_id,
                token_count=0,
                error_message=None,
            )

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[object]:
            yield "corrupted-event"

    _setup_custom_streaming_plan_service(
        context,
        lambda: _IncompleteStreamProvider(),
        project_name="incomplete-streaming-project",
    )


@given("I have a plan service with a dict streaming provider")
def step_streaming_provider_dict(context: Context) -> None:
    class _DictPayloadStreamProvider:
        def __init__(self) -> None:
            self.name = "dict-stream"
            self.model_id = "dict-model"

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            yield {"generate_plan": {"status": "running"}}
            payload = {
                "changes": [
                    {
                        "plan_id": plan.id or 0,
                        "file_path": "dict_payload.py",
                        "operation": "create",
                        "original_content": None,
                        "new_content": "print('dict payload')",
                        "applied": False,
                    }
                ],
                "model_used": self.model_id,
                "token_count": "not-a-number",
            }
            yield {"__end__": payload}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _DictPayloadStreamProvider(),
        project_name="dict-streaming-project",
    )


@given("I have a plan service with a string error streaming provider")
def step_streaming_provider_string_error(context: Context) -> None:
    class _StringErrorStreamProvider:
        def __init__(self) -> None:
            self.name = "string-error-stream"
            self.model_id = "string-model"

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            yield {"__end__": "stub failure"}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _StringErrorStreamProvider(),
        project_name="string-error-project",
    )


@given("I have a plan service with a non-list change streaming provider")
def step_streaming_provider_non_list(context: Context) -> None:
    class _NonListChangeStreamProvider:
        def __init__(self) -> None:
            self.name = "non-list-stream"
            self.model_id = "non-list-model"

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            payload = {
                "changes": {"file_path": "bogus.py"},
                "model_used": self.model_id,
                "token_count": 1,
            }
            yield {"__end__": payload}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _NonListChangeStreamProvider(),
        project_name="non-list-stream-project",
    )


@given("I have a plan service with an invalid change entry streaming provider")
def step_streaming_provider_invalid_entry(context: Context) -> None:
    class _InvalidEntryStreamProvider:
        def __init__(self) -> None:
            self.name = "invalid-entry-stream"
            self.model_id = "invalid-entry-model"

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            payload = {
                "changes": [123],
                "model_used": self.model_id,
                "token_count": 1,
            }
            yield {"__end__": payload}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _InvalidEntryStreamProvider(),
        project_name="invalid-entry-stream-project",
    )


@given("I have a plan service with a non-python streaming provider")
def step_streaming_provider_non_python(context: Context) -> None:
    class _NonPythonStreamProvider:
        def __init__(self) -> None:
            self.name = "non-python-stream"
            self.model_id = "non-python-model"

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            change = Change(
                id=None,
                plan_id=plan.id or 0,
                file_path="notes.txt",
                operation=OperationType.CREATE,
                original_content=None,
                new_content="plain text change",
                new_path=None,
                applied=False,
                applied_at=None,
                created_at=datetime.now(),
            )
            response = ProviderResponse(
                changes=[change],
                model_used=self.model_id,
                token_count=5,
                error_message=None,
            )
            yield {"__end__": response}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _NonPythonStreamProvider(),
        project_name="non-python-stream-project",
    )


@given("I have a plan service with a syntax error streaming provider")
def step_streaming_provider_syntax_error(context: Context) -> None:
    class _SyntaxErrorStreamProvider:
        def __init__(self) -> None:
            self.name = "syntax-error-stream"
            self.model_id = "syntax-model"

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[dict[str, object]]:
            change = Change(
                id=None,
                plan_id=plan.id or 0,
                file_path="bad.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content="def broken(",
                new_path=None,
                applied=False,
                applied_at=None,
                created_at=datetime.now(),
            )
            response = ProviderResponse(
                changes=[change],
                model_used=self.model_id,
                token_count=0,
                error_message=None,
            )
            yield {"__end__": response}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _SyntaxErrorStreamProvider(),
        project_name="syntax-error-stream-project",
    )


@given("mock provider mode is forced")
def step_force_mock_provider_mode(context: Context) -> None:
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"

    if not hasattr(context, "env_vars_to_clean"):
        context.env_vars_to_clean = []
    if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
        context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
    if hasattr(context, "plan_service"):
        context.plan_service.ai_provider = None
    context.provider_resolution = None


@given("the plan service uses a nameless AI provider")
def step_set_nameless_ai_provider(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _NamelessProvider:
        pass

    context.plan_service.ai_provider = _NamelessProvider()


@given("I stub the provider registry factory for lazy initialization")
def step_stub_provider_registry_lazy(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    # Ensure mock-provider forcing doesn't short-circuit registry creation
    original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"

    # Force registry path by clearing any injected provider
    context.plan_service.ai_provider = None

    class _LazyProvider:
        def __init__(self, provider_name: str, model_name: str) -> None:
            self.name = provider_name
            self.model_id = model_name

    class _LazyRegistry:
        def __init__(self) -> None:
            self.requests: list[dict[str, str]] = []

        def create_ai_provider(
            self,
            provider_type: str | ProviderType | None = None,
            model_id: str | None = None,
            max_retries: int = 3,
        ) -> _LazyProvider:
            if isinstance(provider_type, ProviderType):
                provider_name = provider_type.value
            else:
                provider_name = (provider_type or "openai").lower()
            model_name = (model_id or "gpt-4o").lower()
            self.requests.append({"provider": provider_name, "model": model_name})
            return _LazyProvider(provider_name, model_name)

    lazy_registry = _LazyRegistry()
    patcher = patch(
        "cleveragents.application.services.plan_service.get_provider_registry",
        autospec=True,
        return_value=lazy_registry,
    )
    patcher.start()
    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(patcher.stop)

    # Restore mock flag after scenario
    cleanup_handlers.append(
        lambda: (
            os.environ.__setitem__(
                "CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
            )
            if original_mock_flag is not None
            else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
        )
    )

    context.lazy_registry = lazy_registry
    context.plan_service._provider_registry = None


@then('the lazy registry should memoize provider "{provider}" and model "{model}"')
def step_assert_lazy_registry(context: Context, provider: str, model: str) -> None:
    registry = getattr(context, "lazy_registry", None)
    assert registry is not None, "Lazy registry stub was not configured"
    expected_provider = provider.lower()
    expected_model = model.lower()
    assert registry.requests == [
        {"provider": expected_provider, "model": expected_model}
    ], "Lazy registry did not capture the override request"
    assert context.plan_service._provider_registry is registry
    resolution = getattr(context, "provider_resolution", None)
    assert resolution is not None, "Provider resolution result missing"
    provider_instance, resolved_name, resolved_model = resolution[:3]
    assert resolved_name == expected_provider
    assert resolved_model == model
    assert provider_instance.name == expected_provider
    assert provider_instance.model_id == expected_model


@given("the plan service has a cached provider registry instance")
def step_cached_provider_registry(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    cached_registry = SimpleNamespace(name="cached-registry")
    context.plan_service._provider_registry = cached_registry
    context.cached_registry = cached_registry


@when("I fetch the provider registry from the plan service")
def step_fetch_cached_provider_registry(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    try:
        context.registry_result = context.plan_service._get_provider_registry()
        context.exception = None
    except Exception as exc:
        context.registry_result = None
        context.exception = exc


@then("the cached provider registry should be reused")
def step_assert_cached_provider_registry(context: Context) -> None:
    cached = getattr(context, "cached_registry", None)
    result = getattr(context, "registry_result", None)
    assert cached is not None, "Cached registry was not configured"
    assert result is cached
    assert getattr(context, "exception", None) is None


@given('the plan service actor lookup raises ValidationError "{message}"')
def step_actor_lookup_validation_error(context: Context, message: str) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _ActorServiceStub:
        def get_actor(self, actor_name: str) -> None:
            raise ValidationError(message)

        def get_default_actor(self) -> None:
            raise ValidationError(message)

        def ensure_default_mock_actor(self) -> None:
            raise ValidationError(message)

    context.plan_service.actor_service = _ActorServiceStub()


@given("the plan service actor lookup returns no actor and mock provisioning fails")
def step_actor_lookup_returns_none(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _ActorServiceStub:
        def get_actor(self, actor_name: str | None) -> None:
            return None

        def get_default_actor(self) -> None:
            return None

        def ensure_default_mock_actor(self) -> None:
            raise RuntimeError("no mock actor available")

    context.plan_service.actor_service = _ActorServiceStub()


@given(
    'the plan service actor lookup returns actor "{actor_name}" with provider "{provider}" and model "{model}"'
)
def step_actor_lookup_returns_actor(
    context: Context, actor_name: str, provider: str, model: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    actor = Actor(
        id=None,
        name=actor_name,
        provider=provider,
        model=model,
        config_blob={},
        config_hash=Actor.compute_hash({}),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )

    class _ActorServiceStub:
        def get_actor(self, name: str) -> Actor:
            return actor

        def get_default_actor(self) -> Actor:
            return actor

        def ensure_default_mock_actor(self) -> Actor:
            return actor

    context.plan_service.actor_service = _ActorServiceStub()


@given(
    'the plan service actor lookup returns actor "{actor_name}" with provider "{provider}" and no model'
)
def step_actor_lookup_returns_actor_without_model(
    context: Context, actor_name: str, provider: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    actor = Actor(
        id=None,
        name=actor_name,
        provider=provider,
        model="placeholder",
        config_blob={},
        config_hash=Actor.compute_hash({}),
        graph_descriptor=None,
        unsafe=False,
        is_built_in=False,
        is_default=False,
    )
    object.__setattr__(actor, "model", None)

    class _ActorServiceStub:
        def get_actor(self, name: str) -> Actor:
            return actor

        def get_default_actor(self) -> Actor:
            return actor

        def ensure_default_mock_actor(self) -> Actor:
            return actor

    context.plan_service.actor_service = _ActorServiceStub()


@when('I try to resolve actor "{actor_name}"')
def step_try_resolve_actor(context: Context, actor_name: str) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    if actor_name == "ghost/actor":
        context.plan_service.actor_service = None
    try:
        context.actor_resolution = context.plan_service._resolve_actor(actor_name)
        context.exception = None
    except Exception as exc:
        context.actor_resolution = None
        context.exception = exc


@then('the actor resolution should return actor "{actor_name}" from source "{source}"')
def step_assert_actor_resolution(
    context: Context, actor_name: str, source: str
) -> None:
    resolution = getattr(context, "actor_resolution", None)
    assert resolution is not None, "Actor resolution result missing"
    actor, actor_source = resolution
    assert actor.name == actor_name
    assert actor_source == source


@then('the PlanError should include actor detail "{actor_name}"')
def step_assert_plan_error_actor_detail(context: Context, actor_name: str) -> None:
    exc = getattr(context, "exception", None)
    assert isinstance(exc, PlanError), "Expected PlanError to be raised"
    details = getattr(exc, "details", {}) or {}
    assert details.get("actor", "") == actor_name


@given('the plan service AI provider is "{provider_name}" with model "{model_name}"')
def step_set_plan_service_ai_provider(
    context: Context, provider_name: str, model_name: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _Provider:
        def __init__(self, name: str, model_id: str) -> None:
            self.name = name
            self.model_id = model_id

    context.plan_service.ai_provider = _Provider(provider_name, model_name)


@given('the plan service AI provider has name "{provider_name}" without model id')
def step_set_plan_service_ai_provider_name_only(
    context: Context, provider_name: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _ProviderNameOnly:
        def __init__(self, name: str) -> None:
            self.name = name

    context.plan_service.ai_provider = _ProviderNameOnly(provider_name)


@given("the plan service AI provider has no identifier attributes")
def step_set_anonymous_ai_provider(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
    if not hasattr(context, "env_vars_to_clean"):
        context.env_vars_to_clean = []
    if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
        context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")

    class _AnonymousProvider:
        """Provider stub without name/model_id attributes."""

        pass

    context.plan_service.ai_provider = _AnonymousProvider()
    context.provider_resolution = None


@given("the plan service uses a delayed mock provider")
def step_set_delayed_mock_provider(context: Context) -> None:
    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)

    settings = Settings()

    class _DelayedProvider:
        def __init__(self) -> None:
            self.name = "delayed-provider"
            self.model_id = "delayed-model"

    class _DelayedPlanService(PlanService):
        def __init__(self, *args: Any, **kwargs: Any) -> None:
            super().__init__(*args, **kwargs)
            self._provider_toggle = False
            self._late_provider = kwargs.get("ai_provider") or _DelayedProvider()

        @property
        def ai_provider(self):  # type: ignore[override]
            if not getattr(self, "_provider_toggle", False):
                self._provider_toggle = True
                return None
            return self._late_provider

        @ai_provider.setter
        def ai_provider(self, value):  # type: ignore[override]
            self._late_provider = value

        def _use_mock_provider(self) -> bool:
            return True

    context.plan_service = _DelayedPlanService(
        settings=settings, unit_of_work=context.unit_of_work, ai_provider=None
    )


@given("the plan service actor provider resolution will fail")
def step_force_actor_provider_resolution_failure(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    context.plan_service.actor_service = SimpleNamespace(name="stub-actor-service")
    patcher = patch.object(
        context.plan_service,
        "_resolve_ai_provider_for_actor",
        side_effect=PlanError(message="actor resolution failed"),
    )
    patcher.start()
    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(patcher.stop)


@when('I resolve the AI provider for actor "{actor_name}"')
def step_resolve_ai_provider_for_actor(context: Context, actor_name: str) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    try:
        context.provider_resolution = (
            context.plan_service._resolve_ai_provider_for_actor(actor_name)
        )
        context.exception = None
    except Exception as exc:
        context.provider_resolution = None
        context.exception = exc


@then("the provider resolution should use fallback names")
def step_assert_provider_resolution_fallback(context: Context) -> None:
    resolution = getattr(context, "provider_resolution", None)
    assert resolution is not None, "Provider resolution result missing"
    provider_instance, provider_name, model_name, actor, *_ = resolution
    assert provider_name == "mock-provider"

    actor_model = getattr(actor, "model", None)
    expected_model = actor_model or provider_name
    assert model_name == expected_model

    assert not hasattr(provider_instance, "name")
    assert not hasattr(provider_instance, "model_id")


@then(
    'the nameless provider resolution should return provider "{provider}" and model "{model}"'
)
def step_assert_nameless_provider_resolution(
    context: Context, provider: str, model: str
) -> None:
    resolution = getattr(context, "provider_resolution", None)
    assert resolution is not None, "Provider resolution result missing"
    provider_instance, provider_name, model_name = resolution[:3]
    assert provider_name == provider
    assert model_name == model
    assert not hasattr(provider_instance, "name")
    assert not hasattr(provider_instance, "model_id")


@when(
    'I resolve provider for actor "{actor_name}" with model override "{model_override}"'
)
def step_resolve_provider_with_override(
    context: Context, actor_name: str, model_override: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    try:
        context.provider_resolution = (
            context.plan_service._resolve_ai_provider_for_actor(
                actor_name, model=model_override
            )
        )
        context.exception = None
    except Exception as exc:
        context.provider_resolution = None
        context.exception = exc


@when('I resolve provider for actor "{actor_name}"')
def step_resolve_provider_for_actor(context: Context, actor_name: str) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    try:
        context.provider_resolution = (
            context.plan_service._resolve_ai_provider_for_actor(actor_name)
        )
        context.exception = None
    except Exception as exc:
        context.provider_resolution = None
        context.exception = exc


@when("I try to resolve a provider for the plan service")
def step_resolve_provider_for_plan_service(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    try:
        context.provider_resolution = (
            context.plan_service._resolve_ai_provider_for_actor(None)
        )
        context.exception = None
    except Exception as exc:
        context.provider_resolution = None
        context.exception = exc


@then('the provider resolution should return provider "{provider}" and model "{model}"')
def step_assert_provider_resolution(
    context: Context, provider: str, model: str
) -> None:
    resolution = getattr(context, "provider_resolution", None)
    assert resolution is not None, "Provider resolution result missing"
    provider_instance, provider_name, model_name = resolution[:3]
    assert provider_name == provider
    assert model_name == model
    assert getattr(provider_instance, "name", provider_name) == provider_name
    assert getattr(provider_instance, "model_id", model_name) == model_name


@then('the lazy registry should record actor provider "{provider}" and model "{model}"')
def step_assert_lazy_registry_actor(
    context: Context, provider: str, model: str
) -> None:
    registry = getattr(context, "lazy_registry", None)
    assert registry is not None, "Lazy registry stub was not configured"
    expected = {"provider": provider.lower(), "model": model.lower()}
    assert registry.requests == [expected], (
        "Lazy registry did not capture the actor request"
    )

    resolution = getattr(context, "provider_resolution", None)
    assert resolution is not None, "Provider resolution result missing"
    provider_instance, provider_name, model_name, *_ = resolution
    assert provider_name == expected["provider"]
    assert model_name == model
    assert getattr(provider_instance, "name", provider_name) == expected["provider"]
    assert getattr(provider_instance, "model_id", model_name) == model


@given('the provider registry raises ValueError "{message}" for actor providers')
def step_provider_registry_raises_for_actor(context: Context, message: str) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _Registry:
        def create_ai_provider(self, *args: Any, **kwargs: Any) -> None:
            raise ValueError(message)

    original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"

    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(
        lambda: (
            os.environ.__setitem__(
                "CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
            )
            if original_mock_flag is not None
            else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
        )
    )

    context.plan_service._provider_registry = _Registry()
    context.plan_service.ai_provider = None


@given(
    'the provider registry factory raises ValueError "{message}" for actor providers'
)
def step_provider_registry_factory_raises_for_actor(
    context: Context, message: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    class _Registry:
        def create_ai_provider(self, *args: Any, **kwargs: Any) -> None:
            raise ValueError(message)

    original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"

    patcher = patch(
        "cleveragents.application.services.plan_service.get_provider_registry",
        autospec=True,
        return_value=_Registry(),
    )
    patcher.start()

    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(patcher.stop)
    cleanup_handlers.append(
        lambda: (
            os.environ.__setitem__(
                "CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
            )
            if original_mock_flag is not None
            else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
        )
    )

    context.plan_service.ai_provider = None
    context.plan_service._provider_registry = None


@given(
    'the provider registry returns provider "{provider_name}" with model "{model_name}"'
)
def step_provider_registry_returns_provider(
    context: Context, provider_name: str, model_name: str
) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"

    original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"

    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(
        lambda: (
            os.environ.__setitem__(
                "CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
            )
            if original_mock_flag is not None
            else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
        )
    )

    class _Provider:
        def __init__(self, name: str, model_id: str) -> None:
            self.name = name
            self.model_id = model_id

    class _Registry:
        def __init__(self) -> None:
            self.requests: list[dict[str, Any]] = []

        def create_ai_provider(
            self,
            provider_type: str | ProviderType | None = None,
            model_id: str | None = None,
            max_retries: int = 3,
        ) -> _Provider:
            self.requests.append({"provider": provider_type, "model": model_id})
            return _Provider(provider_name, model_name)

    registry = _Registry()
    context.plan_service.ai_provider = None
    context.plan_service._provider_registry = registry
    context.provider_registry_requests = registry.requests


@given("I have a plan service with an unrecoverable streaming provider")
def step_plan_service_unrecoverable_stream(context: Context) -> None:
    class _UnrecoverableStreamProvider:
        def __init__(self) -> None:
            self.name = "fatal-stream"
            self.model_id = "fatal-model"

        def generate_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> ProviderResponse:
            change = Change(
                id=None,
                plan_id=plan.id or 0,
                file_path="fatal.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content="```python\nif True print('oops')\n```",
                new_path=None,
                applied=False,
                created_at=datetime.now(),
                applied_at=None,
            )
            return ProviderResponse(
                changes=[change],
                model_used=self.model_id,
                token_count=0,
                error_message=None,
            )

        def stream_changes(
            self,
            project: Project,
            plan: Plan,
            contexts: list[PlanContext],
            actor_context: ActorInvocationContext | None = None,
            progress_callback: Callable[[int], None] | None = None,
        ) -> Iterator[object]:
            response = self.generate_changes(
                project,
                plan,
                contexts,
                actor_context=actor_context,
                progress_callback=progress_callback,
            )
            yield {"generate_plan": {"status": "completed", "change_count": 1}}
            yield {"__end__": {"response": response}}

    _setup_custom_streaming_plan_service(
        context,
        lambda: _UnrecoverableStreamProvider(),
        project_name="unrecoverable-stream-project",
    )

    patcher = patch(
        "cleveragents.application.services.plan_service.ast.parse",
        autospec=True,
        side_effect=SyntaxError("invalid python"),
    )
    patcher.start()
    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(patcher.stop)


@when('I stream plan generation with prompt "{prompt}"')
def step_stream_plan_generation(context: Context, prompt: str) -> None:
    """Run the streaming plan generation helper and capture events."""

    async def _collect() -> list[dict[str, Any]]:
        events: list[dict[str, Any]] = []
        async for event in context.plan_service.generate_plan_streaming(
            project=context.test_project,
            description=prompt,
        ):
            events.append(event)
        return events

    context.stream_events = asyncio.run(_collect())
    context.partial_stream_events = context.stream_events
    context.exception = None


@when('I try to stream plan generation with prompt "{prompt}"')
def step_try_stream_plan_generation(context: Context, prompt: str) -> None:
    """Capture streaming events even when the generator raises an error."""

    async def _collect() -> list[dict[str, Any]]:
        events: list[dict[str, Any]] = []
        stream = context.plan_service.generate_plan_streaming(
            project=context.test_project,
            description=prompt,
        )
        try:
            async for event in stream:
                events.append(event)
        except Exception:
            context.partial_stream_events = events
            raise
        return events

    try:
        context.stream_events = asyncio.run(_collect())
        context.partial_stream_events = context.stream_events
        context.exception = None
    except Exception as exc:
        if not hasattr(context, "partial_stream_events"):
            context.partial_stream_events = []
        context.stream_events = context.partial_stream_events
        context.exception = exc


@then('the streaming events should include nodes "{node_list}"')
def step_assert_stream_nodes(context: Context, node_list: str) -> None:
    """Verify that the streamed events contained the expected nodes."""

    assert hasattr(context, "stream_events"), "Streaming events were not captured"
    expected = [node.strip() for node in node_list.split(",") if node.strip()]
    actual = [next(iter(event.keys())) for event in context.stream_events if event]
    for node in expected:
        assert node in actual, f"Expected streaming event '{node}' not found"


@then("the streamed plan should persist token count {expected:d}")
def step_assert_stream_token_count(context: Context, expected: int) -> None:
    """Ensure token counts from streaming providers are stored on the plan."""

    with context.unit_of_work.transaction() as ctx:
        plan = ctx.plans.get_current_for_project(context.test_project.id)
    assert plan is not None, "Plan was not persisted"
    assert plan.token_count == expected, (
        f"Expected token count {expected}, got {plan.token_count}"
    )


@then('the streaming failure events should include an error with message "{message}"')
def step_assert_stream_failure_event(context: Context, message: str) -> None:
    """Verify that streaming failures emit a descriptive error event."""

    events = getattr(context, "partial_stream_events", [])
    failure_payload: dict[str, Any] | None = None
    for event in events:
        if not isinstance(event, dict):
            continue
        payload = event.get("generate_plan")
        if isinstance(payload, dict) and payload.get("status") == "failed":
            failure_payload = payload
            break
    assert failure_payload is not None, "Expected a failure event in the stream"
    assert failure_payload.get("error") == message, (
        f"Expected failure message '{message}', got '{failure_payload.get('error')}'"
    )


@then(
    "the streaming result should include the non-python change without validation errors"
)
def step_assert_non_python_stream(context: Context) -> None:
    """Ensure non-Python changes stream without triggering validation errors."""

    events = getattr(context, "stream_events", [])
    assert events, "Streaming events were not captured"
    end_event = events[-1]
    assert isinstance(end_event, dict) and "__end__" in end_event, (
        "Final streaming event did not include end payload"
    )
    payload = end_event["__end__"]
    changes = payload.get("changes") or []
    assert len(changes) == 1, "Expected exactly one streamed change"
    change = changes[0]
    assert change.file_path.endswith("notes.txt")
    assert change.new_content == "plain text change"


@when('I build the plan with actor "{actor_name}"')
def step_build_plan_with_actor(context: Context, actor_name: str) -> None:
    """Build the current plan using the specified actor selection."""
    if "/" in actor_name:
        provider, model = actor_name.split("/", 1)
    else:
        provider, model = actor_name, "default-model"

    actor_service = context.plan_service.actor_service
    actor_id = actor_name if "/" in actor_name else f"local/{actor_name}"
    actor_service.upsert_actor(
        name=actor_id,
        provider=provider,
        model=model,
        config_blob={"provider": provider, "model": model},
        graph_descriptor=None,
        unsafe=False,
        set_default=True,
        is_built_in=True,
    )

    context.changes = context.plan_service.build_plan(
        project=context.test_project,
        actor=actor_id,
    )


@then(
    'the stub provider registry should record provider "{provider}" and model "{model}"'
)
def step_assert_stub_provider_requests(
    context: Context, provider: str, model: str
) -> None:
    """Verify the stub registry saw the requested provider/model overrides."""
    requests = getattr(context, "stub_provider_requests", None)
    assert requests, "No provider override requests were recorded"
    last_request = requests[-1]
    assert last_request["provider"] == provider, (
        f"Expected provider '{provider}', got '{last_request['provider']}'"
    )
    assert last_request["model"] == model, (
        f"Expected model '{model}', got '{last_request['model']}'"
    )


@given("I have a plan service without providers configured")
def step_plan_service_without_providers(context: Context) -> None:
    """Create a PlanService that will raise when resolving providers."""

    original_mock_flag = os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
    cleanup_handlers = getattr(context, "_cleanup_handlers", None)
    if cleanup_handlers is None:
        cleanup_handlers = []
        context._cleanup_handlers = cleanup_handlers
    cleanup_handlers.append(
        lambda: (
            os.environ.__setitem__(
                "CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
            )
            if original_mock_flag is not None
            else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
        )
    )

    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)
    if not hasattr(context, "temp_dir"):
        context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_service_no_provider_"))

    settings = Settings()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=context.unit_of_work,
        ai_provider=None,
        provider_registry=_NoProviderRegistry(),
    )

    # Provision a default actor so provider resolution reaches the registry path
    context.plan_service.actor_service.ensure_default_mock_actor(force=True)

    project = Project(
        id=None,
        name="no-provider-project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="noop",
        ),
    )
    with context.unit_of_work.transaction() as ctx:
        context.project = ctx.projects.create(project)
    context.test_project = context.project


@given("I have an unsaved project without ID")
def step_create_unsaved_project(context: Context) -> None:
    """Create an unsaved project without ID."""
    context.project = Project(
        id=None,
        name="test_project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="mock-gpt",
        ),
    )


@when("I try to create a plan for the unsaved project")
def step_try_create_plan_unsaved(context: Context) -> None:
    """Try to create a plan for unsaved project."""
    try:
        context.plan_service.create_plan(context.project, "Test prompt", "test-plan")
        context.exception = None
    except Exception as e:
        context.exception = e


@then('a ValidationError should be raised with message "{message}"')
def step_check_validation_error(context: Context, message: str) -> None:
    """Check that a ValidationError was raised with specific message."""
    assert context.exception is not None
    assert isinstance(context.exception, ValidationError)
    assert message in str(context.exception.message)


@when("I get the current plan for the project")
def step_get_current_plan(context: Context) -> None:
    """Fetch the current plan for the active project."""
    context.current_plan_result = context.plan_service.get_current_plan(context.project)
    context.exception = None


@then("the current plan result should be None")
def step_assert_current_plan_none(context: Context) -> None:
    """Assert that no current plan was found."""
    assert getattr(context, "current_plan_result", "__missing__") is None


@when("I list the plans for the project")
def step_list_plans_for_project(context: Context) -> None:
    """Retrieve all plans for the active project."""
    context.plans_result = context.plan_service.list_plans(context.project)
    context.exception = None


@then("the plans result should be an empty list")
def step_assert_plans_empty(context: Context) -> None:
    """Verify no plans were returned."""
    assert getattr(context, "plans_result", None) == []


@when('I attempt to switch to plan "{name}"')
def step_attempt_switch_plan(context: Context, name: str) -> None:
    """Attempt to switch plans while capturing errors."""
    try:
        context.switch_result = context.plan_service.switch_to_plan(
            context.project, name
        )
        context.exception = None
    except Exception as exc:
        context.switch_result = None
        context.exception = exc


@then("the validation error details should hint to initialize the project")
def step_assert_validation_hint(context: Context) -> None:
    """Validate the hint provided with the ValidationError."""
    assert isinstance(context.exception, ValidationError)
    details = getattr(context.exception, "details", {}) or {}
    assert details.get("hint") == "Initialize project first with 'agents init'"


@given('the project has plans named "{first}" and "{second}"')
def step_project_with_named_plans(context: Context, first: str, second: str) -> None:
    """Ensure the project has specific plan names configured."""
    assert context.project.id is not None, (
        "Project must be persisted before adding plans"
    )
    with context.unit_of_work.transaction() as ctx:
        current_plan = ctx.plans.get_current_for_project(context.project.id)
        assert current_plan is not None, "Current plan is required for this step"
        current_plan.name = first
        ctx.plans.update(current_plan)

        second_plan = Plan(
            id=None,
            project_id=context.project.id,
            name=second,
            prompt="Secondary plan",
            status=PlanStatus.PENDING,
            current=False,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            build=None,
            build_started_at=None,
            build_completed_at=None,
            model_used=None,
            token_count=None,
            result=None,
            applied_at=None,
            files_created=None,
            files_modified=None,
            files_deleted=None,
        )
        ctx.plans.create(second_plan)

    context.available_plan_names = [first, second]


@then(
    'the validation error details should list available plans "{first}" and "{second}"'
)
def step_assert_available_plans(context: Context, first: str, second: str) -> None:
    """Check that available plan names are surfaced in the error."""
    assert isinstance(context.exception, ValidationError)
    details = getattr(context.exception, "details", {}) or {}
    assert details.get("available_plans") == [first, second]


@when('I attempt to add prompt "{prompt}" to the current plan')
def step_attempt_add_prompt(context: Context, prompt: str) -> None:
    """Attempt to add instructions to the current plan, capturing errors."""
    try:
        context.plan_service.add_to_plan(context.project, prompt)
        context.exception = None
    except Exception as exc:
        context.exception = exc


@then("the plan error details should suggest creating a plan first")
def step_assert_plan_hint(context: Context) -> None:
    """Confirm the PlanError hint references plan creation."""
    assert isinstance(context.exception, PlanError)
    details = getattr(context.exception, "details", {}) or {}
    assert details.get("hint") == "Create a plan first with 'agents new'"


@given("the current plan prompt is set to None")
def step_set_current_plan_prompt_none(context: Context) -> None:
    """Record the current plan for later patching to simulate a missing prompt."""
    assert context.project.id is not None, "Project must be saved"
    with context.unit_of_work.transaction() as ctx:
        plan = ctx.plans.get_current_for_project(context.project.id)
        assert plan is not None, "Expected a current plan to modify"
        context.current_plan = plan
        context.plan_previous_updated_at = plan.updated_at
        context.plan_id_for_none_prompt = plan.id


@when('I add prompt "{prompt}" to the current plan')
def step_add_prompt_to_current_plan(context: Context, prompt: str) -> None:
    """Add instructions to the current plan and persist the result."""
    assert context.project.id is not None, "Project must be saved"

    with context.unit_of_work.transaction() as ctx:
        plan_before = ctx.plans.get_current_for_project(context.project.id)
        assert plan_before is not None, "Expected a current plan before adding prompt"
        context.plan_previous_updated_at = plan_before.updated_at

    from cleveragents.infrastructure.database.repositories import PlanRepository

    original_get_current = PlanRepository.get_current_for_project

    def get_current_with_none_prompt(self, project_id: int):  # type: ignore[override]
        plan = original_get_current(self, project_id)
        target_plan_id = getattr(context, "plan_id_for_none_prompt", None)
        if plan and plan.id == target_plan_id:
            plan_dict = plan.model_dump()
            plan_with_none = Plan.model_construct(**plan_dict)
            object.__setattr__(plan_with_none, "prompt", None)
            return plan_with_none
        return plan

    with patch.object(
        PlanRepository, "get_current_for_project", get_current_with_none_prompt
    ):
        context.plan_service.add_to_plan(context.project, prompt)

    with context.unit_of_work.transaction() as ctx:
        context.current_plan_after_add = ctx.plans.get_current_for_project(
            context.project.id
        )
    context.added_prompt = prompt


@then('the current plan prompt should be "{expected}"')
def step_assert_current_plan_prompt(context: Context, expected: str) -> None:
    """Verify the current plan prompt matches expectations."""
    plan = getattr(context, "current_plan_after_add", None)
    assert plan is not None, "No plan result found after adding prompt"
    assert plan.prompt == expected


@then("the current plan status should be PENDING")
def step_assert_current_plan_status_pending(context: Context) -> None:
    """Ensure the current plan status is set to PENDING."""
    plan = getattr(context, "current_plan_after_add", None)
    assert plan is not None, "No plan result found after adding prompt"
    assert plan.status == PlanStatus.PENDING


@then("the current plan updated_at should be refreshed")
def step_assert_current_plan_updated(context: Context) -> None:
    """Confirm the plan updated_at timestamp increased after adding prompt."""
    plan = getattr(context, "current_plan_after_add", None)
    assert plan is not None, "No plan result found after adding prompt"
    previous = getattr(context, "plan_previous_updated_at", None)
    assert previous is not None, "No previous updated_at stored"
    assert plan.updated_at > previous


@given("I have a saved project with no current plan")
def step_create_saved_project_no_plan(context: Context) -> None:
    """Create a saved project with no current plan."""
    with context.unit_of_work.transaction() as ctx:
        project = Project(
            id=None,
            name="test_project",
            path=context.temp_dir,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            current_plan_id=None,
            settings=ProjectSettings(
                auto_build=False,
                auto_apply=False,
                confirm_apply=True,
                max_context_size=50 * 1024 * 1024,
                default_model="mock-gpt",
            ),
        )
        context.project = ctx.projects.create(project)


@when("I try to build the plan for the project")
def step_try_build_plan_no_current(context: Context) -> None:
    """Try to build plan when no current plan exists."""
    try:
        context.plan_service.build_plan(context.project)
        context.exception = None
    except Exception as e:
        context.exception = e


@then('a PlanError should be raised with message "{message}"')
def step_check_plan_error(context: Context, message: str) -> None:
    """Check that a PlanError was raised with specific message."""
    assert context.exception is not None, "No exception captured"
    assert isinstance(context.exception, PlanError), (
        f"Unexpected exception type {type(context.exception).__name__}"
    )
    actual_message = str(getattr(context.exception, "message", "")) or str(
        context.exception
    )
    assert message in actual_message, f"Expected '{message}' in '{actual_message}'"


@then('a PlanError should be raised containing "{message}"')
def step_check_plan_error_contains(context: Context, message: str) -> None:
    """Check that a PlanError message contains specific text."""
    assert context.exception is not None
    assert isinstance(context.exception, PlanError)
    assert message in str(context.exception.message)


@then("the PlanError details should include provider diagnostics")
def step_plan_error_details_include_provider_diagnostics(context: Context) -> None:
    """Ensure provider diagnostics are present in the captured PlanError details."""
    assert context.exception is not None, "No exception captured"
    assert isinstance(context.exception, PlanError), (
        f"Expected PlanError but got {type(context.exception).__name__}"
    )
    details = getattr(context.exception, "details", {}) or {}
    required_keys = {
        "configured_providers",
        "expected_env_vars",
        "requested_provider",
        "requested_model",
    }
    missing = sorted(key for key in required_keys if key not in details)
    assert not missing, f"Missing provider diagnostics keys: {', '.join(missing)}"


@given("I configured an auto-debug plan with a disappearing plan ID")
def step_configure_auto_debug_disappearing_plan(context: Context) -> None:
    """Set up a plan service whose current plan loses its ID mid-run."""
    assert hasattr(context, "temp_dir"), (
        "Temporary directory setup is required before configuring auto-debug"
    )

    class FlickeringPlan:
        def __init__(self, plan_id: int) -> None:
            self._plan_id = plan_id
            self._first_access = True
            self.prompt = "Investigate flaky build"
            self.name = "auto-debug-plan"
            self.status = PlanStatus.PENDING
            self.current = True
            self.updated_at = datetime.now()
            self.result = None

        @property
        def id(self) -> int | None:
            if self._first_access:
                self._first_access = False
                return self._plan_id
            return None

    class FakePlansRepository:
        def __init__(self, plan):
            self.plan = plan

        def get_current_for_project(self, project_id: int):
            return self.plan

    class FakeContextsRepository:
        def __init__(self) -> None:
            self.called = False

        def get_for_plan(self, plan_id: int):
            self.called = True
            return []

    class FakeDebugAttemptsRepository:
        def __init__(self) -> None:
            self.records: list = []

        def add(self, attempt):
            attempt.id = len(self.records) + 1
            self.records.append(attempt)
            return attempt

        def get(self, attempt_id: int):
            return None

        def update(self, attempt):
            self.updated_attempt = attempt

    class FakeUnitOfWork:
        def __init__(self, contexts: list[SimpleNamespace]) -> None:
            self._contexts = contexts

        def transaction(self):
            outer = self

            class _Transaction:
                def __enter__(self_inner):
                    if not outer._contexts:
                        raise AssertionError("No fake transaction contexts remaining")
                    return outer._contexts.pop(0)

                def __exit__(self_inner, exc_type, exc_val, exc_tb):
                    return False

            return _Transaction()

    flicker_plan = FlickeringPlan(plan_id=101)
    contexts_repo = FakeContextsRepository()
    debug_repo = FakeDebugAttemptsRepository()
    fake_contexts = [
        SimpleNamespace(plans=FakePlansRepository(flicker_plan)),
        SimpleNamespace(contexts=contexts_repo),
        SimpleNamespace(debug_attempts=debug_repo),
    ]

    context.auto_debug_context_repo = contexts_repo
    context.auto_debug_debug_repo = debug_repo
    context.plan_service = PlanService(
        settings=Settings(),
        unit_of_work=FakeUnitOfWork(fake_contexts),
        ai_provider=None,
    )
    context.project = Project(
        id=1,
        name="auto-debug-project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=1,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="mock-gpt",
        ),
    )


@when("I run auto-debug build with missing plan context")
def step_run_auto_debug_missing_context(context: Context) -> None:
    """Trigger auto-debug build to exercise empty context handling."""
    context.plan_service.build_plan = MagicMock(
        side_effect=RuntimeError("Plan build failed")
    )
    captured_state: dict[str, object] = {}

    class DummyAutoDebugAgent:
        def __init__(self, **_kwargs):
            pass

        def invoke(self, debug_state, config=None):
            captured_state["state"] = debug_state
            captured_state["config"] = config
            return {"result": {"success": False, "fix": None}}

    with (
        patch.object(
            context.plan_service, "_prepare_langsmith_config", return_value={}
        ),
        patch(
            "cleveragents.agents.AutoDebugAgent",
            DummyAutoDebugAgent,
        ),
    ):
        context.auto_debug_result = context.plan_service.auto_debug_build(
            context.project, max_attempts=1
        )

    context.auto_debug_captured_state = captured_state.get("state")


@then("the auto-debug build should fall back to an empty context")
def step_assert_auto_debug_empty_context(context: Context) -> None:
    """Verify empty code context and debug attempt tracking."""
    result = getattr(context, "auto_debug_result", None)
    assert result is not None, "Auto-debug result was not captured"
    success, changes, error = result
    assert success is False
    assert changes == []
    assert error == "Plan build failed"

    contexts_repo = getattr(context, "auto_debug_context_repo", None)
    assert contexts_repo is not None, "Contexts repository tracking missing"
    assert contexts_repo.called is False

    debug_state = getattr(context, "auto_debug_captured_state", None)
    assert debug_state is not None, "Debug state was not recorded"
    assert debug_state.get("code_context") == ""


@given("I have a saved project with current plan missing ID")
def step_create_project_plan_no_id(context: Context) -> None:
    """Create a project with current plan that has no ID."""

    with context.unit_of_work.transaction() as ctx:
        project = Project(
            id=None,
            name="test_project",
            path=context.temp_dir,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            current_plan_id=None,
            settings=ProjectSettings(
                auto_build=False,
                auto_apply=False,
                confirm_apply=True,
                max_context_size=50 * 1024 * 1024,
                default_model="mock-gpt",
            ),
        )
        context.project = ctx.projects.create(project)

        # Create a plan that will be saved
        plan = Plan(
            id=None,
            project_id=context.project.id,
            name="test-plan",
            prompt="Test prompt",
            status=PlanStatus.PENDING,
            current=True,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            build=None,
            build_started_at=None,
            build_completed_at=None,
            model_used=None,
            token_count=None,
            result=None,
            applied_at=None,
            files_created=None,
            files_modified=None,
            files_deleted=None,
        )
        created_plan = ctx.plans.create(plan)

        # Update project to have current plan ID
        context.project.current_plan_id = created_plan.id
        ctx.projects.update(context.project)

        # Store the created plan for mocking
        context.created_plan_with_id = created_plan


@when("I try to build the plan with invalid ID")
def step_try_build_plan_invalid_id(context: Context) -> None:
    """Try to build plan with invalid ID."""
    from unittest.mock import patch

    # Mock get_current_for_project to return a plan without ID
    def mock_get_current(project_id):
        # Return the plan but with ID set to None
        plan = context.created_plan_with_id
        plan.id = None
        return plan

    try:
        # Patch the repository method at the class level
        with patch.object(
            context.unit_of_work.transaction().__enter__().plans.__class__,
            "get_current_for_project",
            side_effect=mock_get_current,
        ):
            context.plan_service.build_plan(context.project)
            context.exception = None
    except Exception as e:
        context.exception = e


@given("I have a saved project with valid current plan")
def step_create_project_with_valid_plan(context: Context) -> None:
    """Create a project with valid current plan."""
    with context.unit_of_work.transaction() as ctx:
        project = Project(
            id=None,
            name="test_project",
            path=context.temp_dir,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            current_plan_id=None,
            settings=ProjectSettings(
                auto_build=False,
                auto_apply=False,
                confirm_apply=True,
                max_context_size=50 * 1024 * 1024,
                default_model="mock-gpt",
            ),
        )
        context.project = ctx.projects.create(project)

        plan = Plan(
            id=None,
            project_id=context.project.id,
            name="test-plan",
            prompt="Test prompt",
            status=PlanStatus.PENDING,
            current=True,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            build=None,
            build_started_at=None,
            build_completed_at=None,
            model_used=None,
            token_count=None,
            result=None,
            applied_at=None,
            files_created=None,
            files_modified=None,
            files_deleted=None,
        )
        context.current_plan = ctx.plans.create(plan)
        if context.project.id and context.current_plan.id:
            ctx.plans.set_current(context.project.id, context.current_plan.id)


@given("I have a plan service without AI provider")
def step_create_plan_service_no_provider(context: Context) -> None:
    """Create a plan service without AI provider."""
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
    if not hasattr(context, "env_vars_to_clean"):
        context.env_vars_to_clean = []
    if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
        context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")

    settings = Settings()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=context.unit_of_work,
        ai_provider=None,
        provider_registry=_NoProviderRegistry(),
    )


@when("I try to build the plan without AI provider")
def step_try_build_without_provider(context: Context) -> None:
    """Try to build plan without AI provider."""
    try:
        context.plan_service.build_plan(context.project)
        context.exception = None
    except Exception as e:
        context.exception = e


@when("I get pending changes for the project")
def step_get_pending_changes(context: Context) -> None:
    """Get pending changes for the project."""
    context.pending_changes = context.plan_service.get_pending_changes(context.project)


@then("the pending changes list should be empty")
def step_check_pending_changes_empty(context: Context) -> None:
    """Check that pending changes list is empty."""
    assert context.pending_changes == []


@given("I have a saved project with current plan")
def step_create_project_with_current_plan(context: Context) -> None:
    """Create a project with current plan."""
    step_create_project_with_valid_plan(context)


@given("the current plan has applied and unapplied changes")
def step_add_mixed_changes(context: Context) -> None:
    """Add both applied and unapplied changes to the plan."""
    with context.unit_of_work.transaction() as ctx:
        # Add applied change
        applied_change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="applied.py",
            operation=OperationType.CREATE,
            original_content=None,
            new_content="applied content",
            new_path=None,
            applied=True,
            applied_at=datetime.now(),
            created_at=datetime.now(),
        )
        ctx.changes.add(applied_change)

        # Add unapplied changes
        for i in range(2):
            unapplied_change = Change(
                id=None,
                plan_id=context.current_plan.id,
                file_path=f"unapplied_{i}.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content=f"unapplied content {i}",
                new_path=None,
                applied=False,
                applied_at=None,
                created_at=datetime.now(),
            )
            ctx.changes.add(unapplied_change)


@then("I should get only the unapplied changes")
def step_check_only_unapplied(context: Context) -> None:
    """Check that only unapplied changes are returned."""
    assert len(context.pending_changes) == 2
    for change in context.pending_changes:
        assert not change.applied
        assert "unapplied" in change.file_path


@when("I try to apply changes without current plan")
def step_try_apply_without_plan(context: Context) -> None:
    """Try to apply changes without current plan."""
    try:
        context.plan_service.apply_changes(context.project)
        context.exception = None
    except Exception as e:
        context.exception = e


@given("the plan has a pending CREATE change")
def step_add_create_change(context: Context) -> None:
    """Add a pending CREATE change to the plan."""
    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="new_file.py",
            operation=OperationType.CREATE,
            original_content=None,
            new_content="# New file content\nprint('Hello')",
            new_path=None,
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        context.create_change = ctx.changes.add(change)


@when("I apply the plan changes")
def step_apply_changes(context: Context) -> None:
    """Apply the changes."""
    context.applied_count = context.plan_service.apply_changes(context.project)


@then("the file should be created with correct content")
def step_check_file_created(context: Context) -> None:
    """Check that file was created with correct content."""
    file_path = context.project.path / "new_file.py"
    assert file_path.exists()
    assert file_path.read_text() == "# New file content\nprint('Hello')"


@then("the change should be marked as applied in plan service")
def step_check_change_applied(context: Context) -> None:
    """Check that change was marked as applied."""
    assert context.applied_count == 1


@given("the plan has a pending MODIFY change for existing file")
def step_add_modify_change(context: Context) -> None:
    """Add a pending MODIFY change for existing file."""
    # Create the file first
    file_path = context.project.path / "existing.py"
    file_path.write_text("# Original content")

    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="existing.py",
            operation=OperationType.MODIFY,
            original_content="# Original content",
            new_content="# Modified content\nprint('Modified')",
            new_path=None,
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        context.modify_change = ctx.changes.add(change)


@then("the file should be modified with new content")
def step_check_file_modified(context: Context) -> None:
    """Check that file was modified with new content."""
    file_path = context.project.path / "existing.py"
    assert file_path.exists()
    assert file_path.read_text() == "# Modified content\nprint('Modified')"


@given("the plan has a pending DELETE change for existing file")
def step_add_delete_change(context: Context) -> None:
    """Add a pending DELETE change for existing file."""
    # Create the file first
    file_path = context.project.path / "to_delete.py"
    file_path.write_text("# To be deleted")

    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="to_delete.py",
            operation=OperationType.DELETE,
            original_content="# To be deleted",
            new_content=None,
            new_path=None,
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        context.delete_change = ctx.changes.add(change)


@then("the file should be deleted")
def step_check_file_deleted(context: Context) -> None:
    """Check that file was deleted."""
    file_path = context.project.path / "to_delete.py"
    assert not file_path.exists()


@given("the plan has a pending MOVE change for existing file")
def step_add_move_change(context: Context) -> None:
    """Add a pending MOVE change for existing file."""
    # Create the file first
    file_path = context.project.path / "old_location.py"
    file_path.write_text("# File to move")

    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="old_location.py",
            operation=OperationType.MOVE,
            original_content="# File to move",
            new_content="# File to move",
            new_path="new_location.py",
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        context.move_change = ctx.changes.add(change)


@then("the file should be moved to new location")
def step_check_file_moved(context: Context) -> None:
    """Check that file was moved to new location."""
    old_path = context.project.path / "old_location.py"
    new_path = context.project.path / "new_location.py"
    assert not old_path.exists()
    assert new_path.exists()
    assert new_path.read_text() == "# File to move"


@given("the plan has a pending MOVE change to a nested relative path")
def step_add_nested_move_change(context: Context) -> None:
    """Add a MOVE change that targets a nested relative destination."""
    source_relative = Path("nested") / "source.py"
    source_path = context.project.path / source_relative
    source_path.parent.mkdir(parents=True, exist_ok=True)
    source_path.write_text("# Nested file to move")

    destination_relative = Path("dest") / "nested" / "target.py"
    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path=str(source_relative),
            operation=OperationType.MOVE,
            original_content="# Nested file to move",
            new_content="# Nested file to move",
            new_path=str(destination_relative),
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        ctx.changes.add(change)

    context.nested_move_paths = (
        source_path,
        context.project.path / destination_relative,
    )


@then("the file should be moved to the nested relative location")
def step_check_nested_move(context: Context) -> None:
    """Verify nested MOVE change relocated the file correctly."""
    assert hasattr(context, "nested_move_paths"), "Nested move paths were not recorded"
    old_path, new_path = context.nested_move_paths
    assert not old_path.exists(), "Original nested file still exists"
    assert new_path.exists(), "Nested destination file was not created"
    assert new_path.read_text() == "# Nested file to move", (
        "Nested destination content mismatch"
    )


@given("the plan has a pending MOVE change to an absolute path")
def step_add_absolute_move_change(context: Context) -> None:
    """Add a MOVE change whose destination is an absolute path."""
    assert getattr(context, "project", None) is not None, "Project is required"
    assert getattr(context, "current_plan", None) is not None, (
        "Current plan is required"
    )

    source_path = context.project.path / "absolute_source.py"
    source_path.write_text("# Absolute move source")
    absolute_target = (context.project.path / "absolute_target.py").resolve()

    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="absolute_source.py",
            operation=OperationType.MOVE,
            original_content="# Absolute move source",
            new_content="# Absolute move source",
            new_path=str(absolute_target),
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        ctx.changes.add(change)

    context.absolute_move_paths = (source_path, absolute_target)


@then("the absolute destination should exist and the source should be removed")
def step_check_absolute_move_destination(context: Context) -> None:
    """Ensure MOVE changes create files at absolute targets."""
    paths = getattr(context, "absolute_move_paths", None)
    assert paths is not None, "Absolute move paths were not recorded"
    source_path, absolute_target = paths
    assert not source_path.exists(), "Source file still exists after MOVE"
    destination_path = Path(absolute_target)
    assert destination_path.exists(), "Absolute destination file missing"
    assert destination_path.read_text() == "# Absolute move source"
    assert getattr(context, "applied_count", 0) == 1


@given("the plan has changes with absolute file paths")
def step_add_absolute_path_changes(context: Context) -> None:
    """Add changes with absolute file paths."""
    abs_path = context.temp_dir / "absolute_file.py"

    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path=str(abs_path),  # Absolute path
            operation=OperationType.CREATE,
            original_content=None,
            new_content="# Absolute path content",
            new_path=None,
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        ctx.changes.add(change)


@then("the files should be created at absolute paths")
def step_check_absolute_paths(context: Context) -> None:
    """Check that files were created at absolute paths."""
    abs_path = context.temp_dir / "absolute_file.py"
    assert abs_path.exists()
    assert abs_path.read_text() == "# Absolute path content"


@then("the plan changes should be marked as applied")
def step_check_changes_marked_applied(context: Context) -> None:
    """Check that changes were marked as applied."""
    assert context.applied_count > 0


@given("the plan has a change that will fail on apply")
def step_add_failing_change(context: Context) -> None:
    """Add a change that will fail when applied."""
    with context.unit_of_work.transaction() as ctx:
        # Create a file then simulate permission denial via mock
        test_file = context.temp_dir / "readonly_file.py"
        test_file.write_text("original content")

        # Try to modify a read-only file - this should fail
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path=str(test_file),  # Existing readonly file
            operation=OperationType.MODIFY,  # Try to modify it
            original_content="original content",
            new_content="# New content that will fail to write",
            new_path=None,
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        ctx.changes.add(change)
        # Store path for the mock in the apply step
        context.readonly_file = str(test_file)


@when("I try to apply the changes")
def step_try_apply_changes(context: Context) -> None:
    """Try to apply changes, simulating PermissionError for readonly targets."""
    target = getattr(context, "readonly_file", None)

    original_write_text = Path.write_text

    def guarded_write_text(self, *args, **kwargs):
        if target and str(self) == target:
            raise PermissionError(f"[Errno 13] Permission denied: '{self}'")
        return original_write_text(self, *args, **kwargs)

    try:
        with (
            patch.object(Path, "write_text", guarded_write_text)
            if target
            else contextlib.nullcontext()
        ):
            result = context.plan_service.apply_changes(context.project)
            context.exception = None
            context.apply_result = result
    except Exception as e:
        context.exception = e


@then("a PlanError should be raised with file operation details")
def step_check_plan_error_with_details(context: Context) -> None:
    """Check that PlanError was raised with file operation details."""
    assert context.exception is not None, (
        f"Expected an exception but got result: {getattr(context, 'apply_result', 'none')}"
    )
    assert isinstance(context.exception, PlanError), (
        f"Expected PlanError but got {type(context.exception).__name__}: {context.exception}"
    )
    assert "Failed to apply change" in str(context.exception.message)


@given("I have a saved project")
def step_create_saved_project(context: Context) -> None:
    """Create a saved project."""
    with context.unit_of_work.transaction() as ctx:
        project = Project(
            id=None,
            name="test_project",
            path=context.temp_dir,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            current_plan_id=None,
            settings=ProjectSettings(
                auto_build=False,
                auto_apply=False,
                confirm_apply=True,
                max_context_size=50 * 1024 * 1024,
                default_model="mock-gpt",
            ),
        )
        context.project = ctx.projects.create(project)


@when("I create a plan with minimal prompt")
def step_create_plan_minimal_prompt(context: Context) -> None:
    """Create a plan with minimal prompt."""
    # Use a single character prompt - the service should generate a name from it
    context.created_plan = context.plan_service.create_plan(context.project, "x", None)


@then("the plan name should be generated from prompt")
def step_check_generated_name(context: Context) -> None:
    """Check that plan name was generated from the prompt."""
    # With prompt "x", the name should be "x"
    assert context.created_plan.name == "x"


@when("I create a plan with very long prompt")
def step_create_plan_long_prompt(context: Context) -> None:
    """Create a plan with very long prompt."""
    long_prompt = "This is a very long prompt with many words that should be truncated"
    context.created_plan = context.plan_service.create_plan(
        context.project, long_prompt, None
    )


@then("the plan name should be truncated to first three words")
def step_check_truncated_name(context: Context) -> None:
    """Check that plan name was truncated."""
    assert context.created_plan.name == "this_is_a"


@given("the plan has a MOVE change for non-existent file")
def step_add_move_nonexistent(context: Context) -> None:
    """Add a MOVE change for non-existent file."""
    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="nonexistent.py",
            operation=OperationType.MOVE,
            original_content=None,
            new_content=None,
            new_path="new_location.py",
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        ctx.changes.add(change)


@then("the MOVE operation should handle missing source gracefully")
def step_check_move_handles_missing(context: Context) -> None:
    """Check that MOVE handles missing source file."""
    # The operation should complete without error even if source doesn't exist
    assert context.applied_count == 1
    # New file should not exist since source didn't exist
    new_path = context.project.path / "new_location.py"
    assert not new_path.exists()


@given("the plan has a DELETE change for non-existent file")
def step_add_delete_nonexistent(context: Context) -> None:
    """Add a DELETE change for non-existent file."""
    with context.unit_of_work.transaction() as ctx:
        change = Change(
            id=None,
            plan_id=context.current_plan.id,
            file_path="already_deleted.py",
            operation=OperationType.DELETE,
            original_content=None,
            new_content=None,
            new_path=None,
            applied=False,
            applied_at=None,
            created_at=datetime.now(),
        )
        ctx.changes.add(change)


@then("the DELETE operation should handle missing file gracefully")
def step_check_delete_handles_missing(context: Context) -> None:
    """Check that DELETE handles missing file."""
    # The operation should complete without error even if file doesn't exist
    assert context.applied_count == 1


@given("I have a plan service with mock AI provider")
def step_create_plan_service_with_mock_provider(context: Context) -> None:
    """Create a plan service with mock AI provider."""
    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
    if not hasattr(context, "env_vars_to_clean"):
        context.env_vars_to_clean = []
    if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
        context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")

    mock_provider = MagicMock()

    # Mock the generate_changes method
    def generate_changes_with_callback(
        project,
        plan,
        contexts,
        actor_context: ActorInvocationContext | None = None,
        progress_callback=None,
    ):
        if progress_callback:
            # Simulate progress updates
            for i in range(0, 101, 20):
                progress_callback(i)

        # Return mock response
        return ProviderResponse(
            changes=[
                Change(
                    id=None,
                    plan_id=plan.id,
                    file_path="generated.py",
                    operation=OperationType.CREATE,
                    original_content=None,
                    new_content="# Generated content",
                    new_path=None,
                    applied=False,
                    applied_at=None,
                    created_at=datetime.now(),
                )
            ],
            model_used="mock-gpt",
            token_count=100,
        )

    mock_provider.generate_changes = generate_changes_with_callback

    settings = Settings()
    context.plan_service = PlanService(
        settings=settings, unit_of_work=context.unit_of_work, ai_provider=mock_provider
    )
    context.mock_provider = mock_provider


@when("I build the plan with progress callback")
def step_build_with_progress_callback(context: Context) -> None:
    """Build plan with progress callback."""
    context.progress_values = []

    def progress_callback(value):
        context.progress_values.append(value)

    context.changes = context.plan_service.build_plan(
        context.project, progress_callback=progress_callback
    )


@then("the progress callback should be called with values from 0 to 100")
def step_check_progress_callback(context: Context) -> None:
    """Check that progress callback was called correctly."""
    assert len(context.progress_values) > 0
    assert 0 in context.progress_values
    assert 100 in context.progress_values
    # Check that values are in ascending order
    for i in range(1, len(context.progress_values)):
        assert context.progress_values[i] >= context.progress_values[i - 1]


@given("the plan has 3 applied and 2 unapplied changes")
def step_add_specific_mixed_changes(context: Context) -> None:
    """Add specific number of applied and unapplied changes."""
    with context.unit_of_work.transaction() as ctx:
        # Add 3 applied changes
        for i in range(3):
            applied_change = Change(
                id=None,
                plan_id=context.current_plan.id,
                file_path=f"applied_{i}.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content=f"applied content {i}",
                new_path=None,
                applied=True,
                applied_at=datetime.now(),
                created_at=datetime.now(),
            )
            ctx.changes.add(applied_change)

        # Add 2 unapplied changes
        for i in range(2):
            unapplied_change = Change(
                id=None,
                plan_id=context.current_plan.id,
                file_path=f"pending_{i}.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content=f"pending content {i}",
                new_path=None,
                applied=False,
                applied_at=None,
                created_at=datetime.now(),
            )
            ctx.changes.add(unapplied_change)


@then("I should get exactly 2 pending changes")
def step_check_exactly_two_pending(context: Context) -> None:
    """Check that exactly 2 pending changes are returned."""
    assert len(context.pending_changes) == 2


@then("the pending changes should not include applied ones")
def step_check_no_applied_in_pending(context: Context) -> None:
    """Check that pending changes don't include applied ones."""
    for change in context.pending_changes:
        assert not change.applied
        assert "pending" in change.file_path
        assert "applied" not in change.file_path


def _ensure_memory_database_url(context: Context) -> None:
    """Ensure plan service uses an in-memory database for memory tests."""
    try:
        if context.plan_service.settings.database_url != "sqlite:///:memory:":
            context.plan_service.settings.database_url = "sqlite:///:memory:"
    except AttributeError:
        context.plan_service.settings = Settings(database_url="sqlite:///:memory:")


def _request_memory_service_for_session(
    context: Context,
    session_id: str,
    persistent: bool,
    max_messages: int | None,
) -> MemoryService:
    if persistent:
        _ensure_memory_database_url(context)
    service = context.plan_service.get_memory_service(
        session_id,
        persistent=persistent,
        max_messages=max_messages,
    )
    context.last_memory_service = service
    context.last_memory_session = session_id
    return service


@when(
    'I request an in-memory memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_in_memory_memory_service(
    context: Context, session_id: str, max_messages: int
) -> None:
    service = _request_memory_service_for_session(
        context, session_id, persistent=False, max_messages=max_messages
    )
    assert isinstance(service, MemoryService)


@when(
    'I request a persistent memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_persistent_memory_service(
    context: Context, session_id: str, max_messages: int
) -> None:
    service = _request_memory_service_for_session(
        context, session_id, persistent=True, max_messages=max_messages
    )
    assert isinstance(service, MemoryService)


@when(
    'I request an in-memory memory service for session "{session_id}" without changing message limit'
)
def step_request_in_memory_without_limit(context: Context, session_id: str) -> None:
    service = _request_memory_service_for_session(
        context, session_id, persistent=False, max_messages=None
    )
    assert isinstance(service, MemoryService)


@then(
    'the session "{session_id}" memory should be in-memory with max messages {max_messages:d}'
)
def step_verify_in_memory_with_max(
    context: Context, session_id: str, max_messages: int
) -> None:
    service = context.plan_service._memory_services.get(session_id)
    assert service is not None
    assert isinstance(service, MemoryService)
    assert service.connection_string is None
    assert service.max_messages == max_messages
    context.last_memory_service = service


@then(
    'the session "{session_id}" memory should be persistent with max messages {max_messages:d}'
)
def step_verify_persistent_with_max(
    context: Context, session_id: str, max_messages: int
) -> None:
    service = context.plan_service._memory_services.get(session_id)
    assert service is not None
    assert isinstance(service, MemoryService)
    assert service.connection_string == context.plan_service.settings.database_url
    assert service.max_messages == max_messages
    context.last_memory_service = service


@then(
    'the session "{session_id}" memory should be in-memory without a connection string'
)
def step_verify_in_memory_without_connection(context: Context, session_id: str) -> None:
    service = context.plan_service._memory_services.get(session_id)
    assert service is not None
    assert isinstance(service, MemoryService)
    assert service.connection_string is None


@when('I request conversation memory for session "{session_id}" with custom keys')
def step_request_conversation_memory_custom(context: Context, session_id: str) -> None:
    adapter = context.plan_service.get_conversation_memory(
        session_id,
        persistent=False,
        memory_key="chat_history",
        input_key="prompt",
        output_key="response",
        return_messages=False,
        max_messages=4,
    )
    context.conversation_adapter = adapter
    context.last_memory_session = session_id


@then("the conversation adapter should expose the custom keys")
def step_verify_conversation_adapter_keys(context: Context) -> None:
    adapter = getattr(context, "conversation_adapter", None)
    assert isinstance(adapter, ConversationBufferMemoryAdapter)
    assert adapter.memory_variables == ["chat_history"]
    assert adapter.input_key == "prompt"
    assert adapter.output_key == "response"
    assert adapter.return_messages is False


@then("the conversation adapter should share the existing memory service")
def step_verify_conversation_adapter_service(context: Context) -> None:
    session_id = getattr(context, "last_memory_session", None)
    assert session_id is not None
    service = context.plan_service._memory_services.get(session_id)
    assert service is not None
    adapter = context.conversation_adapter
    assert adapter._message_history is service.message_history  # type: ignore[attr-defined]


@given('I stored a chat message in session "{session_id}"')
def step_store_chat_message(context: Context, session_id: str) -> None:
    service = _request_memory_service_for_session(
        context, session_id, persistent=False, max_messages=None
    )
    service.add_user_message("hello memory branch")
    assert len(service.message_history.messages) == 1
    context.stored_memory_session = session_id
    context.stored_memory_service = service


@when('I clear the session "{session_id}" memory with forget history')
def step_clear_memory_with_forget(context: Context, session_id: str) -> None:
    assert getattr(context, "stored_memory_session", None) == session_id
    context.plan_service.clear_memory(session_id, forget_history=True)
    context.cleared_memory_service = context.stored_memory_service


@when('I clear the session "{session_id}" memory without forgetting history')
def step_clear_memory_without_forget(context: Context, session_id: str) -> None:
    assert getattr(context, "stored_memory_session", None) == session_id
    context.plan_service.clear_memory(session_id)
    context.cleared_memory_service = context.stored_memory_service


@then('the session "{session_id}" memory should be removed and emptied')
def step_verify_memory_cleared(context: Context, session_id: str) -> None:
    assert session_id not in context.plan_service._memory_services
    service = getattr(context, "cleared_memory_service", None)
    assert isinstance(service, MemoryService)
    assert service.message_history.messages == []


@then('the session "{session_id}" memory should retain its stored messages')
def step_verify_memory_retained(context: Context, session_id: str) -> None:
    service = getattr(context, "cleared_memory_service", None)
    assert isinstance(service, MemoryService), "Memory service reference missing"
    assert service is getattr(context, "stored_memory_service", None)
    assert session_id not in context.plan_service._memory_services
    messages = service.message_history.messages
    assert messages, "Message history should remain intact"


class _StubPlansRepository:
    """Minimal stub for plan repository interactions."""

    def __init__(
        self,
        *,
        create_return_plan: Plan | None = None,
        current_plan: Plan | None = None,
    ) -> None:
        self._create_return_plan = create_return_plan
        self._current_plan = current_plan
        self.created_plans: list[Plan] = []
        self.update_calls: list[Plan] = []
        self.set_current_calls: list[tuple[int | None, int | None]] = []

    def create(self, plan: Plan) -> Plan:
        self.created_plans.append(plan)
        if self._create_return_plan is not None:
            return self._create_return_plan
        return plan

    def get_current_for_project(self, project_id: int | None) -> Plan | None:
        return self._current_plan

    def update(self, plan: Plan) -> None:
        self.update_calls.append(plan)

    def set_current(self, project_id: int | None, plan_id: int | None) -> None:
        self.set_current_calls.append((project_id, plan_id))


class _StaticContext:
    """Context object supplying repositories for stubbed transactions."""

    def __init__(
        self,
        plans_repo: _StubPlansRepository,
        contexts_repo: MagicMock | None = None,
        changes_repo: MagicMock | None = None,
    ) -> None:
        self.plans = plans_repo
        self.contexts = contexts_repo or MagicMock()
        self.changes = changes_repo or MagicMock()


class _StubTransaction:
    """Simple context manager that can invoke an exit callback."""

    def __init__(
        self,
        context_obj: _StaticContext,
        exit_callback: Callable[[], None] | None = None,
    ) -> None:
        self._context = context_obj
        self._exit_callback = exit_callback

    def __enter__(self) -> _StaticContext:
        return self._context

    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        if self._exit_callback is not None:
            self._exit_callback()
        return False


class _SequencedUnitOfWork(UnitOfWork):
    """Unit of work that plays back a sequence of stubbed transactions."""

    def __init__(
        self, transactions: list[tuple[_StaticContext, Callable[[], None] | None]]
    ):
        self._transactions = transactions

    def transaction(self) -> _StubTransaction:  # type: ignore[override]
        if not self._transactions:
            raise RuntimeError("No stub transactions configured")
        if len(self._transactions) > 1:
            context_obj, exit_callback = self._transactions.pop(0)
        else:
            context_obj, exit_callback = self._transactions[0]
        return _StubTransaction(context_obj, exit_callback)


@given(
    "I configure a stub plan service whose current plan loses its ID after the transaction"
)
def step_configure_plan_service_losing_id(context: Context) -> None:
    plan = Plan(
        id=123,
        project_id=1,
        name="stub-plan",
        prompt="stub",
        status=PlanStatus.PENDING,
        current=True,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        build=None,
        build_started_at=None,
        build_completed_at=None,
        model_used=None,
        token_count=None,
        result=None,
        applied_at=None,
        files_created=None,
        files_modified=None,
        files_deleted=None,
    )

    plans_repo = _StubPlansRepository(current_plan=plan)
    context.stub_plans_repo = plans_repo
    static_context = _StaticContext(plans_repo)

    def invalidate_plan_id() -> None:
        plan.id = None

    stub_uow = _SequencedUnitOfWork([(static_context, invalidate_plan_id)])
    context.unit_of_work = stub_uow
    settings = Settings()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=stub_uow,
        ai_provider=None,
    )
    context.project = Project(
        id=1,
        name="stub-project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=plan.id,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="mock-gpt",
        ),
    )


@when("I try to build the plan with the stubbed service")
def step_try_build_plan_stub(context: Context) -> None:
    try:
        context.plan_service.build_plan(context.project)
        context.exception = None
    except Exception as exc:  # pragma: no cover - defensive
        context.exception = exc


@given("I replace the plan service repository with a stub that never assigns plan IDs")
def step_replace_plan_service_with_stub(context: Context) -> None:
    plans_repo = _StubPlansRepository()
    context.stub_plans_repo = plans_repo
    static_context = _StaticContext(plans_repo)
    stub_uow = _SequencedUnitOfWork([(static_context, None)])
    context.unit_of_work = stub_uow
    settings = Settings()
    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=stub_uow,
        ai_provider=None,
    )
    context.project = Project(
        id=7,
        name="stub-project",
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="stub-model",
        ),
    )


@when('I create a plan with stub prompt "{prompt}"')
def step_create_plan_with_stub_prompt(context: Context, prompt: str) -> None:
    context.stub_created_plan = context.plan_service.create_plan(
        context.project,
        prompt,
        None,
    )


@then("the stub-created plan should remain non-current")
def step_verify_stub_plan_not_current(context: Context) -> None:
    plan = getattr(context, "stub_created_plan", None)
    assert isinstance(plan, Plan)
    assert plan.current is False
    assert plan.id is None


@then("the stub repository should not record a current plan")
def step_verify_stub_repository_not_called(context: Context) -> None:
    repo = getattr(context, "stub_plans_repo", None)
    assert isinstance(repo, _StubPlansRepository)
    assert repo.set_current_calls == []


@when('I monitor max message updates for session "{session_id}"')
def step_monitor_max_messages(context: Context, session_id: str) -> None:
    service = context.plan_service._memory_services.get(session_id)
    assert service is not None, "Memory service must exist before monitoring"
    original_setter = service.set_max_messages
    monitored_setter = MagicMock(wraps=original_setter)
    service.set_max_messages = monitored_setter  # type: ignore[assignment]
    context.monitored_service = service
    context.monitored_setter = monitored_setter


@when(
    'I request the same persistent memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_same_persistent_memory(
    context: Context, session_id: str, max_messages: int
) -> None:
    service = _request_memory_service_for_session(
        context,
        session_id,
        persistent=True,
        max_messages=max_messages,
    )
    context.reused_memory_service = service


@then(
    'the session "{session_id}" memory should reuse the existing service without changing limits'
)
def step_verify_memory_service_reused(context: Context, session_id: str) -> None:
    service = context.plan_service._memory_services.get(session_id)
    monitored_service = getattr(context, "monitored_service", None)
    monitored_setter = getattr(context, "monitored_setter", None)
    assert service is monitored_service
    assert monitored_service is not None
    assert monitored_setter is not None
    assert monitored_setter.call_count == 0
    assert monitored_service.max_messages is not None


@given("LangSmith integration is enabled for plan service")
def step_enable_langsmith_integration(context: Context) -> None:
    """Reconfigure the plan service with LangSmith support enabled."""

    if not hasattr(context, "unit_of_work"):
        step_create_unit_of_work_plan(context)

    settings = Settings()
    settings.langsmith_enabled = True
    settings.langsmith_project = "plan-service-tests"
    settings.langsmith_api_key = "plan-service-api-key"
    settings.langsmith_tags = ["global-langsmith"]
    settings.langsmith_user_id = "plan-service-user"

    original_builder = Settings.build_langsmith_config

    def _forward_build_config(
        self: Settings, *, tags=None, metadata=None, run_name=None
    ):
        return original_builder(
            self,
            tags=tags,
            metadata=metadata,
            run_name=run_name,
        )

    builder_patch = patch.object(
        Settings,
        "build_langsmith_config",
        autospec=True,
        side_effect=_forward_build_config,
    )
    builder_mock = builder_patch.start()
    cleanup = getattr(context, "add_cleanup", None)
    if callable(cleanup):
        cleanup(builder_patch.stop)
    else:  # pragma: no cover - behave contexts should supply add_cleanup
        context._langsmith_builder_patch = builder_patch  # type: ignore[attr-defined]
    context.langsmith_builder_mock = builder_mock

    context.plan_service = PlanService(
        settings=settings,
        unit_of_work=context.unit_of_work,
        ai_provider=None,
    )


@when(
    'I prepare a LangSmith config for project "{project_name}" and plan "{plan_name}"'
)
def step_prepare_langsmith_config(
    context: Context, project_name: str, plan_name: str
) -> None:
    """Build and prepare a LangSmith config to capture metadata and tags."""

    project = Project(
        id=101,
        name=project_name,
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="langsmith",
        ),
    )
    plan = Plan(
        id=202,
        project_id=project.id,
        name=plan_name,
        prompt="LangSmith coverage",
        status=PlanStatus.PENDING,
        current=True,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        build=None,
        build_started_at=None,
        build_completed_at=None,
        model_used=None,
        token_count=None,
        result=None,
        applied_at=None,
        files_created=None,
        files_modified=None,
        files_deleted=None,
    )
    config = context.plan_service._prepare_langsmith_config(
        project,
        plan,
        run_name="coverage",
        tags=["custom-tag"],
        metadata={"env": "test"},
    )
    context.langsmith_project = project
    context.langsmith_plan = plan
    context.langsmith_prepared_config = config
    builder_mock = getattr(context, "langsmith_builder_mock", None)
    if builder_mock is not None:
        context.langsmith_builder_call = builder_mock.call_args


@when('I prepare a LangSmith config without plan metadata for project "{project_name}"')
def step_prepare_langsmith_config_without_plan(
    context: Context, project_name: str
) -> None:
    """Prepare a LangSmith config when plan metadata is unavailable."""

    assert hasattr(context, "plan_service"), "PlanService is not configured"
    settings = context.plan_service.settings
    settings.langsmith_tags = []
    settings.langsmith_user_id = None

    project = Project(
        id=None,
        name=project_name,
        path=context.temp_dir,
        created_at=datetime.now(),
        updated_at=datetime.now(),
        current_plan_id=None,
        settings=ProjectSettings(
            auto_build=False,
            auto_apply=False,
            confirm_apply=True,
            max_context_size=50 * 1024 * 1024,
            default_model="langsmith",
        ),
    )
    config = context.plan_service._prepare_langsmith_config(
        project,
        None,
        run_name="coverage-minimal",
        tags=None,
        metadata=None,
    )
    context.langsmith_project = project
    context.langsmith_plan = None
    context.langsmith_prepared_config = config
    builder_mock = getattr(context, "langsmith_builder_mock", None)
    if builder_mock is not None:
        context.langsmith_builder_call = builder_mock.call_args


@then(
    'the LangSmith builder should receive metadata for project "{project_name}" and plan "{plan_name}"'
)
def step_assert_langsmith_builder_metadata(
    context: Context, project_name: str, plan_name: str
) -> None:
    """Verify the builder received merged metadata and tags."""

    call = getattr(context, "langsmith_builder_call", None)
    assert call is not None, "LangSmith builder was not invoked"
    _, kwargs = call
    metadata = kwargs.get("metadata", {})
    tags = kwargs.get("tags", [])
    project = getattr(context, "langsmith_project", None)
    plan = getattr(context, "langsmith_plan", None)
    assert metadata.get("project_name") == project_name
    assert metadata.get("plan_name") == plan_name
    assert metadata.get("env") == "test"
    assert metadata.get("project_id") == getattr(project, "id", None)
    assert metadata.get("plan_id") == getattr(plan, "id", None)
    assert metadata.get("user_id") == "plan-service-user"
    assert "service:plan" in tags
    ctr_project = getattr(project, "id", None)
    assert f"project:{ctr_project}" in tags
    assert f"plan:{getattr(plan, 'id', None)}" in tags
    assert "global-langsmith" in tags
    assert "custom-tag" in tags


@then(
    'the LangSmith builder should receive only base metadata for project "{project_name}"'
)
def step_assert_langsmith_builder_base_metadata(
    context: Context, project_name: str
) -> None:
    """Ensure only project metadata is provided when no plan is available."""

    call = getattr(context, "langsmith_builder_call", None)
    assert call is not None, "LangSmith builder was not invoked"
    _, kwargs = call
    metadata = kwargs.get("metadata", {})
    tags = kwargs.get("tags", [])
    assert metadata.get("project_name") == project_name
    assert metadata.get("project_id") is None
    extra_metadata_keys = set(metadata.keys()) - {"project_id", "project_name"}
    assert not extra_metadata_keys, (
        f"Unexpected metadata keys present: {sorted(extra_metadata_keys)}"
    )
    assert tags == ["service:plan"], f"Expected only base service tag, got {tags}"


@then("the prepared LangSmith config should include a generated thread id")
def step_assert_langsmith_thread_id(context: Context) -> None:
    config = getattr(context, "langsmith_prepared_config", None)
    assert config, "LangSmith config was not prepared"
    configurable = config.get("configurable")
    assert isinstance(configurable, dict)
    thread_id = configurable.get("thread_id")
    assert isinstance(thread_id, str)
    assert thread_id.startswith("plan-service-")


@given("I have a plan service with a failing AI provider")
def step_plan_service_with_failing_provider(context: Context) -> None:
    """Configure the plan service to surface provider failures."""

    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
    if not hasattr(context, "env_vars_to_clean"):
        context.env_vars_to_clean = []
    if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
        context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")

    failing_provider = MagicMock()
    failing_provider.generate_changes.return_value = ProviderResponse(
        changes=[],
        model_used="mock-gpt",
        token_count=0,
        error_message="mock provider failure",
    )
    context.plan_service = PlanService(
        settings=Settings(),
        unit_of_work=context.unit_of_work,
        ai_provider=failing_provider,
    )
    context.mock_provider = failing_provider


@when("I attempt to build the plan and the provider returns an error")
def step_build_plan_with_provider_error(context: Context) -> None:
    """Invoke build_plan and capture the provider error."""

    try:
        context.plan_service.build_plan(context.project)
        context.exception = None
    except Exception as exc:  # pragma: no cover - defensive
        context.exception = exc


@when("I run the auto debug build for the project")
def step_run_auto_debug_without_plan(context: Context) -> None:
    """Run auto_debug_build while capturing exceptions."""

    try:
        context.auto_debug_result = context.plan_service.auto_debug_build(
            context.project
        )
        context.exception = None
    except Exception as exc:
        context.auto_debug_result = None
        context.exception = exc


@given("auto debug retries should run with a failing build pipeline")
def step_force_auto_debug_failure(context: Context) -> None:
    """Mark that auto_debug_build should simulate failing build attempts."""

    context.force_auto_debug_failure = True


@when("I run the auto debug build with max attempts {attempts:d}")
def step_run_auto_debug_with_attempts(context: Context, attempts: int) -> None:
    """Execute auto_debug_build, optionally forcing failures for coverage."""

    def failing_build(
        self: PlanService,
        project: Project,
        progress_callback: Callable[[int], None] | None = None,
    ) -> list[Change]:
        raise RuntimeError("Simulated auto debug failure")

    agent_path = "cleveragents.agents.AutoDebugAgent"

    try:
        if getattr(context, "force_auto_debug_failure", False):
            with (
                patch.object(PlanService, "build_plan", side_effect=failing_build),
                patch(agent_path) as agent_cls,
            ):
                agent_instance = MagicMock()
                agent_instance.invoke.return_value = {
                    "result": {"success": False, "fix": {}},
                }
                agent_cls.return_value = agent_instance
                context.auto_debug_result = context.plan_service.auto_debug_build(
                    context.project, max_attempts=attempts
                )
        else:
            context.auto_debug_result = context.plan_service.auto_debug_build(
                context.project, max_attempts=attempts
            )
        context.exception = None
    except Exception as exc:
        context.auto_debug_result = None
        context.exception = exc


@when("I run the auto debug build with a retrying success")
def step_run_auto_debug_retry_success(context: Context) -> None:
    assert hasattr(context, "plan_service"), "PlanService is not configured"
    current_plan = getattr(context, "current_plan", None)
    assert current_plan is not None, "Current plan is not configured"

    call_count = {"count": 0}

    def build_plan_side_effect(
        self: PlanService,
        project: Project,
        progress_callback: Callable[[int], None] | None = None,
    ) -> list[Change]:
        call_count["count"] += 1
        if call_count["count"] == 1:
            raise RuntimeError("first failure")
        return [
            Change(
                id=None,
                plan_id=current_plan.id or 0,
                file_path="after_retry.py",
                operation=OperationType.CREATE,
                original_content=None,
                new_content="# retry success",
                new_path=None,
                applied=False,
                applied_at=None,
                created_at=datetime.now(),
            )
        ]

    agent_path = "cleveragents.agents.AutoDebugAgent"
    with (
        patch.object(PlanService, "build_plan", side_effect=build_plan_side_effect),
        patch(agent_path) as agent_cls,
    ):
        agent_instance = MagicMock()
        agent_instance.invoke.return_value = {"result": {"success": False, "fix": {}}}
        agent_cls.return_value = agent_instance
        context.auto_debug_result = context.plan_service.auto_debug_build(
            context.project, max_attempts=2
        )

    with context.unit_of_work.transaction() as ctx:
        context.latest_debug_attempts = ctx.debug_attempts.get_for_plan(current_plan.id)


@then('the auto debug result should indicate failure with error message "{message}"')
def step_assert_auto_debug_failure(context: Context, message: str) -> None:
    """Validate that auto_debug_build returned a failure tuple."""

    result = getattr(context, "auto_debug_result", None)
    assert result is not None, "Auto debug result was not captured"
    success, changes, error_message = result
    assert success is False
    assert isinstance(changes, list)
    assert isinstance(error_message, str)
    assert message in error_message


@then("the auto debug attempt should be marked successful after retry")
def step_assert_auto_debug_retry_success(context: Context) -> None:
    result = getattr(context, "auto_debug_result", None)
    assert result is not None, "Auto debug result was not captured"
    success, changes, error_message = result
    assert success is True
    assert isinstance(changes, list)
    assert error_message is None

    attempts = getattr(context, "latest_debug_attempts", []) or []
    assert attempts, "No debug attempts were recorded"
    assert any(attempt.success for attempt in attempts), (
        "Debug attempt was not marked successful"
    )


@when("I try to stream plan generation without an AI provider")
def step_stream_generate_without_provider(context: Context) -> None:
    """Attempt to consume the streaming generator without configuring an AI provider."""

    import asyncio

    os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
    if not hasattr(context, "env_vars_to_clean"):
        context.env_vars_to_clean = []
    if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
        context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")

    # Ensure the plan service cannot resolve a provider regardless of environment
    context.plan_service.ai_provider = None
    context.plan_service._provider_registry = _NoProviderRegistry()

    async def _consume_stream() -> None:
        generator = context.plan_service.generate_plan_streaming(
            context.project,
            description="Stream coverage scenario",
        )
        await generator.__anext__()

    try:
        asyncio.run(_consume_stream())
        context.exception = None
    except Exception as exc:
        context.exception = exc
