"""
Tests for the MCP Search Strategy (ReAct pattern).

These tests verify:
- ReAct loop behavior (thought → action → observation)
- LLM response parsing
- Tool execution (web search, MCP tools)
- Error handling and recovery
- Max iteration limits
"""

from unittest.mock import MagicMock, patch

import pytest

# Skip all tests if MCP is not available
try:
    import mcp  # noqa: F401

    MCP_AVAILABLE = True
except ImportError:
    MCP_AVAILABLE = False

pytestmark = pytest.mark.skipif(
    not MCP_AVAILABLE, reason="MCP package not installed"
)


class TestMCPSearchStrategyInit:
    """Tests for MCPSearchStrategy initialization."""

    def test_init_with_defaults(self):
        """Test initialization with default parameters."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_search = MagicMock()

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
        )

        assert strategy.model == mock_model
        assert strategy.search == mock_search
        assert strategy.mcp_servers == []
        assert strategy.max_iterations == 10
        assert strategy.include_web_search is True

    def test_init_with_custom_params(self):
        """Test initialization with custom parameters."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_search = MagicMock()
        mcp_servers = [{"name": "test", "command": "test"}]

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
            mcp_servers=mcp_servers,
            max_iterations=5,
            include_web_search=False,
        )

        assert strategy.mcp_servers == mcp_servers
        assert strategy.max_iterations == 5
        assert strategy.include_web_search is False

    def test_init_with_settings_snapshot(self):
        """Test initialization with settings snapshot."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_search = MagicMock()
        settings = {"test.setting": {"value": "test"}}

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
            settings_snapshot=settings,
        )

        assert strategy.settings_snapshot == settings


class TestResponseParsing:
    """Tests for LLM response parsing."""

    def test_parse_thought_and_answer(self):
        """Test parsing response with thought and answer."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        response = """THOUGHT: I have gathered enough information about quantum computing.
ANSWER: Quantum computing is a type of computation that uses quantum bits (qubits)."""

        result = strategy._parse_response(response)

        assert result["type"] == "answer"
        assert "quantum" in result["thought"].lower()
        assert "qubits" in result["content"].lower()

    def test_parse_thought_and_action(self):
        """Test parsing response with thought and action."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        response = """THOUGHT: I need to search for more information about quantum computing.
ACTION: web_search
ARGUMENTS: {"query": "quantum computing basics"}"""

        result = strategy._parse_response(response)

        assert result["type"] == "action"
        assert result["tool"] == "web_search"
        assert result["arguments"]["query"] == "quantum computing basics"

    def test_parse_action_without_json_arguments(self):
        """Test parsing action with non-JSON arguments format."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        response = """THOUGHT: Let me search.
ACTION: web_search
ARGUMENTS: query = "test query" """

        result = strategy._parse_response(response)

        assert result["type"] == "action"
        assert result["tool"] == "web_search"
        # Falls back to query extraction
        assert result["arguments"].get("query") == "test query"

    def test_parse_invalid_response(self):
        """Test parsing invalid response."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        response = "This is just some text without proper format"

        result = strategy._parse_response(response)

        assert result["type"] == "error"
        assert "Could not parse" in result["message"]

    def test_parse_case_insensitive(self):
        """Test parsing is case insensitive."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        response = """thought: I have the answer.
answer: The answer is 42."""

        result = strategy._parse_response(response)

        assert result["type"] == "answer"
        assert "42" in result["content"]


class TestToolBuilding:
    """Tests for tool description building."""

    def test_build_tools_with_web_search(self):
        """Test building tools includes web search when enabled."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_search = MagicMock()
        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=mock_search,
            include_web_search=True,
        )

        tools = strategy._build_tool_descriptions()

        assert any(t["name"] == "web_search" for t in tools)

    def test_build_tools_without_web_search(self):
        """Test building tools excludes web search when disabled."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            include_web_search=False,
        )

        tools = strategy._build_tool_descriptions()

        assert not any(t["name"] == "web_search" for t in tools)

    def test_format_tool_descriptions(self):
        """Test formatting tool descriptions for the LLM."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        tools = [
            {
                "name": "test_tool",
                "description": "A test tool",
                "parameters": {
                    "arg1": {"type": "string"},
                    "arg2": {"type": "number"},
                },
            }
        ]

        formatted = strategy._format_tool_descriptions(tools)

        assert "test_tool" in formatted
        assert "A test tool" in formatted
        assert "arg1" in formatted


