"""
Tests for the LangGraph agent research strategy.

Tests cover:
- SearchResultsCollector thread safety and behavior
- Tool factory functions
- Strategy instantiation and configuration
- Citation offset handling for detailed report mode
- Error handling paths
"""

import threading
from unittest.mock import MagicMock, patch


# ---------------------------------------------------------------------------
# SearchResultsCollector tests
# ---------------------------------------------------------------------------


class TestSearchResultsCollector:
    """Tests for the thread-safe SearchResultsCollector."""

    def _make_collector(self, all_links=None):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            SearchResultsCollector,
        )

        links = all_links if all_links is not None else []
        return SearchResultsCollector(links), links

    def test_add_results_indexes_correctly(self):
        collector, all_links = self._make_collector()
        results = [
            {"title": "A", "link": "http://a.com", "snippet": "a"},
            {"title": "B", "link": "http://b.com", "snippet": "b"},
        ]
        start = collector.add_results(results, engine_name="test")

        assert start == 0
        assert len(collector.results) == 2
        assert collector.results[0]["index"] == "1"
        assert collector.results[1]["index"] == "2"

    def test_add_results_continues_indexing(self):
        collector, _ = self._make_collector()
        collector.add_results(
            [{"title": "A", "link": "http://a.com", "snippet": "a"}],
            engine_name="test",
        )
        start = collector.add_results(
            [{"title": "B", "link": "http://b.com", "snippet": "b"}],
            engine_name="test",
        )

        assert start == 1
        assert collector.results[1]["index"] == "2"

    def test_add_results_normalizes_url_to_link(self):
        collector, _ = self._make_collector()
        results = [{"title": "A", "url": "http://a.com", "snippet": "a"}]
        collector.add_results(results)

        assert "link" in collector.results[0]
        assert collector.results[0]["link"] == "http://a.com"

    def test_add_results_preserves_existing_link(self):
        collector, _ = self._make_collector()
        results = [
            {
                "title": "A",
                "link": "http://link.com",
                "url": "http://url.com",
                "snippet": "a",
            }
        ]
        collector.add_results(results)

        assert collector.results[0]["link"] == "http://link.com"

    def test_add_results_sets_source_engine(self):
        collector, _ = self._make_collector()
        results = [{"title": "A", "link": "http://a.com", "snippet": "a"}]
        collector.add_results(results, engine_name="arxiv")

        assert collector.results[0]["source_engine"] == "arxiv"

    def test_add_results_appends_to_all_links(self):
        all_links = []
        collector, _ = self._make_collector(all_links)
        results = [{"title": "A", "link": "http://a.com", "snippet": "a"}]
        collector.add_results(results)

        assert len(all_links) == 1
        assert all_links[0]["index"] == "1"

    def test_reset_clears_results_but_not_all_links(self):
        all_links = []
        collector, _ = self._make_collector(all_links)
        collector.add_results(
            [{"title": "A", "link": "http://a.com", "snippet": "a"}]
        )
        assert len(collector.results) == 1
        assert len(all_links) == 1

        collector.reset()

        assert len(collector.results) == 0
        assert len(collector.sources) == 0
        # all_links must NOT be cleared
        assert len(all_links) == 1

    def test_sources_tracks_links(self):
        collector, _ = self._make_collector()
        collector.add_results(
            [
                {"title": "A", "link": "http://a.com", "snippet": "a"},
                {"title": "B", "link": "http://b.com", "snippet": "b"},
            ]
        )

        assert set(collector.sources) == {"http://a.com", "http://b.com"}

    def test_add_results_does_not_mutate_input(self):
        collector, _ = self._make_collector()
        original = {"title": "A", "link": "http://a.com", "snippet": "a"}
        collector.add_results([original])

        # Original dict should NOT have index/source_engine added
        assert "index" not in original

    def test_empty_results_returns_current_length(self):
        collector, _ = self._make_collector()
        collector.add_results(
            [{"title": "A", "link": "http://a.com", "snippet": "a"}]
        )
        start = collector.add_results([])
        assert start == 1

    def test_thread_safety_no_duplicate_indices(self):
        """Multiple threads adding results should never produce duplicate indices."""
        collector, _ = self._make_collector()
        results_per_thread = [
            {"title": f"T{i}", "link": f"http://{i}.com", "snippet": f"s{i}"}
            for i in range(5)
        ]
        errors = []

        def add_batch(thread_id):
            try:
                collector.add_results(
                    [dict(r) for r in results_per_thread],
                    engine_name=f"thread-{thread_id}",
                )
            except Exception as exc:
                errors.append(exc)

        threads = [
            threading.Thread(target=add_batch, args=(i,)) for i in range(4)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors
        all_results = collector.results
        assert len(all_results) == 20  # 4 threads × 5 results
        indices = [r["index"] for r in all_results]
        assert len(indices) == len(set(indices)), "Duplicate indices found!"


# ---------------------------------------------------------------------------
# Format results helper
# ---------------------------------------------------------------------------


class TestFormatResults:
    def test_format_results_basic(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            _format_results,
        )

        results = [
            {
                "title": "Test",
                "link": "http://test.com",
                "snippet": "A snippet",
            },
        ]
        output = _format_results(results, start_idx=0)
        assert "[1]" in output
        assert "Test" in output
        assert "http://test.com" in output
        assert "A snippet" in output

    def test_format_results_offset(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            _format_results,
        )

        results = [
            {"title": "Test", "link": "http://test.com", "snippet": "snip"},
        ]
        output = _format_results(results, start_idx=5)
        assert "[6]" in output

    def test_format_empty_returns_no_results(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            _format_results,
        )

        assert _format_results([], 0) == "No results."


# ---------------------------------------------------------------------------
# Strategy instantiation and configuration
# ---------------------------------------------------------------------------


class TestLangGraphAgentStrategy:
    """Test strategy construction and configuration."""

    def _make_strategy(self, **overrides):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            LangGraphAgentStrategy,
        )

        defaults = {
            "model": MagicMock(),
            "search": MagicMock(),
            "all_links_of_system": [],
            "settings_snapshot": {"search.tool": {"value": "duckduckgo"}},
        }
        defaults.update(overrides)
        return LangGraphAgentStrategy(**defaults)

    def test_basic_instantiation(self):
        strategy = self._make_strategy()
        assert strategy is not None
        assert hasattr(strategy, "analyze_topic")
        assert hasattr(strategy, "collector")

    def test_default_params(self):
        strategy = self._make_strategy()
        assert strategy.max_iterations == 50
        assert strategy.max_sub_iterations == 8
        assert strategy.include_sub_research is True

    def test_custom_params(self):
        strategy = self._make_strategy(
            max_iterations=50, max_sub_iterations=3, include_sub_research=False
        )
        assert strategy.max_iterations == 50
        assert strategy.max_sub_iterations == 3
        assert strategy.include_sub_research is False

    def test_low_max_iterations_uses_default(self):
        """Pipeline-style low values (e.g. search.iterations=3) should not
        constrain the agent — it needs many more ReAct cycles."""
        strategy = self._make_strategy(max_iterations=3)
        assert strategy.max_iterations == 50  # DEFAULT_MAX_ITERATIONS

    def test_super_init_called_with_kwargs(self):
        """Verify base class attributes are set correctly."""
        all_links = [{"existing": True}]
        strategy = self._make_strategy(all_links_of_system=all_links)
        assert strategy.all_links_of_system is all_links

    def test_collector_shares_all_links_reference(self):
        all_links = []
        strategy = self._make_strategy(all_links_of_system=all_links)
        strategy.collector.add_results(
            [{"title": "T", "link": "http://t.com", "snippet": "s"}]
        )
        assert len(all_links) == 1

    def test_engine_name_from_settings(self):
        strategy = self._make_strategy(
            settings_snapshot={"search.tool": {"value": "brave"}}
        )
        assert strategy._search_engine_name == "brave"

    def test_engine_name_from_settings_string(self):
        strategy = self._make_strategy(
            settings_snapshot={"search.tool": "searxng"}
        )
        assert strategy._search_engine_name == "searxng"

    def test_engine_name_fallback_to_class(self):
        mock_search = MagicMock()
        mock_search.__class__.__name__ = "DuckDuckGoSearchEngine"
        strategy = self._make_strategy(search=mock_search, settings_snapshot={})
        assert strategy._search_engine_name == "duckduckgo"


