"""
Comprehensive pytest tests for NewsAnalyzer pure logic helpers.

Focuses on edge cases and scenarios not covered by existing test files:
- _validate_news_item: truthy/falsy edge cases, numeric values, type variations
- _count_categories: case sensitivity, special characters, None category value
- _summarize_impact: float scores, extreme values, mixed missing/present scores
- _prepare_snippets: snippet vs content precedence, multiple results ordering,
  edge-length strings (exactly 200 chars), results with no recognized fields
- _empty_analysis: structural guarantees, timestamp format
- close(): ownership semantics
- analyze_news: exception path returns empty analysis
"""

import json
from datetime import datetime
from unittest.mock import Mock, patch

import pytest

from local_deep_research.news.core.news_analyzer import NewsAnalyzer


# ---------------------------------------------------------------------------
# Fixture: create analyzer without hitting real get_llm
# ---------------------------------------------------------------------------


@pytest.fixture
def analyzer():
    """NewsAnalyzer with a no-op mock LLM (does not own it)."""
    mock_llm = Mock()
    return NewsAnalyzer(llm_client=mock_llm)


@pytest.fixture
def analyzer_no_llm():
    """NewsAnalyzer where llm_client is explicitly None."""
    na = NewsAnalyzer(llm_client=Mock())
    na.llm_client = None
    return na


# ===========================================================================
# _validate_news_item  – edge cases beyond empty/missing
# ===========================================================================


class TestValidateNewsItemEdgeCases:
    """Edge-case validation for _validate_news_item."""

    def test_integer_headline_truthy(self, analyzer):
        """Non-zero int is truthy -> should pass the `and item[field]` check."""
        assert analyzer._validate_news_item({"headline": 42, "summary": "ok"})

    def test_integer_zero_headline_falsy(self, analyzer):
        """0 is falsy -> should fail validation."""
        assert not analyzer._validate_news_item(
            {"headline": 0, "summary": "ok"}
        )

    def test_whitespace_only_headline_passes(self, analyzer):
        """Whitespace-only string is truthy (non-empty)."""
        assert analyzer._validate_news_item(
            {"headline": "   ", "summary": "text"}
        )

    def test_whitespace_only_summary_passes(self, analyzer):
        assert analyzer._validate_news_item({"headline": "h", "summary": "  "})

    def test_list_headline_truthy(self, analyzer):
        """A non-empty list is truthy."""
        assert analyzer._validate_news_item(
            {"headline": ["x"], "summary": "ok"}
        )

    def test_empty_list_headline_falsy(self, analyzer):
        """Empty list is falsy."""
        assert not analyzer._validate_news_item(
            {"headline": [], "summary": "ok"}
        )

    def test_false_boolean_headline(self, analyzer):
        assert not analyzer._validate_news_item(
            {"headline": False, "summary": "ok"}
        )

    def test_true_boolean_headline(self, analyzer):
        assert analyzer._validate_news_item({"headline": True, "summary": "ok"})

    def test_both_none(self, analyzer):
        assert not analyzer._validate_news_item(
            {"headline": None, "summary": None}
        )

    def test_only_extra_fields_no_required(self, analyzer):
        assert not analyzer._validate_news_item(
            {"category": "Tech", "impact_score": 9, "url": "http://x.com"}
        )


# ===========================================================================
# _count_categories  – edge cases
# ===========================================================================