class TestWebSearchExecution:
    """Tests for web search tool execution."""

    def test_execute_web_search_success(self):
        """Test successful web search execution."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_search = MagicMock()
        mock_search.run.return_value = [
            {
                "title": "Result 1",
                "snippet": "Content 1",
                "link": "https://example.com/1",
            },
            {
                "title": "Result 2",
                "snippet": "Content 2",
                "link": "https://example.com/2",
            },
        ]

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=mock_search,
        )

        result = strategy._execute_web_search({"query": "test query"})

        assert result["status"] == "success"
        assert "Result 1" in result["content"]
        assert "Result 2" in result["content"]
        assert len(strategy._sources) == 2

    def test_execute_web_search_empty_query(self):
        """Test web search with empty query."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        result = strategy._execute_web_search({})

        assert result["status"] == "error"
        assert "No query" in result["content"]

    def test_execute_web_search_error(self):
        """Test web search error handling."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_search = MagicMock()
        mock_search.run.side_effect = Exception("Search API error")

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=mock_search,
        )

        result = strategy._execute_web_search({"query": "test"})

        assert result["status"] == "error"
        assert "Search failed" in result["content"]


class TestToolExecution:
    """Tests for general tool execution."""

    def test_execute_unknown_tool(self):
        """Test executing an unknown tool."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        tools = [
            {"name": "web_search", "executor": strategy._execute_web_search}
        ]

        result = strategy._execute_tool("unknown_tool", {}, tools)

        assert result["status"] == "error"
        assert "Unknown tool" in result["content"]

    def test_execute_tool_no_executor(self):
        """Test executing tool without executor."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        tools = [{"name": "no_exec_tool", "description": "No executor"}]

        result = strategy._execute_tool("no_exec_tool", {}, tools)

        assert result["status"] == "error"
        assert "no executor" in result["content"]


class TestReActLoop:
    """Tests for the main ReAct loop."""

    def test_analyze_topic_immediate_answer(self):
        """Test analyze_topic when LLM immediately provides answer."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = """THOUGHT: I already know this.
ANSWER: The capital of France is Paris."""
        mock_response.tool_calls = []
        mock_model.invoke.return_value = mock_response

        bound_model = MagicMock()
        bound_model.invoke.return_value = mock_response
        mock_model.bind_tools.return_value = bound_model

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
        )

        result = strategy.analyze_topic("What is the capital of France?")

        assert result["iterations"] == 1
        assert "Paris" in result["formatted_findings"]
        assert "Paris" in result["current_knowledge"]

    def test_analyze_topic_with_search(self):
        """Test analyze_topic with web search action."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        # First response: action, second response: answer
        mock_model = MagicMock()
        responses = [
            MagicMock(
                content="""THOUGHT: I need to search.
ACTION: web_search
ARGUMENTS: {"query": "quantum computing"}"""
            ),
            MagicMock(
                content="""THOUGHT: Now I have enough info.
ANSWER: Quantum computing uses qubits."""
            ),
        ]
        responses[0].tool_calls = []
        responses[1].tool_calls = []
        mock_model.invoke.side_effect = responses

        bound_model = MagicMock()
        bound_model.invoke.side_effect = responses
        mock_model.bind_tools.return_value = bound_model

        mock_search = MagicMock()
        mock_search.run.return_value = [
            {
                "title": "Quantum",
                "snippet": "Quantum info",
                "link": "https://example.com",
            }
        ]

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
        )

        result = strategy.analyze_topic("What is quantum computing?")

        assert result["iterations"] == 2
        assert mock_search.run.called
        assert "qubits" in result["formatted_findings"]

    def test_analyze_topic_max_iterations(self):
        """Test analyze_topic respects max iterations limit."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        # Always return action, never answer
        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = """THOUGHT: Need more info.
ACTION: web_search
ARGUMENTS: {"query": "test"}"""
        mock_response.tool_calls = []
        mock_model.invoke.return_value = mock_response

        bound_model = MagicMock()
        bound_model.invoke.return_value = mock_response
        mock_model.bind_tools.return_value = bound_model

        mock_search = MagicMock()
        mock_search.run.return_value = [
            {"title": "T", "snippet": "S", "link": "L"}
        ]

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
            max_iterations=3,
        )

        result = strategy.analyze_topic("Test query")

        # Should stop at max iterations
        assert result["iterations"] == 3

    def test_analyze_topic_still_has_research_tools(self):
        """Test analyze_topic still works without web_search because research tools are available."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = """THOUGHT: I have the information.
