"""Comprehensive tests for report_generator.py targeting uncovered paths.

Focuses on areas with gaps in existing test suites:
- close() resource cleanup with _owns_llm flag
- _format_final_report TOC assembly, section ordering, missing sections, metadata
- _truncate_at_sentence_boundary additional edge cases
- _build_previous_context boundary conditions
- _research_and_generate_sections iteration restore on exception, pipe in section name
- _generate_error_report format
- generate_report progress callback sequencing
"""

from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.report_generator import (
    IntegratedReportGenerator,
    DEFAULT_MAX_CONTEXT_CHARS,
    DEFAULT_MAX_CONTEXT_SECTIONS,
)

MODULE = "local_deep_research.report_generator"


# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_search_system():
    """Minimal mock search system with strategy."""
    system = MagicMock()
    system.strategy = MagicMock()
    system.strategy.settings_snapshot = {"search.iterations": 3}
    system.strategy.max_iterations = 3
    system.all_links_of_system = []
    system.analyze_topic.return_value = {"current_knowledge": "mock content"}
    return system


@pytest.fixture
def mock_model():
    """Minimal mock LLM."""
    model = MagicMock()
    model.invoke.return_value = MagicMock(content="STRUCTURE\nEND_STRUCTURE")
    return model


@pytest.fixture
def generator(mock_search_system, mock_model):
    """IntegratedReportGenerator with all deps mocked via __new__."""
    gen = IntegratedReportGenerator.__new__(IntegratedReportGenerator)
    gen.search_system = mock_search_system
    gen.model = mock_model
    gen.searches_per_section = 2
    gen.max_context_sections = DEFAULT_MAX_CONTEXT_SECTIONS
    gen.max_context_chars = DEFAULT_MAX_CONTEXT_CHARS
    gen._owns_llm = False
    return gen


# ---------------------------------------------------------------------------
# close() -- resource cleanup
# ---------------------------------------------------------------------------


class TestClose:
    """Tests for close() method and _owns_llm lifecycle."""

    def test_close_when_owns_llm_calls_close(self, generator, mock_model):
        """When _owns_llm is True, close() calls model.close()."""
        generator._owns_llm = True
        generator.model = mock_model

        generator.close()
        mock_model.close.assert_called_once()

    def test_close_when_not_owns_llm_does_nothing(self, generator, mock_model):
        """When _owns_llm is False, close() must not call model.close()."""
        generator._owns_llm = False
        generator.model = mock_model

        generator.close()
        mock_model.close.assert_not_called()

    def test_close_when_model_is_none(self, generator):
        """When model is None (regardless of _owns_llm), close() is safe."""
        generator._owns_llm = True
        generator.model = None

        # Should not raise
        generator.close()

    def test_owns_llm_set_true_when_no_search_system_or_llm(self):
        """Constructor sets _owns_llm=True when neither search_system nor llm given."""
        with (
            patch(f"{MODULE}.get_llm") as mock_get_llm,
            patch(f"{MODULE}.AdvancedSearchSystem"),
            patch(
                f"{MODULE}.get_setting_from_snapshot",
                side_effect=lambda k, default=None, settings_snapshot=None: (
                    default
                ),
            ),
        ):
            mock_get_llm.return_value = MagicMock()
            gen = IntegratedReportGenerator()
            assert gen._owns_llm is True

    def test_owns_llm_set_false_when_search_system_provided(
        self, mock_search_system
    ):
        """Constructor sets _owns_llm=False when search_system is provided."""
        mock_search_system.model = MagicMock()
        with patch(
            f"{MODULE}.get_setting_from_snapshot",
            side_effect=lambda k, default=None, settings_snapshot=None: default,
        ):
            gen = IntegratedReportGenerator(search_system=mock_search_system)
            assert gen._owns_llm is False

    def test_owns_llm_set_false_when_llm_provided(self):
        """Constructor sets _owns_llm=False when llm is provided."""
        with (
            patch(f"{MODULE}.AdvancedSearchSystem"),
            patch(
                f"{MODULE}.get_setting_from_snapshot",
                side_effect=lambda k, default=None, settings_snapshot=None: (
                    default
                ),
            ),
        ):
            gen = IntegratedReportGenerator(llm=MagicMock())
            assert gen._owns_llm is False


# ---------------------------------------------------------------------------
# _format_final_report -- detailed TOC and assembly
# ---------------------------------------------------------------------------


