"""Tests for report generator progress tracking, cancellation, and iteration override.

Covers three fixes to IntegratedReportGenerator:
1. Iteration override fix — settings_snapshot is now modified instead of the
   dead-code search_system.max_iterations attribute.
2. Progress callbacks — subsection-level progress reported during report generation.
3. Cancellation — progress_callback fires before iteration override so termination
   propagates cleanly without corrupting settings.
"""

from unittest.mock import MagicMock

import pytest

from local_deep_research.report_generator import IntegratedReportGenerator
from local_deep_research.web.services.research_service import (
    _DETAILED_REPORT_PROGRESS_END,
    _DETAILED_REPORT_PROGRESS_START,
)


# ── Fixtures ──


@pytest.fixture
def mock_search_system():
    """Create a mock search system with a strategy that has settings_snapshot."""
    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": "Test research content"
    }
    return system


@pytest.fixture
def mock_model():
    """Create a mock LLM model."""
    model = MagicMock()
    model.invoke.return_value = MagicMock(
        content=(
            "STRUCTURE\n"
            "1. Introduction\n"
            "   - Overview | Provide an overview\n"
            "   - Background | Historical context\n"
            "2. Analysis\n"
            "   - Data | Present the data\n"
            "END_STRUCTURE"
        )
    )
    return model


@pytest.fixture
def generator(mock_search_system, mock_model):
    """Create an IntegratedReportGenerator with mocked dependencies."""
    gen = IntegratedReportGenerator.__new__(IntegratedReportGenerator)
    gen.search_system = mock_search_system
    gen.model = mock_model
    gen.searches_per_section = 2
    gen.max_context_sections = 3
    gen.max_context_chars = 4000
    return gen


@pytest.fixture
def simple_structure():
    """A simple report structure with known subsection counts."""
    return [
        {
            "name": "Introduction",
            "subsections": [
                {"name": "Overview", "purpose": "Provide an overview"},
                {"name": "Background", "purpose": "Historical context"},
            ],
        },
        {
            "name": "Analysis",
            "subsections": [
                {"name": "Data", "purpose": "Present the data"},
            ],
        },
    ]


@pytest.fixture
def initial_findings():
    """Standard initial findings dict."""
    return {"current_knowledge": "Initial research findings about the topic."}


# ── Progress callback tests ──


class TestProgressCallbackInGenerateReport:
    """Tests for progress_callback parameter in generate_report()."""

    def test_generate_report_without_callback_works(
        self, generator, initial_findings
    ):
        """generate_report() still works when no progress_callback is passed."""
        result = generator.generate_report(initial_findings, "test query")
        assert "content" in result
        assert "metadata" in result

    def test_generate_report_calls_callback_for_all_phases(
        self, generator, initial_findings
    ):
        """progress_callback is called for structure, section research, formatting, and completion."""
        callback = MagicMock()
        generator.generate_report(
            initial_findings, "test query", progress_callback=callback
        )

        # Extract all phases reported
        phases = [c.args[2]["phase"] for c in callback.call_args_list]
        assert "report_structure" in phases
        assert "report_section_research" in phases
        assert "report_formatting" in phases
        assert "report_complete" in phases

    def test_progress_starts_at_zero_and_ends_at_100(
        self, generator, initial_findings
    ):
        """First callback is 0%, last is 100%."""
        callback = MagicMock()
        generator.generate_report(
            initial_findings, "test query", progress_callback=callback
        )

        first_pct = callback.call_args_list[0].args[1]
        last_pct = callback.call_args_list[-1].args[1]
        assert first_pct == 0
        assert last_pct == 100

    def test_progress_is_monotonically_nondecreasing(
        self, generator, initial_findings
    ):
        """Progress percentages never decrease."""
        callback = MagicMock()
        generator.generate_report(
            initial_findings, "test query", progress_callback=callback
        )

        percentages = [c.args[1] for c in callback.call_args_list]
        for i in range(1, len(percentages)):
            assert percentages[i] >= percentages[i - 1], (
                f"Progress went backwards: {percentages[i - 1]} -> {percentages[i]}"
            )