ANSWER: The answer based on research."""
        mock_model.invoke.return_value = mock_response

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=None,  # No search
            include_web_search=False,  # Web search disabled
            mcp_servers=[],  # No MCP servers
        )

        # Strategy still has research tools available (sub-research and download_content)
        # When include_sub_research=True (default) and depth < max_depth:
        # - focused_research, download_content
        tools = strategy._build_tool_descriptions()
        assert len(tools) >= 2  # focused_research, download_content
        assert any(t["name"] == "focused_research" for t in tools)
        assert any(t["name"] == "download_content" for t in tools)

    def test_analyze_topic_llm_error_recovery(self):
        """Test analyze_topic handles LLM errors."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_model.invoke.side_effect = Exception("LLM API error")
        mock_model.bind_tools.return_value = (
            mock_model  # bind_tools returns model that also raises
        )

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
        )

        result = strategy.analyze_topic("Test query")

        # The error should be captured in either the error key or the formatted_findings
        error_captured = (
            "error" in result
            or "LLM error" in result.get("formatted_findings", "")
            or "Error:" in result.get("formatted_findings", "")
            or "failed" in result.get("formatted_findings", "").lower()
            or "failed" in result.get("current_knowledge", "").lower()
        )
        assert error_captured, f"Error should be captured in result: {result}"


class TestHistoryFormatting:
    """Tests for reasoning history formatting."""

    def test_format_empty_history(self):
        """Test formatting empty history."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )
        strategy._history = []

        formatted = strategy._format_history()

        assert "No research done yet" in formatted

    def test_format_history_with_entries(self):
        """Test formatting history with entries."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )
        strategy._history = [
            {
                "role": "assistant",
                "thought": "I need to search",
                "action": "web_search",
                "arguments": {"query": "test"},
            },
            {
                "role": "tool",
                "tool": "web_search",
                "observation": "Found some results",
            },
        ]

        formatted = strategy._format_history()

        assert "THOUGHT:" in formatted
        assert "ACTION:" in formatted
        assert "OBSERVATION" in formatted


class TestAnswerSynthesis:
    """Tests for answer synthesis when max iterations reached."""

    def test_synthesize_answer(self):
        """Test answer synthesis with gathered info."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = "Synthesized answer based on research."
        mock_model.invoke.return_value = mock_response

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
        )
        strategy._history = [
            {
                "role": "tool",
                "tool": "web_search",
                "observation": "Some findings",
            }
        ]

        answer = strategy._synthesize_answer("Test query")

        assert "Synthesized answer" in answer

    def test_synthesize_answer_error(self):
        """Test answer synthesis error handling."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_model.invoke.side_effect = Exception("Synthesis failed")

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
        )

        answer = strategy._synthesize_answer("Test query")

        assert "synthesis failed" in answer.lower()


class TestProgressCallback:
    """Tests for progress callback functionality."""

    def test_progress_callback_called(self):
        """Test progress callback is called during analysis."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = """THOUGHT: Done.
ANSWER: The answer."""
        mock_model.invoke.return_value = mock_response

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
        )

        progress_calls = []

        def callback(message, progress, data):
            progress_calls.append((message, progress, data))

        strategy.set_progress_callback(callback)
        strategy.analyze_topic("Test")

        assert len(progress_calls) > 0
        assert any("init" in str(call) for call in progress_calls)


class TestMCPToolDiscovery:
    """Tests for MCP tool discovery."""

    def test_discover_mcp_tools_no_servers(self):
        """Test MCP discovery with no servers configured."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            mcp_servers=[],
        )

        tools = strategy._discover_mcp_tools()

        assert tools == []

    def test_discover_mcp_tools_connection_error(self):
        """Test MCP discovery handles connection errors gracefully."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            mcp_servers=[{"name": "test", "command": "nonexistent-command"}],
        )

        # The discovery should handle errors gracefully and return empty
        # when connection fails (import is inside the method)
        with patch(
            "local_deep_research.mcp.client.MCPClientManager"
        ) as mock_manager:
            # Make the manager raise on connect
            mock_instance = MagicMock()
            mock_instance.connect_all.side_effect = Exception(
                "Connection failed"
            )
            mock_manager.return_value = mock_instance

            tools = strategy._discover_mcp_tools()

        # Should return empty list on error, not raise
        assert tools == []


class TestStrategyExport:
    """Tests for strategy export and import."""

    def test_strategy_exported_from_package(self):
        """Test MCPSearchStrategy is exported from strategies package."""
        from local_deep_research.advanced_search_system.strategies import (
            MCPSearchStrategy,
        )

        assert MCPSearchStrategy is not None

    def test_strategy_registered_in_factory(self):
        """Test strategy is registered in search_system_factory."""
        from local_deep_research.search_system_factory import create_strategy

        # Just verify the import path works - actual creation would need mocks
        assert callable(create_strategy)


class TestErrorResponse:
    """Tests for error response creation."""

    def test_create_error_response(self):
        """Test error response structure."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        response = strategy._create_error_response("Test error")

        assert response["findings"] == []
        assert response["iterations"] == 0
        assert response["questions"] == {}
        assert "Test error" in response["formatted_findings"]
        assert response["error"] == "Test error"


