"""Tests for DeepAgent — self-learning agent execution.

Covers: spawn(), NathanMiddleware hook mapping, compaction detection,
safety bounds, error handling, and edge cases.
All tests use mock agents — no real LLM calls.
Architecture ref: ARCHITECTURE.md Sections 4 (Component 7), 6 (Flow), 8 (Edge Cases).
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage

from netanel_core.config import NathanConfig
from netanel_core.deep_agent import (
    AgentModel,
    DeepAgent,
    NathanMiddleware,
    SpawnResult,
    _is_tool_validation_error,
)
from netanel_core.hooks import HookManager
from netanel_core.memory_store import MemoryStore

# ────────────────────────────────────────────
# Test Infrastructure
# ────────────────────────────────────────────


class MockAgentModel:
    """Mock agent that satisfies AgentModel Protocol.

    Returns configurable responses and tracks invocations.
    """

    def __init__(self, response: dict[str, Any] | None = None) -> None:
        self.response = response or {
            "messages": [{"role": "assistant", "content": "Task completed successfully."}]
        }
        self.calls: list[tuple[dict, dict | None]] = []

    def invoke(self, input: dict, config: dict | None = None) -> dict:  # noqa: A002
        self.calls.append((input, config))
        return self.response


class ErrorAgentModel:
    """Mock agent that raises an exception on invoke."""

    def __init__(self, error: Exception | None = None) -> None:
        self.error = error or RuntimeError("Agent crashed")

    def invoke(self, input: dict, config: dict | None = None) -> dict:  # noqa: A002
        raise self.error


class RecursionLimitAgent:
    """Mock agent that raises RecursionError (simulates GraphRecursionError)."""

    def invoke(self, input: dict, config: dict | None = None) -> dict:  # noqa: A002
        raise RecursionError("Recursion limit of 50 reached")


# ────────────────────────────────────────────
# Fixtures
# ────────────────────────────────────────────


@pytest.fixture
def config(tmp_path):
    """Config with temp directory for isolated tests."""
    return NathanConfig(
        namespace="test_deep_agent",
        memories_dir=tmp_path / "memories",
    )


@pytest.fixture
def store(config):
    """MemoryStore ready for testing."""
    return MemoryStore(config)


@pytest.fixture
def hooks(store):
    """HookManager ready for testing."""
    return HookManager(store)


@pytest.fixture
def mock_model():
    """Default mock agent with single successful response."""
    return MockAgentModel()


@pytest.fixture
def mock_hooks():
    """MagicMock HookManager for hook-call-verification tests."""
    m = MagicMock(spec=HookManager)
    m.post_step.return_value = None
    return m


@pytest.fixture
def agent(config, hooks, mock_model):
    """DeepAgent with mock model injected."""
    return DeepAgent(config, hooks, model=mock_model)


TASK = "test task"
"""Shared task string used across TestHookIntegration tests."""


@pytest.fixture
def task_prompt():
    """Task string used in context-injection tests."""
    return "My task"


@pytest.fixture
def system_prompt():
    """System prompt (role + memories) used in context-injection tests."""
    return "Role + memories combined"


@pytest.fixture
def task_only_prompt():
    """Task string for fallback-path tests (no system_prompt provided)."""
    return "Task only"


# ────────────────────────────────────────────
# TestBasicExecution
# ────────────────────────────────────────────


class TestBasicExecution:
    """spawn() basic behavior and SpawnResult structure."""

    def test_spawn_returns_spawn_result(self, agent):
        """spawn() returns a SpawnResult instance."""
        result = agent.spawn("Do something")
        assert isinstance(result, SpawnResult)

    def test_spawn_result_has_correct_fields(self, agent):
        """SpawnResult contains all required fields."""
        result = agent.spawn("Do something")
        assert isinstance(result.result, str)
        assert isinstance(result.steps_taken, int)
        assert isinstance(result.compactions, int)
        assert isinstance(result.completed, bool)
        assert isinstance(result.metadata, dict)

    def test_spawn_extracts_result_from_state(self, agent):
        """spawn() extracts the final message content as result."""
        result = agent.spawn("Do something")
        assert result.result == "Task completed successfully."
        assert result.completed is True

    def test_prompt_forwarded_to_agent(self, agent, mock_model):
        """spawn() passes the prompt as a user message to the agent."""
        agent.spawn("Analyze this code")
        assert len(mock_model.calls) == 1
        input_dict = mock_model.calls[0][0]
        assert input_dict["messages"][0]["content"] == "Analyze this code"

    def test_recursion_limit_from_config(self, agent, mock_model):
        """spawn() passes recursion_limit from config to invoke."""
        agent.spawn("Do something")
        config_dict = mock_model.calls[0][1]
        assert config_dict["recursion_limit"] == agent._config.safety.max_iterations

    def test_tools_default_to_empty_list(self, agent, mock_model):
        """spawn() with no tools defaults to empty list without crashing."""
        result = agent.spawn("Do something")
        assert result.completed is True

    def test_spawn_with_dict_message_response(self, config, hooks):
        """spawn() extracts result from dict-style messages."""
        model = MockAgentModel(
            response={"messages": [{"role": "assistant", "content": "Dict result"}]}
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")
        assert result.result == "Dict result"

    def test_spawn_uses_system_prompt_when_provided(
        self, config, hooks, task_prompt, system_prompt
    ):
        """BUG-012: spawn() passes system_prompt to _create_agent when explicitly given.

        Before fix: _create_agent always received `prompt` as system prompt —
        memories from ContextBuilder were never injected into the agent.
        After fix: when system_prompt is provided, it becomes the effective
        system prompt, while `prompt` remains the user message.
        """
        from unittest.mock import patch

        mock_agent = MockAgentModel()
        with patch.object(DeepAgent, "_create_agent", return_value=mock_agent) as mock_create:
            agent = DeepAgent(config, hooks)  # no injected model — calls _create_agent
            agent.spawn(task_prompt, system_prompt=system_prompt)

        # _create_agent must have received the custom system_prompt, not the task
        create_args = mock_create.call_args[0]  # positional args
        assert create_args[0] == system_prompt

    def test_spawn_falls_back_to_prompt_when_no_system_prompt(
        self, config, hooks, task_only_prompt
    ):
        """BUG-012: spawn() falls back to prompt as system prompt when system_prompt=None.

        Backward compatibility: callers that do not pass system_prompt continue
        to work exactly as before. The prompt is used as the effective system prompt.
        """
        from unittest.mock import patch

        mock_agent = MockAgentModel()
        with patch.object(DeepAgent, "_create_agent", return_value=mock_agent) as mock_create:
            agent = DeepAgent(config, hooks)
            agent.spawn(task_only_prompt)  # no system_prompt provided

        # _create_agent must have received prompt as the fallback system prompt
        create_args = mock_create.call_args[0]
        assert create_args[0] == task_only_prompt


# ────────────────────────────────────────────
# TestHookIntegration
# ────────────────────────────────────────────


class TestHookIntegration:
    """Verify HookManager hooks fire correctly via NathanMiddleware."""

    def test_on_complete_not_called_from_spawn(self, config, mock_hooks):
        """spawn() does NOT fire on_complete — LearningLLM._agent_path() does (BUG-031).

        on_complete must fire AFTER evaluation, not before. DeepAgent has no
        evaluator — it cannot know if output is quality-verified.
        """
        agent = DeepAgent(config, mock_hooks, model=MockAgentModel())
        agent.spawn(TASK)
        mock_hooks.on_complete.assert_not_called()

    def test_hook_returning_none_handled(self, config, mock_hooks):
        """Hooks returning None don't crash the agent."""
        mock_hooks.on_complete.return_value = None
        agent = DeepAgent(config, mock_hooks, model=MockAgentModel())
        result = agent.spawn(TASK)
        assert result.completed is True


