"""
High-value tests for RecursiveDecompositionStrategy.

Covers pure logic paths:
- _decide_decomposition: DECOMPOSE/SEARCH_DIRECTLY response parsing, subtask extraction
- _aggregate_subtask_results: link deduplication (dict vs string), questions format conversion
- Max recursion depth guard
- analyze_topic initialization and routing
"""

from unittest.mock import MagicMock, Mock, patch


from local_deep_research.advanced_search_system.strategies.recursive_decomposition_strategy import (
    RecursiveDecompositionStrategy,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

MODULE = "local_deep_research.advanced_search_system.strategies.recursive_decomposition_strategy"


def _make_strategy(**kwargs):
    """Create a RecursiveDecompositionStrategy with mocked dependencies."""
    model = MagicMock()
    search = MagicMock()
    defaults = dict(
        model=model,
        search=search,
        all_links_of_system=[],
        max_recursion_depth=5,
    )
    defaults.update(kwargs)
    return RecursiveDecompositionStrategy(**defaults)


def _model_response(text):
    resp = Mock()
    resp.content = text
    return resp


# ---------------------------------------------------------------------------
# _decide_decomposition
# ---------------------------------------------------------------------------


class TestDecideDecomposition:
    """Tests for LLM response parsing to DECOMPOSE or SEARCH_DIRECTLY."""

    def test_parses_decompose_decision(self):
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "DECISION: DECOMPOSE\n"
            "REASONING: Complex multi-part question\n"
            "SUBTASKS:\n"
            "1. Find the population of city X\n"
            "2. Find the GDP of country Y\n"
            "DEPENDENCIES: Subtask 2 depends on subtask 1"
        )
        result = strategy._decide_decomposition("complex query")

        assert result["should_decompose"] is True
        assert result["reasoning"] == "Complex multi-part question"
        assert len(result["subtasks"]) == 2
        assert "population of city X" in result["subtasks"][0]
        assert "GDP of country Y" in result["subtasks"][1]
        assert len(result["dependencies"]) == 1

    def test_parses_search_directly_decision(self):
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: Simple factual question"
        )
        result = strategy._decide_decomposition("simple query")

        assert result["should_decompose"] is False
        assert result["reasoning"] == "Simple factual question"
        assert result["subtasks"] == []

    def test_unknown_decision_defaults_to_search_directly(self):
        """Non-DECOMPOSE value defaults to search directly."""
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "DECISION: MAYBE\nREASONING: unclear"
        )
        result = strategy._decide_decomposition("query")
        assert result["should_decompose"] is False

    def test_no_decision_line_defaults_to_search_directly(self):
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "I think we should search for this directly."
        )
        result = strategy._decide_decomposition("query")
        assert result["should_decompose"] is False

    def test_subtasks_with_dash_format(self):
        """Supports dash-prefixed subtask lines."""
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "DECISION: DECOMPOSE\n"
            "REASONING: Has parts\n"
            "SUBTASKS:\n"
            "- First subtask\n"
            "- Second subtask\n"
            "DEPENDENCIES: None"
        )
        result = strategy._decide_decomposition("query")
        assert result["should_decompose"] is True
        assert len(result["subtasks"]) == 2
        assert result["subtasks"][0] == "First subtask"

    def test_empty_subtasks_with_decompose(self):
        """DECOMPOSE with no subtask lines still returns should_decompose=True."""
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "DECISION: DECOMPOSE\n"
            "REASONING: Should split\n"
            "SUBTASKS:\n"
            "DEPENDENCIES: None"
        )
        result = strategy._decide_decomposition("query")
        assert result["should_decompose"] is True
        assert result["subtasks"] == []

    def test_handles_think_tags(self):
        """Think tags in LLM output are stripped."""
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "<think>Hmm let me think...</think>\n"
            "DECISION: SEARCH_DIRECTLY\n"
            "REASONING: Simple"
        )
        result = strategy._decide_decomposition("query")
        assert result["should_decompose"] is False

    def test_includes_original_query_context(self):
        """When original_query differs from current query, context is included in prompt."""
        strategy = _make_strategy()
        strategy.original_query = "Master research topic"
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: sub-query"
        )
        strategy._decide_decomposition("sub-question about aspect")
        # Verify the prompt included the original query context
        prompt_arg = strategy.model.invoke.call_args[0][0]
        assert "Master research topic" in prompt_arg

    def test_no_context_when_same_query(self):
        """No context line when original_query matches current query."""
        strategy = _make_strategy()
        strategy.original_query = "same query"
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: direct"
        )
        strategy._decide_decomposition("same query")
        prompt_arg = strategy.model.invoke.call_args[0][0]
        assert "Original research topic" not in prompt_arg

    def test_colon_in_reasoning_preserved(self):
        strategy = _make_strategy()
        strategy.original_query = None
        strategy.model.invoke.return_value = _model_response(
            "DECISION: DECOMPOSE\n"
            "REASONING: Has parts: A, B, and C\n"
            "SUBTASKS:\n"
            "1. Part A\n"
            "DEPENDENCIES: none"
        )
        result = strategy._decide_decomposition("query")
        assert result["reasoning"] == "Has parts: A, B, and C"