class TestCriticalBugFixes:
    """Tests for critical bug fixes identified in PR review."""

    def test_child_strategy_inherits_depth(self):
        """Verify child strategy gets parent.depth + 1 to prevent infinite recursion."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        # Create a parent strategy at depth 1
        parent_strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            depth=1,
            max_depth=3,
            include_sub_research=True,
        )

        # Mock the strategy factory at the module level import
        with patch(
            "local_deep_research.search_system_factory.create_strategy"
        ) as mock_create:
            # Set up mock to return a strategy-like object
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = {
                "findings": [],
                "sources": [],
                "formatted_findings": "test",
                "current_knowledge": "test",
            }
            mock_create.return_value = mock_child

            # Execute strategy research
            parent_strategy._execute_strategy_research(
                {"query": "test query"}, "focused-iteration"
            )

            # Verify create_strategy was called with depth=parent.depth+1
            mock_create.assert_called_once()
            call_kwargs = mock_create.call_args[1]
            assert call_kwargs.get("depth") == 2, (
                "Child strategy should have depth = parent.depth + 1"
            )
            assert call_kwargs.get("max_depth") == 3, (
                "Child strategy should inherit max_depth"
            )

    def test_parse_response_missing_tool_key(self):
        """Test graceful handling when parsed response missing 'tool' key."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        # Return an action-like response but with no valid tool parsing
        mock_response = MagicMock()
        mock_response.content = """THOUGHT: I need to do something.
ACTION:
ARGUMENTS: {"query": "test"}"""
        mock_response.tool_calls = []

        # Expect this to continue the loop, not crash
        bound_model = MagicMock()
        bound_model.invoke.return_value = mock_response
        mock_model.bind_tools.return_value = bound_model

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
            max_iterations=1,  # Limit iterations
        )

        # This should not raise KeyError
        result = strategy.analyze_topic("Test query")

        # Should complete without crashing
        assert "iterations" in result

    def test_parse_response_empty_tool_calls_list(self):
        """Test handling when tool_calls exists but is empty list."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Create a mock response with empty tool_calls list
        mock_response = MagicMock()
        mock_response.tool_calls = []  # Empty list (truthy but no elements)
        mock_response.content = """THOUGHT: I have the answer.
ANSWER: The answer is 42."""

        # This should not raise IndexError
        result = strategy._parse_response(mock_response)

        # Should fall back to text-based parsing
        assert result["type"] == "answer"
        assert "42" in result["content"]

    def test_citation_handler_returns_none(self):
        """Test graceful fallback when citation handler returns None."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = """THOUGHT: Done.
