"""
Tests for research_service synthesis and report generation.

Tests cover:
- Quick mode synthesis
- Report generation
- News search integration
"""

from unittest.mock import Mock, MagicMock, patch
import pytest


class TestQuickModeSynthesis:
    """Tests for quick mode synthesis."""

    def test_quick_mode_synthesis_success(self):
        """Quick mode synthesis completes successfully."""
        results = {
            "findings": [{"content": "Test finding", "phase": "search"}],
            "formatted_findings": "# Research Summary\n\nTest summary.",
            "iterations": 2,
        }

        # Synthesis should have formatted findings
        assert results.get("formatted_findings")
        assert not results["formatted_findings"].startswith("Error:")

    def test_quick_mode_synthesis_token_limit_error(self):
        """Quick mode synthesis detects token limit errors."""
        results = {
            "findings": [{"content": "Finding", "phase": "search"}],
            "formatted_findings": "Error: context length exceeded",
            "iterations": 2,
        }

        error_message = results["formatted_findings"].lower()
        if "token limit" in error_message or "context length" in error_message:
            error_type = "token_limit"
        else:
            error_type = "unknown"

        assert error_type == "token_limit"

    def test_quick_mode_synthesis_timeout_error(self):
        """Quick mode synthesis detects timeout errors."""
        results = {
            "formatted_findings": "Error: request timed out",
            "iterations": 1,
        }

        error_message = results["formatted_findings"].lower()
        if "timeout" in error_message or "timed out" in error_message:
            error_type = "timeout"
        else:
            error_type = "unknown"

        assert error_type == "timeout"

    def test_quick_mode_synthesis_rate_limit_error(self):
        """Quick mode synthesis detects rate limit errors."""
        results = {
            "formatted_findings": "Error: rate limit exceeded",
            "iterations": 1,
        }

        error_message = results["formatted_findings"].lower()
        if "rate limit" in error_message:
            error_type = "rate_limit"
        else:
            error_type = "unknown"

        assert error_type == "rate_limit"

    def test_quick_mode_synthesis_connection_error(self):
        """Quick mode synthesis detects connection errors."""
        results = {
            "formatted_findings": "Error: connection refused",
            "iterations": 1,
        }

        error_message = results["formatted_findings"].lower()
        if "connection" in error_message or "network" in error_message:
            error_type = "connection"
        else:
            error_type = "unknown"

        assert error_type == "connection"

    def test_quick_mode_synthesis_llm_error(self):
        """Quick mode synthesis detects general LLM errors."""
        results = {
            "formatted_findings": "Error: LLM error during synthesis",
            "iterations": 1,
        }

        error_message = results["formatted_findings"].lower()
        if (
            "llm error" in error_message
            or "final answer synthesis fail" in error_message
        ):
            error_type = "llm_error"
        else:
            error_type = "unknown"

        assert error_type == "llm_error"

    def test_quick_mode_synthesis_fallback_cascade_level_1(self):
        """Quick mode synthesis uses synthesized content as first fallback."""
        results = {
            "findings": [
                {"content": "Search finding", "phase": "search"},
                {
                    "content": "Good synthesis content",
                    "phase": "Final synthesis",
                },
            ],
            "formatted_findings": "Error: post-processing failed",
            "iterations": 2,
        }

        # Level 1 fallback: use Final synthesis content
        fallback_content = None
        for finding in results.get("findings", []):
            if finding.get("phase") == "Final synthesis":
                content = finding.get("content", "")
                if not content.startswith("Error:"):
                    fallback_content = content
                    break

        assert fallback_content == "Good synthesis content"

    def test_quick_mode_synthesis_fallback_cascade_level_2(self):
        """Quick mode synthesis uses current_knowledge as second fallback."""
        results = {
            "findings": [
                {
                    "content": "Error: synthesis failed",
                    "phase": "Final synthesis",
                }
            ],
            "formatted_findings": "Error: synthesis failed",
            "current_knowledge": "Accumulated knowledge from search",
            "iterations": 2,
        }

        # Level 2 fallback: use current_knowledge
        fallback_content = None

        # Try Level 1 first
        for finding in results.get("findings", []):
            if finding.get("phase") == "Final synthesis":
                content = finding.get("content", "")
                if not content.startswith("Error:"):
                    fallback_content = content
                    break

        # Level 2
        if not fallback_content and results.get("current_knowledge"):
            fallback_content = results["current_knowledge"]

        assert fallback_content == "Accumulated knowledge from search"

    def test_quick_mode_synthesis_fallback_cascade_level_3(self):
        """Quick mode synthesis combines findings as last fallback."""
        results = {
            "findings": [
                {"content": "Finding 1", "phase": "search"},
                {"content": "Finding 2", "phase": "analysis"},
            ],
            "formatted_findings": "Error: all synthesis failed",
            "current_knowledge": "",
            "iterations": 2,
        }

        # Level 3 fallback: combine all non-error findings
        fallback_content = None

        # Skip Levels 1 and 2
        # Level 3
        valid_findings = [
            f"## {f.get('phase', 'Finding')}\n\n{f.get('content', '')}"
            for f in results.get("findings", [])
            if f.get("content")
            and not f.get("content", "").startswith("Error:")
        ]

        if valid_findings:
            fallback_content = "# Research Results (Fallback Mode)\n\n"
            fallback_content += "\n\n".join(valid_findings)

        assert fallback_content is not None
        assert "Finding 1" in fallback_content
        assert "Finding 2" in fallback_content

    def test_quick_mode_synthesis_all_fallbacks_exhausted(self):
        """Quick mode synthesis handles exhausted fallbacks."""
        results = {
            "findings": [],
            "formatted_findings": "Error: complete failure",
            "current_knowledge": "",
            "iterations": 0,
        }

        # All fallbacks fail
        fallback_content = None

        for finding in results.get("findings", []):
            if finding.get("phase") == "Final synthesis":
                content = finding.get("content", "")
                if not content.startswith("Error:"):
                    fallback_content = content
                    break

        if not fallback_content and results.get("current_knowledge"):
            fallback_content = results["current_knowledge"]

        if not fallback_content:
            valid_findings = [
                f
                for f in results.get("findings", [])
                if f.get("content")
                and not f.get("content", "").startswith("Error:")
            ]
            if valid_findings:
                fallback_content = "Combined findings"

        # All fallbacks exhausted
        assert fallback_content is None

    def test_quick_mode_synthesis_partial_content_recovery(self):
        """Quick mode synthesis recovers partial content from errors."""
        results = {
            "findings": [
                {"content": "Complete finding 1", "phase": "search"},
                {"content": "Error: partial", "phase": "synthesis"},
                {"content": "Complete finding 3", "phase": "analysis"},
            ],
            "formatted_findings": "Error: synthesis incomplete",
            "iterations": 2,
        }

        # Extract valid findings only
        valid_findings = [
            f
            for f in results.get("findings", [])
            if f.get("content")
            and not f.get("content", "").startswith("Error:")
        ]

        assert len(valid_findings) == 2

    def test_quick_mode_synthesis_context_overflow_recovery(self):
        """Quick mode synthesis handles context overflow."""
        results = {
            "findings": [{"content": "Finding", "phase": "search"}],
            "formatted_findings": "Error: maximum context length exceeded",
            "iterations": 1,
        }

        error_msg = results["formatted_findings"].lower()
        is_overflow = (
            "context length" in error_msg or "context limit" in error_msg
        )

        assert is_overflow

    def test_quick_mode_synthesis_streaming_response_handling(self):
        """Quick mode synthesis handles streaming responses."""
        # Streaming responses should be accumulated
        chunks = ["Part 1", " Part 2", " Part 3"]
        full_response = "".join(chunks)

        assert full_response == "Part 1 Part 2 Part 3"

    def test_quick_mode_synthesis_progress_callback_sequencing(self):
        """Quick mode synthesis calls progress callbacks in order."""
        progress_calls = []

        def progress_callback(message, progress, metadata):
            progress_calls.append((message, progress, metadata.get("phase")))

        # Simulate progress sequence
        progress_callback(
            "Starting synthesis", 85, {"phase": "output_generation"}
        )
        progress_callback(
            "Generating summary", 90, {"phase": "output_generation"}
        )
        progress_callback("Saving report", 95, {"phase": "report_complete"})
        progress_callback("Completed", 100, {"phase": "complete"})

        assert len(progress_calls) == 4
        assert progress_calls[0][1] == 85
        assert progress_calls[-1][1] == 100
        assert progress_calls[-1][2] == "complete"

    def test_quick_mode_synthesis_empty_results_handling(self):
        """Quick mode synthesis handles empty results."""
        results = {
            "findings": [],
            "formatted_findings": "",
            "iterations": 0,
        }

        # No findings should raise an exception in actual code
        has_findings = bool(
            results.get("findings") or results.get("formatted_findings")
        )
        assert not has_findings