class TestCountCategoriesEdgeCases:
    """Edge cases for _count_categories."""

    def test_none_category_value_treated_as_none_key(self, analyzer):
        """If category key exists but value is None, .get returns None (not 'Other')."""
        items = [{"category": None}]
        result = analyzer._count_categories(items)
        # .get("category", "Other") returns None because key exists
        assert None in result
        assert result[None] == 1

    def test_empty_string_category_is_kept(self, analyzer):
        """Empty-string category is not replaced by 'Other'."""
        items = [{"category": ""}]
        result = analyzer._count_categories(items)
        assert "" in result
        assert result[""] == 1

    def test_case_sensitive_categories(self, analyzer):
        """'Tech' and 'tech' are counted separately."""
        items = [{"category": "Tech"}, {"category": "tech"}]
        result = analyzer._count_categories(items)
        assert result == {"Tech": 1, "tech": 1}

    def test_single_item_many_categories_single_count(self, analyzer):
        items = [{"category": f"cat_{i}"} for i in range(50)]
        result = analyzer._count_categories(items)
        assert len(result) == 50
        assert all(v == 1 for v in result.values())

    def test_special_characters_in_category(self, analyzer):
        items = [
            {"category": "AI & ML"},
            {"category": "AI & ML"},
            {"category": "U.S. Politics"},
        ]
        result = analyzer._count_categories(items)
        assert result["AI & ML"] == 2
        assert result["U.S. Politics"] == 1

    def test_unicode_categories(self, analyzer):
        items = [{"category": "Politique"}, {"category": "Politique"}]
        result = analyzer._count_categories(items)
        assert result["Politique"] == 2


# ===========================================================================
# _summarize_impact  – edge cases
# ===========================================================================


class TestSummarizeImpactEdgeCases:
    """Edge cases for _summarize_impact."""

    def test_empty_returns_only_average_and_high_count(self, analyzer):
        """Empty list returns exactly {average: 0, high_impact_count: 0} with no max/min."""
        result = analyzer._summarize_impact([])
        assert result == {"average": 0, "high_impact_count": 0}
        assert "max" not in result
        assert "min" not in result

    def test_all_default_scores(self, analyzer):
        """Items without impact_score all default to 5."""
        items = [{}, {}, {}]
        result = analyzer._summarize_impact(items)
        assert result["average"] == 5.0
        assert result["max"] == 5
        assert result["min"] == 5
        assert result["high_impact_count"] == 0

    def test_float_impact_scores(self, analyzer):
        """Float scores are handled (sum/len works on floats)."""
        items = [{"impact_score": 7.5}, {"impact_score": 8.5}]
        result = analyzer._summarize_impact(items)
        assert result["average"] == 8.0
        assert result["max"] == 8.5
        assert result["min"] == 7.5
        # 8.5 >= 8 -> 1 high impact
        assert result["high_impact_count"] == 1

    def test_boundary_score_exactly_8(self, analyzer):
        """Score of exactly 8 counts as high impact."""
        items = [{"impact_score": 8}]
        result = analyzer._summarize_impact(items)
        assert result["high_impact_count"] == 1

    def test_boundary_score_just_below_8(self, analyzer):
        """Score of 7.99 does not count as high impact."""
        items = [{"impact_score": 7.99}]
        result = analyzer._summarize_impact(items)
        assert result["high_impact_count"] == 0

    def test_very_large_score(self, analyzer):
        items = [{"impact_score": 1000}]
        result = analyzer._summarize_impact(items)
        assert result["max"] == 1000
        assert result["high_impact_count"] == 1

    def test_negative_score(self, analyzer):
        items = [{"impact_score": -3}]
        result = analyzer._summarize_impact(items)
        assert result["average"] == -3
        assert result["min"] == -3
        assert result["high_impact_count"] == 0

    def test_zero_score(self, analyzer):
        items = [{"impact_score": 0}]
        result = analyzer._summarize_impact(items)
        assert result["average"] == 0
        assert result["high_impact_count"] == 0

    def test_mixed_present_and_missing_scores(self, analyzer):
        """Mix of items with and without impact_score."""
        items = [
            {"impact_score": 10},
            {},  # defaults to 5
            {"impact_score": 3},
        ]
        result = analyzer._summarize_impact(items)
        assert result["average"] == 6.0  # (10+5+3)/3
        assert result["max"] == 10
        assert result["min"] == 3
        assert result["high_impact_count"] == 1  # only 10

    def test_single_high_impact(self, analyzer):
        items = [{"impact_score": 9}]
        result = analyzer._summarize_impact(items)
        assert result["average"] == 9
        assert result["high_impact_count"] == 1
        assert result["max"] == 9
        assert result["min"] == 9