class TestProgressCallbackInSectionResearch:
    """Tests for subsection-level progress tracking."""

    def test_progress_reported_for_each_subsection(
        self, generator, simple_structure, initial_findings
    ):
        """Each subsection triggers a progress callback."""
        callback = MagicMock()
        generator._research_and_generate_sections(
            initial_findings,
            simple_structure,
            "test query",
            progress_callback=callback,
        )

        # 3 subsections total (2 in Introduction + 1 in Analysis)
        section_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_section_research"
        ]
        assert len(section_calls) == 3

    def test_progress_message_includes_section_and_subsection_names(
        self, generator, simple_structure, initial_findings
    ):
        """Progress messages reference section > subsection."""
        callback = MagicMock()
        generator._research_and_generate_sections(
            initial_findings,
            simple_structure,
            "test query",
            progress_callback=callback,
        )

        messages = [c.args[0] for c in callback.call_args_list]
        assert any("Introduction" in m and "Overview" in m for m in messages)
        assert any("Introduction" in m and "Background" in m for m in messages)
        assert any("Analysis" in m and "Data" in m for m in messages)

    def test_progress_percentage_scales_with_subsections(
        self, generator, simple_structure, initial_findings
    ):
        """Progress ranges from 10% to 90% across subsections."""
        callback = MagicMock()
        generator._research_and_generate_sections(
            initial_findings,
            simple_structure,
            "test query",
            progress_callback=callback,
        )

        section_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_section_research"
        ]
        percentages = [c.args[1] for c in section_calls]

        # First subsection: 10 + (0/3)*80 = 10
        assert percentages[0] == 10
        # Second subsection: 10 + (1/3)*80 ≈ 36
        assert percentages[1] == 36
        # Third subsection: 10 + (2/3)*80 ≈ 63
        assert percentages[2] == 63

    def test_empty_subsections_section_gets_one_progress_call(
        self, generator, initial_findings
    ):
        """A section with no subsections auto-creates one and gets one progress call."""
        structure = [{"name": "Solo Section", "subsections": []}]
        callback = MagicMock()

        generator._research_and_generate_sections(
            initial_findings,
            structure,
            "test query",
            progress_callback=callback,
        )

        section_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_section_research"
        ]
        assert len(section_calls) == 1

    def test_no_callback_still_works(
        self, generator, simple_structure, initial_findings
    ):
        """_research_and_generate_sections works without progress_callback."""
        result = generator._research_and_generate_sections(
            initial_findings, simple_structure, "test query"
        )
        assert "Introduction" in result
        assert "Analysis" in result


# ── Cancellation tests ──


class TestCancellation:
    """Tests for cancellation via progress_callback exception."""

    def test_cancellation_stops_processing(
        self, generator, simple_structure, initial_findings
    ):
        """When progress_callback raises, processing stops immediately."""
        call_count = 0

        def cancelling_callback(message, pct, metadata):
            nonlocal call_count
            call_count += 1
            if call_count == 2:  # Cancel on second subsection
                raise Exception("Research was terminated by user")

        with pytest.raises(Exception, match="terminated by user"):
            generator._research_and_generate_sections(
                initial_findings,
                simple_structure,
                "test query",
                progress_callback=cancelling_callback,
            )

        # Only 1 subsection should have been fully searched
        # (first callback fires, search happens, second callback fires and raises)
        assert generator.search_system.analyze_topic.call_count == 1

    def test_cancelled_before_iteration_override(
        self, generator, simple_structure, initial_findings
    ):
        """Cancellation fires before iteration override so settings_snapshot is never modified."""
        original_value = generator.search_system.strategy.settings_snapshot[
            "search.iterations"
        ]

        def cancel_immediately(message, pct, metadata):
            raise Exception("Research was terminated by user")

        with pytest.raises(Exception, match="terminated by user"):
            generator._research_and_generate_sections(
                initial_findings,
                simple_structure,
                "test query",
                progress_callback=cancel_immediately,
            )

        # Settings should be untouched since cancellation was before override
        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == original_value
        )

    def test_terminated_propagates_and_iterations_not_corrupted(
        self, generator, initial_findings
    ):
        """If analyze_topic raises after override, settings are still restored."""
        generator.search_system.analyze_topic.side_effect = Exception(
            "Research was terminated by user"
        )

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Test purpose"}],
            }
        ]

        callback = MagicMock()  # Non-cancelling callback

        with pytest.raises(Exception, match="terminated by user"):
            generator._research_and_generate_sections(
                initial_findings,
                structure,
                "test query",
                progress_callback=callback,
            )

        # Despite the error, iterations should be restored
        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == 3
        )


# ── Iteration override tests ──