class TestReportGeneration:
    """Tests for report generation."""

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    @patch(
        "local_deep_research.web.services.research_service.get_citation_formatter"
    )
    def test_report_generation_success(self, mock_formatter, mock_get_session):
        """Report generation completes successfully."""
        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_fmt = Mock()
        mock_fmt.format_document.return_value = "# Formatted Report"
        mock_formatter.return_value = mock_fmt

        # Simulate report generation
        content = "# Raw Report"
        formatted = mock_fmt.format_document(content)

        assert formatted == "# Formatted Report"

    @patch("local_deep_research.exporters.ExporterRegistry")
    def test_report_generation_pdf_export_success(self, mock_registry):
        """Report PDF export succeeds."""
        mock_exporter = Mock()
        mock_result = Mock()
        mock_result.content = b"PDF content"
        mock_result.filename = "report.pdf"
        mock_result.mimetype = "application/pdf"
        mock_exporter.export.return_value = mock_result
        mock_registry.get_exporter.return_value = mock_exporter

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

        content, filename, mimetype = export_report_to_memory(
            "# Report", "pdf", title="Test"
        )

        assert content == b"PDF content"
        assert filename.endswith(".pdf")
        assert mimetype == "application/pdf"

    @patch("local_deep_research.exporters.ExporterRegistry")
    def test_report_generation_pdf_export_failure_recovery(self, mock_registry):
        """Report PDF export handles failure."""
        mock_exporter = Mock()
        mock_exporter.export.side_effect = Exception("PDF error")
        mock_registry.get_exporter.return_value = mock_exporter

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

        with pytest.raises(Exception) as exc_info:
            export_report_to_memory("# Report", "pdf", title="Test")

        assert "PDF error" in str(exc_info.value)

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_report_generation_database_commit_success(self, mock_get_session):
        """Report generation commits to database."""
        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        # Simulate DB commit
        mock_session.commit()

        mock_session.commit.assert_called()

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_report_generation_database_commit_failure(self, mock_get_session):
        """Report generation handles database commit failure."""
        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_session.commit.side_effect = Exception("DB error")
        mock_get_session.return_value = mock_session

        with pytest.raises(Exception) as exc_info:
            mock_session.commit()

        assert "DB error" in str(exc_info.value)

    def test_report_generation_metadata_json_parsing(self):
        """Report generation parses metadata JSON."""
        import json

        metadata_str = (
            '{"iterations": 3, "generated_at": "2024-01-01T00:00:00Z"}'
        )
        metadata = json.loads(metadata_str)

        assert metadata["iterations"] == 3
        assert "generated_at" in metadata

    def test_report_generation_metadata_invalid_json(self):
        """Report generation handles invalid metadata JSON."""
        import json

        metadata_str = "invalid json {"

        try:
            metadata = json.loads(metadata_str)
        except json.JSONDecodeError:
            metadata = {}

        assert metadata == {}

    def test_report_generation_storage_abstraction(self):
        """Report generation uses storage abstraction."""
        # Simulate storage abstraction
        mock_storage = Mock()
        mock_storage.save_report.return_value = True

        success = mock_storage.save_report(
            research_id=123,
            content="# Report",
            metadata={},
            username="testuser",
        )

        assert success
        mock_storage.save_report.assert_called_once()

    def test_report_generation_file_write_error(self):
        """Report generation handles file write errors."""
        mock_storage = Mock()
        mock_storage.save_report.side_effect = IOError("Disk full")

        with pytest.raises(IOError) as exc_info:
            mock_storage.save_report(
                research_id=123,
                content="# Report",
                metadata={},
                username="testuser",
            )

        assert "Disk full" in str(exc_info.value)

    def test_report_generation_path_creation(self):
        """Report generation creates output paths."""
        from pathlib import Path

        output_dir = Path("/tmp/test_research")
        research_dir = output_dir / "research_123"

        # Path should be constructable
        assert str(research_dir) == "/tmp/test_research/research_123"

    def test_report_generation_existing_file_overwrite(self):
        """Report generation overwrites existing files."""
        # New content should replace old content
        old_content = "# Old Report"
        new_content = "# New Report"

        # Simulating overwrite
        content = new_content

        assert content == "# New Report"
        assert content != old_content

    def test_report_generation_unicode_content_handling(self):
        """Report generation handles Unicode content."""
        content = (
            "# Report with Unicode\n\nTest: 日本語, émojis 🎉, symbols ∑∏∫"
        )

        # Content should be preserved
        assert "日本語" in content
        assert "🎉" in content
        assert "∑" in content