# ===========================================================================
# _prepare_snippets  – edge cases
# ===========================================================================


class TestPrepareSnippetsEdgeCases:
    """Edge cases for _prepare_snippets."""

    def test_empty_list_returns_empty_string(self, analyzer):
        assert analyzer._prepare_snippets([]) == ""

    def test_snippet_exactly_200_chars(self, analyzer):
        """Snippet of exactly 200 chars is included in full (plus '...')."""
        text = "A" * 200
        result = analyzer._prepare_snippets([{"snippet": text}])
        assert "A" * 200 + "..." in result

    def test_snippet_201_chars_truncated(self, analyzer):
        text = "B" * 201
        result = analyzer._prepare_snippets([{"snippet": text}])
        assert "B" * 200 in result
        assert "B" * 201 not in result

    def test_content_exactly_200_chars(self, analyzer):
        text = "C" * 200
        result = analyzer._prepare_snippets([{"content": text}])
        assert "C" * 200 + "..." in result

    def test_snippet_takes_precedence_over_content(self, analyzer):
        """When both snippet and content exist, snippet is used."""
        result = analyzer._prepare_snippets(
            [{"snippet": "SNIPPET_TEXT", "content": "CONTENT_TEXT"}]
        )
        assert "SNIPPET_TEXT" in result
        assert "CONTENT_TEXT" not in result

    def test_content_used_when_snippet_missing(self, analyzer):
        result = analyzer._prepare_snippets([{"content": "CONTENT_ONLY"}])
        assert "Content: CONTENT_ONLY" in result

    def test_content_used_when_snippet_empty_string(self, analyzer):
        """Empty-string snippet is falsy, so content should be used."""
        result = analyzer._prepare_snippets(
            [{"snippet": "", "content": "FALLBACK"}]
        )
        assert "Content: FALLBACK" in result
        assert "Snippet:" not in result

    def test_no_recognized_fields(self, analyzer):
        """Result with no title/url/snippet/content produces just the number prefix."""
        result = analyzer._prepare_snippets([{"random_key": "value"}])
        assert result.strip() == "[1]"

    def test_multiple_results_numbered_sequentially(self, analyzer):
        results = [{"title": f"T{i}"} for i in range(5)]
        output = analyzer._prepare_snippets(results)
        for i in range(1, 6):
            assert f"[{i}]" in output

    def test_title_url_snippet_all_present(self, analyzer):
        result = analyzer._prepare_snippets(
            [{"title": "Headline", "url": "http://x.com", "snippet": "Blurb"}]
        )
        assert "Title: Headline" in result
        assert "URL: http://x.com" in result
        assert "Snippet: Blurb" in result

    def test_newlines_separate_results(self, analyzer):
        results = [{"title": "A"}, {"title": "B"}]
        output = analyzer._prepare_snippets(results)
        # There should be at least a blank line between entries due to
        # internal newlines in each snippet block joined by \n
        assert len(output.split("\n\n")) >= 2
        assert "[1]" in output
        assert "[2]" in output

    def test_empty_title_skipped(self, analyzer):
        """Empty-string title is falsy, so Title: line should not appear."""
        result = analyzer._prepare_snippets([{"title": ""}])
        assert "Title:" not in result

    def test_empty_url_skipped(self, analyzer):
        result = analyzer._prepare_snippets([{"url": ""}])
        assert "URL:" not in result


# ===========================================================================
# _empty_analysis  – structural guarantees
# ===========================================================================


