"""
Comprehensive coverage tests for topic_generator.py.

Focuses on areas with insufficient coverage in existing test files:
- _generate_with_llm prompt construction and truncation logic
- _generate_with_llm response cleaning pipeline (non-string filtering, length cap)
- _generate_with_llm comma-separated text fallback path
- _generate_with_llm close_llm always called (finally block)
- generate_topics orchestration with real _validate_topics (no double-mock)
- _validate_topics boundary and ordering edge cases
"""

from unittest.mock import Mock, patch


from local_deep_research.news.utils.topic_generator import (
    _generate_with_llm,
    _validate_topics,
    generate_topics,
)


# ---------------------------------------------------------------------------
# _generate_with_llm – prompt construction
# ---------------------------------------------------------------------------


class TestGenerateWithLLMPromptConstruction:
    """Verify the prompt fed to the LLM is built correctly."""

    def _invoke_and_capture_prompt(
        self, query="q", findings="", category="", max_topics=5
    ):
        """Helper: call _generate_with_llm with a mock LLM and return the prompt string."""
        mock_llm = Mock()
        mock_llm.invoke.return_value = Mock(content='["tag"]')

        with patch(
            "local_deep_research.config.llm_config.get_llm",
            return_value=mock_llm,
        ):
            _generate_with_llm(query, findings, category, max_topics)

        prompt = mock_llm.invoke.call_args[0][0]
        return prompt

    def test_prompt_contains_query(self):
        prompt = self._invoke_and_capture_prompt(query="climate crisis")
        assert "climate crisis" in prompt

    def test_query_truncated_at_500(self):
        long_query = "x" * 600
        prompt = self._invoke_and_capture_prompt(query=long_query)
        assert "x" * 500 in prompt
        assert "x" * 501 not in prompt

    def test_query_not_truncated_when_short(self):
        prompt = self._invoke_and_capture_prompt(query="short")
        assert "short" in prompt

    def test_findings_included_when_present(self):
        prompt = self._invoke_and_capture_prompt(findings="some findings text")
        assert "some findings text" in prompt

    def test_findings_truncated_at_1000(self):
        long_findings = "f" * 1500
        prompt = self._invoke_and_capture_prompt(findings=long_findings)
        assert "f" * 1000 in prompt
        assert "f" * 1001 not in prompt

    def test_findings_omitted_when_empty(self):
        prompt = self._invoke_and_capture_prompt(findings="")
        assert "Content:" not in prompt

    def test_category_included_when_present(self):
        prompt = self._invoke_and_capture_prompt(category="Technology")
        assert "Category: Technology" in prompt

    def test_category_omitted_when_empty(self):
        prompt = self._invoke_and_capture_prompt(category="")
        assert "Category:" not in prompt

    def test_max_topics_in_prompt(self):
        prompt = self._invoke_and_capture_prompt(max_topics=7)
        assert "7" in prompt


# ---------------------------------------------------------------------------
# _generate_with_llm – response cleaning
# ---------------------------------------------------------------------------


class TestGenerateWithLLMResponseCleaning:
    """Verify the cleaning pipeline inside _generate_with_llm."""

    def _run_with_llm_content(self, content, max_topics=5):
        """Helper: mock LLM returning `content` and return the result list."""
        mock_llm = Mock()
        mock_llm.invoke.return_value = Mock(content=content)

        with patch(
            "local_deep_research.config.llm_config.get_llm",
            return_value=mock_llm,
        ):
            return _generate_with_llm("q", "f", "", max_topics)

    def test_valid_json_array_parsed(self):
        result = self._run_with_llm_content('["AI", "Climate"]')
        assert result == ["AI", "Climate"]

    def test_non_string_items_filtered(self):
        """Items that are not strings should be removed."""
        result = self._run_with_llm_content('[123, "Valid", null, true]')
        assert result == ["Valid"]

    def test_empty_string_items_filtered(self):
        result = self._run_with_llm_content('["", "Valid", "  "]')
        # empty and whitespace-only are filtered (strip then falsy check)
        assert result == ["Valid"]

    def test_items_over_30_chars_filtered(self):
        long = "a" * 31
        result = self._run_with_llm_content(f'["{long}", "short"]')
        assert result == ["short"]

    def test_exactly_30_char_item_kept(self):
        item = "a" * 30
        result = self._run_with_llm_content(f'["{item}"]')
        assert result == [item]

    def test_max_topics_limits_json_result(self):
        result = self._run_with_llm_content(
            '["a1", "b2", "c3", "d4", "e5"]', max_topics=2
        )
        assert len(result) == 2
        assert result == ["a1", "b2"]

    def test_items_are_stripped(self):
        result = self._run_with_llm_content('[" padded "]')
        assert result == ["padded"]