# ---------------------------------------------------------------------------
# Citation offset for detailed report mode
# ---------------------------------------------------------------------------


class TestCitationOffset:
    """Test that nr_of_links is handled correctly across multiple calls."""

    def _make_strategy(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            LangGraphAgentStrategy,
        )

        model = MagicMock()
        model.invoke = MagicMock(
            return_value=MagicMock(content="Synthesized answer")
        )
        return LangGraphAgentStrategy(
            model=model,
            search=MagicMock(),
            all_links_of_system=[],
            settings_snapshot={"search.tool": {"value": "mock"}},
        )

    def test_collector_reset_on_analyze_topic(self):
        """Collector should be reset at the start of each analyze_topic call."""
        strategy = self._make_strategy()

        # Pre-populate collector
        strategy.collector.add_results(
            [{"title": "Old", "link": "http://old.com", "snippet": "old"}]
        )
        assert len(strategy.collector.results) == 1

        # analyze_topic should reset the collector
        with patch(
            "local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy.LangGraphAgentStrategy._build_tools",
            return_value=[],
        ):
            result = strategy.analyze_topic("test query")

        # Collector should have been reset (even though _build_tools returned empty)
        # reset() happens before _build_tools, so the error path still resets
        assert result["error"] is not None  # error because no tools
        assert len(strategy.collector.results) == 0  # verify reset happened

    def test_all_links_accumulates_across_calls(self):
        """all_links_of_system should grow across calls, not reset."""
        strategy = self._make_strategy()
        all_links = strategy.all_links_of_system

        strategy.collector.add_results(
            [{"title": "A", "link": "http://a.com", "snippet": "a"}]
        )
        assert len(all_links) == 1

        strategy.collector.reset()

        strategy.collector.add_results(
            [{"title": "B", "link": "http://b.com", "snippet": "b"}]
        )
        assert len(all_links) == 2

    def test_citation_indices_unique_across_sections(self):
        """After reset, new results should get globally unique indices
        (not restart from 1) so detailed report citations don't collide."""
        strategy = self._make_strategy()

        # Section 1: adds 2 results → indices "1", "2"
        strategy.collector.add_results(
            [
                {"title": "A", "link": "http://a.com", "snippet": "a"},
                {"title": "B", "link": "http://b.com", "snippet": "b"},
            ]
        )
        assert strategy.all_links_of_system[0]["index"] == "1"
        assert strategy.all_links_of_system[1]["index"] == "2"

        # Simulate new section: reset per-call state
        strategy.collector.reset()

        # Section 2: should continue from "3", not restart at "1"
        strategy.collector.add_results(
            [
                {"title": "C", "link": "http://c.com", "snippet": "c"},
                {"title": "D", "link": "http://d.com", "snippet": "d"},
            ]
        )
        assert strategy.all_links_of_system[2]["index"] == "3"
        assert strategy.all_links_of_system[3]["index"] == "4"


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------


