"""
Comprehensive coverage tests for RelevanceService.

Focuses on edge cases, boundary conditions, and scoring combinations
not covered by existing test files. Tests the actual service methods
(not reimplementations) to ensure real code coverage.
"""

import pytest
from unittest.mock import Mock

from local_deep_research.news.core.relevance_service import (
    RelevanceService,
    get_relevance_service,
)


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


def _card(
    impact_score=5,
    category="General",
    topics=None,
    interaction=None,
    viewed=False,
    *,
    spec=None,
):
    """Build a mock card. Use spec=[] to strip all attrs, or spec=[...] to whitelist."""
    if spec is not None:
        card = Mock(spec=spec)
        # Manually set only the attrs listed in spec
        for attr in spec:
            if attr == "impact_score":
                setattr(card, attr, impact_score)
            elif attr == "category":
                setattr(card, attr, category)
            elif attr == "topics":
                setattr(card, attr, topics if topics is not None else [])
            elif attr == "interaction":
                setattr(
                    card, attr, interaction if interaction is not None else {}
                )
        return card

    card = Mock()
    card.impact_score = impact_score
    card.category = category
    card.topics = topics if topics is not None else []
    card.interaction = (
        interaction
        if interaction is not None
        else {
            "views": 0,
            "votes_up": 0,
            "votes_down": 0,
        }
    )
    if viewed:
        card.interaction["viewed"] = True
    return card


@pytest.fixture
def svc():
    return RelevanceService()


# ===========================================================================
# calculate_relevance - scoring arithmetic
# ===========================================================================


class TestCalculateRelevanceArithmetic:
    """Verify exact numeric outcomes for all factor combinations."""

    def test_no_prefs_none_returns_impact_div_10(self, svc):
        assert svc.calculate_relevance(
            _card(impact_score=3), None
        ) == pytest.approx(0.3)

    def test_no_prefs_empty_dict_is_falsy(self, svc):
        """Empty dict {} is falsy for `not user_prefs`, so takes the no-prefs path."""
        # Empty dict -> not {} is True -> returns impact_score / 10
        assert svc.calculate_relevance(
            _card(impact_score=5), {}
        ) == pytest.approx(0.5)

    def test_base_score_is_half(self, svc):
        """With prefs but no matching attrs the base is 0.5 modified only by impact."""
        card = _card(impact_score=5, spec=["impact_score"])
        result = svc.calculate_relevance(card, {"impact_threshold": 5})
        assert result == pytest.approx(0.6)  # 0.5 + 0.1

    def test_liked_category_adds_0_2(self, svc):
        card = _card(category="AI", impact_score=5)
        base = svc.calculate_relevance(
            _card(category="Other", impact_score=5),
            {"liked_categories": ["AI"], "impact_threshold": 5},
        )
        boosted = svc.calculate_relevance(
            card,
            {"liked_categories": ["AI"], "impact_threshold": 5},
        )
        assert boosted - base == pytest.approx(0.2)

    def test_disliked_category_subtracts_0_2(self, svc):
        card = _card(category="Spam", impact_score=5)
        base = svc.calculate_relevance(
            _card(category="Other", impact_score=5),
            {"disliked_categories": ["Spam"], "impact_threshold": 5},
        )
        penalised = svc.calculate_relevance(
            card,
            {"disliked_categories": ["Spam"], "impact_threshold": 5},
        )
        assert base - penalised == pytest.approx(0.2)

    def test_impact_above_threshold_adds_0_1(self, svc):
        prefs = {"impact_threshold": 4}
        card = _card(impact_score=5)
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.6)

    def test_impact_below_threshold_subtracts_0_1(self, svc):
        prefs = {"impact_threshold": 6}
        card = _card(impact_score=5)
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.4)

    def test_impact_equal_threshold_counts_as_above(self, svc):
        prefs = {"impact_threshold": 5}
        card = _card(impact_score=5)
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.6)

    def test_topic_match_adds_0_1(self, svc):
        card = _card(impact_score=5, topics=["deep learning"])
        prefs = {"liked_topics": ["deep"], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.7)

    def test_all_positive_factors(self, svc):
        card = _card(impact_score=9, category="Tech", topics=["ai models"])
        prefs = {
            "liked_categories": ["Tech"],
            "impact_threshold": 5,
            "liked_topics": ["ai"],
        }
        # 0.5 + 0.2 + 0.1 + 0.1 = 0.9
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.9)

    def test_all_negative_factors(self, svc):
        card = _card(impact_score=1, category="Junk", topics=[])
        prefs = {
            "disliked_categories": ["Junk"],
            "impact_threshold": 10,
        }
        # 0.5 - 0.2 - 0.1 = 0.2
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.2)