# ────────────────────────────────────────────
# TestNathanMiddleware
# ────────────────────────────────────────────


class TestNathanMiddleware:
    """NathanMiddleware hook mapping and state management."""

    def test_before_agent_resets_counters(self, hooks, config):
        """before_agent resets all tracking state."""
        mw = NathanMiddleware(hooks, config, "test")
        mw._step_count = 5
        mw._total_tokens = 1000
        mw._compaction_count = 2
        mw._pre_compaction_fired = True
        mw._last_state = {"messages": []}

        mw.before_agent({}, MagicMock())

        assert mw._step_count == 0
        assert mw._total_tokens == 0
        assert mw._compaction_count == 0
        assert mw._pre_compaction_fired is False
        assert mw._last_state is None

    def test_after_model_increments_step_count(self, hooks, config):
        """after_model increments step count per call."""
        mw = NathanMiddleware(hooks, config, "test")

        mw.after_model({"messages": [{"content": "step 1"}]}, MagicMock())
        assert mw._step_count == 1

        mw.after_model({"messages": [{"content": "step 2"}]}, MagicMock())
        assert mw._step_count == 2

    def test_after_model_tracks_tokens(self, hooks, config):
        """after_model estimates and accumulates token count."""
        mw = NathanMiddleware(hooks, config, "test")
        # "hello world" = 11 chars / 4 = 2 tokens
        mw.after_model({"messages": [{"content": "hello world"}]}, MagicMock())
        assert mw._total_tokens == 2

    def test_after_model_calls_post_step(self, config):
        """after_model fires post_step hook with task and output."""
        mock_hooks = MagicMock(spec=HookManager)
        mw = NathanMiddleware(mock_hooks, config, "my task")

        mw.after_model({"messages": [{"content": "step output"}]}, MagicMock())

        mock_hooks.post_step.assert_called_once_with(
            task="my task",
            step_output="step output",
        )

    def test_after_model_handles_empty_messages(self, hooks, config):
        """after_model doesn't crash on empty messages list."""
        mw = NathanMiddleware(hooks, config, "test")
        mw.after_model({"messages": []}, MagicMock())
        assert mw._step_count == 1  # Incremented but no post_step fire

    def test_after_agent_captures_last_state(self, hooks, config):
        """after_agent stores the final state for result extraction."""
        mw = NathanMiddleware(hooks, config, "test")
        state = {"messages": [{"content": "final"}]}

        mw.after_agent(state, MagicMock())

        assert mw._last_state is not None
        assert mw._last_state["messages"][0]["content"] == "final"

    def test_after_agent_handles_none_state(self, hooks, config):
        """after_agent doesn't crash on None state."""
        mw = NathanMiddleware(hooks, config, "test")
        mw.after_agent(None, MagicMock())
        assert mw._last_state is None

    def test_estimate_tokens(self, hooks, config):
        """Token estimation uses chars/4 pattern."""
        mw = NathanMiddleware(hooks, config, "test")
        assert mw._estimate_tokens("") == 0
        assert mw._estimate_tokens("abcd") == 1  # 4/4 = 1
        assert mw._estimate_tokens("a" * 100) == 25  # 100/4 = 25


# ────────────────────────────────────────────
# TestCompaction
# ────────────────────────────────────────────