class TestErrorHandling:
    """Test error paths return proper error dicts."""

    def _make_strategy(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            LangGraphAgentStrategy,
        )

        return LangGraphAgentStrategy(
            model=MagicMock(),
            search=MagicMock(),
            all_links_of_system=[],
            settings_snapshot={"search.tool": {"value": "mock"}},
        )

    def test_error_result_structure(self):
        strategy = self._make_strategy()
        result = strategy._error_result("something broke")

        assert result["error"] == "something broke"
        assert result["findings"] == []
        assert result["iterations"] == 0
        assert result["current_knowledge"] == ""
        assert isinstance(result["reasoning_trace"], list)

    def test_no_tools_returns_error(self):
        strategy = self._make_strategy()
        with patch(
            "local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy.LangGraphAgentStrategy._build_tools",
            return_value=[],
        ):
            result = strategy.analyze_topic("test")

        assert result["error"] is not None
        assert "No tools" in result["error"]

    def test_agent_creation_failure_returns_error(self):
        strategy = self._make_strategy()
        with (
            patch(
                "local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy.LangGraphAgentStrategy._build_tools",
                return_value=[MagicMock()],
            ),
            patch(
                "langchain.agents.create_agent",
                side_effect=ValueError("Model doesn't support tools"),
            ),
        ):
            result = strategy.analyze_topic("test")

        assert result["error"] is not None
        assert "tool calling" in result["error"]

    def test_format_agent_error_includes_exception_type(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            LangGraphAgentStrategy,
        )

        msg = LangGraphAgentStrategy._format_agent_error(ValueError("boom"))

        assert "ValueError" in msg
        assert "boom" in msg


# ---------------------------------------------------------------------------
# Factory integration
# ---------------------------------------------------------------------------


class TestFactoryIntegration:
    """Test that the strategy integrates with the factory correctly."""

    def test_factory_creates_langgraph_agent(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            LangGraphAgentStrategy,
        )
        from local_deep_research.search_system_factory import create_strategy

        strategy = create_strategy(
            strategy_name="langgraph-agent",
            model=MagicMock(),
            search=MagicMock(),
            settings_snapshot={},
        )
        assert isinstance(strategy, LangGraphAgentStrategy)

    def test_factory_underscore_alias(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            LangGraphAgentStrategy,
        )
        from local_deep_research.search_system_factory import create_strategy

        strategy = create_strategy(
            strategy_name="langgraph_agent",
            model=MagicMock(),
            search=MagicMock(),
            settings_snapshot={},
        )
        assert isinstance(strategy, LangGraphAgentStrategy)

    def test_strategy_in_available_list(self):
        from local_deep_research.search_system_factory import (
            get_available_strategies,
        )

        names = [s["name"] for s in get_available_strategies()]
        assert "langgraph-agent" in names

    def test_factory_passes_custom_params(self):
        from local_deep_research.search_system_factory import create_strategy

        strategy = create_strategy(
            strategy_name="langgraph-agent",
            model=MagicMock(),
            search=MagicMock(),
            settings_snapshot={},
            max_iterations=20,
            max_sub_iterations=3,
            include_sub_research=False,
        )
        assert strategy.max_iterations == 20
        assert strategy.max_sub_iterations == 3
        assert strategy.include_sub_research is False