class TestCalculateRelevanceClamping:
    """Ensure clamping to [0, 1] when prefs path is taken."""

    def test_max_clamp(self, svc):
        """Even with all boosts the score must not exceed 1.0."""
        card = _card(impact_score=10, category="X", topics=["x"])
        prefs = {
            "liked_categories": ["X"],
            "impact_threshold": 1,
            "liked_topics": ["x"],
        }
        assert svc.calculate_relevance(card, prefs) <= 1.0

    def test_min_clamp(self, svc):
        """Even with all penalties the score must not go below 0.0."""
        card = _card(impact_score=0, category="Bad", topics=[])
        prefs = {
            "disliked_categories": ["Bad"],
            "impact_threshold": 100,
        }
        assert svc.calculate_relevance(card, prefs) >= 0.0

    def test_no_prefs_path_does_not_clamp(self, svc):
        """The no-prefs path returns impact/10 directly - no clamping."""
        assert svc.calculate_relevance(
            _card(impact_score=-5), None
        ) == pytest.approx(-0.5)
        assert svc.calculate_relevance(
            _card(impact_score=15), None
        ) == pytest.approx(1.5)


class TestCalculateRelevanceMissingAttributes:
    """Cards missing optional attributes should not raise."""

    def test_no_category_attr(self, svc):
        card = _card(spec=["impact_score", "topics", "interaction"])
        card.impact_score = 5
        card.topics = []
        card.interaction = {}
        result = svc.calculate_relevance(card, {"liked_categories": ["Tech"]})
        assert isinstance(result, float)

    def test_no_impact_score_attr_with_prefs(self, svc):
        card = _card(spec=["category", "topics", "interaction"])
        card.category = "Tech"
        card.topics = []
        card.interaction = {}
        result = svc.calculate_relevance(card, {"impact_threshold": 5})
        # No impact_score attr -> skip impact check -> just base 0.5
        assert result == pytest.approx(0.5)

    def test_no_topics_attr(self, svc):
        card = _card(spec=["impact_score", "category", "interaction"])
        card.impact_score = 5
        card.category = "Tech"
        card.interaction = {}
        result = svc.calculate_relevance(card, {"liked_topics": ["ai"]})
        assert isinstance(result, float)

    def test_no_impact_score_attr_no_prefs(self, svc):
        """getattr default of 5 used when card has no impact_score and prefs is None."""
        card = _card(spec=[])
        assert svc.calculate_relevance(card, None) == pytest.approx(0.5)