ANSWER: The answer is here."""
        mock_response.tool_calls = []
        mock_model.invoke.return_value = mock_response

        bound_model = MagicMock()
        bound_model.invoke.return_value = mock_response
        mock_model.bind_tools.return_value = bound_model

        mock_citation_handler = MagicMock()
        mock_citation_handler.analyze_followup.return_value = (
            None  # Returns None
        )

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
            citation_handler=mock_citation_handler,
        )

        # Add some search results to trigger citation handling
        strategy.all_search_results = [
            {"title": "Test", "link": "http://test.com"}
        ]

        # This should not raise AttributeError
        result = strategy.analyze_topic("Test query")

        # Should complete and have the answer
        assert "current_knowledge" in result
        assert result["current_knowledge"]  # Should not be empty


class TestSubResearchParameters:
    """Tests for iterations and search_engine parameters in _execute_strategy_research."""

    def _make_strategy(self, **kwargs):
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        return MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            include_sub_research=True,
            **kwargs,
        )

    def _mock_child_result(self):
        return {
            "findings": [],
            "sources": [],
            "formatted_findings": "test result",
            "current_knowledge": "test",
        }

    def test_iterations_forwarded_to_create_strategy(self):
        """iterations parameter is passed as max_iterations to create_strategy."""
        strategy = self._make_strategy()

        with patch(
            "local_deep_research.search_system_factory.create_strategy"
        ) as mock_create:
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "iterations": 12}, "focused-iteration"
            )

            call_kwargs = mock_create.call_args[1]
            assert call_kwargs["max_iterations"] == 12

    def test_iterations_not_passed_when_absent(self):
        """When iterations is not provided, max_iterations is not passed."""
        strategy = self._make_strategy()

        with patch(
            "local_deep_research.search_system_factory.create_strategy"
        ) as mock_create:
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test"}, "focused-iteration"
            )

            call_kwargs = mock_create.call_args[1]
            assert "max_iterations" not in call_kwargs

    def test_iterations_clamped_to_min_1(self):
        """iterations=0 and negative values are clamped to 1."""
        strategy = self._make_strategy()

        with patch(
            "local_deep_research.search_system_factory.create_strategy"
        ) as mock_create:
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "iterations": -5}, "focused-iteration"
            )
            assert mock_create.call_args[1]["max_iterations"] == 1

    def test_iterations_clamped_to_max_25(self):
        """iterations above 25 are clamped to 25."""
        strategy = self._make_strategy()

        with patch(
            "local_deep_research.search_system_factory.create_strategy"
        ) as mock_create:
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "iterations": 100}, "focused-iteration"
            )
            assert mock_create.call_args[1]["max_iterations"] == 25

    def test_iterations_invalid_string_ignored(self):
        """Non-numeric iterations string falls back to default (not passed)."""
        strategy = self._make_strategy()

        with patch(
            "local_deep_research.search_system_factory.create_strategy"
        ) as mock_create:
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "iterations": "abc"}, "focused-iteration"
            )
            assert "max_iterations" not in mock_create.call_args[1]

    def test_search_engine_override_passed_to_child(self):
        """search_engine parameter creates override engine for the child strategy."""
        strategy = self._make_strategy()

        mock_override = MagicMock()
        with (
            patch(
                "local_deep_research.search_system_factory.create_strategy"
            ) as mock_create,
            patch(
                "local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
                return_value=mock_override,
            ),
        ):
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "search_engine": "pubmed"},
                "focused-iteration",
            )

            # Child strategy should get the override engine, not self.search
            assert mock_create.call_args[1]["search"] is mock_override

    def test_search_engine_invalid_falls_back_to_default(self):
        """Invalid search_engine falls back to default engine."""
        strategy = self._make_strategy()

        with (
            patch(
                "local_deep_research.search_system_factory.create_strategy"
            ) as mock_create,
            patch(
                "local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
                return_value=None,
            ),
        ):
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "search_engine": "nonexistent"},
                "focused-iteration",
            )

            # Should fall back to self.search
            assert mock_create.call_args[1]["search"] is strategy.search

    def test_search_engine_cleanup_on_success(self):
        """Override engine is closed after successful sub-research."""
        strategy = self._make_strategy()

        mock_override = MagicMock()
        with (
            patch(
                "local_deep_research.search_system_factory.create_strategy"
            ) as mock_create,
            patch(
                "local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
                return_value=mock_override,
            ),
            patch(
                "local_deep_research.utilities.resource_utils.safe_close"
            ) as mock_close,
        ):
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test", "search_engine": "pubmed"},
                "focused-iteration",
            )

            mock_close.assert_called_once_with(
                mock_override, "sub-research engine (pubmed)"
            )

    def test_search_engine_cleanup_on_error(self):
        """Override engine is closed even when sub-research raises."""
        strategy = self._make_strategy()

        mock_override = MagicMock()
        with (
            patch(
                "local_deep_research.search_system_factory.create_strategy"
            ) as mock_create,
            patch(
                "local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
                return_value=mock_override,
            ),
            patch(
                "local_deep_research.utilities.resource_utils.safe_close"
            ) as mock_close,
        ):
            mock_create.side_effect = RuntimeError("strategy creation failed")

            strategy._execute_strategy_research(
                {"query": "test", "search_engine": "pubmed"},
                "focused-iteration",
            )

            # Engine should still be cleaned up despite the error
            mock_close.assert_called_once_with(
                mock_override, "sub-research engine (pubmed)"
            )

    def test_no_cleanup_when_no_override(self):
        """No cleanup when search_engine was not requested."""
        strategy = self._make_strategy()

        with (
            patch(
                "local_deep_research.search_system_factory.create_strategy"
            ) as mock_create,
            patch(
                "local_deep_research.utilities.resource_utils.safe_close"
            ) as mock_close,
        ):
            mock_child = MagicMock()
            mock_child.analyze_topic.return_value = self._mock_child_result()
            mock_create.return_value = mock_child

            strategy._execute_strategy_research(
                {"query": "test"}, "focused-iteration"
            )

            mock_close.assert_not_called()


class TestHighPriorityBugFixes:
    """Tests for high-priority bug fixes."""

    def test_execute_web_search_empty_results(self):
        """Test handling when search returns empty list."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_search = MagicMock()
        mock_search.run.return_value = []  # Empty results

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=mock_search,
        )

        result = strategy._execute_web_search({"query": "test query"})

        assert result["status"] == "success"
        assert "No search results found" in result["content"]
        assert "test query" in result["content"]

    def test_execute_web_search_tracks_all_sources(self):
        """Test that all source URLs are tracked across multiple searches."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_search = MagicMock()
        # Return search results
        mock_search.run.return_value = [
            {
                "title": "Result 1",
                "snippet": "Content 1",
                "link": "https://example.com/1",
            },
            {
                "title": "Result 2",
                "snippet": "Content 2",
                "link": "https://example.com/2",
            },
        ]

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=mock_search,
        )

        # Execute twice
        strategy._execute_web_search({"query": "test1"})
        strategy._execute_web_search({"query": "test2"})

        # All results should be tracked (deduplication is not required here,
        # the test verifies that sources are properly tracked)
        assert len(strategy.all_search_results) == 4
        assert len(strategy._sources) == 4

    def test_analyze_topic_large_history(self):
        """Test ReAct loop with 10+ iterations of history doesn't cause issues."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()

        # Build a sequence of responses: many actions, then an answer
        # Use proper string content instead of MagicMock
        def make_action_response():
            response = MagicMock()
            response.content = """THOUGHT: Need more info.
ACTION: web_search
ARGUMENTS: {"query": "test"}"""
            response.tool_calls = []
            return response

        def make_answer_response():
            response = MagicMock()
            response.content = """THOUGHT: Got enough info.
ANSWER: Final answer after many iterations."""
            response.tool_calls = []
            return response

        # 9 actions, then answer on 10th
        responses = [make_action_response() for _ in range(9)] + [
            make_answer_response()
        ]

        bound_model = MagicMock()
        bound_model.invoke.side_effect = responses
        mock_model.bind_tools.return_value = bound_model

        mock_search = MagicMock()
        mock_search.run.return_value = [
            {"title": "T", "snippet": "S", "link": "L"}
        ]

        # Mock citation handler to return proper dict with the final answer
        mock_citation_handler = MagicMock()
        mock_citation_handler.analyze_followup.return_value = {
            "content": "Final answer after many iterations.",
            "documents": [],
        }

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
            max_iterations=10,
            citation_handler=mock_citation_handler,
        )

        result = strategy.analyze_topic("Test query")

        assert result["iterations"] == 10
        # The test verifies we can handle 10+ iterations without errors
        # and that the loop completes successfully
        assert "findings" in result
        assert len(result.get("reasoning_trace", [])) > 0

    def test_synthesize_answer_no_search_results(self):
        """Test answer synthesis when no search has been performed."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()
        mock_response = MagicMock()
        mock_response.content = "Synthesized answer from no search results."
        mock_model.invoke.return_value = mock_response

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=MagicMock(),
        )
        # No history, no search results
        strategy._history = []
        strategy.all_search_results = []

        answer = strategy._synthesize_answer("Test query")

        # Should still produce an answer
        assert answer
        assert "Synthesized answer" in answer


class TestTimeoutAndRetry:
    """Tests for timeout handling and retry logic."""

    def test_run_async_timeout_parameter_exists(self):
        """Test that run_async accepts timeout parameter."""
        import inspect

        from local_deep_research.mcp.client import run_async

        # Verify run_async has timeout parameter
        sig = inspect.signature(run_async)
        assert "timeout" in sig.parameters
        # Default timeout should be 300 seconds (5 minutes)
        assert sig.parameters["timeout"].default == 300.0

    def test_execute_mcp_tool_has_timeout_parameter(self):
        """Test that _execute_mcp_tool accepts timeout parameter."""
        import inspect

        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        # Verify _execute_mcp_tool has timeout parameter
        sig = inspect.signature(MCPSearchStrategy._execute_mcp_tool)
        assert "timeout" in sig.parameters
        # Default is None sentinel; actual timeout comes from MCP_TOOL_CALL_TIMEOUT
        assert sig.parameters["timeout"].default is None
        assert MCPSearchStrategy.MCP_TOOL_CALL_TIMEOUT == 60.0

    def test_mcp_discovery_failure_not_permanently_cached(self):
        """Test that MCP discovery failure doesn't permanently cache empty result."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            mcp_servers=[{"name": "test", "command": "nonexistent"}],
        )

        # First discovery fails
        tools1 = strategy._discover_mcp_tools()
        assert tools1 == []

        # Cache should NOT be set on failure (allows retry)
        assert strategy._mcp_tools_cache is None

        # Second attempt should try again (not use stale cache)
        tools2 = strategy._discover_mcp_tools()
        # Still fails, but the point is it tried again (cache still None)
        assert tools2 == []
        assert strategy._mcp_tools_cache is None