class TestFormatFinalReportDetailed:
    """Detailed tests for _format_final_report output structure."""

    @pytest.fixture
    def _patch_importlib(self):
        """Patch importlib.import_module to return a mock with format_links_to_markdown."""
        with patch(f"{MODULE}.importlib") as mock_imp:
            mock_utils = MagicMock()
            mock_utils.search_utilities.format_links_to_markdown.return_value = "- [Link1](http://a.com)"
            mock_imp.import_module.return_value = mock_utils
            yield mock_imp

    def test_toc_numbering_matches_structure_order(
        self, generator, _patch_importlib
    ):
        """TOC entries use 1-based numbering matching structure list order."""
        structure = [
            {"name": "Alpha", "subsections": [{"name": "A1", "purpose": "p1"}]},
            {"name": "Beta", "subsections": [{"name": "B1", "purpose": "p2"}]},
            {"name": "Gamma", "subsections": [{"name": "G1", "purpose": "p3"}]},
        ]
        sections = {
            "Alpha": "Alpha content",
            "Beta": "Beta content",
            "Gamma": "Gamma content",
        }

        report = generator._format_final_report(sections, structure, "q")
        content = report["content"]

        assert "1. **Alpha**" in content
        assert "2. **Beta**" in content
        assert "3. **Gamma**" in content

    def test_toc_subsection_numbering(self, generator, _patch_importlib):
        """Subsections use i.j numbering format."""
        structure = [
            {
                "name": "Section",
                "subsections": [
                    {"name": "Sub1", "purpose": "first"},
                    {"name": "Sub2", "purpose": "second"},
                    {"name": "Sub3", "purpose": "third"},
                ],
            }
        ]
        sections = {"Section": "content"}

        report = generator._format_final_report(sections, structure, "q")
        content = report["content"]

        assert "1.1 Sub1" in content
        assert "1.2 Sub2" in content
        assert "1.3 Sub3" in content

    def test_toc_subsection_includes_purpose_in_italics(
        self, generator, _patch_importlib
    ):
        """Subsection TOC entries include purpose in italics after pipe."""
        structure = [
            {
                "name": "S",
                "subsections": [
                    {"name": "Detail", "purpose": "explain details"}
                ],
            }
        ]
        sections = {"S": "content"}

        report = generator._format_final_report(sections, structure, "q")
        content = report["content"]

        assert "_explain details_" in content

    def test_section_content_appears_in_structure_order(
        self, generator, _patch_importlib
    ):
        """Section content is emitted in the order defined by structure, not dict order."""
        structure = [
            {"name": "Zebra", "subsections": []},
            {"name": "Apple", "subsections": []},
        ]
        sections = {
            "Apple": "Apple body text",
            "Zebra": "Zebra body text",
        }

        report = generator._format_final_report(sections, structure, "q")
        content = report["content"]

        zebra_pos = content.index("Zebra body text")
        apple_pos = content.index("Apple body text")
        assert zebra_pos < apple_pos, "Sections must appear in structure order"

    def test_missing_section_content_omitted_gracefully(
        self, generator, _patch_importlib
    ):
        """If a section name is in structure but not in sections dict, it is skipped."""
        structure = [
            {"name": "Present", "subsections": []},
            {"name": "Missing", "subsections": []},
        ]
        sections = {"Present": "Present content"}

        report = generator._format_final_report(sections, structure, "q")
        content = report["content"]

        assert "Present content" in content

    def test_sources_section_appended_at_end(self, generator, _patch_importlib):
        """Final report ends with ## Sources followed by formatted links."""
        structure = [{"name": "A", "subsections": []}]
        sections = {"A": "body"}

        report = generator._format_final_report(sections, structure, "q")
        content = report["content"]

        assert content.rstrip().endswith("- [Link1](http://a.com)")

    def test_research_summary_present(self, generator, _patch_importlib):
        """Report contains '# Research Summary' section."""
        structure = [{"name": "A", "subsections": []}]
        sections = {"A": "body"}

        report = generator._format_final_report(sections, structure, "q")
        assert "# Research Summary" in report["content"]

    def test_metadata_searches_per_section(self, generator, _patch_importlib):
        """Metadata includes searches_per_section value."""
        generator.searches_per_section = 7
        structure = [{"name": "A", "subsections": []}]
        sections = {"A": "body"}

        report = generator._format_final_report(sections, structure, "q")
        assert report["metadata"]["searches_per_section"] == 7

    def test_metadata_initial_sources_counts_links(
        self, generator, _patch_importlib
    ):
        """Metadata initial_sources equals len(all_links_of_system)."""
        generator.search_system.all_links_of_system = [
            {"link": "a"},
            {"link": "b"},
            {"link": "c"},
        ]
        structure = []
        sections = {}

        report = generator._format_final_report(sections, structure, "q")
        assert report["metadata"]["initial_sources"] == 3

    def test_metadata_generated_at_is_iso_format(
        self, generator, _patch_importlib
    ):
        """generated_at is a valid ISO 8601 string."""
        from datetime import datetime

        structure = []
        sections = {}

        report = generator._format_final_report(sections, structure, "q")
        # Should not raise
        datetime.fromisoformat(report["metadata"]["generated_at"])

    def test_empty_structure_produces_valid_report(
        self, generator, _patch_importlib
    ):
        """Empty structure list still produces a report with TOC header and sources."""
        report = generator._format_final_report({}, [], "q")
        assert "Table of Contents" in report["content"]
        assert "## Sources" in report["content"]
        assert report["metadata"]["sections_researched"] == 0


