"""Tests for FindingsRepository — truncation, error detection, untested methods, formatting fallback."""

from unittest.mock import MagicMock, patch

import pytest
from langchain_core.documents import Document

from local_deep_research.advanced_search_system.findings.repository import (
    FindingsRepository,
    format_links,
)


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


def _make_repo():
    """Create a FindingsRepository with a mock LLM."""
    model = MagicMock()
    return FindingsRepository(model=model)


# ===========================================================================
# 1. format_links utility
# ===========================================================================


class TestFormatLinks:
    """Verify the format_links helper."""

    def test_single_link(self):
        """Single link formatted with index."""
        result = format_links(
            [{"title": "Example", "url": "https://example.com"}]
        )
        assert "1. Example" in result
        assert "https://example.com" in result

    def test_multiple_links(self):
        """Multiple links numbered sequentially."""
        links = [
            {"title": "A", "url": "https://a.com"},
            {"title": "B", "url": "https://b.com"},
        ]
        result = format_links(links)
        assert "1. A" in result
        assert "2. B" in result

    def test_empty_links(self):
        """Empty list yields empty string."""
        assert format_links([]) == ""


# ===========================================================================
# 2. add_documents() — entirely untested method
# ===========================================================================


class TestAddDocuments:
    """Verify add_documents extends the document list."""

    def test_add_documents_extends_list(self):
        """Documents are appended to the internal list."""
        repo = _make_repo()
        docs = [
            Document(page_content="Doc 1"),
            Document(page_content="Doc 2"),
        ]
        repo.add_documents(docs)

        assert len(repo.documents) == 2
        assert repo.documents[0].page_content == "Doc 1"

    def test_add_documents_accumulates(self):
        """Multiple calls accumulate documents."""
        repo = _make_repo()
        repo.add_documents([Document(page_content="A")])
        repo.add_documents(
            [Document(page_content="B"), Document(page_content="C")]
        )

        assert len(repo.documents) == 3

    def test_add_empty_documents(self):
        """Adding empty list is harmless."""
        repo = _make_repo()
        repo.add_documents([])
        assert len(repo.documents) == 0


# ===========================================================================
# 3. set_questions_by_iteration() — entirely untested method
# ===========================================================================


class TestSetQuestionsByIteration:
    """Verify set_questions_by_iteration stores a copy."""

    def test_stores_questions(self):
        """Questions dict is stored by iteration number."""
        repo = _make_repo()
        questions = {1: ["Q1", "Q2"], 2: ["Q3"]}
        repo.set_questions_by_iteration(questions)

        assert repo.questions_by_iteration == {1: ["Q1", "Q2"], 2: ["Q3"]}

    def test_stores_copy_not_reference(self):
        """Modification of original dict does not affect stored copy."""
        repo = _make_repo()
        questions = {1: ["Q1"]}
        repo.set_questions_by_iteration(questions)

        questions[2] = ["Q2"]
        assert 2 not in repo.questions_by_iteration

    def test_overwrites_previous(self):
        """Calling again overwrites previous questions."""
        repo = _make_repo()
        repo.set_questions_by_iteration({1: ["old"]})
        repo.set_questions_by_iteration({1: ["new"]})

        assert repo.questions_by_iteration[1] == ["new"]


# ===========================================================================
# 4. format_findings_to_text — exception handler
# ===========================================================================


class TestFormatFindingsToText:
    """Verify format_findings_to_text and its exception fallback."""

    @patch(
        "local_deep_research.advanced_search_system.findings.repository.format_findings"
    )
    def test_successful_formatting(self, mock_format):
        """Successful formatting returns the formatted report."""
        mock_format.return_value = "Formatted report"
        repo = _make_repo()
        repo.questions_by_iteration = {1: ["Q1"]}

        result = repo.format_findings_to_text(
            [{"phase": "test", "content": "data"}],
            "synthesized content",
        )
        assert result == "Formatted report"
        mock_format.assert_called_once()

    @patch(
        "local_deep_research.advanced_search_system.findings.repository.format_findings"
    )
    def test_exception_returns_fallback_message(self, mock_format):
        """When format_findings raises, fallback error message is returned."""
        mock_format.side_effect = RuntimeError("formatting broke")
        repo = _make_repo()

        result = repo.format_findings_to_text([], "raw synthesis")

        assert "Error during final formatting" in result
        assert "raw synthesis" in result