class TestNewsSearchIntegration:
    """Tests for news search integration in research."""

    def test_news_search_headline_generation(self):
        """News search generates headlines."""

        # Mock headline generation
        headline = "AI Breakthroughs Reshape Technology Landscape"

        assert headline
        assert len(headline) < 100

    def test_news_search_topic_extraction(self):
        """News search extracts topics."""
        topics = ["Climate Policy", "Renewable Energy", "Global Warming"]

        assert len(topics) > 0
        assert all(isinstance(t, str) for t in topics)

    def test_news_search_subscription_updates(self):
        """News search updates subscription status."""
        subscription_id = "sub_123"
        metadata = {"subscription_id": subscription_id}

        # Should have subscription_id in metadata
        assert "subscription_id" in metadata
        assert metadata["subscription_id"] == "sub_123"

    def test_news_search_empty_results_handling(self):
        """News search handles empty results."""
        results = {
            "findings": [],
            "formatted_findings": "",
            "iterations": 0,
        }

        # Empty results should be detected
        has_results = bool(results.get("findings"))
        assert not has_results

    def test_news_search_llm_failure_graceful_degradation(self):
        """News search degrades gracefully on LLM failure."""
        # When LLM fails, headline generation should be skipped
        try:
            raise Exception("LLM unavailable")
        except Exception:
            headline = None

        # Headline should be None, not crash
        assert headline is None

    def test_news_search_rate_limiting_integration(self):
        """News search respects rate limits."""
        rate_limits = {
            "google_news": {"max_requests": 10, "period": 60},
        }

        # Rate limit config should be accessible
        assert "google_news" in rate_limits
        assert rate_limits["google_news"]["max_requests"] == 10

    def test_news_search_cache_integration(self):
        """News search uses cache."""
        cached_results = {
            "query": "test news",
            "results": ["result1", "result2"],
            "cached_at": "2024-01-01T00:00:00Z",
        }

        # Cache should have expected structure
        assert "results" in cached_results
        assert "cached_at" in cached_results

    def test_news_search_metadata_storage(self):
        """News search stores metadata correctly."""
        metadata = {
            "is_news_search": True,
            "search_type": "news_analysis",
            "category": "Technology",
        }

        assert metadata.get("is_news_search") is True
        assert metadata.get("search_type") == "news_analysis"