class TestCompaction:
    """Compaction detection via wrap_model_call.

    wrap_model_call now accepts ModelRequest and returns ModelResponse.
    Use _make_request() and _make_handler() helpers for clean test setup.
    """

    @staticmethod
    def _make_request(n_messages: int) -> ModelRequest:
        """Create a ModelRequest with n_messages AIMessages."""
        return ModelRequest(
            model=MagicMock(),
            messages=[AIMessage(content=f"msg {i}") for i in range(n_messages)],
            system_prompt="test",
        )

    @staticmethod
    def _make_handler(n_result_messages: int):
        """Return a handler callable that produces a ModelResponse with n messages."""
        result = ModelResponse(
            result=[AIMessage(content=f"r{i}") for i in range(n_result_messages)]
        )
        return MagicMock(return_value=result)

    def test_pre_compaction_fires_at_threshold(self, config):
        """pre_compaction fires when tokens exceed 80% of max_tokens_per_task."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.pre_compaction.return_value = None
        mw = NathanMiddleware(mock_hooks, config, "test")

        # Derive threshold from config so test stays correct if max_tokens_per_task changes
        mw._total_tokens = int(config.safety.max_tokens_per_task * mw.COMPACTION_THRESHOLD) + 1

        mw.wrap_model_call(self._make_request(5), self._make_handler(5))

        mock_hooks.pre_compaction.assert_called_once()
        assert mw._pre_compaction_fired is True

    def test_pre_compaction_does_not_fire_below_threshold(self, config):
        """pre_compaction does NOT fire when tokens are below 80%."""
        mock_hooks = MagicMock(spec=HookManager)
        mw = NathanMiddleware(mock_hooks, config, "test")
        mw._total_tokens = 1000  # Well below threshold

        mw.wrap_model_call(self._make_request(5), self._make_handler(5))

        mock_hooks.pre_compaction.assert_not_called()

    def test_pre_compaction_fires_only_once(self, config):
        """pre_compaction fires once, then flag prevents re-firing."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.pre_compaction.return_value = None
        mw = NathanMiddleware(mock_hooks, config, "test")
        mw._total_tokens = int(config.safety.max_tokens_per_task * mw.COMPACTION_THRESHOLD) + 1

        handler = self._make_handler(5)
        mw.wrap_model_call(self._make_request(5), handler)
        mw.wrap_model_call(self._make_request(5), handler)

        # Only called once despite two wrap_model_call invocations
        assert mock_hooks.pre_compaction.call_count == 1

    def test_post_compaction_fires_on_message_decrease(self, config):
        """post_compaction fires when message count in result drops vs request."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.post_compaction.return_value = ([], None)
        mw = NathanMiddleware(mock_hooks, config, "test")

        # Request: 10 messages. Handler returns 3 messages (summarized)
        mw.wrap_model_call(self._make_request(10), self._make_handler(3))

        mock_hooks.post_compaction.assert_called_once()
        assert mw._compaction_count == 1

    def test_post_compaction_resets_pre_compaction_flag(self, config):
        """After post_compaction, the pre_compaction flag resets for next cycle."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.pre_compaction.return_value = None
        mock_hooks.post_compaction.return_value = ([], None)
        mw = NathanMiddleware(mock_hooks, config, "test")
        mw._total_tokens = 85_000

        # pre_compaction fires, summarization detected, post_compaction fires
        mw.wrap_model_call(self._make_request(10), self._make_handler(3))

        assert mw._pre_compaction_fired is False  # Reset by post_compaction

    def test_no_compaction_when_message_count_same(self, config):
        """post_compaction does NOT fire when message count stays the same."""
        mock_hooks = MagicMock(spec=HookManager)
        mw = NathanMiddleware(mock_hooks, config, "test")

        mw.wrap_model_call(self._make_request(5), self._make_handler(5))

        mock_hooks.post_compaction.assert_not_called()

    def test_compaction_count_tracked_in_spawn_result(self, config):
        """SpawnResult.compactions reflects middleware compaction count."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.on_complete.return_value = None
        model = MockAgentModel()
        agent = DeepAgent(config, mock_hooks, model=model)
        result = agent.spawn("test")
        assert result.compactions == 0  # No compaction in simple mock


# ────────────────────────────────────────────
# BUG-017 Regression Tests
# ────────────────────────────────────────────


class TestBug017CompactionTypeCheck:
    """Regression tests for BUG-017: compaction detection with incompatible types.

    wrap_model_call must not raise TypeError when result.result is a string
    or non-list, and must still detect compaction when result.messages shrinks
    or result.result is a list that shrinks.
    """

    @staticmethod
    def _make_request(n_messages: int) -> ModelRequest:
        return ModelRequest(
            model=MagicMock(),
            messages=[AIMessage(content=f"msg {i}") for i in range(n_messages)],
            system_prompt="test",
        )

    def test_string_result_does_not_trigger_compaction(self, config):
        """BUG-017: result.result is a plain string — no TypeError, no false compaction."""
        mock_hooks = MagicMock(spec=HookManager)
        mw = NathanMiddleware(mock_hooks, config, "test")

        # ModelResponse with result as a plain string (incompatible with old len() call)
        string_response = ModelResponse(result="summarized context text")
        handler = MagicMock(return_value=string_response)

        # Must not raise TypeError; must NOT fire post_compaction
        mw.wrap_model_call(self._make_request(5), handler)

        mock_hooks.post_compaction.assert_not_called()
        assert mw._compaction_count == 0

    def test_messages_list_shrink_triggers_compaction(self, config):
        """BUG-017: result.messages list that shrunk → compaction IS detected."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.post_compaction.return_value = ([], None)
        mw = NathanMiddleware(mock_hooks, config, "test")

        # Build response with both .result (list) and .messages (shorter list)
        response = ModelResponse(result=[AIMessage(content="r0")])
        response.messages = [AIMessage(content="summary")]  # 1 message (preferred attr)
        handler = MagicMock(return_value=response)

        # Request: 5 messages, result.messages: 1 → compaction detected
        mw.wrap_model_call(self._make_request(5), handler)

        mock_hooks.post_compaction.assert_called_once()
        assert mw._compaction_count == 1

    def test_result_list_shrink_triggers_compaction(self, config):
        """BUG-017: result.result is a list that shrunk → compaction detected."""
        mock_hooks = MagicMock(spec=HookManager)
        mock_hooks.post_compaction.return_value = ([], None)
        mw = NathanMiddleware(mock_hooks, config, "test")

        # ModelResponse with result as a short list (2 items), request had 10 messages
        response = ModelResponse(result=[AIMessage(content="r0"), AIMessage(content="r1")])
        handler = MagicMock(return_value=response)

        mw.wrap_model_call(self._make_request(10), handler)

        mock_hooks.post_compaction.assert_called_once()
        assert mw._compaction_count == 1

    def test_non_list_non_string_result_no_crash_no_compaction(self, config):
        """BUG-017: result.result is an integer — no crash, no false compaction."""
        mock_hooks = MagicMock(spec=HookManager)
        mw = NathanMiddleware(mock_hooks, config, "test")

        response = ModelResponse(result=42)
        handler = MagicMock(return_value=response)

        mw.wrap_model_call(self._make_request(5), handler)

        mock_hooks.post_compaction.assert_not_called()
        assert mw._compaction_count == 0