# ===========================================================================
# 5. Knowledge truncation in synthesize_findings
# ===========================================================================


class TestKnowledgeTruncation:
    """Verify truncation logic when knowledge exceeds limits."""

    def test_no_truncation_under_limit(self):
        """Content under 24000 chars is not truncated."""
        repo = _make_repo()
        content = "x" * 20000
        repo.model.invoke.return_value = MagicMock(content="synthesized")

        result = repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": content}],
        )
        # Should succeed without truncation marker
        assert "content truncated" not in result

    def test_truncation_over_24000_chars(self):
        """Content over 24000 chars triggers truncation (also needs estimated_tokens > 12000)."""
        repo = _make_repo()
        # Need > 48000 chars so estimated_tokens (len/4) > max_safe_tokens (12000)
        # AND len > 24000 for the actual truncation
        content = "A" * 25000 + "B" * 25000  # 50000 chars total

        # Capture the prompt passed to model.invoke
        captured_prompts = []

        def capture_invoke(prompt):
            captured_prompts.append(prompt)
            return MagicMock(content="result")

        repo.model.invoke.side_effect = capture_invoke

        repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": content}],
        )

        # The prompt should contain the truncation marker
        assert len(captured_prompts) == 1
        assert "content truncated due to length" in captured_prompts[0]

    def test_exactly_24000_chars_no_truncation(self):
        """At 24000 chars estimated_tokens=6000 < max_safe_tokens=12000, so no truncation."""
        repo = _make_repo()
        content = "x" * 24000

        captured_prompts = []

        def capture_invoke(prompt):
            captured_prompts.append(prompt)
            return MagicMock(content="result")

        repo.model.invoke.side_effect = capture_invoke

        repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": content}],
        )

        assert len(captured_prompts) == 1
        assert "content truncated" not in captured_prompts[0]

    def test_truncation_preserves_start_and_end(self):
        """Truncated content keeps first 12000 and last 12000 chars."""
        repo = _make_repo()
        # Need > 48000 chars for estimated_tokens > max_safe_tokens AND > 24000 for truncation
        content = "START" + "x" * 50000 + "END__"  # well over both thresholds

        captured_prompts = []

        def capture_invoke(prompt):
            captured_prompts.append(prompt)
            return MagicMock(content="result")

        repo.model.invoke.side_effect = capture_invoke

        repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": content}],
        )

        prompt = captured_prompts[0]
        assert "START" in prompt  # beginning preserved
        assert "END__" in prompt  # ending preserved


# ===========================================================================
# 6. LLM exception type detection (lines 430-466)
# ===========================================================================


class TestLLMExceptionTypeDetection:
    """Verify error classification in synthesize_findings exception handler."""

    @pytest.mark.parametrize(
        "error_msg, expected_fragment",
        [
            ("Request timed out after 30s", "LLM timeout"),
            ("too many tokens in request", "token limit"),
            ("context length exceeded", "token limit"),
            ("rate limit exceeded", "rate limit"),
            ("rate_limit_error", "rate limit"),
            ("connection refused", "connection issues"),
            ("network error occurred", "connection issues"),
            ("invalid api key provided", "authentication"),
            ("authentication failed", "authentication"),
        ],
    )
    def test_error_type_detection(self, error_msg, expected_fragment):
        """Each error message pattern maps to the correct user-facing message."""
        repo = _make_repo()
        repo.model.invoke.side_effect = Exception(error_msg)

        result = repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": "data"}],
        )

        assert expected_fragment in result

    def test_unknown_error_includes_details(self):
        """Unknown error type includes the actual error message."""
        repo = _make_repo()
        repo.model.invoke.side_effect = Exception(
            "something completely unexpected"
        )

        result = repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": "data"}],
        )

        assert "something completely unexpected" in result
        assert "LLM error:" in result


# ===========================================================================
# 7. old_formatting=True path
# ===========================================================================


