"""Tests for the tiered journal scoring pipeline and filter_results behavior.

Covers: predatory auto-removal, whitelist override, OpenAlex/DOAJ tiers,
journal deduplication, SearXNG fail-fast, cache staleness, edge cases.
"""

import time
from datetime import timedelta
from unittest.mock import MagicMock, patch


MODULE = (
    "local_deep_research.advanced_search_system"
    ".filters.journal_reputation_filter"
)


def _make_filter(threshold=5, exclude_non_published=False, reanalysis_days=365):
    from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
        JournalReputationFilter,
    )

    mock_model = MagicMock()
    mock_engine = MagicMock()
    mock_engine.is_available = True

    with (
        patch(f"{MODULE}.create_search_engine", return_value=mock_engine),
        patch(f"{MODULE}.get_llm", return_value=mock_model),
    ):
        filt = JournalReputationFilter(
            model=mock_model,
            reliability_threshold=threshold,
            max_context=3000,
            exclude_non_published=exclude_non_published,
            quality_reanalysis_period=timedelta(days=reanalysis_days),
        )
    return filt, mock_model


def _mock_dm(filt):
    """Return a MagicMock replacing the filter's data manager."""
    dm = MagicMock()
    dm.is_predatory.return_value = (False, None)
    dm.is_whitelisted.return_value = False
    dm.lookup_openalex.return_value = None
    dm.lookup_doaj.return_value = None
    dm.derive_quality_score.return_value = None
    dm.expand_abbreviation.return_value = None
    dm.score_from_affiliations.return_value = None
    filt._JournalReputationFilter__data_manager = dm
    return dm


def _mock_db_no_cache(filt):
    """Mock DB session to return no cached journal."""
    mock_session = MagicMock()
    mock_session.__enter__ = MagicMock(return_value=mock_session)
    mock_session.__exit__ = MagicMock(return_value=False)
    mock_session.query.return_value.filter_by.return_value.first.return_value = None
    mock_session.query.return_value.filter.return_value.first.return_value = (
        None
    )
    patch.object(
        filt,
        "_JournalReputationFilter__db_session",
        return_value=mock_session,
    ).start()
    return mock_session


def _mock_clean_name(filt, name="Test Journal"):
    """Mock __clean_journal_name to return a fixed name."""
    patch.object(
        filt,
        "_JournalReputationFilter__clean_journal_name",
        return_value=name,
    ).start()


class TestPredatoryAutoRemoval:
    def test_predatory_journal_auto_removed(self):
        """Predatory journal not on whitelist → result dropped."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "Predatory Journal")

        dm.is_predatory.return_value = (True, "stop-predatory")
        dm.is_whitelisted.return_value = False

        results = filt.filter_results(
            [{"journal_ref": "Predatory Journal", "title": "Bad Paper"}],
            "test query",
        )
        assert len(results) == 0

    def test_predatory_whitelist_override_keeps_result(self):
        """Predatory + whitelisted (DOAJ) → result kept."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "Frontiers Journal")

        dm.is_predatory.return_value = (True, "beall")
        dm.is_whitelisted.return_value = True
        dm.lookup_openalex.return_value = {
            "h_index": 50,
            "is_in_doaj": True,
            "type": "journal",
            "quartile": "Q2",
        }
        dm.derive_quality_score.return_value = 7

        results = filt.filter_results(
            [{"journal_ref": "Frontiers in X", "title": "Good Paper"}],
            "test query",
        )
        assert len(results) == 1

    def test_mixed_batch_predatory_and_good(self):
        """[good, predatory, good] → 2 results returned."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)

        def fake_predatory(*, journal_name=None, publisher_name=None):
            if journal_name and "predatory" in journal_name.lower():
                return (True, "stop-predatory")
            return (False, None)

        dm.is_predatory.side_effect = fake_predatory
        dm.lookup_openalex.return_value = {
            "h_index": 50,
            "is_in_doaj": False,
            "type": "journal",
        }
        dm.derive_quality_score.return_value = 7

        patch.object(
            filt,
            "_JournalReputationFilter__clean_journal_name",
            side_effect=lambda x: x.lower().strip(),
        ).start()

        results = filt.filter_results(
            [
                {"journal_ref": "Nature", "title": "Paper A"},
                {"journal_ref": "Predatory Journal", "title": "Paper B"},
                {"journal_ref": "Science", "title": "Paper C"},
            ],
            "test query",
        )
        assert len(results) == 2


class TestOpenAlexTier:
    def test_openalex_scores_from_h_index(self):
        """OpenAlex hit with h_index=85 → result kept."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "Good Journal")

        dm.lookup_openalex.return_value = {
            "h_index": 85,
            "is_in_doaj": False,
            "type": "journal",
            "quartile": "Q1",
            "issn_l": "1234-5678",
            "impact_factor": 5.2,
            "publisher": "Good Publisher",
        }
        dm.derive_quality_score.return_value = 8

        results = filt.filter_results(
            [{"journal_ref": "Good Journal", "title": "Paper"}],
            "test query",
        )
        assert len(results) == 1
        dm.derive_quality_score.assert_called_once()