class TestCalculateRelevanceTopicEdgeCases:
    """Topic matching subtleties."""

    def test_partial_substring_match(self, svc):
        """'art' should match 'artificial'."""
        card = _card(topics=["Artificial Intelligence"])
        prefs = {"liked_topics": ["art"], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.7)

    def test_only_first_topic_match_counted(self, svc):
        """Multiple matching topics still yield only +0.1."""
        card = _card(topics=["AI News", "AI Research", "AI Updates"])
        prefs = {"liked_topics": ["ai"], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.7)

    def test_topic_case_insensitive(self, svc):
        card = _card(topics=["QUANTUM COMPUTING"])
        prefs = {"liked_topics": ["quantum"], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.7)

    def test_empty_liked_topics_no_boost(self, svc):
        card = _card(topics=["Something"])
        prefs = {"liked_topics": [], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.6)

    def test_empty_card_topics_no_boost(self, svc):
        card = _card(topics=[])
        prefs = {"liked_topics": ["ai"], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.6)

    def test_no_liked_topics_key_in_prefs(self, svc):
        """Prefs without 'liked_topics' key -> defaults to empty list."""
        card = _card(topics=["AI"])
        prefs = {"impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.6)

    def test_liked_topic_not_in_card_topics(self, svc):
        card = _card(topics=["Climate Change"])
        prefs = {"liked_topics": ["blockchain"], "impact_threshold": 5}
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.6)


class TestCalculateRelevanceCategoryPrecedence:
    """When category is in both liked and disliked, liked wins (checked first)."""

    def test_liked_checked_before_disliked(self, svc):
        card = _card(category="Tech", impact_score=5, topics=[])
        prefs = {
            "liked_categories": ["Tech"],
            "disliked_categories": ["Tech"],
            "impact_threshold": 5,
        }
        # liked branch matches first due to if/elif -> +0.2
        assert svc.calculate_relevance(card, prefs) == pytest.approx(0.8)


# ===========================================================================
# calculate_trending_score
# ===========================================================================


class TestCalculateTrendingScoreEdgeCases:
    """Edge cases for trending score calculation."""

    def test_no_impact_score_returns_zero(self, svc):
        card = Mock(spec=[])
        assert svc.calculate_trending_score(card) == 0.0

    def test_zero_everything(self, svc):
        card = _card(
            impact_score=0,
            interaction={"views": 0, "votes_up": 0, "votes_down": 0},
        )
        assert svc.calculate_trending_score(card) == 0.0

    def test_exact_formula(self, svc):
        card = _card(
            impact_score=6,
            interaction={"views": 30, "votes_up": 15, "votes_down": 3},
        )
        # engagement = 30 + 15*2 - 3 = 57
        # trending = 6 + 57/10 = 11.7
        assert svc.calculate_trending_score(card) == pytest.approx(11.7)

    def test_negative_net_engagement(self, svc):
        card = _card(
            impact_score=5,
            interaction={"views": 0, "votes_up": 0, "votes_down": 100},
        )
        # 5 + (-100)/10 = -5.0
        assert svc.calculate_trending_score(card) == pytest.approx(-5.0)

    def test_missing_interaction_keys_default_zero(self, svc):
        card = _card(impact_score=4, interaction={})
        assert svc.calculate_trending_score(card) == pytest.approx(4.0)

    def test_partial_interaction_keys(self, svc):
        card = _card(impact_score=3, interaction={"views": 20})
        # votes_up=0, votes_down=0 via .get defaults
        assert svc.calculate_trending_score(card) == pytest.approx(5.0)

    def test_upvotes_weighted_double(self, svc):
        card = _card(
            impact_score=0,
            interaction={"views": 0, "votes_up": 10, "votes_down": 0},
        )
        # 0 + (0 + 20 - 0)/10 = 2.0
        assert svc.calculate_trending_score(card) == pytest.approx(2.0)

    def test_large_values(self, svc):
        card = _card(
            impact_score=10,
            interaction={
                "views": 1_000_000,
                "votes_up": 500_000,
                "votes_down": 50_000,
            },
        )
        expected = 10 + (1_000_000 + 500_000 * 2 - 50_000) / 10
        assert svc.calculate_trending_score(card) == pytest.approx(expected)


# ===========================================================================
# filter_trending
# ===========================================================================