# ---------------------------------------------------------------------------
# _truncate_at_sentence_boundary -- additional edge cases
# ---------------------------------------------------------------------------


class TestTruncateEdgeCases:
    """Additional edge cases for _truncate_at_sentence_boundary."""

    def test_text_with_only_punctuation(self, generator):
        """Text like '...' should not crash."""
        result = generator._truncate_at_sentence_boundary("...", 2)
        assert result == "..\n[...truncated]"

    def test_sentence_ending_at_exactly_max_chars(self, generator):
        """When sentence ends exactly at max_chars, use that boundary."""
        text = "Hello. World."
        # max_chars=6: truncated = "Hello." -> period at index 5 followed by end
        result = generator._truncate_at_sentence_boundary(text, 6)
        assert result.startswith("Hello.")
        assert "[...truncated]" in result

    def test_max_chars_one(self, generator):
        """max_chars=1 with longer text."""
        result = generator._truncate_at_sentence_boundary("ab", 1)
        assert result == "a\n[...truncated]"

    def test_exclamation_at_end_of_truncated_region(self, generator):
        """Exclamation mark at very end of truncated region is a valid boundary."""
        text = "Stop! More text follows here and keeps going."
        # "Stop!" is 5 chars, max_chars=5: truncated = "Stop!"
        result = generator._truncate_at_sentence_boundary(text, 5)
        assert "Stop!" in result
        assert "[...truncated]" in result

    def test_mixed_sentence_endings(self, generator):
        """Text with mixed .!? picks last valid one before limit."""
        text = "First. Second! Third? Fourth sentence that is very very long."
        # max_chars=25: truncated = "First. Second! Third? Fou"
        result = generator._truncate_at_sentence_boundary(text, 25)
        # Last valid boundary: "?" at index 20, followed by " ", 21 > 20 (80% of 25)
        assert "Third?" in result
        assert "[...truncated]" in result

    def test_newline_after_period_is_valid_boundary(self, generator):
        """Period followed by newline counts as sentence boundary."""
        text = (
            "Done.\nMore text that extends beyond the character limit by a lot."
        )
        result = generator._truncate_at_sentence_boundary(text, 10)
        assert "Done." in result
        assert "[...truncated]" in result

    def test_period_in_middle_of_word_not_boundary(self, generator):
        """Period not followed by space/newline/end is not a boundary."""
        # "file.txt" has a period but it's followed by 't'
        text = "file.txt is the name of the file and it is important to know."
        result = generator._truncate_at_sentence_boundary(text, 12)
        # "file.txt is " -> period at 4 followed by 't', not space
        # No valid boundary -> hard truncation
        assert result == text[:12] + "\n[...truncated]"

    def test_unicode_text_truncation(self, generator):
        """Unicode characters are handled correctly in truncation."""
        text = "Bonjour le monde. C'est magnifique! Plus de texte ici."
        result = generator._truncate_at_sentence_boundary(text, 40)
        assert "[...truncated]" in result


# ---------------------------------------------------------------------------
# _build_previous_context -- boundary conditions
# ---------------------------------------------------------------------------