class TestDoajTier:
    def test_doaj_listed(self):
        """DOAJ-listed journal → uses DOAJ quality score."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "OA Journal")

        dm.lookup_openalex.return_value = None  # Tier 2 misses
        dm.lookup_doaj.return_value = {"name": "OA Journal"}
        dm.derive_quality_score.return_value = 5  # DOAJ listed → score 5

        results = filt.filter_results(
            [
                {
                    "journal_ref": "OA Journal",
                    "title": "Paper",
                    "issn": "1234-5678",
                }
            ],
            "test query",
        )
        # DOAJ listing scores 5, threshold is 4 → result kept
        assert len(results) == 1


class TestJournalDedup:
    def test_dedup_scores_once_per_unique_journal(self):
        """3 results, 2 with same journal → __score_journal called 2x."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)

        # Clean name maps both "Nature Vol 1" and "Nature Vol 2" to "nature"
        def clean(name):
            if "nature" in name.lower():
                return "nature"
            return name.lower().strip()

        patch.object(
            filt,
            "_JournalReputationFilter__clean_journal_name",
            side_effect=clean,
        ).start()

        dm.lookup_openalex.return_value = {
            "h_index": 100,
            "is_in_doaj": False,
            "type": "journal",
        }
        dm.derive_quality_score.return_value = 8

        results = filt.filter_results(
            [
                {"journal_ref": "Nature Vol 1", "title": "A"},
                {"journal_ref": "Nature Vol 2", "title": "B"},
                {"journal_ref": "Science", "title": "C"},
            ],
            "test query",
        )
        assert len(results) == 3  # All pass threshold
        # dm.is_predatory should be called 2x (once per unique journal)
        assert dm.is_predatory.call_count == 2


class TestSearxngFailFast:
    def test_counter_resets_between_batches(self):
        """Failure counter resets to 0 at start of each filter_results call."""
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "Journal")
        dm.derive_quality_score.return_value = 5

        # Call filter_results twice - counter should reset each time
        filt.filter_results([{"journal_ref": "J1", "title": "A"}], "q")
        filt.filter_results([{"journal_ref": "J2", "title": "B"}], "q")
        # No assertion needed beyond "no crash" — the reset is internal

    def test_tier4_skipped_after_two_consecutive_failures(self):
        """After 2 consecutive Tier 4 (LLM) failures, remaining journals skip Tier 4."""
        filt, mock_model = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)

        # Make each journal_ref clean to a unique name
        call_count = [0]

        def clean_name_side_effect(name):
            call_count[0] += 1
            return f"Journal_{call_count[0]}"

        with patch.object(
            filt,
            "_JournalReputationFilter__clean_journal_name",
            side_effect=clean_name_side_effect,
        ):
            # All tiers before LLM return nothing
            dm.lookup_openalex.return_value = None
            dm.lookup_doaj.return_value = None
            dm.derive_quality_score.return_value = None
            dm.score_from_affiliations.return_value = None
            dm.expand_abbreviation.return_value = None

            # Make SearXNG available but LLM analysis fails
            filt._JournalReputationFilter__searxng_available = True

            # Enable Tier 4 (LLM scoring) — it's opt-in via settings
            with patch(
                "local_deep_research.config.search_config.get_setting_from_snapshot",
                return_value=True,
            ):
                with patch.object(
                    filt,
                    "_JournalReputationFilter__analyze_journal_reputation",
                    side_effect=ValueError("LLM parse error"),
                ) as mock_analyze:
                    # Send 4 journals — after 2 failures, the 3rd and
                    # 4th should skip Tier 4 entirely
                    filt.filter_results(
                        [
                            {"journal_ref": "J1", "title": "Paper1"},
                            {"journal_ref": "J2", "title": "Paper2"},
                            {"journal_ref": "J3", "title": "Paper3"},
                            {"journal_ref": "J4", "title": "Paper4"},
                        ],
                        "test query",
                    )

                    # LLM should only be called twice (fail-fast after 2)
                    assert mock_analyze.call_count == 2