class TestFilterTrendingComprehensive:
    """Comprehensive filter_trending tests."""

    def test_empty_list(self, svc):
        assert svc.filter_trending([]) == []

    def test_all_below_min_impact(self, svc):
        cards = [_card(impact_score=i) for i in range(1, 7)]
        assert svc.filter_trending(cards, min_impact=7) == []

    def test_all_above_min_impact(self, svc):
        cards = [_card(impact_score=i) for i in range(8, 11)]
        result = svc.filter_trending(cards, min_impact=7)
        assert len(result) == 3

    def test_exact_min_impact_included(self, svc):
        card = _card(impact_score=7)
        result = svc.filter_trending([card], min_impact=7)
        assert len(result) == 1

    def test_one_below_min_impact_excluded(self, svc):
        card = _card(impact_score=6)
        result = svc.filter_trending([card], min_impact=7)
        assert len(result) == 0

    def test_sorted_descending_by_trending(self, svc):
        c_low = _card(
            impact_score=7,
            interaction={"views": 0, "votes_up": 0, "votes_down": 0},
        )
        c_mid = _card(
            impact_score=8,
            interaction={"views": 10, "votes_up": 0, "votes_down": 0},
        )
        c_high = _card(
            impact_score=9,
            interaction={"views": 50, "votes_up": 10, "votes_down": 0},
        )
        result = svc.filter_trending([c_low, c_high, c_mid], min_impact=7)
        scores = [c.trending_score for c in result]
        assert scores == sorted(scores, reverse=True)

    def test_limit_truncates(self, svc):
        cards = [_card(impact_score=8) for _ in range(20)]
        assert len(svc.filter_trending(cards, min_impact=7, limit=5)) == 5

    def test_limit_zero_returns_empty(self, svc):
        card = _card(impact_score=10)
        assert svc.filter_trending([card], min_impact=7, limit=0) == []

    def test_limit_larger_than_results(self, svc):
        cards = [_card(impact_score=8) for _ in range(3)]
        result = svc.filter_trending(cards, min_impact=7, limit=100)
        assert len(result) == 3

    def test_default_min_impact_is_7(self, svc):
        card6 = _card(impact_score=6)
        card7 = _card(impact_score=7)
        result = svc.filter_trending([card6, card7])
        assert card7 in [r for r in result]
        assert card6 not in [r for r in result]

    def test_default_limit_is_10(self, svc):
        cards = [_card(impact_score=9) for _ in range(15)]
        assert len(svc.filter_trending(cards)) == 10

    def test_trending_score_attribute_set(self, svc):
        card = _card(
            impact_score=8,
            interaction={"views": 5, "votes_up": 1, "votes_down": 0},
        )
        result = svc.filter_trending([card], min_impact=7)
        assert hasattr(result[0], "trending_score")
        # 8 + (5 + 2 - 0)/10 = 8.7
        assert result[0].trending_score == pytest.approx(8.7)

    def test_cards_without_impact_score_excluded(self, svc):
        card_with = _card(impact_score=9)
        card_without = Mock(spec=[])  # no impact_score
        result = svc.filter_trending([card_with, card_without], min_impact=7)
        assert len(result) == 1
        assert result[0] is card_with

    def test_min_impact_zero_includes_all(self, svc):
        cards = [_card(impact_score=i) for i in range(0, 5)]
        result = svc.filter_trending(cards, min_impact=0, limit=100)
        assert len(result) == 5

    def test_mixed_impact_scores_correct_filter(self, svc):
        scores = [10, 7, 3, 8, 6, 9, 7, 1]
        cards = [_card(impact_score=s) for s in scores]
        result = svc.filter_trending(cards, min_impact=7)
        for c in result:
            assert c.impact_score >= 7
        assert len(result) == 5  # 10, 7, 8, 9, 7

    def test_stable_order_for_equal_trending_scores(self, svc):
        """All cards with same trending score still return correct count."""
        cards = [
            _card(
                impact_score=8,
                interaction={"views": 0, "votes_up": 0, "votes_down": 0},
            )
            for _ in range(5)
        ]
        result = svc.filter_trending(cards, min_impact=7, limit=3)
        assert len(result) == 3
        assert all(c.trending_score == result[0].trending_score for c in result)