class TestOldFormattingPath:
    """Verify the old_formatting=True branch in synthesize_findings."""

    @patch(
        "local_deep_research.advanced_search_system.findings.repository.format_findings"
    )
    def test_old_formatting_converts_strings(self, mock_format):
        """String findings are converted to dicts with phase labels."""
        mock_format.return_value = "old-formatted"
        repo = _make_repo()
        repo.questions_by_iteration = {1: ["Q1"]}

        result = repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=["finding one", "finding two"],
            old_formatting=True,
        )

        assert result == "old-formatted"
        call_args = mock_format.call_args
        findings_list = (
            call_args.kwargs.get("findings_list")
            or call_args[1].get("findings_list")
            or call_args[0][0]
        )
        assert findings_list[0]["phase"] == "Finding 1"
        assert findings_list[1]["phase"] == "Finding 2"

    @patch(
        "local_deep_research.advanced_search_system.findings.repository.format_findings"
    )
    def test_old_formatting_preserves_dicts(self, mock_format):
        """Dict findings are passed through as-is."""
        mock_format.return_value = "old-formatted"
        repo = _make_repo()

        repo.synthesize_findings(
            query="test",
            sub_queries=[],
            findings=[{"phase": "Analysis", "content": "data"}],
            old_formatting=True,
        )

        call_args = mock_format.call_args
        findings_list = call_args.kwargs.get("findings_list") or call_args[0][0]
        assert findings_list[0]["phase"] == "Analysis"


# ===========================================================================
# 8. accumulated_knowledge handling
# ===========================================================================


class TestAccumulatedKnowledge:
    """Verify accumulated_knowledge parameter behavior."""

    def test_none_accumulated_joins_findings(self):
        """When accumulated_knowledge is None, findings are joined."""
        repo = _make_repo()

        captured_prompts = []

        def capture_invoke(prompt):
            captured_prompts.append(prompt)
            return MagicMock(content="result")

        repo.model.invoke.side_effect = capture_invoke

        repo.synthesize_findings(
            query="test",
            sub_queries=["sq1"],
            findings=[{"content": "part1"}, {"content": "part2"}],
            accumulated_knowledge=None,
        )

        # Both parts should appear in the prompt
        prompt = captured_prompts[0]
        assert "part1" in prompt
        assert "part2" in prompt

    def test_response_with_content_attr(self):
        """Response object with .content attribute is handled."""
        repo = _make_repo()
        repo.model.invoke.return_value = MagicMock(content="synthesized answer")

        result = repo.synthesize_findings(
            query="test",
            sub_queries=[],
            findings=[{"content": "data"}],
        )
        assert result == "synthesized answer"

    def test_response_without_content_attr(self):
        """String response (no .content) is converted via str()."""
        repo = _make_repo()

        class PlainResponse:
            def __str__(self):
                return "string response"

        repo.model.invoke.return_value = PlainResponse()

        result = repo.synthesize_findings(
            query="test",
            sub_queries=[],
            findings=[{"content": "data"}],
        )
        assert "string response" in result


# ===========================================================================
# 9. add_finding edge cases
# ===========================================================================


class TestAddFinding:
    """Verify add_finding behavior with different input types."""

    def test_string_finding_converted_to_dict(self):
        """String finding is wrapped in a standard dict."""
        repo = _make_repo()
        repo.add_finding("query1", "some text")

        findings = repo.get_findings("query1")
        assert len(findings) == 1
        assert findings[0]["content"] == "some text"
        assert findings[0]["phase"] == "Synthesis"

    def test_dict_finding_stored_directly(self):
        """Dict finding is stored as-is."""
        repo = _make_repo()
        finding = {"phase": "Analysis", "content": "data", "extra": "field"}
        repo.add_finding("query1", finding)

        assert repo.get_findings("query1")[0]["extra"] == "field"

    def test_final_synthesis_creates_synthesis_key(self):
        """Finding with 'Final synthesis' phase creates a _synthesis key."""
        repo = _make_repo()
        repo.add_finding(
            "query1",
            {"phase": "Final synthesis", "content": "final answer"},
        )

        synthesis = repo.get_findings("query1_synthesis")
        assert len(synthesis) == 1
        assert synthesis[0]["content"] == "final answer"

    def test_clear_findings(self):
        """clear_findings removes all findings for a query."""
        repo = _make_repo()
        repo.add_finding("q", "data")
        repo.clear_findings("q")

        assert repo.get_findings("q") == []

    def test_clear_nonexistent_query_is_noop(self):
        """Clearing a query that doesn't exist does nothing."""
        repo = _make_repo()
        repo.clear_findings("nonexistent")  # should not raise