class TestIterationOverride:
    """Tests for the iteration override fix."""

    def test_settings_snapshot_set_to_1_during_search(
        self, generator, initial_findings
    ):
        """During analyze_topic, strategy.settings_snapshot['search.iterations'] is 1."""
        captured_values = []

        def capture_iterations(query):
            captured_values.append(
                generator.search_system.strategy.settings_snapshot.get(
                    "search.iterations"
                )
            )
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture_iterations

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert captured_values == [1]

    def test_settings_snapshot_restored_after_search(
        self, generator, initial_findings
    ):
        """After search completes, settings_snapshot is restored to original value."""
        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == 3
        )

    def test_settings_snapshot_restored_after_error(
        self, generator, initial_findings
    ):
        """After search error, settings_snapshot is still restored."""
        generator.search_system.analyze_topic.side_effect = RuntimeError(
            "Search failed"
        )

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        with pytest.raises(RuntimeError, match="Search failed"):
            generator._research_and_generate_sections(
                initial_findings, structure, "test query"
            )

        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == 3
        )

    def test_absent_key_removed_after_search(self, generator, initial_findings):
        """If search.iterations wasn't in snapshot before, it's removed after."""
        del generator.search_system.strategy.settings_snapshot[
            "search.iterations"
        ]

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert (
            "search.iterations"
            not in generator.search_system.strategy.settings_snapshot
        )

    def test_absent_key_removed_after_error(self, generator, initial_findings):
        """If search.iterations wasn't in snapshot, it's removed even after error."""
        del generator.search_system.strategy.settings_snapshot[
            "search.iterations"
        ]
        generator.search_system.analyze_topic.side_effect = RuntimeError("fail")

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        with pytest.raises(RuntimeError):
            generator._research_and_generate_sections(
                initial_findings, structure, "test query"
            )

        assert (
            "search.iterations"
            not in generator.search_system.strategy.settings_snapshot
        )

    def test_max_iterations_belt_and_suspenders(
        self, generator, initial_findings
    ):
        """strategy.max_iterations is also set to 1 during search."""
        captured_values = []

        def capture_max_iter(query):
            captured_values.append(
                generator.search_system.strategy.max_iterations
            )
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture_max_iter

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert captured_values == [1]
        # Restored after
        assert generator.search_system.strategy.max_iterations == 3

    def test_max_iterations_restored_after_error(
        self, generator, initial_findings
    ):
        """strategy.max_iterations is restored even after error."""
        generator.search_system.analyze_topic.side_effect = RuntimeError("fail")

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        with pytest.raises(RuntimeError):
            generator._research_and_generate_sections(
                initial_findings, structure, "test query"
            )

        assert generator.search_system.strategy.max_iterations == 3

    def test_multiple_subsections_each_override_and_restore(
        self, generator, simple_structure, initial_findings
    ):
        """Each subsection independently overrides and restores iterations."""
        captured_values = []

        def capture_iterations(query):
            captured_values.append(
                generator.search_system.strategy.settings_snapshot.get(
                    "search.iterations"
                )
            )
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture_iterations

        generator._research_and_generate_sections(
            initial_findings, simple_structure, "test query"
        )

        # Each search should see iterations=1
        assert captured_values == [1, 1, 1]
        # After all, should be restored
        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == 3
        )


# ── Completed subsections counter tests ──


class TestCompletedSubsectionsCounter:
    """Tests that completed_subsections only increments after success."""

    def test_counter_not_incremented_on_error(
        self, generator, initial_findings
    ):
        """completed_subsections doesn't increment when analyze_topic raises."""
        call_count = 0

        def fail_on_first(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise RuntimeError("First subsection failed")
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = fail_on_first

        structure = [
            {
                "name": "Section",
                "subsections": [
                    {"name": "Sub1", "purpose": "P1"},
                    {"name": "Sub2", "purpose": "P2"},
                ],
            }
        ]

        progress_pcts = []
        callback = MagicMock(
            side_effect=lambda msg, pct, meta: progress_pcts.append(pct)
        )

        with pytest.raises(RuntimeError, match="First subsection failed"):
            generator._research_and_generate_sections(
                initial_findings,
                structure,
                "test query",
                progress_callback=callback,
            )

        # Only 1 progress call happened (for the first subsection, before it failed)
        section_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_section_research"
        ]
        assert len(section_calls) == 1


# ── Report progress callback wrapper tests ──