# ===========================================================================
# personalize_feed
# ===========================================================================


class TestPersonalizeFeedComprehensive:
    """Comprehensive personalize_feed tests."""

    def test_empty_cards(self, svc):
        assert svc.personalize_feed([], None) == []

    def test_empty_cards_with_prefs(self, svc):
        assert svc.personalize_feed([], {"liked_categories": ["Tech"]}) == []

    def test_sets_relevance_score_attr(self, svc):
        card = _card(impact_score=7)
        result = svc.personalize_feed([card], None)
        assert result[0].relevance_score == pytest.approx(0.7)

    def test_sorted_by_relevance_descending(self, svc):
        c1 = _card(impact_score=3)
        c2 = _card(impact_score=9)
        c3 = _card(impact_score=6)
        result = svc.personalize_feed([c1, c2, c3], None)
        scores = [c.relevance_score for c in result]
        assert scores == sorted(scores, reverse=True)

    def test_include_seen_true_keeps_viewed(self, svc):
        seen = _card(impact_score=5, viewed=True)
        unseen = _card(impact_score=5)
        result = svc.personalize_feed([seen, unseen], None, include_seen=True)
        assert len(result) == 2

    def test_include_seen_false_removes_viewed(self, svc):
        seen = _card(impact_score=5, viewed=True)
        unseen = _card(impact_score=5)
        result = svc.personalize_feed([seen, unseen], None, include_seen=False)
        assert len(result) == 1
        assert result[0] is unseen

    def test_all_seen_excluded_returns_empty(self, svc):
        cards = [_card(impact_score=5, viewed=True) for _ in range(3)]
        result = svc.personalize_feed(cards, None, include_seen=False)
        assert result == []

    def test_default_include_seen_is_true(self, svc):
        seen = _card(impact_score=5, viewed=True)
        result = svc.personalize_feed([seen], None)
        assert len(result) == 1

    def test_viewed_key_missing_card_included(self, svc):
        """Cards without 'viewed' key should be included even with include_seen=False."""
        card = _card(impact_score=5, interaction={"views": 10})
        result = svc.personalize_feed([card], None, include_seen=False)
        assert len(result) == 1

    def test_viewed_false_card_included(self, svc):
        card = _card(impact_score=5, interaction={"viewed": False})
        result = svc.personalize_feed([card], None, include_seen=False)
        assert len(result) == 1

    def test_viewed_none_card_included(self, svc):
        """viewed=None is falsy, so card is included."""
        card = _card(impact_score=5, interaction={"viewed": None})
        result = svc.personalize_feed([card], None, include_seen=False)
        assert len(result) == 1

    def test_viewed_zero_card_included(self, svc):
        """viewed=0 is falsy, so card is included."""
        card = _card(impact_score=5, interaction={"viewed": 0})
        result = svc.personalize_feed([card], None, include_seen=False)
        assert len(result) == 1

    def test_preserves_card_identity(self, svc):
        card = _card(impact_score=7)
        result = svc.personalize_feed([card], None)
        assert result[0] is card

    def test_with_prefs_sorts_correctly(self, svc):
        liked = _card(category="Tech", impact_score=5, topics=[])
        disliked = _card(category="Sports", impact_score=5, topics=[])
        neutral = _card(category="Science", impact_score=5, topics=[])
        prefs = {
            "liked_categories": ["Tech"],
            "disliked_categories": ["Sports"],
            "impact_threshold": 5,
        }
        result = svc.personalize_feed([disliked, neutral, liked], prefs)
        assert result[0] is liked
        assert result[-1] is disliked

    def test_relevance_set_before_filtering(self, svc):
        """Even seen cards get relevance_score set (just filtered from output)."""
        seen = _card(impact_score=8, viewed=True)
        svc.personalize_feed([seen], None, include_seen=False)
        # The card still got its relevance_score set before the filter check
        assert seen.relevance_score == pytest.approx(0.8)

    def test_single_card_no_prefs(self, svc):
        card = _card(impact_score=10)
        result = svc.personalize_feed([card], None)
        assert len(result) == 1
        assert result[0].relevance_score == pytest.approx(1.0)

    def test_many_cards_sorted(self, svc):
        cards = [_card(impact_score=i) for i in range(1, 11)]
        result = svc.personalize_feed(cards, None)
        scores = [c.relevance_score for c in result]
        assert scores == sorted(scores, reverse=True)

    def test_prefs_none_and_empty_dict_same_path(self, svc):
        """Both None and {} are falsy, so both use impact/10 path."""
        card1 = _card(impact_score=6)
        card2 = _card(impact_score=6)
        svc.personalize_feed([card1], None)
        svc.personalize_feed([card2], {})
        assert card1.relevance_score == pytest.approx(0.6)
        assert card2.relevance_score == pytest.approx(0.6)

    def test_prefs_with_content_uses_prefs_path(self, svc):
        """Non-empty prefs dict uses the prefs scoring path."""
        card = _card(impact_score=6)
        svc.personalize_feed([card], {"impact_threshold": 5})
        # prefs path: 0.5 base + 0.1 (6 >= 5) = 0.6
        assert card.relevance_score == pytest.approx(0.6)