# ---------------------------------------------------------------------------
# fetch_content collector registration (regression for PR #3457)
# ---------------------------------------------------------------------------


class TestFetchContentCollectorRegistration:
    """Regression coverage for PR #3457.

    Prior to the fix, ``_make_fetch_content_tool`` accepted ``collector`` but
    never used it, so every URL opened via the LLM's ``fetch_content`` tool
    was silently dropped from the final Sources section and citation system.
    These tests pin the fix: a successful fetch must register the URL, a
    duplicate fetch must reuse the existing citation index, and a failed
    fetch must not register anything.
    """

    def _make_collector(self):
        from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
            SearchResultsCollector,
        )

        return SearchResultsCollector([])

    def _fetcher_cm(
        self, *, status="success", title="Page", content="Body", error=None
    ):
        """Return a MagicMock that behaves like ``ContentFetcher(...)``."""
        result = {"status": status, "title": title, "content": content}
        if error is not None:
            result["error"] = error
        fetcher = MagicMock()
        fetcher.fetch.return_value = result
        cm = MagicMock()
        cm.__enter__.return_value = fetcher
        cm.__exit__.return_value = False
        return cm

    def _make_tool(self, collector):
        from local_deep_research.advanced_search_system.tools.fetch import (
            build_fetch_tool,
        )

        return build_fetch_tool("full", collector)

    def test_successful_fetch_registers_url_in_collector(self):
        collector = self._make_collector()
        tool = self._make_tool(collector)
        cm = self._fetcher_cm(title="Hello", content="some body text")

        with patch(
            "local_deep_research.content_fetcher.ContentFetcher",
            return_value=cm,
        ):
            output = tool.invoke({"url": "http://example.com/page"})

        assert "http://example.com/page" in collector.sources
        assert len(collector.results) == 1
        entry = collector.results[0]
        assert entry["link"] == "http://example.com/page"
        assert entry["title"] == "Hello"
        assert entry["source_engine"] == "fetch"
        # Tool return is prefixed with the 1-based citation index so the
        # agent can cite fetched pages the same way it cites web_search hits.
        assert output.startswith("[1] ")

    def test_repeated_fetch_of_same_url_reuses_citation_index(self):
        collector = self._make_collector()
        # Simulate web_search having already captured this URL.
        collector.add_results(
            [
                {
                    "title": "From search",
                    "link": "http://example.com/page",
                    "snippet": "snip",
                }
            ],
            engine_name="web",
        )
        assert len(collector.results) == 1

        tool = self._make_tool(collector)
        cm = self._fetcher_cm(title="From fetch", content="full body")

        with patch(
            "local_deep_research.content_fetcher.ContentFetcher",
            return_value=cm,
        ):
            output = tool.invoke({"url": "http://example.com/page"})

        # No duplicate entry; the fetch reuses the existing citation slot.
        assert len(collector.results) == 1
        assert output.startswith("[1] ")

    def test_failed_fetch_does_not_register_url(self):
        collector = self._make_collector()
        tool = self._make_tool(collector)
        cm = self._fetcher_cm(
            status="error", title="", content="", error="timeout"
        )

        with patch(
            "local_deep_research.content_fetcher.ContentFetcher",
            return_value=cm,
        ):
            output = tool.invoke({"url": "http://broken.example/page"})

        assert collector.results == []
        assert collector.sources == []
        assert "Failed to fetch" in output

    def test_long_content_snippet_is_truncated_with_ellipsis(self):
        collector = self._make_collector()
        tool = self._make_tool(collector)
        cm = self._fetcher_cm(title="Long", content="A" * 500)

        with patch(
            "local_deep_research.content_fetcher.ContentFetcher",
            return_value=cm,
        ):
            tool.invoke({"url": "http://example.com/long"})

        snippet = collector.results[0]["snippet"]
        assert snippet.endswith("...")
        assert len(snippet) == 203  # 200 chars + "..."

    def test_find_by_url_returns_index_when_present(self):
        collector = self._make_collector()
        collector.add_results(
            [{"title": "A", "link": "http://a.com", "snippet": "a"}],
            engine_name="web",
        )
        assert collector.find_by_url("http://a.com") == 1

    def test_find_by_url_returns_none_when_absent(self):
        collector = self._make_collector()
        collector.add_results(
            [{"title": "A", "link": "http://a.com", "snippet": "a"}],
            engine_name="web",
        )
        assert collector.find_by_url("http://missing.com") is None