class TestEdgeCases:
    def test_empty_string_journal_ref_treated_as_none(self):
        """journal_ref='' → treated as no journal."""
        filt, _ = _make_filter(threshold=4, exclude_non_published=False)

        results = filt.filter_results(
            [{"journal_ref": "", "title": "Paper"}],
            "test query",
        )
        assert len(results) == 1  # Kept (exclude_non_published=False)

    def test_whitespace_only_journal_ref_goes_through_scoring(self):
        """journal_ref='   ' is truthy — goes through scoring pipeline."""
        filt, _ = _make_filter(threshold=4, exclude_non_published=False)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        # Whitespace-only names get cleaned by __clean_journal_name
        _mock_clean_name(filt, "")
        dm.derive_quality_score.return_value = 5

        filt.filter_results(
            [{"journal_ref": "   ", "title": "Paper"}],
            "test query",
        )
        # Passes through scoring pipeline without crash (whitespace is truthy)

    def test_stale_cache_triggers_rescoring(self):
        """Cached 400 days ago, period=365 → re-evaluate."""
        filt, _ = _make_filter(threshold=4, reanalysis_days=365)
        dm = _mock_dm(filt)
        _mock_clean_name(filt, "Old Journal")

        # Mock stale DB cache
        stale_journal = MagicMock()
        stale_journal.quality = 8
        stale_journal.quality_analysis_time = int(time.time()) - (400 * 86400)
        stale_journal.is_predatory = False

        mock_session = MagicMock()
        mock_session.__enter__ = MagicMock(return_value=mock_session)
        mock_session.__exit__ = MagicMock(return_value=False)
        mock_session.query.return_value.filter_by.return_value.first.return_value = stale_journal
        mock_session.query.return_value.filter.return_value.first.return_value = stale_journal
        patch.object(
            filt,
            "_JournalReputationFilter__db_session",
            return_value=mock_session,
        ).start()

        dm.lookup_openalex.return_value = {
            "h_index": 50,
            "is_in_doaj": False,
            "type": "journal",
        }
        dm.derive_quality_score.return_value = 7

        filt.filter_results(
            [{"journal_ref": "Old Journal", "title": "Paper"}],
            "test query",
        )
        # Should have called is_predatory (went past stale cache)
        dm.is_predatory.assert_called()

    def test_db_session_error_returns_predatory_free_safe_list(self):
        """DB session error → safety net returns a predatory-free list,
        NOT the raw input. Returning the input would re-admit any
        predatory sources Tier 1 already caught — the filter's
        safety-contract inversion fix (S4 in PR #3081). In this test
        the crash fires before any result was scored, so we get an
        empty list.
        """
        filt, _ = _make_filter(threshold=4)

        # Make clean_journal_name raise to trigger the safety net
        patch.object(
            filt,
            "_JournalReputationFilter__clean_journal_name",
            side_effect=RuntimeError("DB connection lost"),
        ).start()

        results = filt.filter_results(
            [
                {"journal_ref": "Nature", "title": "A"},
                {"journal_ref": "Science", "title": "B"},
            ],
            "test query",
        )
        # Safety net returns `filtered` (predatory-free, possibly
        # empty), never the raw input.
        assert results == []