# ────────────────────────────────────────────
# BUG-042 Regression Tests
# ────────────────────────────────────────────


class TestBug042SystemPromptTypeGuard:
    """Regression tests for BUG-042: _create_agent raises TypeError for non-string prompt.

    deepagents may reformat a non-string system_prompt as
    [{"type":"text","text":"..."}] which the OpenAI API rejects.
    The fix adds an isinstance check before calling create_deep_agent.

    Uses patch("deepagents.create_deep_agent") matching the TestMiddlewareWiring
    pattern — deepagents is installed, so we patch the function directly.
    """

    def test_non_string_prompt_raises_type_error(self, config, hooks):
        """_create_agent raises TypeError when prompt is not a plain string."""
        agent = DeepAgent(config, hooks, model=None)

        # TypeError fires before create_deep_agent is called — patch prevents real invocation
        with (
            patch("deepagents.create_deep_agent", return_value=MagicMock(spec=AgentModel)),
            pytest.raises(TypeError, match="system_prompt must be a plain string"),
        ):
            agent._create_agent(
                prompt=["not", "a", "string"],  # type: ignore[arg-type]
                tools=[],
                middleware=NathanMiddleware(hooks, config, "test"),
            )

    def test_dict_prompt_raises_type_error(self, config, hooks):
        """_create_agent raises TypeError when prompt is a dict."""
        agent = DeepAgent(config, hooks, model=None)

        with (
            patch("deepagents.create_deep_agent", return_value=MagicMock(spec=AgentModel)),
            pytest.raises(TypeError, match="system_prompt must be a plain string"),
        ):
            agent._create_agent(
                prompt={"type": "text", "text": "hello"},  # type: ignore[arg-type]
                tools=[],
                middleware=NathanMiddleware(hooks, config, "test"),
            )

    def test_string_prompt_passes_type_guard(self, config, hooks):
        """_create_agent succeeds normally when prompt is a plain string."""
        agent = DeepAgent(config, hooks, model=None)

        mock_agent = MagicMock()

        def mock_create(model, tools, system_prompt, middleware=None, backend=None, **kwargs):
            return mock_agent

        with patch("deepagents.create_deep_agent", side_effect=mock_create):
            result = agent._create_agent(
                prompt="analyze this code",
                tools=[],
                middleware=NathanMiddleware(hooks, config, "test"),
            )

        assert result is mock_agent

    def test_type_error_message_includes_type_name(self, config, hooks):
        """TypeError message includes the actual type name for debuggability."""
        agent = DeepAgent(config, hooks, model=None)

        with (
            patch("deepagents.create_deep_agent", return_value=MagicMock(spec=AgentModel)),
            pytest.raises(TypeError, match="list"),
        ):
            agent._create_agent(
                prompt=["bad"],  # type: ignore[arg-type]
                tools=[],
                middleware=NathanMiddleware(hooks, config, "test"),
            )


# ────────────────────────────────────────────
# TestSafetyBounds
# ────────────────────────────────────────────


class TestSafetyBounds:
    """recursion_limit enforcement and safety bounds."""

    def test_recursion_limit_matches_config(self, config, hooks):
        """recursion_limit passed to invoke equals config.safety.max_iterations."""
        model = MockAgentModel()
        agent = DeepAgent(config, hooks, model=model)
        agent.spawn("test")
        _, invoke_config = model.calls[0]
        assert invoke_config["recursion_limit"] == config.safety.max_iterations

    def test_custom_max_iterations(self, hooks, tmp_path):
        """Custom max_iterations in config propagates to invoke."""
        from netanel_core.config import SafetyBounds

        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(max_iterations=25),
        )
        model = MockAgentModel()
        agent = DeepAgent(config, hooks, model=model)
        agent.spawn("test")
        _, invoke_config = model.calls[0]
        assert invoke_config["recursion_limit"] == 25

    def test_recursion_error_caught(self, config, hooks):
        """RecursionError (GraphRecursionError) results in completed=False."""
        model = RecursionLimitAgent()
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")
        assert result.completed is False
        assert result.metadata["error"] == "max_iterations"
        assert result.metadata["max_iterations_reached"] is True


# ────────────────────────────────────────────
# TestErrorHandling
# ────────────────────────────────────────────