class TestMCPPartialFailure:
    """Tests for MCP discovery and execution with partial failures."""

    def test_mcp_discovery_partial_failure_returns_working_tools(self):
        """Test when some MCP servers connect but others fail."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            mcp_servers=[
                {"name": "working", "command": "python"},
                {"name": "broken", "command": "nonexistent"},
            ],
        )

        # Mock the client manager to simulate partial failure
        with patch(
            "local_deep_research.mcp.client.MCPClientManager"
        ) as MockManager:
            mock_instance = MagicMock()

            # Simulate: working server returns tools, broken server fails silently
            async def mock_connect_all():
                return mock_instance

            mock_instance.connect_all.return_value.__aenter__ = MagicMock(
                return_value=mock_instance
            )
            mock_instance.connect_all.return_value.__aexit__ = MagicMock(
                return_value=None
            )

            # Return tools only from working server
            async def mock_list_tools():
                return {
                    "working": [
                        {"name": "tool1", "description": "A working tool"}
                    ]
                    # "broken" server not in results (failed to connect)
                }

            mock_instance.list_all_tools = mock_list_tools
            MockManager.return_value = mock_instance

            # The discovery should return tools from working servers
            # even if some servers failed
            tools = strategy._discover_mcp_tools()

            # Should have tools from working server
            # (exact behavior depends on implementation)
            assert isinstance(tools, list)

    def test_mcp_tool_call_with_server_not_found(self):
        """Test calling a tool on a server that doesn't exist."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
            mcp_servers=[{"name": "real_server", "command": "python"}],
        )

        # Try to call tool on non-existent server
        result = strategy._execute_mcp_tool(
            "nonexistent_server", "some_tool", {"arg": "value"}
        )

        assert result["status"] == "error"
        assert "Unknown server" in result["content"]