# ---------------------------------------------------------------------------
# _generate_with_llm – comma-separated fallback
# ---------------------------------------------------------------------------


class TestGenerateWithLLMCommaFallback:
    """When extract_json returns None but content has commas, split on comma."""

    def _run_with_non_json(self, content, max_topics=5):
        mock_llm = Mock()
        mock_llm.invoke.return_value = Mock(content=content)

        with (
            patch(
                "local_deep_research.config.llm_config.get_llm",
                return_value=mock_llm,
            ),
            patch(
                "local_deep_research.news.utils.topic_generator.extract_json",
                return_value=None,
            ),
        ):
            return _generate_with_llm("q", "f", "", max_topics)

    def test_comma_separated_parsed(self):
        result = self._run_with_non_json("AI, Climate, Economy")
        assert "AI" in result
        assert "Climate" in result
        assert "Economy" in result

    def test_quotes_stripped_from_comma_items(self):
        result = self._run_with_non_json('"AI", "Climate"')
        assert "AI" in result
        assert "Climate" in result

    def test_long_items_filtered_in_comma_path(self):
        long = "z" * 31
        result = self._run_with_non_json(f"valid, {long}")
        assert "valid" in result
        assert long not in result

    def test_empty_items_filtered_in_comma_path(self):
        result = self._run_with_non_json("AI, , , Climate")
        assert "" not in result
        assert len(result) == 2

    def test_max_topics_applied_in_comma_path(self):
        result = self._run_with_non_json("aa, bb, cc, dd, ee", max_topics=2)
        assert len(result) == 2

    def test_no_comma_returns_empty_list(self):
        """If content has no comma and JSON parsing failed, function returns []."""
        mock_llm = Mock()
        mock_llm.invoke.return_value = Mock(content="just plain text")

        with (
            patch(
                "local_deep_research.config.llm_config.get_llm",
                return_value=mock_llm,
            ),
            patch(
                "local_deep_research.news.utils.topic_generator.extract_json",
                return_value=None,
            ),
        ):
            result = _generate_with_llm("q", "f", "", 5)

        # Function falls through the try block without explicit return,
        # then the outer except catches the implicit None and returns []
        assert result == []


# ---------------------------------------------------------------------------
# _generate_with_llm – error handling and resource cleanup
# ---------------------------------------------------------------------------


class TestGenerateWithLLMErrorHandling:
    """Verify error handling and LLM cleanup."""

    def test_returns_empty_list_on_get_llm_failure(self):
        with patch(
            "local_deep_research.config.llm_config.get_llm",
            side_effect=Exception("no LLM"),
        ):
            result = _generate_with_llm("q", "f", "", 5)
        assert result == []

    def test_returns_empty_list_on_invoke_failure(self):
        mock_llm = Mock()
        mock_llm.invoke.side_effect = RuntimeError("invoke boom")

        with patch(
            "local_deep_research.config.llm_config.get_llm",
            return_value=mock_llm,
        ):
            result = _generate_with_llm("q", "f", "", 5)

        assert result == []
        # close should still be called (finally block)
        mock_llm.close.assert_called_once()

    def test_close_called_on_success(self):
        mock_llm = Mock()
        mock_llm.invoke.return_value = Mock(content='["tag"]')

        with patch(
            "local_deep_research.config.llm_config.get_llm",
            return_value=mock_llm,
        ):
            _generate_with_llm("q", "f", "", 5)

        mock_llm.close.assert_called_once()


# ---------------------------------------------------------------------------
# generate_topics – orchestration (uses real _validate_topics)
# ---------------------------------------------------------------------------