# ===========================================================================
# Singleton
# ===========================================================================


class TestGetRelevanceServiceSingleton:
    """Tests for the module-level singleton."""

    def test_returns_instance(self):
        svc = get_relevance_service()
        assert isinstance(svc, RelevanceService)

    def test_returns_same_instance(self):
        s1 = get_relevance_service()
        s2 = get_relevance_service()
        assert s1 is s2

    def test_singleton_reset(self):
        """After resetting the module global, a new instance is created."""
        import local_deep_research.news.core.relevance_service as mod

        old = mod._relevance_service
        mod._relevance_service = None
        try:
            new = mod.get_relevance_service()
            assert isinstance(new, RelevanceService)
            # If old existed, new might differ (depends on prior tests)
        finally:
            # Restore to avoid cross-test pollution
            mod._relevance_service = old


# ===========================================================================
# Integration-style: combinations of methods
# ===========================================================================


class TestMethodInteractions:
    """Test interactions between methods."""

    def test_filter_then_personalize(self, svc):
        """filter_trending output can be fed to personalize_feed."""
        cards = [
            _card(
                impact_score=9,
                interaction={"views": 100, "votes_up": 10, "votes_down": 0},
            ),
            _card(
                impact_score=8,
                interaction={"views": 50, "votes_up": 5, "votes_down": 0},
            ),
            _card(
                impact_score=3,
                interaction={"views": 200, "votes_up": 20, "votes_down": 0},
            ),
        ]
        trending = svc.filter_trending(cards, min_impact=7)
        assert len(trending) == 2  # only impact 9 and 8

        personalized = svc.personalize_feed(trending, None)
        assert len(personalized) == 2
        # Sorted by relevance (impact/10)
        assert (
            personalized[0].relevance_score >= personalized[1].relevance_score
        )

    def test_trending_score_is_consistent_with_calculate(self, svc):
        """filter_trending sets trending_score using calculate_trending_score."""
        card = _card(
            impact_score=8,
            interaction={"views": 30, "votes_up": 5, "votes_down": 2},
        )
        svc.filter_trending([card], min_impact=7)
        expected = svc.calculate_trending_score(card)
        assert card.trending_score == pytest.approx(expected)

    def test_personalize_uses_calculate_relevance(self, svc):
        """personalize_feed sets relevance_score using calculate_relevance."""
        card = _card(impact_score=7, category="Tech", topics=["ai"])
        prefs = {
            "liked_categories": ["Tech"],
            "liked_topics": ["ai"],
            "impact_threshold": 5,
        }
        svc.personalize_feed([card], prefs)
        expected = svc.calculate_relevance(card, prefs)
        assert card.relevance_score == pytest.approx(expected)