class TestReportProgressCallbackWrapper:
    """Tests for the report_progress_callback wrapper in research_service."""

    def test_maps_0_to_range_start(self):
        """0% internal maps to start of report range (10%)."""
        outer_callback = MagicMock()
        start = _DETAILED_REPORT_PROGRESS_START
        end = _DETAILED_REPORT_PROGRESS_END
        report_range = end - start

        # Simulate the wrapper logic from research_service.py
        def report_progress_callback(message, progress_percent, metadata):
            if progress_percent is not None:
                adjusted = start + (progress_percent / 100) * report_range
            else:
                adjusted = progress_percent
            outer_callback(message, adjusted, metadata)

        report_progress_callback("test", 0, {"phase": "report_structure"})
        outer_callback.assert_called_with(
            "test", 10.0, {"phase": "report_structure"}
        )

    def test_maps_50_to_range_midpoint(self):
        """50% internal maps to midpoint of report range (55%)."""
        outer_callback = MagicMock()
        start = _DETAILED_REPORT_PROGRESS_START
        end = _DETAILED_REPORT_PROGRESS_END
        report_range = end - start

        def report_progress_callback(message, progress_percent, metadata):
            if progress_percent is not None:
                adjusted = start + (progress_percent / 100) * report_range
            else:
                adjusted = progress_percent
            outer_callback(message, adjusted, metadata)

        report_progress_callback(
            "test", 50, {"phase": "report_section_research"}
        )
        outer_callback.assert_called_with(
            "test", 55.0, {"phase": "report_section_research"}
        )

    def test_maps_100_to_range_end(self):
        """100% internal maps to end of report range (100%)."""
        outer_callback = MagicMock()
        start = _DETAILED_REPORT_PROGRESS_START
        end = _DETAILED_REPORT_PROGRESS_END
        report_range = end - start

        def report_progress_callback(message, progress_percent, metadata):
            if progress_percent is not None:
                adjusted = start + (progress_percent / 100) * report_range
            else:
                adjusted = progress_percent
            outer_callback(message, adjusted, metadata)

        report_progress_callback("test", 100, {"phase": "report_complete"})
        outer_callback.assert_called_with(
            "test", 100.0, {"phase": "report_complete"}
        )

    def test_none_progress_passed_through(self):
        """None progress is passed through unchanged."""
        outer_callback = MagicMock()
        start = _DETAILED_REPORT_PROGRESS_START
        end = _DETAILED_REPORT_PROGRESS_END
        report_range = end - start

        def report_progress_callback(message, progress_percent, metadata):
            if progress_percent is not None:
                adjusted = start + (progress_percent / 100) * report_range
            else:
                adjusted = progress_percent
            outer_callback(message, adjusted, metadata)

        report_progress_callback("test", None, {"phase": "error"})
        outer_callback.assert_called_with("test", None, {"phase": "error"})

    def test_no_double_mapping_with_outer_phases(self):
        """Report phases don't match outer callback's report_generation check."""
        # The phases used by report generator (report_structure,
        # report_section_research, report_formatting) must NOT match
        # the outer callback's "report_generation" phase check to avoid
        # double-mapping.
        report_phases = [
            "report_structure",
            "report_section_research",
            "report_formatting",
            "report_complete",
        ]
        for phase in report_phases:
            assert phase != "report_generation"


# ── Empty subsections handling ──


class TestEmptySubsectionsHandling:
    """Tests for consistent handling of sections with no subsections."""

    def test_empty_subsections_auto_created(self, generator, initial_findings):
        """Sections with empty subsections get one auto-created subsection."""
        structure = [{"name": "Empty Section", "subsections": []}]

        result = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # Should have generated content for the auto-created subsection
        assert "Empty Section" in result
        assert generator.search_system.analyze_topic.call_count == 1

    def test_section_with_pipe_in_name_parsed_correctly(
        self, generator, initial_findings
    ):
        """Sections with '|' in name get purpose parsed from the name."""
        structure = [{"name": "My Section | Custom purpose", "subsections": []}]

        result = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert "My Section | Custom purpose" in result

    def test_progress_count_consistent_for_empty_subsections(
        self, generator, initial_findings
    ):
        """Progress counting uses max(len(subsections), 1) for empty sections."""
        structure = [
            {"name": "Section A", "subsections": []},
            {
                "name": "Section B",
                "subsections": [
                    {"name": "Sub1", "purpose": "P1"},
                    {"name": "Sub2", "purpose": "P2"},
                ],
            },
        ]

        callback = MagicMock()
        generator._research_and_generate_sections(
            initial_findings,
            structure,
            "test query",
            progress_callback=callback,
        )

        # Total: 1 (auto-created) + 2 = 3 subsections
        section_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_section_research"
        ]
        assert len(section_calls) == 3


# ── Integration test ──