class TestCitationFormatting:
    """Tests for citation formatting in reports."""

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_citation_formatter_domain_id_hyperlinks(self, mock_get_setting):
        """Citation formatter handles domain_id_hyperlinks mode."""
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )
        from local_deep_research.text_optimization import CitationMode

        mock_get_setting.return_value = "domain_id_hyperlinks"

        formatter = get_citation_formatter()

        assert formatter.mode == CitationMode.DOMAIN_ID_HYPERLINKS

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_citation_formatter_domain_id_always_hyperlinks(
        self, mock_get_setting
    ):
        """Citation formatter handles domain_id_always_hyperlinks mode."""
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )
        from local_deep_research.text_optimization import CitationMode

        mock_get_setting.return_value = "domain_id_always_hyperlinks"

        formatter = get_citation_formatter()

        assert formatter.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS


class TestSourceExtraction:
    """Tests for source extraction from search results."""

    def test_source_extraction_from_findings(self):
        """Sources are extracted from findings correctly."""
        from local_deep_research.utilities.search_utilities import (
            extract_links_from_search_results,
        )

        search_results = [
            {"link": "https://example.com/1", "title": "Result 1"},
            {"link": "https://example.com/2", "title": "Result 2"},
        ]

        links = extract_links_from_search_results(search_results)

        assert len(links) == 2

    def test_source_extraction_empty_results(self):
        """Source extraction handles empty results."""
        from local_deep_research.utilities.search_utilities import (
            extract_links_from_search_results,
        )

        search_results = []

        links = extract_links_from_search_results(search_results)

        assert links == []

    def test_source_extraction_duplicate_links(self):
        """Source extraction handles duplicate links."""
        search_results = [
            {"link": "https://example.com", "title": "Result 1"},
            {"link": "https://example.com", "title": "Result 2"},
        ]

        # Extract unique links
        links = list(
            set(r.get("link") for r in search_results if r.get("link"))
        )

        assert len(links) == 1