class TestErrorHandling:
    """Graceful degradation — spawn() never raises."""

    def test_agent_error_returns_spawn_result(self, config, hooks):
        """Agent crash returns SpawnResult with completed=False."""
        model = ErrorAgentModel(RuntimeError("boom"))
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")
        assert isinstance(result, SpawnResult)
        assert result.completed is False
        assert "boom" in result.metadata["error"]

    def test_empty_response_handled(self, config, hooks):
        """Agent returning empty messages still works."""
        model = MockAgentModel(response={"messages": []})
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")
        assert result.completed is True
        assert result.result == ""

    def test_import_error_for_deepagents(self, config, hooks):
        """_create_agent raises ImportError with helpful message when deepagents missing."""
        agent = DeepAgent(config, hooks, model=None)
        with patch.dict("sys.modules", {"deepagents": None}):
            # spawn catches the ImportError and returns SpawnResult
            result = agent.spawn("test")
            assert result.completed is False
            assert "error" in result.metadata


# ────────────────────────────────────────────
# TestEdgeCases
# ────────────────────────────────────────────


class TestEdgeCases:
    """Boundary conditions and isolation."""

    def test_empty_tools_list(self, config, hooks):
        """spawn() with empty tools doesn't crash."""
        model = MockAgentModel()
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test", tools=[])
        assert result.completed is True

    def test_empty_prompt(self, config, hooks):
        """spawn() with empty prompt doesn't crash."""
        model = MockAgentModel()
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("")
        assert result.completed is True

    def test_state_reset_between_spawns(self, config, hooks):
        """Two spawn() calls have independent step counts."""
        model = MockAgentModel()
        agent = DeepAgent(config, hooks, model=model)

        result1 = agent.spawn("task 1")
        result2 = agent.spawn("task 2")

        # Both should have step count 0 (mock doesn't trigger after_model)
        assert result1.steps_taken == 0
        assert result2.steps_taken == 0

    def test_spawn_result_defaults(self):
        """SpawnResult default values are correct."""
        sr = SpawnResult(result="", steps_taken=0, compactions=0)
        assert sr.completed is False
        assert sr.metadata == {}

    def test_agent_model_protocol(self):
        """MockAgentModel satisfies AgentModel Protocol."""
        mock = MockAgentModel()
        assert isinstance(mock, AgentModel)


# ────────────────────────────────────────────
# TestMiddlewareWiring (Regression — BUG-004)
# ────────────────────────────────────────────


class TestMiddlewareWiring:
    """Regression tests for BUG-004: middleware must be passed to create_deep_agent().

    These tests patch deepagents.create_deep_agent to capture kwargs and verify
    that NathanMiddleware is actually wired into the agent pipeline.
    """

    def test_middleware_passed_to_create_deep_agent(self, config, hooks):
        """BUG-004 regression: middleware kwarg must be present in create_deep_agent()."""
        captured_kwargs: dict = {}

        def mock_create(model, tools, system_prompt, middleware=None, **kwargs):
            captured_kwargs.update({"middleware": middleware, "tools": tools})
            # Return a simple mock agent
            mock_agent = MockAgentModel()
            return mock_agent

        with patch("deepagents.create_deep_agent", side_effect=mock_create):
            agent = DeepAgent(config, hooks, model=None)
            agent.spawn("test task")

        assert "middleware" in captured_kwargs, "middleware kwarg missing from create_deep_agent()"
        assert captured_kwargs["middleware"] is not None, "middleware must not be None"
        assert len(captured_kwargs["middleware"]) == 1, "exactly one middleware expected"
        assert isinstance(captured_kwargs["middleware"][0], NathanMiddleware), (
            "middleware must be NathanMiddleware instance"
        )

    def test_middleware_is_nathanmiddleware_subclass(self, config, hooks):
        """NathanMiddleware must be a proper AgentMiddleware subclass."""
        from langchain.agents.middleware.types import AgentMiddleware

        mw = NathanMiddleware(hooks, config, "task")
        assert isinstance(mw, AgentMiddleware), "NathanMiddleware must inherit from AgentMiddleware"


# ────────────────────────────────────────────
# TestCreateBackend (Regression — BUG-014, BUG-015)
# ────────────────────────────────────────────