class TestEndToEndProgress:
    """Integration tests for the full generate_report flow with progress."""

    def test_full_report_generation_with_progress(
        self, generator, initial_findings
    ):
        """Full generate_report flow calls progress for all phases in order."""
        phases_seen = []

        def tracking_callback(message, pct, metadata):
            phases_seen.append(metadata.get("phase"))

        generator.generate_report(
            initial_findings,
            "test query",
            progress_callback=tracking_callback,
        )

        # Verify phase ordering
        assert phases_seen[0] == "report_structure"
        assert phases_seen[-2] == "report_formatting"
        assert phases_seen[-1] == "report_complete"

        # Section research phases should be in the middle
        research_phases = [
            p for p in phases_seen if p == "report_section_research"
        ]
        assert len(research_phases) >= 1

    def test_full_report_cancellation_at_structure_phase(
        self, generator, initial_findings
    ):
        """Cancellation during structure determination propagates cleanly."""

        def cancel_at_structure(message, pct, metadata):
            if metadata.get("phase") == "report_structure":
                raise Exception("Research was terminated by user")

        with pytest.raises(Exception, match="terminated by user"):
            generator.generate_report(
                initial_findings,
                "test query",
                progress_callback=cancel_at_structure,
            )

    def test_full_report_cancellation_during_section_research(
        self, generator, initial_findings
    ):
        """Cancellation during section research propagates cleanly."""
        call_count = 0

        def cancel_after_first_section(message, pct, metadata):
            nonlocal call_count
            if metadata.get("phase") == "report_section_research":
                call_count += 1
                if call_count >= 2:
                    raise Exception("Research was terminated by user")

        with pytest.raises(Exception, match="terminated by user"):
            generator.generate_report(
                initial_findings,
                "test query",
                progress_callback=cancel_after_first_section,
            )

        # Settings should be restored after cancellation
        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == 3
        )


# ── Content accumulation tests ──