class TestExtractSynthesizedAnswer:
    """Pin the contract for _extract_synthesized_answer.

    Regression: quick-mode research used to save the full format_findings
    blob (synthesized + sources + iteration questions + detailed
    findings + ALL SOURCES) into research.report_content because the
    quick-mode save site assigned ``clean_markdown =
    raw_formatted_findings``. format_document_split only knows ##
    Sources headers, so the blob's ## ALL SOURCES / ## DETAILED
    FINDINGS sections survived into the chat view (visible in UI
    test screenshots after page reload).
    """

    def test_prefers_final_synthesis_finding(self):
        from local_deep_research.web.services.research_service import (
            _extract_synthesized_answer,
        )

        results = {
            "findings": [
                {"phase": "search", "content": "raw search hits"},
                {"phase": "Final synthesis", "content": "the answer"},
            ],
            "current_knowledge": "wider context",
        }
        assert _extract_synthesized_answer(results) == "the answer"

    def test_falls_back_to_current_knowledge(self):
        from local_deep_research.web.services.research_service import (
            _extract_synthesized_answer,
        )

        # No Final synthesis finding (e.g. standard_strategy returns
        # current_knowledge as the synthesized content)
        results = {
            "findings": [{"phase": "search", "content": "raw"}],
            "current_knowledge": "the answer",
        }
        assert _extract_synthesized_answer(results) == "the answer"

    def test_returns_empty_when_nothing_available(self):
        from local_deep_research.web.services.research_service import (
            _extract_synthesized_answer,
        )

        assert _extract_synthesized_answer({}) == ""
        assert _extract_synthesized_answer({"findings": []}) == ""
        assert (
            _extract_synthesized_answer(
                {"findings": [{"phase": "Final synthesis", "content": ""}]}
            )
            == ""
        )

    def test_does_not_return_format_findings_blob(self):
        """The whole point: never return the format_findings blob."""
        from local_deep_research.web.services.research_service import (
            _extract_synthesized_answer,
        )

        # Simulate the kind of blob format_findings produces — a
        # synthesized answer followed by [N] (source nr: N) URL lines
        # and the ## ALL SOURCES section. The function should ignore
        # this if a clean Final synthesis content is also present.
        formatted_blob = (
            "Photosynthesis is the process by which plants...\n\n"
            "[1] A History of Plant Sci [Q1 *****]\n"
            "   URL: https://example.com/1\n\n"
            "## ALL SOURCES:\n"
            "[1] A History of Plant Sci\n"
            "   URL: https://example.com/1\n\n"
        )
        results = {
            "findings": [
                {
                    "phase": "Final synthesis",
                    "content": (
                        "Photosynthesis is the process by which plants..."
                    ),
                }
            ],
            "formatted_findings": formatted_blob,
            "current_knowledge": (
                "Photosynthesis is the process by which plants..."
            ),
        }
        out = _extract_synthesized_answer(results)
        assert "## ALL SOURCES" not in out
        assert "URL: https://example.com" not in out
        assert "[1] A History of Plant Sci" not in out
        assert out == "Photosynthesis is the process by which plants..."