class TestGenerateTopicsOrchestration:
    """Test generate_topics with real _validate_topics (only mock _generate_with_llm)."""

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_llm_topics_validated_and_lowercased(self, mock_llm):
        mock_llm.return_value = ["AI", "Climate Change", "AI"]
        result = generate_topics("query")
        # Duplicates removed, lowercased
        assert result == ["ai", "climate change"]

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_llm_empty_gives_failure_marker(self, mock_llm):
        mock_llm.return_value = []
        result = generate_topics("query")
        # "[Topic generation failed]" goes through _validate_topics
        # It is 25 chars, >= 2, so it passes through as lowercase
        assert result == ["[topic generation failed]"]

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_llm_returns_all_invalid_gives_no_valid(self, mock_llm):
        mock_llm.return_value = ["a", ""]  # all too short or empty
        result = generate_topics("query")
        assert result == ["[No valid topics]"]

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_max_topics_forwarded_to_llm(self, mock_llm):
        mock_llm.return_value = ["aa", "bb", "cc"]
        generate_topics("q", max_topics=7)
        assert mock_llm.call_args[0][3] == 7

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_category_forwarded_to_llm(self, mock_llm):
        mock_llm.return_value = ["tag"]
        generate_topics("q", category="Sports")
        assert mock_llm.call_args[0][2] == "Sports"

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_findings_forwarded_to_llm(self, mock_llm):
        mock_llm.return_value = ["tag"]
        generate_topics("q", findings="some findings")
        assert mock_llm.call_args[0][1] == "some findings"

    @patch("local_deep_research.news.utils.topic_generator._generate_with_llm")
    def test_default_parameters(self, mock_llm):
        mock_llm.return_value = []
        generate_topics("q")
        # (query, findings, category, max_topics)
        args = mock_llm.call_args[0]
        assert args == ("q", "", "", 5)


# ---------------------------------------------------------------------------
# _validate_topics – additional boundary / ordering tests
# ---------------------------------------------------------------------------


class TestValidateTopicsAdditional:
    """Cover edge cases not well-tested elsewhere."""

    def test_whitespace_after_strip_becomes_too_short(self):
        """A topic that is long enough pre-strip but too short after."""
        result = _validate_topics(["  x  "], max_topics=5)
        # "x" is 1 char after strip -> filtered
        assert result == ["[No valid topics]"]

    def test_dedup_happens_after_strip(self):
        """' AI ' and 'AI' should be treated as duplicates after stripping."""
        result = _validate_topics(["  AI  ", "AI"], max_topics=5)
        assert result == ["ai"]

    def test_max_topics_zero_still_returns_one(self):
        """max_topics=0 is a degenerate case: the >= check means one topic
        gets appended before the break triggers, so we get exactly one."""
        result = _validate_topics(["valid", "topic"], max_topics=0)
        assert result == ["valid"]

    def test_large_number_of_topics(self):
        topics = [f"topic{i:04d}" for i in range(200)]
        result = _validate_topics(topics, max_topics=10)
        assert len(result) == 10
        assert result[0] == "topic0000"

    def test_preserves_internal_whitespace(self):
        """Internal spaces in multi-word topics should be preserved."""
        result = _validate_topics(["climate change"], max_topics=5)
        assert result == ["climate change"]

    def test_tab_and_newline_stripped(self):
        result = _validate_topics(["\tAI\n"], max_topics=5)
        assert result == ["ai"]

    def test_failure_marker_from_llm_passes_through(self):
        """The '[Topic generation failed]' marker should survive validation."""
        result = _validate_topics(["[Topic generation failed]"], max_topics=5)
        assert result == ["[topic generation failed]"]

    def test_special_chars_preserved(self):
        result = _validate_topics(["COVID-19", "AI/ML"], max_topics=5)
        assert "covid-19" in result
        assert "ai/ml" in result

    def test_unicode_topics_preserved(self):
        result = _validate_topics(["klima", "umwelt"], max_topics=5)
        assert result == ["klima", "umwelt"]

    def test_exactly_boundary_lengths(self):
        """2-char kept, 1-char dropped, 30-char kept, 31-char dropped."""
        result = _validate_topics(
            ["ab", "a", "c" * 30, "d" * 31], max_topics=10
        )
        assert "ab" in result
        assert "a" not in result
        assert "c" * 30 in result
        assert "d" * 31 not in result