class TestBuildPreviousContextBoundary:
    """Boundary conditions for _build_previous_context."""

    def test_exactly_max_context_sections_findings(self, generator):
        """When findings count equals max_context_sections, all are included."""
        generator.max_context_sections = 3
        findings = ["Finding A", "Finding B", "Finding C"]
        result = generator._build_previous_context(findings)
        assert "Finding A" in result
        assert "Finding B" in result
        assert "Finding C" in result

    def test_one_more_than_max_drops_oldest(self, generator):
        """When findings count is max+1, oldest is dropped."""
        generator.max_context_sections = 2
        findings = ["Old", "Mid", "New"]
        result = generator._build_previous_context(findings)
        assert "Old" not in result
        assert "Mid" in result
        assert "New" in result

    def test_context_chars_exactly_at_limit_no_truncation(self, generator):
        """When context length equals max_context_chars, no truncation marker."""
        generator.max_context_chars = 20
        # Single finding exactly 20 chars
        finding = "x" * 20
        result = generator._build_previous_context([finding])
        assert "[...truncated]" not in result

    def test_context_chars_one_over_limit_triggers_truncation(self, generator):
        """When context length is max_context_chars + 1, truncation happens."""
        generator.max_context_chars = 20
        finding = "x" * 21
        result = generator._build_previous_context([finding])
        assert "[...truncated]" in result

    def test_max_context_sections_zero_returns_all(self, generator):
        """max_context_sections=0 -> [-0:] is full list in Python, so all are included."""
        generator.max_context_sections = 0
        result = generator._build_previous_context(["A", "B"])
        # [-0:] == [0:] == full list, so both findings appear
        assert "A" in result
        assert "B" in result

    def test_single_finding_with_large_char_limit(self, generator):
        """Single small finding with huge char limit is not truncated."""
        generator.max_context_chars = 100000
        result = generator._build_previous_context(["Small finding."])
        assert "Small finding." in result
        assert "[...truncated]" not in result


# ---------------------------------------------------------------------------
# _research_and_generate_sections -- iteration override and restore
# ---------------------------------------------------------------------------


class TestIterationOverrideRestore:
    """Tests that iteration settings are restored even on exception."""

    def test_settings_restored_after_analyze_topic_exception(
        self, generator, mock_search_system
    ):
        """If analyze_topic raises, iteration settings are still restored."""
        mock_search_system.strategy.settings_snapshot = {"search.iterations": 5}
        mock_search_system.strategy.max_iterations = 5
        mock_search_system.analyze_topic.side_effect = RuntimeError("boom")

        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        with pytest.raises(RuntimeError, match="boom"):
            generator._research_and_generate_sections(
                {"current_knowledge": "init"}, structure, "q"
            )

        # Settings must be restored
        assert (
            mock_search_system.strategy.settings_snapshot["search.iterations"]
            == 5
        )
        assert mock_search_system.strategy.max_iterations == 5

    def test_settings_restored_when_key_not_originally_present(
        self, generator, mock_search_system
    ):
        """If search.iterations was not in snapshot originally, it is removed after."""
        mock_search_system.strategy.settings_snapshot = {}  # no key
        mock_search_system.strategy.max_iterations = 3
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "ok"
        }

        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        # Key should be removed since it was not originally present
        assert (
            "search.iterations"
            not in mock_search_system.strategy.settings_snapshot
        )

    def test_iteration_set_to_one_during_analyze_topic(
        self, generator, mock_search_system
    ):
        """During analyze_topic, search.iterations should be 1."""
        captured_value = {}

        def capture_settings(query):
            captured_value["iterations"] = (
                mock_search_system.strategy.settings_snapshot.get(
                    "search.iterations"
                )
            )
            captured_value["max_iterations"] = (
                mock_search_system.strategy.max_iterations
            )
            return {"current_knowledge": "content"}

        mock_search_system.analyze_topic.side_effect = capture_settings
        mock_search_system.strategy.settings_snapshot = {"search.iterations": 5}
        mock_search_system.strategy.max_iterations = 5

        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert captured_value["iterations"] == 1
        assert captured_value["max_iterations"] == 1


# ---------------------------------------------------------------------------
# _research_and_generate_sections -- section with pipe in name
# ---------------------------------------------------------------------------