class TestEmptyAnalysisStructure:
    """Structural tests for _empty_analysis."""

    def test_returns_dict(self, analyzer):
        result = analyzer._empty_analysis()
        assert isinstance(result, dict)

    def test_all_expected_keys(self, analyzer):
        result = analyzer._empty_analysis()
        expected = {
            "items",
            "item_count",
            "big_picture",
            "watch_for",
            "patterns",
            "topics",
            "categories",
            "impact_summary",
            "timestamp",
        }
        assert set(result.keys()) == expected

    def test_items_is_list(self, analyzer):
        assert isinstance(analyzer._empty_analysis()["items"], list)

    def test_watch_for_is_list(self, analyzer):
        assert isinstance(analyzer._empty_analysis()["watch_for"], list)

    def test_topics_is_list(self, analyzer):
        assert isinstance(analyzer._empty_analysis()["topics"], list)

    def test_categories_is_dict(self, analyzer):
        assert isinstance(analyzer._empty_analysis()["categories"], dict)

    def test_impact_summary_is_dict(self, analyzer):
        assert isinstance(analyzer._empty_analysis()["impact_summary"], dict)

    def test_timestamp_is_valid_iso(self, analyzer):
        ts = analyzer._empty_analysis()["timestamp"]
        parsed = datetime.fromisoformat(ts)
        assert parsed.tzinfo is not None  # should be timezone-aware

    def test_impact_summary_keys(self, analyzer):
        summary = analyzer._empty_analysis()["impact_summary"]
        assert "average" in summary
        assert "high_impact_count" in summary

    def test_two_calls_produce_different_timestamps(self, analyzer):
        """Timestamps should reflect the call time (not cached)."""
        t1 = analyzer._empty_analysis()["timestamp"]
        t2 = analyzer._empty_analysis()["timestamp"]
        # They may be equal if called within the same microsecond;
        # just verify both parse correctly
        datetime.fromisoformat(t1)
        datetime.fromisoformat(t2)


# ===========================================================================
# close()  – ownership semantics
# ===========================================================================


class TestClose:
    """Tests for close() ownership logic."""

    def test_close_does_not_close_external_llm(self):
        """When llm_client is provided externally, close() should NOT close it."""
        mock_llm = Mock()
        na = NewsAnalyzer(llm_client=mock_llm)
        assert na._owns_llm is False
        na.close()
        mock_llm.close.assert_not_called()

    def test_close_closes_owned_llm(self):
        """When no llm_client is provided, NewsAnalyzer creates one and owns it."""
        with patch(
            "local_deep_research.news.core.news_analyzer.get_llm"
        ) as mock_get:
            mock_llm = Mock()
            mock_get.return_value = mock_llm
            na = NewsAnalyzer()
            assert na._owns_llm is True
            na.close()
            mock_llm.close.assert_called_once()


# ===========================================================================
# analyze_news  – exception handling path
# ===========================================================================


class TestAnalyzeNewsExceptionPath:
    """Tests that analyze_news returns _empty_analysis on internal errors."""

    def test_exception_in_extract_returns_empty(self):
        """If extract_news_items raises, analyze_news catches and returns empty."""
        mock_llm = Mock()
        na = NewsAnalyzer(llm_client=mock_llm)
        with patch.object(
            na, "extract_news_items", side_effect=RuntimeError("boom")
        ):
            result = na.analyze_news([{"content": "data"}])
        assert result["items"] == []
        assert result["item_count"] == 0

    def test_empty_search_results_skips_llm(self):
        """Empty input should return empty analysis without invoking LLM."""
        mock_llm = Mock()
        na = NewsAnalyzer(llm_client=mock_llm)
        result = na.analyze_news([])
        mock_llm.invoke.assert_not_called()
        assert result["item_count"] == 0


# ===========================================================================
# extract_news_items  – ID generation and filtering
# ===========================================================================