# ---------------------------------------------------------------------------
# _aggregate_subtask_results
# ---------------------------------------------------------------------------


class TestAggregateSubtaskResults:
    """Tests for result aggregation with link dedup and question format conversion."""

    def _setup_strategy(self):
        strategy = _make_strategy()
        strategy.findings = []
        strategy.questions_by_iteration = []
        strategy.original_query = "original topic"
        strategy.model.invoke.return_value = _model_response(
            "Synthesized answer text"
        )
        return strategy

    def test_synthesizes_subtask_results(self):
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "Knowledge from sub 1",
                    "all_links_of_system": [],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 1,
            }
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "original query", subtask_results, 0
            )

        assert result["current_knowledge"] == "Synthesized answer text"
        assert result["strategy"] == "recursive_decomposition"

    def test_deduplicates_string_links(self):
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "K1",
                    "all_links_of_system": ["http://a.com", "http://b.com"],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 1,
            },
            {
                "subtask": "Sub 2",
                "result": {
                    "current_knowledge": "K2",
                    "all_links_of_system": ["http://b.com", "http://c.com"],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 2,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "query", subtask_results, 0
            )

        links = result["all_links_of_system"]
        link_strs = [str(link) for link in links]
        assert len(link_strs) == 3  # a, b, c (b deduplicated)

    def test_deduplicates_dict_links_by_url(self):
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "K1",
                    "all_links_of_system": [
                        {"link": "http://example.com", "title": "Example"},
                    ],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 1,
            },
            {
                "subtask": "Sub 2",
                "result": {
                    "current_knowledge": "K2",
                    "all_links_of_system": [
                        {"link": "http://example.com", "title": "Example Dup"},
                    ],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 2,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "query", subtask_results, 0
            )

        assert len(result["all_links_of_system"]) == 1

    def test_dict_links_with_url_key(self):
        """Dict links use 'url' key when 'link' is missing."""
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "K",
                    "all_links_of_system": [
                        {"url": "http://a.com"},
                        {"url": "http://a.com"},
                    ],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 1,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "q", subtask_results, 0
            )

        assert len(result["all_links_of_system"]) == 1

    def test_questions_list_format_conversion(self):
        """List-type questions are converted to dict format."""
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "K",
                    "all_links_of_system": [],
                    "findings": [],
                    "questions_by_iteration": [
                        ["Q1", "Q2"],
                        ["Q3"],
                    ],
                },
                "depth": 1,
                "index": 1,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "q", subtask_results, 0
            )

        qdict = result["questions_by_iteration"]
        assert isinstance(qdict, dict)
        assert 1 in qdict
        assert qdict[1] == ["Q1", "Q2"]
        assert 2 in qdict

    def test_questions_dict_format_merged(self):
        """Dict-type questions are merged directly."""
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "K",
                    "all_links_of_system": [],
                    "findings": [],
                    "questions_by_iteration": [
                        {10: ["Q from dict"]},
                    ],
                },
                "depth": 1,
                "index": 1,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "q", subtask_results, 0
            )

        qdict = result["questions_by_iteration"]
        assert 10 in qdict

    def test_none_questions_skipped(self):
        """None values in questions list are skipped."""
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "Sub 1",
                "result": {
                    "current_knowledge": "K",
                    "all_links_of_system": [],
                    "findings": [],
                    "questions_by_iteration": [None, ["Q1"]],
                },
                "depth": 1,
                "index": 1,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "q", subtask_results, 0
            )

        # None should be converted as a single-item list via str()
        # Actually looking at the code: None is not iterable for isinstance checks
        # but the 'if questions is not None' guard skips it for the total count
        assert isinstance(result["questions"], dict)

    def test_result_has_expected_keys(self):
        strategy = self._setup_strategy()
        subtask_results = [
            {
                "subtask": "S",
                "result": {
                    "current_knowledge": "K",
                    "all_links_of_system": [],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 1,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            result = strategy._aggregate_subtask_results(
                "q", subtask_results, 0
            )

        expected_keys = {
            "current_knowledge",
            "formatted_findings",
            "findings",
            "iterations",
            "questions_by_iteration",
            "all_links_of_system",
            "sources",
            "subtask_results",
            "strategy",
            "recursion_depth",
            "questions",
        }
        assert expected_keys.issubset(result.keys())

    def test_progress_callback_called_during_synthesis(self):
        strategy = self._setup_strategy()
        callback = Mock()
        strategy.progress_callback = callback
        subtask_results = [
            {
                "subtask": "S",
                "result": {
                    "current_knowledge": "K",
                    "all_links_of_system": [],
                    "findings": [],
                    "questions_by_iteration": [],
                },
                "depth": 1,
                "index": 1,
            },
        ]

        with patch(f"{MODULE}.format_findings", return_value="formatted"):
            strategy._aggregate_subtask_results("q", subtask_results, 0)

        callback.assert_called()
        # Check synthesis phase callback
        synthesis_calls = [
            c for c in callback.call_args_list if "Synthesizing" in str(c)
        ]
        assert len(synthesis_calls) >= 1


# ---------------------------------------------------------------------------
# Max recursion depth guard
# ---------------------------------------------------------------------------


class TestMaxRecursionDepth:
    """Tests for recursion depth protection."""

    def test_at_max_depth_falls_back_to_source_strategy(self):
        """When recursion_depth >= max, uses source-based strategy."""
        strategy = _make_strategy(max_recursion_depth=3)
        strategy.all_links_of_system = []
        strategy.questions_by_iteration = []
        strategy.findings = []

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"current_knowledge": "direct", "findings": []},
        ) as mock_source:
            result = strategy.analyze_topic("query", recursion_depth=3)

        mock_source.assert_called_once_with("query")
        assert result["current_knowledge"] == "direct"

    def test_below_max_depth_proceeds_normally(self):
        """Below max depth, proceeds with decomposition decision."""
        strategy = _make_strategy(max_recursion_depth=5)
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: simple"
        )

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"current_knowledge": "found", "findings": []},
        ):
            result = strategy.analyze_topic("query", recursion_depth=0)

        assert result["current_knowledge"] == "found"

    def test_exactly_at_max_triggers_guard(self):
        strategy = _make_strategy(max_recursion_depth=2)
        strategy.all_links_of_system = []
        strategy.questions_by_iteration = []
        strategy.findings = []

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"current_knowledge": "fallback", "findings": []},
        ):
            result = strategy.analyze_topic("query", recursion_depth=2)

        assert result["current_knowledge"] == "fallback"

    def test_above_max_also_triggers_guard(self):
        strategy = _make_strategy(max_recursion_depth=2)
        strategy.all_links_of_system = []
        strategy.questions_by_iteration = []
        strategy.findings = []

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"current_knowledge": "fallback", "findings": []},
        ):
            result = strategy.analyze_topic("query", recursion_depth=10)

        assert result["current_knowledge"] == "fallback"


