"""
Tests for advanced_search_system/filters/journal_reputation_filter.py

Tests cover:
- JournalReputationFilter initialization
- create_default class method
- __check_result method
- __clean_journal_name method
- __analyze_journal_reputation method
- filter_results method
"""

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

import pytest


class TestJournalFilterError:
    """Tests for JournalFilterError exception."""

    def test_exception_exists(self):
        """Test that JournalFilterError is defined."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalFilterError,
        )

        assert issubclass(JournalFilterError, Exception)

    def test_exception_can_be_raised(self):
        """Test that JournalFilterError can be raised and caught."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalFilterError,
        )

        with pytest.raises(JournalFilterError):
            raise JournalFilterError("Test error")


class TestJournalReputationFilterInit:
    """Tests for JournalReputationFilter initialization."""

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_init_with_all_params(self, mock_get_llm, mock_create_engine):
        """Test initialization with all parameters provided."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_model = Mock()
        mock_engine = Mock()
        mock_create_engine.return_value = mock_engine

        filter_obj = JournalReputationFilter(
            model=mock_model,
            reliability_threshold=5,
            max_context=2000,
            exclude_non_published=True,
            quality_reanalysis_period=timedelta(days=180),
        )

        assert filter_obj.model is mock_model
        mock_get_llm.assert_not_called()  # Should not call since model provided

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_init_without_model_uses_default(
        self, mock_get_llm, mock_create_engine
    ):
        """Test that default model is fetched when none provided."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_default_model = Mock()
        mock_get_llm.return_value = mock_default_model
        mock_engine = Mock()
        mock_create_engine.return_value = mock_engine

        filter_obj = JournalReputationFilter(
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        mock_get_llm.assert_called_once()
        assert filter_obj.model is mock_default_model

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_init_forwards_settings_snapshot_to_get_llm(
        self, mock_get_llm, mock_create_engine
    ):
        """When model=None, the snapshot from __init__ must be forwarded
        to get_llm so the LLM PEP at llm_config.py:295 can evaluate
        scope / require_local_endpoint. Dropping it here previously
        bypassed the PEP entirely.
        """
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_get_llm.return_value = Mock()
        mock_create_engine.return_value = Mock()

        snapshot = {
            "policy.egress_scope": "private_only",
            "llm.provider": "ollama",
            "search.tool": "arxiv",
        }
        JournalReputationFilter(model=None, settings_snapshot=snapshot)

        mock_get_llm.assert_called_once()
        call_kwargs = mock_get_llm.call_args.kwargs
        assert call_kwargs.get("settings_snapshot") is snapshot, (
            "settings_snapshot must be forwarded to get_llm — "
            f"got call_args={mock_get_llm.call_args!r}"
        )

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_create_default_propagates_policy_denied_error(
        self, mock_get_llm, mock_create_engine
    ):
        """When the snapshot-forwarded get_llm raises PolicyDeniedError
        (cloud provider + require_local_endpoint), create_default must
        let the exception propagate — silently returning None would
        leave unfiltered results through under the very scope the user
        asked for.
        """
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )
        from local_deep_research.security.egress.policy import (
            Decision,
            PolicyDeniedError,
        )

        mock_create_engine.return_value = Mock()
        mock_get_llm.side_effect = PolicyDeniedError(
            Decision(False, "provider_cloud_only"), target="openai"
        )

        snapshot = {
            "policy.egress_scope": "both",
            "llm.require_local_endpoint": True,
            "llm.provider": "openai",
            "search.tool": "arxiv",
            "search.engine.web.arxiv.journal_reputation.enabled": True,
        }

        with pytest.raises(PolicyDeniedError) as exc_info:
            JournalReputationFilter.create_default(
                model=None,
                engine_name="arxiv",
                settings_snapshot=snapshot,
            )
        assert exc_info.value.target == "openai"

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_init_reads_settings_when_params_not_provided(
        self, mock_get_llm, mock_create_engine, mock_get_setting
    ):
        """Test that settings are read when parameters not provided."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_create_engine.return_value = Mock()
        mock_get_setting.side_effect = [4, 3000, False, 365]

        JournalReputationFilter(model=Mock())

        assert mock_get_setting.call_count == 4


class TestCreateDefault:
    """Tests for JournalReputationFilter.create_default class method."""

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_returns_none_when_settings_read_fails(self, mock_get_setting):
        """A settings-read exception must not silently default to enabled.

        Regression guard for the previous ``except Exception: enabled =
        True`` fallback that hid real configuration errors (corrupted
        settings snapshot, DB lock, etc.) and kept the filter running
        as if nothing had gone wrong.
        """
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_get_setting.side_effect = RuntimeError("settings snapshot broken")

        result = JournalReputationFilter.create_default(
            model=Mock(), engine_name="test_engine"
        )

        # Propagates to the top-level handler → None (filter not applied
        # for this engine) rather than silently defaulting to enabled.
        assert result is None

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_returns_filter_when_enabled(
        self, mock_get_setting, mock_create_engine
    ):
        """Test that filter is returned when enabled."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_get_setting.side_effect = [True, 4, 3000, False, 365]
        mock_create_engine.return_value = Mock()

        result = JournalReputationFilter.create_default(
            model=Mock(), engine_name="test_engine"
        )

        assert result is not None
        assert isinstance(result, JournalReputationFilter)

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_returns_filter_even_without_searxng(
        self, mock_get_setting, mock_create_engine
    ):
        """Test that filter is returned even when SearXNG is unavailable."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_get_setting.side_effect = [True, 4, 3000, False, 365]
        mock_create_engine.return_value = None  # SearXNG unavailable

        result = JournalReputationFilter.create_default(
            model=Mock(), engine_name="test_engine"
        )

        # Filter should still be created — SearXNG is optional now
        assert result is not None
        assert isinstance(result, JournalReputationFilter)


class TestCleanJournalName:
    """Tests for __clean_journal_name private method."""

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_regex_cleans_simple_names_without_llm(
        self, mock_get_llm, mock_create_engine
    ):
        """Test that regex handles common patterns without LLM."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_model = Mock()
        mock_create_engine.return_value = Mock()

        filter_obj = JournalReputationFilter(
            model=mock_model,
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        # Regex should handle this without LLM
        result = filter_obj._JournalReputationFilter__clean_journal_name(
            "Nature Vol. 123, pp. 45-67"
        )

        assert result == "Nature"
        # LLM should NOT be called for this simple case
        assert not mock_model.invoke.called


class TestAnalyzeJournalReputation:
    """Tests for __analyze_journal_reputation private method (Tier 4)."""

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_analyzes_reputation_successfully(
        self, mock_get_llm, mock_create_engine
    ):
        """Test successful journal reputation analysis via SearXNG + LLM."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_model = Mock()
        mock_response = Mock()
        mock_response.content = "8"
        mock_model.invoke.return_value = mock_response

        # Mock SearXNG engine with search results
        mock_engine = Mock()
        mock_engine.is_available = True
        mock_engine.run.return_value = [
            {"snippet": "Nature is a Q1 journal with high impact factor"}
        ]
        mock_create_engine.return_value = mock_engine

        filter_obj = JournalReputationFilter(
            model=mock_model,
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        result = (
            filter_obj._JournalReputationFilter__analyze_journal_reputation(
                "Nature"
            )
        )

        assert result == 8

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_raises_on_invalid_response(self, mock_get_llm, mock_create_engine):
        """Test that ValueError is raised on invalid response."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_model = Mock()
        mock_response = Mock()
        mock_response.content = "not a number"
        mock_model.invoke.return_value = mock_response

        mock_engine = Mock()
        mock_engine.is_available = True
        mock_engine.run.return_value = [{"snippet": "Info"}]
        mock_create_engine.return_value = mock_engine

        filter_obj = JournalReputationFilter(
            model=mock_model,
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        with pytest.raises(ValueError):
            filter_obj._JournalReputationFilter__analyze_journal_reputation(
                "Bad Journal"
            )


class TestCachedJournalQuality:
    """Tests for cached journal quality via filter_results."""

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_user_db_session"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_search_context"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_uses_cached_journal_quality(
        self, mock_get_llm, mock_create_engine, mock_context, mock_session
    ):
        """Test that cached journal quality is used when available."""
        import time

        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_create_engine.return_value = Mock()
        mock_context.return_value = {
            "username": "test",
            "user_password": "pass",
        }

        # Mock journal from database with recent analysis
        mock_journal = Mock()
        mock_journal.quality = 8
        mock_journal.quality_analysis_time = int(time.time())  # Recent
        mock_journal.is_predatory = False

        # Cache predicate now chains two .filter() calls: one for
        # score_source=="llm", another for quality_model matching the
        # current LLM identifier. Cached quality (8) is in VALID_QUALITY_SCORES.
        mock_session_context = MagicMock()
        mock_session_context.__enter__.return_value.query.return_value.filter_by.return_value.filter.return_value.filter.return_value.first.return_value = mock_journal
        mock_session.return_value = mock_session_context

        mock_model = Mock()
        mock_response = Mock()
        mock_response.content = "Test Journal"
        mock_model.invoke.return_value = mock_response

        filter_obj = JournalReputationFilter(
            model=mock_model,
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        results = filter_obj.filter_results(
            [{"title": "Test", "journal_ref": "Test Journal"}],
            "test query",
        )

        assert len(results) == 1  # 8 >= 4 threshold → kept
        # journal_name_matched is the post-clean name the filter keyed
        # on — persisted on Paper.container_title by the write path so
        # the dashboard can GROUP BY it and enrich from the ref DB.
        assert results[0].get("journal_name_matched") == "Test Journal"
        assert results[0].get("journal_quality") == 8
        # LLM cache hit is tagged "llm" so the write path knows this
        # score came from Tier 4 and should be frozen on Paper. The
        # gate MUST NOT trust `journal_id is not None` alone — a stale
        # Journal row from a prior LLM-enabled session would poll into
        # a new Tier-2 pass and stamp the wrong source.
        assert results[0].get("journal_quality_source") == "llm"

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_user_db_session"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_search_context"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_tier2_ref_db_hit_is_tagged_openalex_not_llm(
        self, mock_get_llm, mock_create_engine, mock_context, mock_session
    ):
        """Regression guard for B2: a Tier-2 OpenAlex match MUST NOT be
        tagged as "llm", even if a stale Journal row exists in the user
        DB from a prior LLM-enabled session.

        Before the fix, `save_research_sources` gated ``journal_quality``
        persistence on ``journal_id is not None`` — which would resolve
        to a non-None id for any name matching a past LLM-cached row,
        wrongly stamping a Tier-2 score as an LLM verdict.
        """
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        # Simulate Tier 2 returning a real OpenAlex hit. The filter's
        # data manager is the path to the reference DB; we mock its
        # lookup_openalex to return a scored entry so Tier 2 succeeds
        # before the LLM cache / Tier 4 paths even run.
        mock_create_engine.return_value = Mock()
        mock_context.return_value = {"username": "t", "user_password": "p"}
        # No Journal cache row for this test — ensure mock_session
        # returns None from the cache lookup chain.
        mock_session_context = MagicMock()
        mock_session_context.__enter__.return_value.query.return_value.filter_by.return_value.filter.return_value.filter.return_value.first.return_value = None
        mock_session.return_value = mock_session_context

        filter_obj = JournalReputationFilter(
            model=Mock(),
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        # Patch the data manager to emit a Tier-2 match. Use the same
        # double-underscore name-mangling dance the existing tests use
        # when reaching into the filter internals.
        mock_dm = Mock()
        mock_dm.is_predatory.return_value = (False, None)
        mock_dm.is_whitelisted.return_value = False
        mock_dm.lookup_openalex.return_value = {
            "h_index": 250,
            "is_in_doaj": False,
            "issn_l": None,
            "type": "journal",
            "quartile": "Q1",
        }
        # derive_quality_score lives on the DM; return a fixed score
        mock_dm.derive_quality_score.return_value = 10
        mock_dm.score_from_affiliations.return_value = None
        filter_obj._JournalReputationFilter__data_manager = mock_dm

        results = filter_obj.filter_results(
            [
                {
                    "title": "Attention Is All You Need",
                    "journal_ref": "Nature",
                    "issn": "0028-0836",
                }
            ],
            "test query",
        )

        assert len(results) == 1
        assert results[0]["journal_quality"] == 10
        # CRITICAL: Tier-2 origin must be tagged "openalex", not "llm".
        # The write path reads this tag and refuses to persist
        # journal_quality on Paper when it's not "llm".
        assert results[0]["journal_quality_source"] == "openalex"


class TestPredatoryNotReadmittedOnCrash:
    """Regression guard for S4: a crash in filter_results must NOT
    re-admit predatory journals. Predatory removal is a safety
    contract — auto-removed journals must stay out even when the
    filter's top-level safety net catches an unexpected exception.
    """

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_user_db_session"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_search_context"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_crash_in_scoring_does_not_leak_predatory(
        self, mock_get_llm, mock_create_engine, mock_context, mock_session
    ):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_create_engine.return_value = Mock()
        mock_context.return_value = {"username": "t", "user_password": "p"}
        mock_session_context = MagicMock()
        mock_session_context.__enter__.return_value.query.return_value.filter_by.return_value.filter.return_value.filter.return_value.first.return_value = None
        mock_session.return_value = mock_session_context

        filter_obj = JournalReputationFilter(
            model=Mock(),
            reliability_threshold=2,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

        # Mock the data manager: Tier-1 flags "Predatory Journal" as
        # predatory, then __score_journal itself raises mid-batch on
        # a later result. The except branch MUST return `filtered`
        # (predatory-free), not `results` (raw input).
        def _score_raising(name, result):
            if "boom" in name.lower():
                raise RuntimeError("simulated scoring crash")
            # Otherwise delegate — but this test uses a minimal path:
            # first journal is predatory (via is_predatory), second
            # raises. Neither should reach __score_journal's Tier 2+.
            return 8, "openalex"

        mock_dm = Mock()

        # is_predatory returns True ONLY for the predatory test journal.
        def _is_predatory(journal_name=None, publisher_name=None):
            return (
                (True, "stop-predatory-journals")
                if journal_name == "Predatory Journal"
                else (False, None)
            )

        mock_dm.is_predatory.side_effect = _is_predatory
        mock_dm.is_whitelisted.return_value = False
        mock_dm.lookup_openalex.return_value = None
        mock_dm.lookup_doaj.return_value = None
        mock_dm.score_from_affiliations.return_value = None
        # expand_abbreviation must return None — otherwise Mock's default
        # truthy return value becomes the "cleaned name" and downstream
        # string comparisons silently break.
        mock_dm.expand_abbreviation.return_value = None
        # Force the exception on the third journal's __score_journal call.
        filter_obj._JournalReputationFilter__data_manager = mock_dm

        # Monkeypatch __score_journal so we control the crash timing.
        real_score_journal = filter_obj._JournalReputationFilter__score_journal

        def _wrapped_score(name, result):
            if name == "Crash Journal":
                raise RuntimeError("simulated crash mid-batch")
            return real_score_journal(name, result)

        filter_obj._JournalReputationFilter__score_journal = _wrapped_score

        predatory_result = {
            "title": "Fake Paper",
            "journal_ref": "Predatory Journal",
        }
        crash_result = {
            "title": "Trigger Paper",
            "journal_ref": "Crash Journal",
        }

        out = filter_obj.filter_results(
            [predatory_result, crash_result],
            "test query",
        )

        # Predatory MUST NOT appear in the output, even though the
        # filter's top-level except caught the crash from `Crash Journal`.
        titles = [r.get("title") for r in out]
        assert "Fake Paper" not in titles, (
            "Predatory source leaked into output after filter crash — "
            "regression on S4 safety contract"
        )


class TestInheritance:
    """Tests for class inheritance."""

    def test_inherits_from_base_filter(self):
        """Test that JournalReputationFilter inherits from BaseFilter."""
        from local_deep_research.advanced_search_system.filters.base_filter import (
            BaseFilter,
        )
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        assert issubclass(JournalReputationFilter, BaseFilter)


class TestTrailingYearStripping:
    """The trailing-year regex moved from a post-hoc retry into the
    cleaning pipeline. These tests lock in the behavior so it can't
    silently regress.
    """

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def _make_filter(self, mock_get_llm, mock_create_engine):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_create_engine.return_value = Mock()
        return JournalReputationFilter(
            model=Mock(),
            reliability_threshold=4,
            max_context=3000,
            exclude_non_published=False,
            quality_reanalysis_period=timedelta(days=365),
        )

    @pytest.mark.parametrize(
        "raw,expected",
        [
            ("NeurIPS 2023", "NeurIPS"),
            ("ICML 2024", "ICML"),
            ("ACL 2022", "ACL"),
            ("Journal of Physics 2023", "Journal of Physics"),
        ],
    )
    def test_strips_trailing_year_for_conferences(self, raw, expected):
        filter_obj = self._make_filter()
        result = filter_obj._JournalReputationFilter__regex_clean_journal_name(
            raw
        )
        assert result == expected

    def test_preserves_year_in_parentheses_via_existing_regex(self):
        # The parenthesized-year regex (line ~350) handles this case;
        # the new trailing-year regex must not interfere.
        filter_obj = self._make_filter()
        result = filter_obj._JournalReputationFilter__regex_clean_journal_name(
            "Nature (2023)"
        )
        assert result == "Nature"

    def test_handles_leading_and_trailing_year_together(self):
        # Leading-year regex strips "2023 ", then trailing-year strips
        # " 2024" — both should compose cleanly.
        filter_obj = self._make_filter()
        result = filter_obj._JournalReputationFilter__regex_clean_journal_name(
            "2023 Nature 2024"
        )
        assert result == "Nature"

    @pytest.mark.parametrize(
        "raw,expected",
        [
            # Empty trailing parens from arxiv journal_refs where the
            # citation year got stripped upstream.
            ("Physical Review Research ()", "Physical Review Research"),
            # Truncated volume/page markers: arxiv preview sometimes
            # cuts off "vol. 63" after the comma but before the digits,
            # leaving ", v" or ", vol" at the end.
            (
                "Plasma Physics and Controlled Fusion, v",
                "Plasma Physics and Controlled Fusion",
            ),
            ("Nature Physics, vol", "Nature Physics"),
            ("Journal of Chemistry, p", "Journal of Chemistry"),
            ("Journal of Chemistry, pp", "Journal of Chemistry"),
            ("Journal of Chemistry, no", "Journal of Chemistry"),
            # Regression cases from a live fresh-install search that
            # the earlier regex already handled — guard them against
            # being re-broken by the new patterns.
            ("Information Fusion Elsevier", "Information Fusion"),
            ("Information Fusion, 126", "Information Fusion"),
            ("Nature (London)", "Nature"),
        ],
    )
    def test_strips_arxiv_journal_ref_noise(self, raw, expected):
        filter_obj = self._make_filter()
        result = filter_obj._JournalReputationFilter__regex_clean_journal_name(
            raw
        )
        assert result == expected


def _empty_journal_quality_db():
    """Build a real JournalQualityDB attached to an empty in-memory
    SQLite. Lets the filter exercise its real Tier 1/2/3 SQL queries
    against a known-empty dataset, so an "unknown journal" actually
    falls through to Tier 4 — without the test having to mock every
    method on the data manager.
    """
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker

    from local_deep_research.journal_quality.db import JournalQualityDB
    from local_deep_research.journal_quality.models import JournalQualityBase

    db = JournalQualityDB()
    engine = create_engine("sqlite:///:memory:")
    JournalQualityBase.metadata.create_all(engine)
    db._engine = engine
    db._SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
    return db


class TestTier4SettingWiring:
    """The tier-4 LLM scoring path is gated by
    ``search.journal_reputation.enable_llm_scoring``. Default off so the
    bundled DB-only path stays the production behavior.

    These tests run the REAL ``__score_journal`` against a real (empty)
    JournalQualityDB so Tier 1/2/3 SQL actually executes. We only mock
    the external boundaries: the LLM ``model.invoke`` and the SearXNG
    engine. The assertion checks whether ``model.invoke`` was called —
    which is the actual signal that "tier 4 was invoked".
    """

    def _setting_patcher(self, *, enable_llm: bool):
        """Patch only ``enable_llm_scoring``; let other settings flow
        through to the real implementation."""
        from local_deep_research.config import search_config as sc

        original = sc.get_setting_from_snapshot

        def _patched(key, default=None, *args, **kwargs):
            if key == "search.journal_reputation.enable_llm_scoring":
                return enable_llm
            return original(key, default, *args, **kwargs)

        return patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            side_effect=_patched,
        )

    def _build_filter(self, *, enable_llm: bool):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_engine = Mock()
        mock_engine.is_available = True
        mock_engine.run.return_value = [
            {"snippet": "context about an unknown journal"}
        ]

        mock_model = Mock()
        mock_response = Mock()
        mock_response.content = "8"
        mock_model.invoke.return_value = mock_response

        # Real data manager bound to an empty in-memory DB.
        empty_db = _empty_journal_quality_db()

        with (
            patch(
                "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine",
                return_value=mock_engine,
            ),
            patch(
                "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
            ),
            patch(
                "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_journal_data_manager",
                return_value=empty_db,
            ),
        ):
            filter_obj = JournalReputationFilter(
                model=mock_model,
                reliability_threshold=4,
                max_context=3000,
                exclude_non_published=False,
                quality_reanalysis_period=timedelta(days=365),
            )

        return filter_obj, mock_model, mock_engine, empty_db

    def test_tier4_disabled_by_default_does_not_call_llm(self):
        """With the setting OFF, an unknown journal should fall through
        all bundled-data tiers and exit WITHOUT touching the LLM. This
        is the regression we're guarding — the previous code had a
        hardcoded `_enable_tier4 = False` which made the setting unreachable;
        now the setting is the only knob.
        """
        filter_obj, mock_model, mock_engine, empty_db = self._build_filter(
            enable_llm=False
        )

        try:
            with self._setting_patcher(enable_llm=False):
                # __score_journal now returns (score, source_tag). The tag
                # identifies which tier produced the score for rendering;
                # quality itself is resolved live, not frozen on Paper.
                score, source_tag = (
                    filter_obj._JournalReputationFilter__score_journal(
                        "Some Unknown Journal",
                        {"journal_ref": "Some Unknown Journal"},
                    )
                )

            # Real Tier 1/2/3 ran against an empty DB → all returned None.
            # Tier 4 is gated off → __score_journal exits via the
            # low-confidence branch, returning 3 (below default threshold).
            assert score == 3
            assert source_tag == "low_confidence"
            # The actual external boundary: the LLM was never invoked.
            mock_model.invoke.assert_not_called()
            # SearXNG was never queried either.
            mock_engine.run.assert_not_called()
        finally:
            empty_db.reset()

    def test_tier4_enabled_invokes_llm_for_unknown_journal(self):
        """With the setting ON, the same unknown journal should reach
        Tier 4 — the LLM gets called and we get back the parsed score.
        """
        filter_obj, mock_model, mock_engine, empty_db = self._build_filter(
            enable_llm=True
        )

        try:
            with self._setting_patcher(enable_llm=True):
                score, source_tag = (
                    filter_obj._JournalReputationFilter__score_journal(
                        "Some Unknown Journal",
                        {"journal_ref": "Some Unknown Journal"},
                    )
                )

            # Real Tier 1/2/3 missed → real Tier 4 ran → mocked LLM returned 8.
            assert score == 8
            assert source_tag == "llm"
            assert mock_model.invoke.call_count >= 1
            assert mock_engine.run.call_count >= 1
        finally:
            empty_db.reset()


class TestLlmCacheWriteUsesNfkcNormalization:
    """The filter's LLM cache write path must produce name_lower values
    identical to scoring.normalize_name(name) — NFKC + lower + strip.
    Bare .lower() leaves compatibility characters intact (U+2122 ™,
    ligatures, fullwidth letters) and would diverge from the migration
    backfill and reference DB, producing silent cache misses and,
    on migration, UNIQUE violations that abort the upgrade.
    """

    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.create_search_engine"
    )
    @patch(
        "local_deep_research.advanced_search_system.filters.journal_reputation_filter.get_llm"
    )
    def test_normalize_name_is_imported(self, mock_get_llm, mock_create_engine):
        """Guard against silent revert — if someone inlines .lower()
        again this import check will not catch it alone, but it locks
        the filter's dependency on normalize_name in the module.
        """
        from local_deep_research.advanced_search_system.filters import (
            journal_reputation_filter,
        )
        from local_deep_research.journal_quality.scoring import normalize_name

        assert journal_reputation_filter.normalize_name is normalize_name

    def test_nfkc_normalization_semantics(self):
        """Verify the normalize_name expected value for a compatibility
        character. Reviewers can cross-check this against the migration
        backfill (0006:257) and the reference DB builder to confirm
        all three paths agree.
        """
        from local_deep_research.journal_quality.scoring import normalize_name

        # U+2122 (™) NFKC-decomposes to "TM"; lower+strip → "tm".
        assert normalize_name("Physics Letters\u2122") == "physics letterstm"
        # Case-variant inputs collapse to the same canonical form.
        assert normalize_name("NATURE MEDICINE") == normalize_name(
            "Nature Medicine"
        )
        # Leading/trailing whitespace is stripped.
        assert normalize_name("  Cell  ") == "cell"