class TestSectionWithPipeInName:
    """When a section has no subsections and contains a pipe, it splits into name|purpose."""

    def test_pipe_in_section_name_creates_subsection_with_purpose(
        self, generator, mock_search_system
    ):
        """Section name 'Overview | General introduction' becomes subsection."""
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "body"
        }

        structure = [
            {"name": "Overview | General introduction", "subsections": []}
        ]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "Overview | General introduction" in sections
        mock_search_system.analyze_topic.assert_called_once()

    def test_no_pipe_in_section_name_uses_section_name_as_subsection(
        self, generator, mock_search_system
    ):
        """Section without pipe and no subsections uses section name as subsection name."""
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "body"
        }

        structure = [{"name": "Introduction", "subsections": []}]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "Introduction" in sections
        # The query should contain the section name
        query_arg = mock_search_system.analyze_topic.call_args[0][0]
        assert "Introduction" in query_arg


# ---------------------------------------------------------------------------
# _research_and_generate_sections -- empty current_knowledge handling
# ---------------------------------------------------------------------------


class TestEmptySubsectionResults:
    """When analyze_topic returns empty/None current_knowledge."""

    def test_none_current_knowledge_shows_limited_info_message(
        self, generator, mock_search_system
    ):
        """None current_knowledge produces 'Limited information' placeholder."""
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": None
        }

        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "Limited information" in sections["S"]

    def test_empty_string_current_knowledge_shows_limited_info(
        self, generator, mock_search_system
    ):
        """Empty string current_knowledge produces 'Limited information' placeholder."""
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": ""
        }

        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "Limited information" in sections["S"]

    def test_missing_current_knowledge_key_shows_limited_info(
        self, generator, mock_search_system
    ):
        """Missing current_knowledge key produces 'Limited information' placeholder."""
        mock_search_system.analyze_topic.return_value = {}

        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "Limited information" in sections["S"]


# ---------------------------------------------------------------------------
# _research_and_generate_sections -- subsection header logic
# ---------------------------------------------------------------------------


class TestSubsectionHeaders:
    """Tests for subsection header inclusion based on subsection count."""

    def test_single_subsection_no_header(self, generator, mock_search_system):
        """Section with one subsection does not add ## subsection header."""
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "body"
        }

        structure = [
            {"name": "S", "subsections": [{"name": "OnlySub", "purpose": "p"}]}
        ]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "## OnlySub" not in sections["S"]

    def test_multiple_subsections_add_headers(
        self, generator, mock_search_system
    ):
        """Section with multiple subsections adds ## headers for each."""
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "body"
        }

        structure = [
            {
                "name": "S",
                "subsections": [
                    {"name": "SubA", "purpose": "pA"},
                    {"name": "SubB", "purpose": "pB"},
                ],
            }
        ]

        sections = generator._research_and_generate_sections(
            {"current_knowledge": "init"}, structure, "q"
        )

        assert "## SubA" in sections["S"]
        assert "## SubB" in sections["S"]


# ---------------------------------------------------------------------------
# _generate_error_report -- output format
# ---------------------------------------------------------------------------


class TestGenerateErrorReport:
    """Tests for _generate_error_report output."""

    def test_contains_error_header(self, generator):
        result = generator._generate_error_report("my query", "oops")
        assert "=== ERROR REPORT ===" in result

    def test_contains_query(self, generator):
        result = generator._generate_error_report("my query", "oops")
        assert "my query" in result

    def test_contains_error_message(self, generator):
        result = generator._generate_error_report("q", "something broke")
        assert "something broke" in result

    def test_empty_error_message(self, generator):
        result = generator._generate_error_report("q", "")
        assert "Error:" in result


# ---------------------------------------------------------------------------
# generate_report -- progress callback ordering
# ---------------------------------------------------------------------------