class TestReActLoopRobustness:
    """Tests for ReAct loop edge cases and robustness."""

    def test_repeated_identical_searches_tracked(self):
        """Test that repeated identical search queries are tracked in history."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()

        # LLM keeps requesting the same search
        def make_same_action():
            response = MagicMock()
            response.content = """THOUGHT: Need to search.
ACTION: web_search
ARGUMENTS: {"query": "same query every time"}"""
            response.tool_calls = []
            return response

        # After 3 identical searches, finally gives answer
        answer_response = MagicMock()
        answer_response.content = """THOUGHT: Done.
ANSWER: Final answer."""
        answer_response.tool_calls = []

        responses = [make_same_action() for _ in range(3)] + [answer_response]

        bound_model = MagicMock()
        bound_model.invoke.side_effect = responses
        mock_model.bind_tools.return_value = bound_model

        mock_search = MagicMock()
        mock_search.run.return_value = [
            {"title": "Result", "snippet": "Content", "link": "http://test.com"}
        ]

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
            max_iterations=5,
        )

        result = strategy.analyze_topic("Test")

        # Should complete and track all searches in history
        assert result["iterations"] == 4
        # All 3 identical searches should be in history
        search_actions = [
            h
            for h in result["reasoning_trace"]
            if h.get("action") == "web_search"
        ]
        assert len(search_actions) == 3

    def test_nested_json_arguments_parsing(self):
        """Test parsing action with nested JSON arguments via native tool calls."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Native tool calls handle nested JSON properly
        mock_response = MagicMock()
        mock_response.tool_calls = [
            {
                "name": "web_search",
                "args": {
                    "query": "test",
                    "filters": {
                        "date": {"start": "2024-01-01", "end": "2024-12-31"},
                        "sources": ["arxiv", "pubmed"],
                    },
                },
            }
        ]
        mock_response.content = "Complex search"

        result = strategy._parse_response(mock_response)

        assert result["type"] == "action"
        assert result["tool"] == "web_search"
        assert result["arguments"]["query"] == "test"
        assert "filters" in result["arguments"]
        assert result["arguments"]["filters"]["date"]["start"] == "2024-01-01"

    def test_multiple_tool_calls_uses_first(self):
        """Test that when LLM returns multiple tool_calls, first is used."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Mock response with multiple tool calls
        mock_response = MagicMock()
        mock_response.tool_calls = [
            {"name": "web_search", "args": {"query": "first query"}},
            {"name": "web_search", "args": {"query": "second query"}},
            {"name": "download_content", "args": {"url": "http://test.com"}},
        ]
        mock_response.content = "Multiple tool calls"

        result = strategy._parse_response(mock_response)

        # Should use the first tool call
        assert result["type"] == "action"
        assert result["tool"] == "web_search"
        assert result["arguments"]["query"] == "first query"

    def test_tool_execution_exception_recorded_in_history(self):
        """Test that tool execution exceptions are properly recorded."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        mock_model = MagicMock()

        # First: action that will fail, Second: answer
        action_response = MagicMock()
        action_response.content = """THOUGHT: Search.
ACTION: web_search
ARGUMENTS: {"query": "test"}"""
        action_response.tool_calls = []

        answer_response = MagicMock()
        answer_response.content = """THOUGHT: Done.
ANSWER: Answer after error."""
        answer_response.tool_calls = []

        bound_model = MagicMock()
        bound_model.invoke.side_effect = [action_response, answer_response]
        mock_model.bind_tools.return_value = bound_model

        # Search raises exception
        mock_search = MagicMock()
        mock_search.run.side_effect = Exception("Search API unavailable")

        strategy = MCPSearchStrategy(
            model=mock_model,
            search=mock_search,
            max_iterations=5,
        )

        result = strategy.analyze_topic("Test")

        # Should complete despite error
        assert result["iterations"] == 2
        # Error should be in history as observation
        observations = [
            h for h in result["reasoning_trace"] if h.get("role") == "tool"
        ]
        assert len(observations) == 1
        # Error message format is "Search failed: <error>"
        assert "failed" in observations[0]["observation"].lower()