class TestLLMScoreRejection:
    """Regression tests for LLM output validation.

    The scoring algorithm only emits {1, 4, 5, 6, 7, 8, 10}. Earlier
    code clamped LLM output to [1, 10] with max/min, silently accepting
    invalid scores like 2, 3, 9 — which then poisoned the cache. After
    the fix, out-of-set scores raise ValueError so the existing failure
    counter + circuit breaker observe them.
    """

    @staticmethod
    def _prime_searxng(filt, snippets=None):
        """Give the filter's SearXNG engine fake snippets so the LLM
        path is reached instead of the "no search results" short-circuit."""
        snippets = snippets or ["Some journal info"]
        fake_results = [{"snippet": s} for s in snippets]
        filt._JournalReputationFilter__engine = MagicMock()
        filt._JournalReputationFilter__engine.run.return_value = fake_results

    def test_invalid_score_raises_value_error(self):
        """LLM returning a digit not in the valid set → ValueError."""
        filt, mock_model = _make_filter(threshold=4)
        self._prime_searxng(filt)

        # LLM returns "9" which is not in {1, 4, 5, 6, 7, 8, 10}
        mock_response = MagicMock()
        mock_response.content = "My analysis: score is 9"
        mock_model.invoke.return_value = mock_response

        import pytest

        with pytest.raises(ValueError, match="out-of-set"):
            filt._JournalReputationFilter__analyze_journal_reputation(
                "Test Journal"
            )

    def test_valid_score_returned_unchanged(self):
        """LLM returning a digit in the valid set → returned as-is."""
        filt, mock_model = _make_filter(threshold=4)
        self._prime_searxng(filt)

        mock_response = MagicMock()
        mock_response.content = "Score: 7 (Q2 journal)"
        mock_model.invoke.return_value = mock_response

        result = filt._JournalReputationFilter__analyze_journal_reputation(
            "Test Journal"
        )
        assert result == 7

    def test_no_digit_still_raises_value_error(self):
        """LLM returning no digit → ValueError (unchanged from pre-fix)."""
        filt, mock_model = _make_filter(threshold=4)
        self._prime_searxng(filt)

        mock_response = MagicMock()
        mock_response.content = "I cannot determine the score"
        mock_model.invoke.return_value = mock_response

        import pytest

        with pytest.raises(ValueError, match="Failed to parse"):
            filt._JournalReputationFilter__analyze_journal_reputation(
                "Test Journal"
            )

    def test_out_of_range_integer_raises(self):
        """LLM returning 11 (clamped previously) → ValueError now."""
        filt, mock_model = _make_filter(threshold=4)
        self._prime_searxng(filt)

        mock_response = MagicMock()
        mock_response.content = "Score: 11"
        mock_model.invoke.return_value = mock_response

        import pytest

        with pytest.raises(ValueError, match="out-of-set"):
            filt._JournalReputationFilter__analyze_journal_reputation(
                "Test Journal"
            )


class TestTierFallthrough:
    """All tiers miss (and LLM disabled) → low-confidence floor (score 3)."""

    def test_all_tiers_none_lands_on_low_confidence(self):
        """Predatory miss, OpenAlex miss, DOAJ miss, no affiliations,
        no cached LLM, Tier 4 disabled → result is tagged with
        score=3, source='low_confidence'. With threshold=2 the result
        passes through so we can assert the tag on the returned dict.
        """
        filt, _ = _make_filter(threshold=2)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "Unknown Journal")
        # All tiers explicit None for clarity
        dm.is_predatory.return_value = (False, None)
        dm.lookup_openalex.return_value = None
        dm.lookup_doaj.return_value = None
        dm.score_from_affiliations.return_value = None

        results = filt.filter_results(
            [
                {
                    "journal_ref": "Unknown Journal",
                    "title": "Paper",
                    "issn": "0000-0000",
                }
            ],
            "test query",
        )
        assert len(results) == 1
        assert results[0]["journal_quality"] == 3
        assert results[0]["journal_quality_source"] == "low_confidence"


class TestTierShortCircuit:
    """Once a tier scores the journal, later tiers must not run."""

    def test_tier2_hit_skips_tier3_and_tier35(self):
        """OpenAlex (Tier 2) scores the journal → lookup_doaj and
        score_from_affiliations must NOT be called. Guards against
        future regressions where someone adds an unconditional lookup
        after Tier 2.
        """
        filt, _ = _make_filter(threshold=4)
        dm = _mock_dm(filt)
        _mock_db_no_cache(filt)
        _mock_clean_name(filt, "Good Journal")

        # Tier 2 hit with type='journal' (not 'repository') and score=7
        # (>5) so the preprint-lift branch at Tier 2 won't touch
        # score_from_affiliations either.
        dm.lookup_openalex.return_value = {
            "h_index": 80,
            "is_in_doaj": False,
            "type": "journal",
            "quartile": "Q1",
            "issn_l": "1234-5678",
            "impact_factor": 5.0,
            "publisher": "Publisher",
        }
        dm.derive_quality_score.return_value = 7

        filt.filter_results(
            [
                {
                    "journal_ref": "Good Journal",
                    "title": "Paper",
                    "issn": "1234-5678",
                    "affiliations": [{"name": "Some University"}],
                }
            ],
            "test query",
        )

        assert dm.lookup_openalex.call_count == 1
        assert dm.lookup_doaj.call_count == 0
        assert dm.score_from_affiliations.call_count == 0