# ---------------------------------------------------------------------------
# analyze_topic initialization
# ---------------------------------------------------------------------------


class TestAnalyzeTopicInit:
    """Tests for top-level initialization in analyze_topic."""

    def test_clears_links_at_depth_zero(self):
        strategy = _make_strategy()
        strategy.all_links_of_system = ["old_link"]
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: direct"
        )

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"findings": [], "current_knowledge": "k"},
        ):
            strategy.analyze_topic("query", recursion_depth=0)

        # Links should have been cleared at depth 0
        # (may have new ones added by source strategy, but old one gone)
        assert "old_link" not in strategy.all_links_of_system

    def test_stores_original_query_at_depth_zero(self):
        strategy = _make_strategy()
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: direct"
        )

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"findings": [], "current_knowledge": "k"},
        ):
            strategy.analyze_topic("my research topic", recursion_depth=0)

        assert strategy.original_query == "my research topic"

    def test_initializes_findings_list_at_depth_zero(self):
        strategy = _make_strategy()
        strategy.findings = [{"old": True}]
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: direct"
        )

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"findings": [], "current_knowledge": "k"},
        ):
            strategy.analyze_topic("query", recursion_depth=0)

        # Old findings should be cleared, new ones may be added
        assert {"old": True} not in strategy.findings

    def test_progress_callback_at_depth_zero(self):
        strategy = _make_strategy()
        callback = Mock()
        strategy.set_progress_callback(callback)
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: direct"
        )

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"findings": [], "current_knowledge": "k"},
        ):
            strategy.analyze_topic("query", recursion_depth=0)

        # Should have been called with init phase
        init_calls = [
            c for c in callback.call_args_list if c[0][2].get("phase") == "init"
        ]
        assert len(init_calls) >= 1

    def test_adds_search_decision_to_findings(self):
        """When SEARCH_DIRECTLY, adds decision to findings."""
        strategy = _make_strategy()
        strategy.model.invoke.return_value = _model_response(
            "DECISION: SEARCH_DIRECTLY\nREASONING: simple enough"
        )

        with patch.object(
            strategy,
            "_use_source_based_strategy",
            return_value={"findings": [], "current_knowledge": "k"},
        ):
            strategy.analyze_topic("query", recursion_depth=0)

        # Should have a finding about the search decision
        phases = [f.get("phase", "") for f in strategy.findings]
        assert any("Direct Search Decision" in p for p in phases)

    def test_adds_decomposition_decision_to_findings(self):
        """When DECOMPOSE, adds decomposition decision to findings."""
        strategy = _make_strategy()
        strategy.model.invoke.return_value = _model_response(
            "DECISION: DECOMPOSE\n"
            "REASONING: multi-part\n"
            "SUBTASKS:\n"
            "1. Part A\n"
            "2. Part B\n"
            "DEPENDENCIES: none"
        )

        # Mock _handle_decomposition to avoid full recursion
        with patch.object(
            strategy,
            "_handle_decomposition",
            return_value={"findings": [], "current_knowledge": "k"},
        ):
            strategy.analyze_topic("complex query", recursion_depth=0)

        phases = [f.get("phase", "") for f in strategy.findings]
        assert any("Decomposition Decision" in p for p in phases)