class TestSpecializedSearchEngines:
    """Tests for specialized search engine tool execution."""

    def test_execute_specialized_search_tracks_source_engine(self):
        """Test that specialized search results include source engine info."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Mock the search engine factory
        with patch(
            "local_deep_research.web_search_engines.search_engine_factory.create_search_engine"
        ) as mock_factory:
            mock_engine = MagicMock()
            mock_engine.run.return_value = [
                {
                    "title": "arXiv Paper",
                    "snippet": "Abstract",
                    "link": "http://arxiv.org/123",
                }
            ]
            mock_factory.return_value = mock_engine

            result = strategy._execute_specialized_search(
                {"query": "quantum computing"}, "arxiv"
            )

            assert result["status"] == "success"
            # Check source engine is tracked
            assert len(strategy.all_search_results) == 1
            assert strategy.all_search_results[0]["source_engine"] == "arxiv"


class TestClientContentHandling:
    """Tests for MCP client content handling edge cases."""

    def test_call_tool_with_error_response(self):
        """Test client properly handles error responses from MCP tools."""

        # Simulate the content extraction logic
        mock_result = MagicMock()
        mock_result.isError = True
        mock_result.content = "Tool execution failed: invalid arguments"

        # Error responses should be detected
        assert mock_result.isError is True

    def test_empty_content_list_returns_empty_string(self):
        """Test that empty content list returns empty string, not error."""
        # Simulate content extraction with empty list
        content = []
        result_content = "\n".join(content) if content else ""

        assert result_content == ""


class TestArgumentValidation:
    """Tests for tool argument validation and sanitization."""

    def test_validate_tool_arguments_truncates_long_strings(self):
        """Test that very long argument strings are truncated."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Create arguments with very long string
        long_string = "x" * 20000  # 20K chars
        arguments = {"query": long_string, "normal": "value"}

        sanitized = strategy._validate_tool_arguments(arguments)

        # Long string should be truncated to MAX_ARG_LENGTH (10000)
        assert len(sanitized["query"]) == 10000
        assert sanitized["normal"] == "value"

    def test_validate_tool_arguments_handles_non_dict(self):
        """Test that non-dict arguments return empty dict."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Non-dict should return empty dict
        assert strategy._validate_tool_arguments(None) == {}
        assert strategy._validate_tool_arguments("string") == {}
        assert strategy._validate_tool_arguments([1, 2, 3]) == {}

    def test_validate_tool_arguments_filters_non_string_keys(self):
        """Test that non-string keys are filtered out."""
        from local_deep_research.advanced_search_system.strategies.mcp_strategy import (
            MCPSearchStrategy,
        )

        strategy = MCPSearchStrategy(
            model=MagicMock(),
            search=MagicMock(),
        )

        # Dict with mixed key types
        arguments = {"valid": "value", 123: "invalid_key", "also_valid": "ok"}

        sanitized = strategy._validate_tool_arguments(arguments)

        # Only string keys should remain
        assert "valid" in sanitized
        assert "also_valid" in sanitized
        assert 123 not in sanitized