class TestContentAccumulation:
    """Tests for context passing between subsections to avoid repetition."""

    def test_later_subsections_receive_earlier_content_in_query(
        self, generator, simple_structure, initial_findings
    ):
        """Queries for later subsections include content from earlier ones."""
        captured_queries = []

        def capture_query(query):
            captured_queries.append(query)
            return {
                "current_knowledge": f"Content for call {len(captured_queries)}"
            }

        generator.search_system.analyze_topic.side_effect = capture_query

        generator._research_and_generate_sections(
            initial_findings, simple_structure, "test query"
        )

        # First subsection query should NOT contain previous content marker
        assert "CONTENT ALREADY WRITTEN" not in captured_queries[0]

        # Second subsection (same section) should contain first subsection's content
        assert "CONTENT ALREADY WRITTEN" in captured_queries[1]
        assert "Content for call 1" in captured_queries[1]

        # Third subsection (different section) should contain earlier content
        assert "CONTENT ALREADY WRITTEN" in captured_queries[2]

    def test_accumulated_findings_formatted_with_section_labels(
        self, generator, initial_findings
    ):
        """Accumulated findings include [Section > Subsection] labels."""
        captured_queries = []

        def capture_query(query):
            captured_queries.append(query)
            return {"current_knowledge": "Some content"}

        generator.search_system.analyze_topic.side_effect = capture_query

        structure = [
            {
                "name": "First Section",
                "subsections": [
                    {"name": "Sub A", "purpose": "Purpose A"},
                    {"name": "Sub B", "purpose": "Purpose B"},
                ],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # Second query should reference the first subsection's label
        assert "[First Section > Sub A]" in captured_queries[1]

    def test_empty_content_not_accumulated(self, generator, initial_findings):
        """Subsections with empty content are not added to accumulated_findings."""
        call_count = 0

        def alternating_content(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return {"current_knowledge": ""}  # Empty
            return {"current_knowledge": "Real content"}

        generator.search_system.analyze_topic.side_effect = alternating_content

        structure = [
            {
                "name": "Section",
                "subsections": [
                    {"name": "Empty Sub", "purpose": "P1"},
                    {"name": "Real Sub", "purpose": "P2"},
                ],
            }
        ]

        captured_queries = []
        original_side_effect = generator.search_system.analyze_topic.side_effect

        def capture_and_call(query):
            captured_queries.append(query)
            return original_side_effect(query)

        generator.search_system.analyze_topic.side_effect = capture_and_call

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # Second query should NOT contain previous context since first was empty
        assert "CONTENT ALREADY WRITTEN" not in captured_queries[1]

    def test_context_limited_to_max_context_sections(
        self, generator, initial_findings
    ):
        """Only the last max_context_sections are included in context."""
        generator.max_context_sections = 2
        captured_queries = []

        def capture_query(query):
            captured_queries.append(query)
            return {"current_knowledge": f"Content #{len(captured_queries)}"}

        generator.search_system.analyze_topic.side_effect = capture_query

        structure = [
            {
                "name": f"Section {i}",
                "subsections": [{"name": f"Sub {i}", "purpose": f"P{i}"}],
            }
            for i in range(4)
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # Fourth query (index 3) should contain sections 2 and 3, not section 1
        # (max_context_sections=2, so only last 2 are included)
        last_query = captured_queries[3]
        assert "Content #3" in last_query  # Most recent
        assert "Content #2" in last_query  # Second most recent
        assert "Content #1" not in last_query  # Dropped due to limit


# ── Query construction tests ──


class TestQueryConstruction:
    """Tests for verifying queries passed to analyze_topic."""

    def test_subsection_query_contains_purpose(
        self, generator, initial_findings
    ):
        """Query includes the subsection's purpose."""
        captured_queries = []

        def capture(query):
            captured_queries.append(query)
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture

        structure = [
            {
                "name": "Section",
                "subsections": [
                    {"name": "Details", "purpose": "Explain the mechanism"}
                ],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert "Explain the mechanism" in captured_queries[0]

    def test_subsection_query_contains_original_query(
        self, generator, initial_findings
    ):
        """Query includes the user's original research query."""
        captured_queries = []

        def capture(query):
            captured_queries.append(query)
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "my specific research topic"
        )

        assert "my specific research topic" in captured_queries[0]

    def test_subsection_query_lists_other_sections(
        self, generator, initial_findings
    ):
        """Query includes names of other sections for context."""
        captured_queries = []

        def capture(query):
            captured_queries.append(query)
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture

        structure = [
            {
                "name": "Introduction",
                "subsections": [{"name": "Overview", "purpose": "P1"}],
            },
            {
                "name": "Methodology",
                "subsections": [{"name": "Approach", "purpose": "P2"}],
            },
            {
                "name": "Results",
                "subsections": [{"name": "Findings", "purpose": "P3"}],
            },
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # First section's query should mention the other sections
        assert "Methodology" in captured_queries[0]
        assert "Results" in captured_queries[0]

        # Second section's query should mention its siblings
        assert "Introduction" in captured_queries[1]
        assert "Results" in captured_queries[1]

    def test_multi_subsection_query_lists_sibling_subsections(
        self, generator, initial_findings
    ):
        """When a section has multiple subsections, each query lists its siblings."""
        captured_queries = []

        def capture(query):
            captured_queries.append(query)
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture

        structure = [
            {
                "name": "Analysis",
                "subsections": [
                    {"name": "Quantitative", "purpose": "Numbers and stats"},
                    {"name": "Qualitative", "purpose": "Themes and patterns"},
                    {"name": "Mixed", "purpose": "Combined methods"},
                ],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # First subsection query should reference the other two
        assert "Qualitative" in captured_queries[0]
        assert "Mixed" in captured_queries[0]

        # Second subsection should reference its siblings
        assert "Quantitative" in captured_queries[1]
        assert "Mixed" in captured_queries[1]

    def test_single_subsection_uses_section_level_prompt(
        self, generator, initial_findings
    ):
        """Single-subsection sections use the standalone section prompt."""
        captured_queries = []

        def capture(query):
            captured_queries.append(query)
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture

        structure = [
            {
                "name": "Conclusion",
                "subsections": [
                    {"name": "Conclusion", "purpose": "Summarize findings"}
                ],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # Single-subsection uses "standalone section" language
        assert "standalone section" in captured_queries[0].lower()

    def test_multi_subsection_uses_focused_prompt(
        self, generator, initial_findings
    ):
        """Multi-subsection sections use the focused subsection prompt."""
        captured_queries = []

        def capture(query):
            captured_queries.append(query)
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = capture

        structure = [
            {
                "name": "Analysis",
                "subsections": [
                    {"name": "Sub1", "purpose": "P1"},
                    {"name": "Sub2", "purpose": "P2"},
                ],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        # Multi-subsection uses "Focus ONLY" language
        assert "Focus ONLY" in captured_queries[0]


# ── Section content assembly tests ──


class TestSectionContentAssembly:
    """Tests for how subsection content is assembled into section output."""

    def test_multi_subsection_section_includes_subsection_headers(
        self, generator, initial_findings
    ):
        """Sections with multiple subsections get ## headers for each."""
        generator.search_system.analyze_topic.return_value = {
            "current_knowledge": "Content here"
        }

        structure = [
            {
                "name": "Analysis",
                "subsections": [
                    {"name": "Data Review", "purpose": "Review the data"},
                    {"name": "Interpretation", "purpose": "Interpret results"},
                ],
            }
        ]

        sections = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        content = sections["Analysis"]
        assert "## 1.1 Data Review" in content
        assert "## 1.2 Interpretation" in content

    def test_single_subsection_section_omits_subsection_header(
        self, generator, initial_findings
    ):
        """Sections with one subsection do NOT get a ## header."""
        generator.search_system.analyze_topic.return_value = {
            "current_knowledge": "Content here"
        }

        structure = [
            {
                "name": "Conclusion",
                "subsections": [
                    {"name": "Summary", "purpose": "Summarize findings"}
                ],
            }
        ]

        sections = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        content = sections["Conclusion"]
        assert "## Summary" not in content

    def test_section_starts_with_section_heading(
        self, generator, initial_findings
    ):
        """Each section's content starts with a # heading."""
        generator.search_system.analyze_topic.return_value = {
            "current_knowledge": "Content"
        }

        structure = [
            {
                "name": "My Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        sections = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert sections["My Section"].startswith("# 1. My Section")

    def test_subsection_content_from_analyze_topic_included(
        self, generator, initial_findings
    ):
        """Content returned by analyze_topic appears in the section output."""
        generator.search_system.analyze_topic.return_value = {
            "current_knowledge": "Specific content about the mechanism of action"
        }

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        sections = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        assert (
            "Specific content about the mechanism of action"
            in sections["Section"]
        )

    def test_multiple_subsections_content_combined(
        self, generator, initial_findings
    ):
        """Content from all subsections in a section is combined."""
        call_count = 0

        def unique_content(query):
            nonlocal call_count
            call_count += 1
            return {"current_knowledge": f"UNIQUE_CONTENT_{call_count}"}

        generator.search_system.analyze_topic.side_effect = unique_content

        structure = [
            {
                "name": "Section",
                "subsections": [
                    {"name": "Part A", "purpose": "First part"},
                    {"name": "Part B", "purpose": "Second part"},
                ],
            }
        ]

        sections = generator._research_and_generate_sections(
            initial_findings, structure, "test query"
        )

        content = sections["Section"]
        assert "UNIQUE_CONTENT_1" in content
        assert "UNIQUE_CONTENT_2" in content


# ── Progress metadata completeness tests ──


class TestProgressMetadataCompleteness:
    """Tests for metadata fields in progress callbacks."""

    def test_section_research_metadata_includes_subsection_name(
        self, generator, simple_structure, initial_findings
    ):
        """report_section_research callbacks include subsection name in metadata."""
        callback = MagicMock()
        generator._research_and_generate_sections(
            initial_findings,
            simple_structure,
            "test query",
            progress_callback=callback,
        )

        section_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_section_research"
        ]

        subsection_names = [c.args[2]["subsection"] for c in section_calls]
        assert "Overview" in subsection_names
        assert "Background" in subsection_names
        assert "Data" in subsection_names

    def test_structure_phase_metadata_has_phase_key(
        self, generator, initial_findings
    ):
        """report_structure phase callback has 'phase' in metadata."""
        callback = MagicMock()
        generator.generate_report(
            initial_findings, "test query", progress_callback=callback
        )

        first_call = callback.call_args_list[0]
        assert "phase" in first_call.args[2]
        assert first_call.args[2]["phase"] == "report_structure"

    def test_formatting_phase_metadata_has_phase_key(
        self, generator, initial_findings
    ):
        """report_formatting phase callback has 'phase' in metadata."""
        callback = MagicMock()
        generator.generate_report(
            initial_findings, "test query", progress_callback=callback
        )

        formatting_calls = [
            c
            for c in callback.call_args_list
            if c.args[2].get("phase") == "report_formatting"
        ]
        assert len(formatting_calls) == 1


# ── Progress-before-override ordering test ──


class TestProgressBeforeOverrideOrdering:
    """Tests that progress callback fires BEFORE iteration override."""

    def test_iterations_unchanged_when_progress_callback_fires(
        self, generator, initial_findings
    ):
        """At the moment progress_callback fires, search.iterations is still original."""
        iterations_at_callback_time = []

        def check_iterations(message, pct, metadata):
            if metadata.get("phase") == "report_section_research":
                iterations_at_callback_time.append(
                    generator.search_system.strategy.settings_snapshot.get(
                        "search.iterations"
                    )
                )

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings,
            structure,
            "test query",
            progress_callback=check_iterations,
        )

        # At callback time, iterations should still be original (3), not overridden (1)
        assert iterations_at_callback_time == [3]

    def test_iterations_overridden_only_during_analyze_topic(
        self, generator, initial_findings
    ):
        """Iteration override is active only during analyze_topic, not during callback."""
        timeline = []

        def tracking_callback(message, pct, metadata):
            if metadata.get("phase") == "report_section_research":
                val = generator.search_system.strategy.settings_snapshot.get(
                    "search.iterations"
                )
                timeline.append(("callback", val))

        def tracking_analyze(query):
            val = generator.search_system.strategy.settings_snapshot.get(
                "search.iterations"
            )
            timeline.append(("analyze", val))
            return {"current_knowledge": "content"}

        generator.search_system.analyze_topic.side_effect = tracking_analyze

        structure = [
            {
                "name": "Section",
                "subsections": [{"name": "Sub", "purpose": "Purpose"}],
            }
        ]

        generator._research_and_generate_sections(
            initial_findings,
            structure,
            "test query",
            progress_callback=tracking_callback,
        )

        # Timeline: callback fires with original (3), then analyze sees override (1)
        assert timeline == [("callback", 3), ("analyze", 1)]


# ── Cancellation between sections ──


class TestCancellationBetweenSections:
    """Tests for cancellation that occurs between sections."""

    def test_cancel_after_first_section_before_second(
        self, generator, initial_findings
    ):
        """Cancelling after all subsections of section 1 prevents section 2."""
        sections_started = []

        def cancel_on_second_section(message, pct, metadata):
            if metadata.get("phase") == "report_section_research":
                sub = metadata.get("subsection", "")
                sections_started.append(sub)
                # Cancel when we reach section 2's subsection
                if sub == "Analysis Sub":
                    raise Exception("Research was terminated by user")

        structure = [
            {
                "name": "Section 1",
                "subsections": [{"name": "S1 Sub", "purpose": "P1"}],
            },
            {
                "name": "Section 2",
                "subsections": [{"name": "Analysis Sub", "purpose": "P2"}],
            },
        ]

        with pytest.raises(Exception, match="terminated by user"):
            generator._research_and_generate_sections(
                initial_findings,
                structure,
                "test query",
                progress_callback=cancel_on_second_section,
            )

        # Only section 1's subsection was fully searched
        assert generator.search_system.analyze_topic.call_count == 1

    def test_settings_restored_after_cancel_between_sections(
        self, generator, initial_findings
    ):
        """Settings are clean after cancellation between sections."""
        call_count = 0

        def cancel_on_third(message, pct, metadata):
            nonlocal call_count
            if metadata.get("phase") == "report_section_research":
                call_count += 1
                if call_count == 3:
                    raise Exception("Research was terminated by user")

        structure = [
            {
                "name": "Section 1",
                "subsections": [
                    {"name": "Sub1", "purpose": "P1"},
                    {"name": "Sub2", "purpose": "P2"},
                ],
            },
            {
                "name": "Section 2",
                "subsections": [{"name": "Sub3", "purpose": "P3"}],
            },
        ]

        with pytest.raises(Exception, match="terminated by user"):
            generator._research_and_generate_sections(
                initial_findings,
                structure,
                "test query",
                progress_callback=cancel_on_third,
            )

        # Iterations restored: cancel fires before override for sub3
        assert (
            generator.search_system.strategy.settings_snapshot[
                "search.iterations"
            ]
            == 3
        )
        assert generator.search_system.strategy.max_iterations == 3

    def test_partial_results_available_after_cancel(
        self, generator, initial_findings
    ):
        """After cancellation, the exception propagates - no partial result returned."""
        call_count = 0

        def cancel_on_second_section(message, pct, metadata):
            nonlocal call_count
            if metadata.get("phase") == "report_section_research":
                call_count += 1
                if call_count == 2:
                    raise Exception("Research was terminated by user")

        structure = [
            {
                "name": "Section 1",
                "subsections": [{"name": "Sub1", "purpose": "P1"}],
            },
            {
                "name": "Section 2",
                "subsections": [{"name": "Sub2", "purpose": "P2"}],
            },
        ]

        with pytest.raises(Exception, match="terminated by user"):
            generator._research_and_generate_sections(
                initial_findings,
                structure,
                "test query",
                progress_callback=cancel_on_second_section,
            )

        # The exception propagates — caller must handle it
        # Verify section 1 was fully processed (1 analyze_topic call)
        assert generator.search_system.analyze_topic.call_count == 1


# ── Research service wrapper integration ──


class TestResearchServiceWrapperIntegration:
    """Tests that verify the actual research_service.py wrapper code path."""

    def test_wrapper_defined_in_run_research_process(self):
        """research_service defines report_progress_callback with correct formula."""
        import inspect

        from local_deep_research.web.services.research_service import (
            run_research_process,
        )

        source = inspect.getsource(run_research_process)

        # Verify the wrapper is defined and used
        assert "report_progress_callback" in source
        assert "progress_callback=report_progress_callback" in source
        assert "_DETAILED_REPORT_PROGRESS_START" in source

    def test_wrapper_handles_none_progress(self):
        """The wrapper code handles None progress_percent."""
        import inspect

        from local_deep_research.web.services.research_service import (
            run_research_process,
        )

        source = inspect.getsource(run_research_process)

        # Verify None handling exists
        assert "if progress_percent is not None" in source
