"""
Coverage tests for JournalReputationFilter.

Tests cover missing branches in:
- __init__: SearXNG engine None or is_available=False raises JournalFilterError
- close(): closes engine and LLM when owns_llm=True
- create_default(): disabled by settings returns None; JournalFilterError caught returns None
- __check_result: no journal_ref with exclude_non_published=True/False
- __check_result: db hit within reanalysis period returns cached decision
- __check_result: LLM ValueError in analyze -> returns True (accept by default)
- filter_results: exception in filter returns original results unchanged
"""

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


MODULE = "local_deep_research.advanced_search_system.filters.journal_reputation_filter"


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


def _make_filter(
    engine_available=True,
    threshold=5,
    max_context=3000,
    exclude_non_published=False,
    reanalysis_days=365,
):
    """
    Construct a JournalReputationFilter bypassing real SearXNG and LLM.
    """
    from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
        JournalReputationFilter,
    )

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

    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=max_context,
            exclude_non_published=exclude_non_published,
            quality_reanalysis_period=timedelta(days=reanalysis_days),
        )

    # The filter's `db_ready` probe that gates the scoring path is
    # handled by the conftest's autouse fixture that patches
    # ``Path.exists``/``Path.stat`` for ``journal_quality.db``.
    return filt, mock_model, mock_engine


# ---------------------------------------------------------------------------
# __init__ error paths
# ---------------------------------------------------------------------------


class TestInitErrors:
    """SearXNG unavailability no longer raises — Tier 4 is optional."""

    def test_none_engine_does_not_raise(self):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_model = MagicMock()
        with patch(f"{MODULE}.create_search_engine", return_value=None):
            # Should NOT raise — SearXNG is optional now
            filt = JournalReputationFilter(
                model=mock_model,
                reliability_threshold=5,
                max_context=3000,
                exclude_non_published=False,
                quality_reanalysis_period=timedelta(days=365),
            )
            assert filt is not None

    def test_engine_not_available_does_not_raise(self):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        mock_model = MagicMock()
        mock_engine = MagicMock()
        mock_engine.is_available = False
        with patch(f"{MODULE}.create_search_engine", return_value=mock_engine):
            # Should NOT raise — SearXNG is optional now
            filt = JournalReputationFilter(
                model=mock_model,
                reliability_threshold=5,
                max_context=3000,
                exclude_non_published=False,
                quality_reanalysis_period=timedelta(days=365),
            )
            assert filt is not None


# ---------------------------------------------------------------------------
# close()
# ---------------------------------------------------------------------------


class TestClose:
    """close() cleans up engine and optionally LLM."""

    def test_close_calls_safe_close_on_engine(self):
        filt, mock_model, mock_engine = _make_filter()

        with patch(f"{MODULE}.safe_close") as mock_sc:
            filt.close()

        mock_sc.assert_any_call(mock_engine, "SearXNG engine", allow_none=True)

    def test_close_does_not_close_externally_supplied_llm(self):
        """When model was passed in (not owned), LLM is not closed."""
        filt, mock_model, _ = _make_filter()
        # _owns_llm is False when model is passed in
        filt._owns_llm = False

        with patch(f"{MODULE}.safe_close") as mock_sc:
            filt.close()

        # Ensure LLM safe_close was not called
        for call in mock_sc.call_args_list:
            assert "LLM" not in str(call)


# ---------------------------------------------------------------------------
# create_default()
# ---------------------------------------------------------------------------


class TestCreateDefault:
    """create_default class method."""

    def test_returns_none_when_disabled_in_settings(self):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value=False,
        ):
            result = JournalReputationFilter.create_default(
                engine_name="arxiv", settings_snapshot={}
            )

        assert result is None

    def test_returns_none_on_journal_filter_error(self):
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalFilterError,
            JournalReputationFilter,
        )

        with (
            patch(
                "local_deep_research.config.search_config.get_setting_from_snapshot",
                return_value=True,
            ),
            patch.object(
                JournalReputationFilter,
                "__init__",
                side_effect=JournalFilterError("SearXNG not available"),
            ),
        ):
            result = JournalReputationFilter.create_default(
                engine_name="arxiv", settings_snapshot={}
            )

        assert result is None


# ---------------------------------------------------------------------------
# __check_result paths
# ---------------------------------------------------------------------------