class TestCreateBackend:
    """_create_backend() returns correct backend based on config.safety.backend_type.

    BUG-014/015 regression: Three pillars (bash, files, search) require a real
    filesystem backend. StateBackend is ephemeral — it cannot touch the real
    filesystem, breaking the three-pillar autonomous execution model.
    """

    def test_ephemeral_returns_none(self, hooks, tmp_path):
        """backend_type='ephemeral' returns None (deepagents uses StateBackend default)."""
        from netanel_core.config import SafetyBounds

        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="ephemeral"),
        )
        agent = DeepAgent(config, hooks)
        assert agent._create_backend() is None

    def test_filesystem_creates_filesystem_backend(self, hooks, tmp_path):
        """backend_type='filesystem' creates FilesystemBackend with virtual_mode=False."""
        from netanel_core.config import SafetyBounds

        root_dir = tmp_path / "workspace"
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="filesystem", backend_root_dir=str(root_dir)),
        )
        agent = DeepAgent(config, hooks)

        mock_backends = MagicMock()
        mock_fs_instance = MagicMock()
        mock_backends.FilesystemBackend = MagicMock(return_value=mock_fs_instance)

        with patch.dict("sys.modules", {"deepagents.backends": mock_backends}):
            result = agent._create_backend()

        mock_backends.FilesystemBackend.assert_called_once_with(root_dir=str(root_dir), virtual_mode=False)
        assert result is mock_fs_instance

    def test_local_shell_creates_local_shell_backend(self, hooks, tmp_path):
        """backend_type='local_shell' creates LocalShellBackend with scaled timeout."""
        from netanel_core.config import SafetyBounds

        root_dir = tmp_path / "workspace"
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="local_shell", max_iterations=50, backend_root_dir=str(root_dir)),
        )
        agent = DeepAgent(config, hooks)

        mock_backends = MagicMock()
        mock_ls_instance = MagicMock()
        mock_backends.LocalShellBackend = MagicMock(return_value=mock_ls_instance)

        with patch.dict("sys.modules", {"deepagents.backends": mock_backends}):
            result = agent._create_backend()

        mock_backends.LocalShellBackend.assert_called_once_with(
            root_dir=str(root_dir),
            virtual_mode=False,
            timeout=500.0,  # 50 * 10.0
            inherit_env=True,
        )
        assert result is mock_ls_instance

    def test_timeout_scales_with_max_iterations(self, hooks, tmp_path):
        """LocalShellBackend timeout = max_iterations * 10.0 — scales with safety bounds."""
        from netanel_core.config import SafetyBounds

        root_dir = tmp_path / "workspace"
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="local_shell", max_iterations=20, backend_root_dir=str(root_dir)),
        )
        agent = DeepAgent(config, hooks)

        mock_backends = MagicMock()
        mock_backends.LocalShellBackend = MagicMock(return_value=MagicMock())

        with patch.dict("sys.modules", {"deepagents.backends": mock_backends}):
            agent._create_backend()

        _, kwargs = mock_backends.LocalShellBackend.call_args
        assert kwargs["timeout"] == 200.0  # 20 * 10.0

    def test_custom_root_dir_forwarded_to_filesystem(self, hooks, tmp_path):
        """backend_root_dir from config is forwarded to FilesystemBackend."""
        from netanel_core.config import SafetyBounds

        root_dir = tmp_path / "workspace"
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="filesystem", backend_root_dir=str(root_dir)),
        )
        agent = DeepAgent(config, hooks)

        mock_backends = MagicMock()
        mock_backends.FilesystemBackend = MagicMock(return_value=MagicMock())

        with patch.dict("sys.modules", {"deepagents.backends": mock_backends}):
            agent._create_backend()

        mock_backends.FilesystemBackend.assert_called_once_with(
            root_dir=str(root_dir), virtual_mode=False
        )

    def test_custom_root_dir_forwarded_to_local_shell(self, hooks, tmp_path):
        """backend_root_dir from config is forwarded to LocalShellBackend."""
        from netanel_core.config import SafetyBounds

        root_dir = tmp_path / "workspace"
        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="local_shell", backend_root_dir=str(root_dir)),
        )
        agent = DeepAgent(config, hooks)

        mock_backends = MagicMock()
        mock_backends.LocalShellBackend = MagicMock(return_value=MagicMock())

        with patch.dict("sys.modules", {"deepagents.backends": mock_backends}):
            agent._create_backend()

        _, kwargs = mock_backends.LocalShellBackend.call_args
        assert kwargs["root_dir"] == str(root_dir)


    def test_backend_passed_to_create_deep_agent(self, hooks, tmp_path):
        """BUG-014/015 regression: backend kwarg must be present in create_deep_agent().

        Uses ephemeral backend so _create_backend() returns None without importing
        deepagents.backends — isolates the wiring test from the backend test.
        """
        from netanel_core.config import SafetyBounds

        config = NathanConfig(
            namespace="test",
            memories_dir=tmp_path / "memories",
            safety=SafetyBounds(backend_type="ephemeral"),
        )
        captured: dict = {}

        def mock_create(model, tools, system_prompt, middleware=None, backend=None, **kwargs):
            captured["backend"] = backend
            return MockAgentModel()

        with patch("deepagents.create_deep_agent", side_effect=mock_create):
            agent = DeepAgent(config, hooks, model=None)
            agent.spawn("test task")

        assert "backend" in captured, "backend kwarg missing from create_deep_agent()"
        assert captured["backend"] is None  # ephemeral → None passed


# ────────────────────────────────────────────
# TestSilentRecursionLimit (Regression — BUG-025)
# ────────────────────────────────────────────