class TestGenerateReportProgressOrdering:
    """Verify progress callback phase order in generate_report."""

    def test_phases_in_correct_order(
        self, generator, mock_search_system, mock_model
    ):
        """Phases: structure -> section_research -> formatting -> complete."""
        mock_model.invoke.return_value = MagicMock(
            content="STRUCTURE\n1. Intro\n   - Over | purpose\nEND_STRUCTURE"
        )
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "text"
        }

        with patch(f"{MODULE}.importlib") as mock_imp:
            mock_utils = MagicMock()
            mock_utils.search_utilities.format_links_to_markdown.return_value = ""
            mock_imp.import_module.return_value = mock_utils

            callback = MagicMock()
            generator.generate_report(
                {"current_knowledge": "findings"},
                "q",
                progress_callback=callback,
            )

        phases = [c.args[2]["phase"] for c in callback.call_args_list]
        # First must be structure, last must be complete
        assert phases[0] == "report_structure"
        assert phases[-1] == "report_complete"

        # formatting must come after all section research
        fmt_idx = phases.index("report_formatting")
        research_indices = [
            i for i, p in enumerate(phases) if p == "report_section_research"
        ]
        if research_indices:
            assert fmt_idx > max(research_indices)

    def test_no_callback_does_not_crash(
        self, generator, mock_search_system, mock_model
    ):
        """generate_report works without progress_callback."""
        mock_model.invoke.return_value = MagicMock(
            content="STRUCTURE\n1. S\n   - Sub | p\nEND_STRUCTURE"
        )
        mock_search_system.analyze_topic.return_value = {
            "current_knowledge": "t"
        }

        with patch(f"{MODULE}.importlib") as mock_imp:
            mock_utils = MagicMock()
            mock_utils.search_utilities.format_links_to_markdown.return_value = ""
            mock_imp.import_module.return_value = mock_utils

            result = generator.generate_report({"current_knowledge": "f"}, "q")

        assert "content" in result


# ---------------------------------------------------------------------------
# _generate_sections (deprecated) -- returns empty dict
# ---------------------------------------------------------------------------


class TestDeprecatedGenerateSections:
    """The deprecated _generate_sections always returns {}."""

    def test_returns_empty_dict(self, generator):
        result = generator._generate_sections({}, {}, [], "q")
        assert result == {}

    def test_ignores_all_arguments(self, generator):
        result = generator._generate_sections(
            {"current_knowledge": "data"},
            {"sec": [{"content": "c"}]},
            [{"name": "S", "subsections": []}],
            "query",
        )
        assert result == {}


# ---------------------------------------------------------------------------
# _determine_report_structure -- think tag removal
# ---------------------------------------------------------------------------


class TestDetermineReportStructureThinkTags:
    """Verify that search_utilities.remove_think_tags is called on LLM response."""

    def test_remove_think_tags_called(self, generator, mock_model):
        """remove_think_tags is invoked on model output."""
        mock_model.invoke.return_value = MagicMock(
            content="<think>internal</think>STRUCTURE\n1. Real\nEND_STRUCTURE"
        )

        with patch(
            f"{MODULE}.search_utilities.remove_think_tags",
            wraps=lambda x: x.replace("<think>internal</think>", ""),
        ) as mock_rt:
            findings = {"current_knowledge": "knowledge"}
            structure = generator._determine_report_structure(findings, "q")
            mock_rt.assert_called_once()
            # Should still parse the section
            assert len(structure) == 1
            assert structure[0]["name"] == "Real"


# ---------------------------------------------------------------------------
# _research_and_generate_sections -- questions_by_iteration propagation
# ---------------------------------------------------------------------------


class TestQuestionsPropagation:
    """Tests for questions_by_iteration being set on search system and strategy."""

    def test_questions_set_on_strategy_when_present(
        self, generator, mock_search_system
    ):
        """questions_by_iteration from initial_findings is propagated to strategy."""
        mock_search_system.questions_by_iteration = {}
        mock_search_system.strategy.questions_by_iteration = {}

        initial = {
            "current_knowledge": "init",
            "questions_by_iteration": {"0": ["Q1"], "1": ["Q2", "Q3"]},
        }
        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        generator._research_and_generate_sections(initial, structure, "q")

        assert mock_search_system.strategy.questions_by_iteration == {
            "0": ["Q1"],
            "1": ["Q2", "Q3"],
        }

    def test_no_questions_key_does_not_crash(
        self, generator, mock_search_system
    ):
        """Missing questions_by_iteration in initial_findings is fine."""
        initial = {"current_knowledge": "init"}
        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        # Should not raise
        generator._research_and_generate_sections(initial, structure, "q")

    def test_empty_questions_dict_does_not_propagate(
        self, generator, mock_search_system
    ):
        """Empty questions_by_iteration dict is falsy, so nothing is set."""
        original_q = {"original": True}
        mock_search_system.strategy.questions_by_iteration = original_q

        initial = {"current_knowledge": "init", "questions_by_iteration": {}}
        structure = [
            {"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
        ]

        generator._research_and_generate_sections(initial, structure, "q")

        # Empty dict is falsy -> original should remain
        assert mock_search_system.strategy.questions_by_iteration is original_q