class TestCheckResultNullJournal:
    """Behaviour when result has no journal_ref."""

    def test_no_journal_ref_exclude_true_returns_empty(self):
        """exclude_non_published=True -> result with no journal is excluded."""
        filt, _, _ = _make_filter(exclude_non_published=True)

        results = filt.filter_results([{"title": "paper"}], "test query")
        assert len(results) == 0

    def test_no_journal_ref_exclude_false_keeps_result(self):
        """exclude_non_published=False -> result with no journal is kept."""
        filt, _, _ = _make_filter(exclude_non_published=False)

        results = filt.filter_results([{"title": "paper"}], "test query")
        assert len(results) == 1


class TestCheckResultCachedDb:
    """Behaviour when journal is found in the database."""

    def test_cached_quality_above_threshold_keeps_result(self):
        """DB hit with quality >= threshold within reanalysis period -> kept."""
        filt, mock_model, _ = _make_filter(threshold=5)

        import time

        fake_journal = MagicMock()
        fake_journal.quality = 8
        fake_journal.quality_analysis_time = int(time.time())  # fresh
        fake_journal.is_predatory = False

        mock_session = MagicMock()
        mock_session.__enter__ = MagicMock(return_value=mock_session)
        mock_session.__exit__ = MagicMock(return_value=False)
        # Real code does .filter_by(name=...).filter(score_source=="llm").filter(quality_model==...).first()
        mock_query = mock_session.query.return_value
        mock_query.filter_by.return_value.filter.return_value.filter.return_value.first.return_value = fake_journal

        with (
            patch.object(
                filt,
                "_JournalReputationFilter__clean_journal_name",
                return_value="Nature",
            ),
            patch.object(
                filt,
                "_JournalReputationFilter__db_session",
                return_value=mock_session,
            ),
        ):
            results = filt.filter_results(
                [{"journal_ref": "Nature (2023)", "title": "Test"}],
                "test query",
            )

        assert len(results) == 1

    def test_cached_quality_below_threshold_removes_result(self):
        """DB hit with quality < threshold within reanalysis period -> removed.

        Only Tier 4 (LLM) results are cached in the DB, so the mock
        simulates a cached LLM score below the threshold.
        """
        filt, _, _ = _make_filter(threshold=6)

        import time

        fake_journal = MagicMock()
        fake_journal.quality = 3  # below threshold
        fake_journal.quality_analysis_time = int(time.time())

        mock_session = MagicMock()
        mock_session.__enter__ = MagicMock(return_value=mock_session)
        mock_session.__exit__ = MagicMock(return_value=False)
        # Chain: query(Journal).filter_by(name=...).filter(score_source=="llm").filter(quality_model==...).first()
        mock_query = mock_session.query.return_value
        mock_query.filter_by.return_value.filter.return_value.filter.return_value.first.return_value = fake_journal

        with (
            patch.object(
                filt,
                "_JournalReputationFilter__clean_journal_name",
                return_value="Predatory Journal",
            ),
            patch.object(
                filt,
                "_JournalReputationFilter__db_session",
                return_value=mock_session,
            ),
        ):
            results = filt.filter_results(
                [{"journal_ref": "Predatory Journal Vol 1", "title": "Test"}],
                "test query",
            )

        assert len(results) == 0


class TestScoreJournalUnknown:
    """Unknown journals get a low-confidence score (3) and get filtered out
    at the default threshold (4). This is the deliberate fail-closed
    behavior introduced in the tiered scoring redesign — see the
    "low-confidence (score 3)" branch of __score_journal."""

    def test_unknown_journal_filtered_at_default_threshold(self):
        """Journal not in any tier → score 3 → filtered out at threshold 5."""
        filt, _, _ = _make_filter(threshold=5)

        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

        with (
            patch.object(
                filt,
                "_JournalReputationFilter__clean_journal_name",
                return_value="Unknown Journal",
            ),
            patch.object(
                filt,
                "_JournalReputationFilter__db_session",
                return_value=mock_session,
            ),
            patch.object(
                filt,
                "_JournalReputationFilter__analyze_journal_reputation",
                side_effect=ValueError("bad parse"),
            ),
        ):
            results = filt.filter_results(
                [{"journal_ref": "Unknown Journal 2022", "title": "Test"}],
                "test query",
            )

        # Score 3 < threshold 5 → filtered out
        assert len(results) == 0

    def test_unknown_journal_passes_at_low_threshold(self):
        """Same flow but threshold=2 → unknown score 3 passes."""
        filt, _, _ = _make_filter(threshold=2)

        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

        with (
            patch.object(
                filt,
                "_JournalReputationFilter__clean_journal_name",
                return_value="Unknown Journal",
            ),
            patch.object(
                filt,
                "_JournalReputationFilter__db_session",
                return_value=mock_session,
            ),
            patch.object(
                filt,
                "_JournalReputationFilter__analyze_journal_reputation",
                side_effect=ValueError("bad parse"),
            ),
        ):
            results = filt.filter_results(
                [{"journal_ref": "Unknown Journal 2022", "title": "Test"}],
                "test query",
            )

        assert len(results) == 1