class TestExtractNewsItemsIDsAndFiltering:
    """Tests for extract_news_items ID assignment and item filtering."""

    def test_each_item_gets_unique_id(self):
        mock_llm = Mock()
        items = [
            {"headline": f"News {i}", "summary": f"Summary {i}"}
            for i in range(3)
        ]
        mock_llm.invoke.return_value = Mock(content=json.dumps(items))
        na = NewsAnalyzer(llm_client=mock_llm)
        result = na.extract_news_items([{"title": "source"}])
        ids = [item["id"] for item in result]
        assert len(ids) == len(set(ids)), "All IDs should be unique"

    def test_invalid_items_filtered_out(self):
        mock_llm = Mock()
        items = [
            {"headline": "Valid", "summary": "Yes"},
            {"headline": "", "summary": "No headline"},
            {"summary": "Missing headline entirely"},
            {"headline": "Also valid", "summary": "Content"},
        ]
        mock_llm.invoke.return_value = Mock(content=json.dumps(items))
        na = NewsAnalyzer(llm_client=mock_llm)
        result = na.extract_news_items([{"title": "source"}])
        assert len(result) == 2
        headlines = {r["headline"] for r in result}
        assert "Valid" in headlines
        assert "Also valid" in headlines

    def test_max_items_caps_output(self):
        mock_llm = Mock()
        items = [{"headline": f"H{i}", "summary": f"S{i}"} for i in range(20)]
        mock_llm.invoke.return_value = Mock(content=json.dumps(items))
        na = NewsAnalyzer(llm_client=mock_llm)
        result = na.extract_news_items([{"title": "source"}], max_items=3)
        assert len(result) <= 3


# ===========================================================================
# _validate_news_item + _count_categories + _summarize_impact combined
# ===========================================================================


class TestHelperInteraction:
    """Test helpers work together on realistic data."""

    def test_realistic_news_items(self, analyzer):
        items = [
            {
                "headline": "AI Model Surpasses Human Benchmark",
                "summary": "A new AI model has exceeded human performance on key tasks.",
                "category": "Technology",
                "impact_score": 9,
            },
            {
                "headline": "Markets Rally on Rate Cut Hopes",
                "summary": "Global stock markets surged amid expectations of rate cuts.",
                "category": "Finance",
                "impact_score": 7,
            },
            {
                "headline": "Climate Summit Reaches Agreement",
                "summary": "World leaders agreed on new carbon reduction targets.",
                "category": "Environment",
                "impact_score": 8,
            },
            {
                "headline": "Tech Giant Faces Antitrust Probe",
                "summary": "Regulators launched a new investigation into market dominance.",
                "category": "Technology",
                "impact_score": 6,
            },
        ]

        # All items should be valid
        for item in items:
            assert analyzer._validate_news_item(item)

        # Category counts
        cats = analyzer._count_categories(items)
        assert cats == {"Technology": 2, "Finance": 1, "Environment": 1}

        # Impact summary
        impact = analyzer._summarize_impact(items)
        assert impact["average"] == 7.5
        assert impact["high_impact_count"] == 2  # scores 9 and 8
        assert impact["max"] == 9
        assert impact["min"] == 6

    def test_all_invalid_items(self, analyzer):
        items = [
            {"headline": "", "summary": ""},
            {"category": "Tech"},
            {},
        ]
        for item in items:
            assert not analyzer._validate_news_item(item)

    def test_count_categories_with_all_defaults(self, analyzer):
        """Items with no category key all map to 'Other'."""
        items = [{"headline": "A"}, {"headline": "B"}, {"headline": "C"}]
        result = analyzer._count_categories(items)
        assert result == {"Other": 3}

    def test_summarize_impact_all_defaults(self, analyzer):
        """Items with no impact_score all default to 5."""
        items = [{"headline": "A"}, {"headline": "B"}]
        result = analyzer._summarize_impact(items)
        assert result["average"] == 5.0
        assert result["max"] == 5
        assert result["min"] == 5
        assert result["high_impact_count"] == 0