class TestSilentRecursionLimit:
    """BUG-025 regression: agent silently stops mid-loop when last message is ToolMessage.

    LangGraph issue #2165: GraphRecursionError is NOT raised when the recursion
    limit is hit mid-tool-loop — the agent returns normally with completed=True,
    but the last message is a ToolMessage (tool output, not agent response).

    Fix: detect ToolMessage as last message, return completed=False, prevent
    on_complete from firing with garbage output.
    """

    def test_tool_message_object_returns_completed_false(self, config, hooks):
        """ToolMessage object as last message triggers completed=False (BUG-025)."""
        from langchain_core.messages import ToolMessage

        model = MockAgentModel(
            response={
                "messages": [
                    {"role": "user", "content": "task"},
                    ToolMessage(content="tool output", tool_call_id="call_1"),
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        assert result.completed is False
        assert result.metadata["error"] == "silent_recursion_limit"
        assert result.metadata["max_iterations_reached"] is True

    def test_tool_message_dict_returns_completed_false(self, config, hooks):
        """Dict-style ToolMessage as last message triggers completed=False."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"role": "user", "content": "task"},
                    {"type": "tool", "content": "tool output"},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        assert result.completed is False
        assert result.metadata["error"] == "silent_recursion_limit"

    def test_normal_ai_message_completes_successfully(self, config, hooks):
        """Dict without 'type' field as last message completes normally (msg_type=None path)."""
        model = MockAgentModel(response={"messages": [{"role": "assistant", "content": "Done!"}]})
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        assert result.completed is True
        assert result.result == "Done!"

    def test_ai_message_object_completes_successfully(self, config, hooks):
        """AIMessage object (type='ai') as last message completes normally."""
        from langchain_core.messages import AIMessage

        model = MockAgentModel(
            response={"messages": [AIMessage(content="Agent finished successfully.")]}
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        assert result.completed is True
        assert result.result == "Agent finished successfully."

    def test_on_complete_not_called_on_silent_limit(self, config, mock_hooks):
        """on_complete hook must NOT fire from spawn() — ever (BUG-031 + BUG-025)."""
        from langchain_core.messages import ToolMessage

        mock_hooks.on_complete.return_value = None

        model = MockAgentModel(
            response={
                "messages": [
                    ToolMessage(content="tool output", tool_call_id="call_1"),
                ]
            }
        )
        agent = DeepAgent(config, mock_hooks, model=model)
        agent.spawn(TASK)

        mock_hooks.on_complete.assert_not_called()

    def test_empty_messages_still_completes(self, config, hooks):
        """Empty messages list skips ToolMessage check — still completes normally."""
        model = MockAgentModel(response={"messages": []})
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        # No ToolMessage detected → on_complete fires → completed=True
        assert result.completed is True


# ────────────────────────────────────────────
# TestToolValidationError (Regression — BUG-040)
# ────────────────────────────────────────────


class TestToolValidationError:
    """BUG-040 regression: agent returns raw JSON params as 'result' after tool validation error.

    deepagents issue #947: when a tool receives a wrong parameter name/type,
    deepagents passes the ValidationError to the LLM as a ToolMessage. The LLM
    responds with corrected params as raw JSON text (AIMessage). _extract_result()
    picks this up as a "completed" result — but it's not. The agent gave up and
    returned params, not output.

    Fix: two-factor detection (pure JSON result + validation error in history),
    with recovery exception (successful ToolMessage follows last validation error).
    """

    def test_pure_json_with_validation_error_returns_completed_false(self, config, hooks):
        """Basic case: pure JSON result + validation error in history → completed=False (BUG-040)."""
        model = MockAgentModel(
            response={
                "messages": [
                    {
                        "type": "tool",
                        "content": "ValidationError: 'path' is not a valid parameter for read_file",
                    },
                    {"role": "assistant", "content": '{"file_path": "/home/user/file.txt"}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("read the file")

        assert result.completed is False
        assert result.metadata["error"] == "tool_validation_error"

    def test_pure_json_without_validation_error_returns_completed_true(self, config, hooks):
        """JSON result with no validation error in history → completed=True (legitimate JSON output)."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"role": "assistant", "content": '{"result": "success", "files": ["a.txt"]}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("list files as JSON")

        assert result.completed is True

    def test_prose_result_with_validation_error_returns_completed_true(self, config, hooks):
        """Validation error in history but prose result → completed=True (not a JSON params dump)."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"type": "tool", "content": "ValidationError: wrong parameter"},
                    {
                        "role": "assistant",
                        "content": "I was unable to complete the task due to a tool error.",
                    },
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("do something")

        assert result.completed is True

    def test_invalid_json_with_validation_error_returns_completed_true(self, config, hooks):
        """Result starts/ends with braces but is not valid JSON → not a validation error scenario."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"type": "tool", "content": "validation error: bad param"},
                    {"role": "assistant", "content": "{not valid json at all}"},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        assert result.completed is True

    def test_recovery_after_validation_error_returns_completed_true(self, config, hooks):
        """Agent gets validation error, recovers with successful tool call, returns JSON → completed=True."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"type": "tool", "content": "ValidationError: 'path' is not a valid parameter"},
                    # Successful ToolMessage after validation error — agent recovered
                    {"type": "tool", "content": "file content: hello world"},
                    {"role": "assistant", "content": '{"summary": "file contains hello world"}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("read and summarize file")

        # Recovery detected: successful ToolMessage follows last validation error
        assert result.completed is True

    def test_json_embedded_in_prose_returns_completed_true(self, config, hooks):
        """JSON embedded in prose is not pure JSON — not detected as validation error dump."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"type": "tool", "content": "validation error: incorrect parameter"},
                    {
                        "role": "assistant",
                        "content": 'Here are the corrected params: {"file_path": "/x"}',
                    },
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("test")

        # Result doesn't start with '{' (has 'Here are...') → not pure JSON → completed=True
        assert result.completed is True

    def test_multiple_validation_errors_returns_completed_false(self, config, hooks):
        """Multiple validation errors in history, final result is pure JSON → completed=False."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"type": "tool", "content": "ValidationError: 'path' is invalid"},
                    {"type": "tool", "content": "validation error: 'filename' is not a parameter"},
                    {"role": "assistant", "content": '{"file_path": "/correct/path"}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("read file")

        assert result.completed is False
        assert result.metadata["error"] == "tool_validation_error"


# ────────────────────────────────────────────
# TestIsToolValidationError (unit — helper)
# ────────────────────────────────────────────


class TestIsToolValidationErrorHelper:
    """Unit tests for _is_tool_validation_error() module-level helper."""

    def test_dict_with_validation_error_returns_true(self):
        """Dict ToolMessage with 'ValidationError' in content → True."""
        msg = {"type": "tool", "content": "ValidationError: 'path' is not a valid parameter"}
        assert _is_tool_validation_error(msg) is True

    def test_dict_with_lowercase_validation_error_returns_true(self):
        """Case-insensitive: 'validation error' (lowercase) → True."""
        msg = {"type": "tool", "content": "validation error: wrong param name"}
        assert _is_tool_validation_error(msg) is True

    def test_dict_non_tool_type_returns_false(self):
        """Non-tool message type → False even with validation error content."""
        msg = {"type": "ai", "content": "ValidationError mentioned in AI response"}
        assert _is_tool_validation_error(msg) is False

    def test_dict_tool_without_validation_content_returns_false(self):
        """ToolMessage without validation error content → False."""
        msg = {"type": "tool", "content": "file content: hello world"}
        assert _is_tool_validation_error(msg) is False

    def test_langchain_tool_message_object_returns_true(self):
        """LangChain ToolMessage object with validation error content → True."""
        from langchain_core.messages import ToolMessage

        msg = ToolMessage(content="ValidationError: wrong parameter", tool_call_id="call_1")
        assert _is_tool_validation_error(msg) is True

    def test_langchain_ai_message_object_returns_false(self):
        """LangChain AIMessage object → False (wrong type)."""
        from langchain_core.messages import AIMessage

        msg = AIMessage(content="ValidationError mentioned in analysis")
        assert _is_tool_validation_error(msg) is False


# ────────────────────────────────────────────
# TestExpectJson (Regression — BUG-070)
# ────────────────────────────────────────────


class TestExpectJson:
    """BUG-070 regression: JSON detection falsely flags legitimate JSON task output.

    When the caller explicitly requests a JSON result (expect_json=True), the
    BUG-040 tool-validation-error detector must be bypassed so that valid
    structured output is not misclassified as a failed tool call.
    """

    def test_expect_json_true_bypasses_detection_with_validation_error(self, config, hooks):
        """expect_json=True: JSON result + validation error in history → completed=True (BUG-070)."""
        model = MockAgentModel(
            response={
                "messages": [
                    {
                        "type": "tool",
                        "content": "ValidationError: 'path' is not a valid parameter for read_file",
                    },
                    {"role": "assistant", "content": '{"status": "ok", "count": 3}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("return a JSON summary", expect_json=True)

        assert result.completed is True
        assert result.metadata.get("error") != "tool_validation_error"

    def test_expect_json_false_default_still_detects_validation_error(self, config, hooks):
        """expect_json=False (default): JSON result + validation error → completed=False (BUG-040 preserved)."""
        model = MockAgentModel(
            response={
                "messages": [
                    {
                        "type": "tool",
                        "content": "ValidationError: 'path' is not a valid parameter for read_file",
                    },
                    {"role": "assistant", "content": '{"file_path": "/home/user/file.txt"}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("read the file")

        assert result.completed is False
        assert result.metadata["error"] == "tool_validation_error"

    def test_expect_json_true_no_validation_error_returns_completed_true(self, config, hooks):
        """expect_json=True with no validation error in history → completed=True."""
        model = MockAgentModel(
            response={
                "messages": [
                    {"role": "assistant", "content": '{"items": ["a", "b"], "total": 2}'},
                ]
            }
        )
        agent = DeepAgent(config, hooks, model=model)
        result = agent.spawn("list items as JSON", expect_json=True)

        assert result.completed is True


# ────────────────────────────────────────────
# Import Stability
# ────────────────────────────────────────────


class TestImportStability:
    """Verify the package imports cleanly even when langchain paths are unstable."""

    def test_package_imports_without_error(self, monkeypatch) -> None:
        """Importing netanel_core succeeds even if langchain middleware is absent.

        Regression test for BUG-057: eager import of
        langchain.agents.middleware.types broke the whole package when that
        path did not exist. The fix wraps it in contextlib.suppress with stubs.
        sys.modules is restored automatically by monkeypatch at teardown.
        """
        import importlib
        import sys

        # Snapshot and remove cached modules so reimport is forced
        monkeypatch.delitem(sys.modules, "netanel_core.deep_agent", raising=False)
        monkeypatch.delitem(sys.modules, "netanel_core", raising=False)

        # Block the specific langchain middleware path to simulate its absence
        monkeypatch.setitem(sys.modules, "langchain.agents.middleware.types", None)

        try:
            importlib.import_module("netanel_core")
        except ImportError as exc:
            pytest.fail(f"netanel_core import raised ImportError: {exc}")
        # sys.modules is restored automatically by monkeypatch at test teardown

    def test_basetool_not_imported_at_module_level(self) -> None:
        """BUG-064: BaseTool is behind TYPE_CHECKING — langchain_core.tools NOT loaded at import.

        With `from __future__ import annotations`, all annotations are evaluated as
        strings and never need the actual class at import time. Moving BaseTool to
        TYPE_CHECKING prevents 200+ langchain_core modules from being loaded eagerly.
        """
        import sys

        # netanel_core is already imported; langchain_core.tools IS in sys.modules
        # because other code may load it. What we verify is that deep_agent itself
        # does NOT trigger this import — the module-level import was removed.
        deep_agent_mod = sys.modules.get("netanel_core.deep_agent")
        assert deep_agent_mod is not None, "deep_agent module not found in sys.modules"

        # BaseTool should NOT be available as a module-level attribute of deep_agent
        # (it was there before BUG-064 fix; now it's TYPE_CHECKING only).
        assert not hasattr(deep_agent_mod, "BaseTool"), (
            "BaseTool is exposed as a module-level attribute of deep_agent. "
            "BUG-064: it should be TYPE_CHECKING only to avoid eager langchain_core load."
        )

    def test_import_time_below_threshold(self) -> None:
        """BUG-064: import netanel_core completes in under 500ms.

        Measured by reimporting (subprocess would be more accurate but slow).
        This test guards against regressions that reintroduce eager heavy imports.
        """
        import importlib
        import sys
        import time

        # Clear relevant cached modules to force a fresh import
        mods_to_clear = [k for k in sys.modules if k.startswith("netanel_core")]
        original = {k: sys.modules.pop(k) for k in mods_to_clear}

        try:
            start = time.perf_counter()
            importlib.import_module("netanel_core")
            elapsed_ms = (time.perf_counter() - start) * 1000
        finally:
            # Restore original modules so the rest of the test suite is unaffected
            sys.modules.update(original)

        assert elapsed_ms < 500, (
            f"netanel_core import took {elapsed_ms:.0f}ms — exceeds 500ms threshold. "
            "A heavy eager import was likely reintroduced. Check deep_agent.py."
        )