# ---------------------------------------------------------------------------
# filter_results exception path
# ---------------------------------------------------------------------------


class TestTier36LlmNameCleanup:
    """Tier 3.6 — LLM name-cleanup salvage. When all bundled tiers miss
    AND ``enable_llm_scoring`` is on, the filter asks the LLM to
    canonicalize the name (e.g. strip a venue acronym) and retries the
    cheap OpenAlex lookup before falling through to the expensive Tier 4.
    """

    def _enable_tier4(self):
        from local_deep_research.config import search_config

        return patch.object(
            search_config,
            "get_setting_from_snapshot",
            side_effect=lambda key, default=None, settings_snapshot=None: (
                True
                if key == "search.journal_reputation.enable_llm_scoring"
                else default
            ),
        )

    def test_llm_relabel_then_openalex_hit(self):
        """LLM cleans the name, retry hits OpenAlex, score returned without
        falling through to Tier 4 SearXNG analysis."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        filt, _, _ = _make_filter(threshold=2)

        # Two-stage lookup_openalex: first call (with raw name) misses,
        # second call (with LLM-cleaned name) hits.
        oa_calls = []

        def fake_lookup_openalex(*, source_id=None, issn=None, name=None):
            oa_calls.append(name)
            # First call: raw name "Some Conference Acronym" → miss
            if len(oa_calls) == 1:
                return None
            # Retry call after LLM cleanup → hit
            return {
                "h_index": 80,
                "is_in_doaj": False,
                "type": "conference",
                "issn_l": None,
                "publisher": None,
                "openalex_source_id": "S1234",
                "name": "Cleaned Conference Name",
                "quartile": "Q1",
            }

        with (
            patch.object(
                JournalReputationFilter,
                "_JournalReputationFilter__llm_clean_journal_name",
                return_value="Cleaned Conference Name",
            ),
            patch.object(
                JournalReputationFilter,
                "_JournalReputationFilter__analyze_journal_reputation",
            ) as mock_tier4,
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "lookup_openalex",
                side_effect=fake_lookup_openalex,
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "lookup_doaj",
                return_value=None,
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "is_predatory",
                return_value=(False, None),
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "score_from_affiliations",
                return_value=None,
            ),
            self._enable_tier4(),
        ):
            results = filt.filter_results(
                [
                    {
                        "journal_ref": "Some Conference Acronym",
                        "title": "Test paper",
                    }
                ],
                "query",
            )

        # Tier 4 SearXNG path should NOT have been called — the LLM
        # cleanup retry resolved the venue cheaply.
        mock_tier4.assert_not_called()
        # The retry was made under the LLM-cleaned name.
        assert "Cleaned Conference Name" in oa_calls
        # Result kept (Q1 → JOURNAL_QUALITY_STRONG = 8 ≥ threshold 2,
        # bumped to ELITE 10 because h_index=80 > JOURNAL_HINDEX_ELITE? No
        # — JOURNAL_HINDEX_ELITE is 150. So score is 8.
        assert len(results) == 1

    def test_llm_relabel_no_match_falls_through_to_tier4(self):
        """If LLM cleanup misses too, Tier 4 still fires."""
        from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
            JournalReputationFilter,
        )

        filt, _, _ = _make_filter(threshold=2)

        with (
            patch.object(
                JournalReputationFilter,
                "_JournalReputationFilter__llm_clean_journal_name",
                return_value="Different Name",
            ),
            patch.object(
                JournalReputationFilter,
                "_JournalReputationFilter__analyze_journal_reputation",
                return_value=5,
            ) as mock_tier4,
            patch.object(
                JournalReputationFilter,
                "_JournalReputationFilter__save_llm_score_to_db",
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "lookup_openalex",
                return_value=None,
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "lookup_doaj",
                return_value=None,
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "is_predatory",
                return_value=(False, None),
            ),
            patch.object(
                filt._JournalReputationFilter__data_manager,
                "score_from_affiliations",
                return_value=None,
            ),
            self._enable_tier4(),
        ):
            filt.filter_results(
                [
                    {
                        "journal_ref": "Truly Unknown Journal",
                        "title": "Test",
                    }
                ],
                "query",
            )

        # Tier 4 SearXNG path WAS called because the cleanup retry failed.
        mock_tier4.assert_called_once()
