"""
Tests for the BaseSearchEngine class.

Tests cover:
- Initialization and property access
- Rate limiting integration
- API key checking
- Engine class loading
- Run method behavior
- Relevance filtering
"""

from unittest.mock import Mock

import pytest

from local_deep_research.config.constants import DEFAULT_MAX_FILTERED_RESULTS


class TestBaseSearchEngineInit:
    """Tests for BaseSearchEngine initialization."""

    def test_init_with_defaults(self):
        """Initialize with default values."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        # Create a concrete implementation for testing
        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine()

        assert engine.max_results == 10
        assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS
        assert engine.search_snippets_only is True
        assert engine.llm is None
        assert engine.programmatic_mode is False

    def test_init_with_custom_values(self):
        """Initialize with custom values."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        mock_llm = Mock()
        engine = TestEngine(
            llm=mock_llm,
            max_results=25,
            max_filtered_results=10,
            search_snippets_only=False,
            programmatic_mode=True,
        )

        assert engine.max_results == 25
        assert engine.max_filtered_results == 10
        assert engine.search_snippets_only is False
        assert engine.llm is mock_llm
        assert engine.programmatic_mode is True

    def test_init_with_none_max_results(self):
        """Handle None max_results gracefully."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine(max_results=None)

        assert engine.max_results == 10  # Default value

    def test_init_with_settings_snapshot(self):
        """Initialize with settings snapshot."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        settings = {"search.max_results": {"value": 20}}
        engine = TestEngine(settings_snapshot=settings)

        assert engine.settings_snapshot == settings


class TestBaseSearchEngineProperties:
    """Tests for BaseSearchEngine property access."""

    def test_max_results_setter(self):
        """Set max_results property."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine()
        engine.max_results = 50

        assert engine.max_results == 50

    def test_max_results_setter_minimum_value(self):
        """Ensure max_results is at least 1."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine()
        engine.max_results = 0

        assert engine.max_results == 1

    def test_max_filtered_results_setter(self):
        """Set max_filtered_results property."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine()
        engine.max_filtered_results = 20

        assert engine.max_filtered_results == 20


class TestBaseSearchEngineClassMethods:
    """Tests for BaseSearchEngine class methods."""

    def test_check_api_key_not_required(self):
        """API key check passes when not required."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        config = {"requires_api_key": False}
        result = BaseSearchEngine._check_api_key_availability("test", config)

        assert result is True

    def test_check_api_key_required_and_present(self):
        """API key check passes when required and valid."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        config = {"requires_api_key": True, "api_key": "valid-api-key-123"}
        result = BaseSearchEngine._check_api_key_availability("test", config)

        assert result is True

    def test_check_api_key_required_but_missing(self):
        """API key check fails when required but missing."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        config = {"requires_api_key": True, "api_key": ""}
        result = BaseSearchEngine._check_api_key_availability("test", config)

        assert result is False

    def test_check_api_key_placeholder_values(self):
        """API key check fails for placeholder values."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        placeholders = [
            "PLACEHOLDER",
            "YOUR_API_KEY_HERE",
            "None",
            "BRAVE_API_KEY",
            "YOUR_KEY",
            "null",
        ]

        for placeholder in placeholders:
            config = {"requires_api_key": True, "api_key": placeholder}
            result = BaseSearchEngine._check_api_key_availability(
                "test", config
            )
            assert result is False, f"Failed for placeholder: {placeholder}"

    def test_load_engine_class_success(self):
        """Successfully load engine class."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        config = {
            "module_path": ".engines.search_engine_wikipedia",
            "class_name": "WikipediaSearchEngine",
        }

        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "wikipedia", config
        )

        assert success is True
        assert engine_class is not None
        assert error is None

    def test_load_engine_class_missing_config(self):
        """Fail to load engine class with missing config."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        config = {"module_path": ".engines.search_engine_wikipedia"}

        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "wikipedia", config
        )

        assert success is False
        assert engine_class is None
        assert error is not None

    def test_load_engine_class_invalid_module(self):
        """Fail to load engine class from invalid module."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        config = {
            "module_path": ".engines.nonexistent_engine",
            "class_name": "NonexistentEngine",
        }

        success, engine_class, error = BaseSearchEngine._load_engine_class(
            "nonexistent", config
        )

        assert success is False
        assert engine_class is None
        assert error is not None


class TestBaseSearchEngineRun:
    """Tests for BaseSearchEngine run method."""

    def test_run_returns_empty_for_no_results(self):
        """Run returns empty list when no results."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine(programmatic_mode=True)
        results = engine.run("test query")

        assert results == []

    def test_run_returns_previews_when_snippets_only(self):
        """Run returns previews when search_snippets_only is True."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return [
                    {
                        "title": "Result 1",
                        "snippet": "Snippet 1",
                        "url": "http://a.com",
                    },
                    {
                        "title": "Result 2",
                        "snippet": "Snippet 2",
                        "url": "http://b.com",
                    },
                ]

            def _get_full_content(self, relevant_items):
                for item in relevant_items:
                    item["full_content"] = "Full content here"
                return relevant_items

        engine = TestEngine(programmatic_mode=True, search_snippets_only=True)
        results = engine.run("test query")

        assert len(results) == 2
        assert "full_content" not in results[0]  # Should not have full content

    def test_run_gets_full_content_when_not_snippets_only(self):
        """Run gets full content when search_snippets_only is False."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return [
                    {
                        "title": "Result 1",
                        "snippet": "Snippet 1",
                        "url": "http://a.com",
                    },
                ]

            def _get_full_content(self, relevant_items):
                for item in relevant_items:
                    item["full_content"] = "Full content here"
                return relevant_items

        engine = TestEngine(programmatic_mode=True, search_snippets_only=False)
        results = engine.run("test query")

        assert len(results) == 1
        assert results[0]["full_content"] == "Full content here"

    def test_run_applies_preview_filters(self):
        """Run applies preview filters."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )
        from local_deep_research.advanced_search_system.filters.base_filter import (
            BaseFilter,
        )

        class TestFilter(BaseFilter):
            def filter_results(self, results, query):
                return [
                    r for r in results if "keep" in r.get("title", "").lower()
                ]

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return [
                    {
                        "title": "Keep Result",
                        "snippet": "S1",
                        "url": "http://a.com",
                    },
                    {
                        "title": "Drop Result",
                        "snippet": "S2",
                        "url": "http://b.com",
                    },
                ]

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine(
            programmatic_mode=True, preview_filters=[TestFilter()]
        )
        results = engine.run("test query")

        assert len(results) == 1
        assert results[0]["title"] == "Keep Result"

    def test_run_handles_exception(self):
        """Run handles exceptions gracefully."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                raise Exception("Search failed")

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine(programmatic_mode=True)
        results = engine.run("test query")

        assert results == []

    def test_invoke_calls_run(self):
        """Invoke method calls run."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return [
                    {"title": "Result", "snippet": "S", "url": "http://a.com"}
                ]

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine(programmatic_mode=True)
        results = engine.invoke("test query")

        assert len(results) == 1


class TestBaseSearchEngineRelevanceFiltering:
    """Tests for BaseSearchEngine relevance filtering."""

    def test_filter_for_relevance_without_llm(self):
        """Filter returns all results when no LLM."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        engine = TestEngine(programmatic_mode=True, llm=None)
        previews = [
            {
                "title": "Result 1",
                "snippet": "Snippet 1",
                "url": "http://a.com",
            },
            {
                "title": "Result 2",
                "snippet": "Snippet 2",
                "url": "http://b.com",
            },
        ]

        result = engine._filter_for_relevance(previews, "test query")

        assert result == previews

    def test_filter_for_relevance_with_single_result(self):
        """Filter returns single result without LLM call."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        mock_llm = Mock()
        engine = TestEngine(programmatic_mode=True, llm=mock_llm)
        previews = [
            {"title": "Result 1", "snippet": "Snippet 1", "url": "http://a.com"}
        ]

        result = engine._filter_for_relevance(previews, "test query")

        assert result == previews
        mock_llm.invoke.assert_not_called()

    def test_filter_for_relevance_with_llm(self):
        """Filter uses LLM for ranking."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        mock_llm = Mock()
        mock_llm.invoke.return_value = "1, 0"

        engine = TestEngine(
            programmatic_mode=True, llm=mock_llm, max_filtered_results=5
        )
        previews = [
            {
                "title": "Result 0",
                "snippet": "Snippet 0",
                "url": "http://a.com",
            },
            {
                "title": "Result 1",
                "snippet": "Snippet 1",
                "url": "http://b.com",
            },
        ]

        result = engine._filter_for_relevance(previews, "test query")

        assert len(result) == 2
        # Should return in ranked order: [1, 0] means Result 1 first
        assert result[0]["title"] == "Result 1"
        assert result[1]["title"] == "Result 0"

    def test_filter_for_relevance_handles_invalid_indices(self):
        """Filter handles out-of-range indices gracefully."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        mock_llm = Mock()
        mock_llm.invoke.return_value = "0, 5, 1, 100"

        engine = TestEngine(programmatic_mode=True, llm=mock_llm)
        previews = [
            {
                "title": "Result 0",
                "snippet": "Snippet 0",
                "url": "http://a.com",
            },
            {
                "title": "Result 1",
                "snippet": "Snippet 1",
                "url": "http://b.com",
            },
        ]

        result = engine._filter_for_relevance(previews, "test query")

        # Should only include valid indices
        assert len(result) == 2
        assert result[0]["title"] == "Result 0"
        assert result[1]["title"] == "Result 1"

    def test_filter_for_relevance_handles_llm_error(self):
        """Filter handles LLM errors gracefully."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class TestEngine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, relevant_items):
                return relevant_items

        mock_llm = Mock()
        mock_llm.invoke.side_effect = Exception("LLM Error")

        engine = TestEngine(
            programmatic_mode=True, llm=mock_llm, max_filtered_results=5
        )
        previews = [
            {
                "title": "Result 0",
                "snippet": "Snippet 0",
                "url": "http://a.com",
            },
            {
                "title": "Result 1",
                "snippet": "Snippet 1",
                "url": "http://b.com",
            },
        ]

        result = engine._filter_for_relevance(previews, "test query")

        # Should fallback to top results
        assert len(result) <= 5


class TestBaseSearchEngineClassAttributes:
    """Tests for BaseSearchEngine class attributes."""

    def test_default_class_attributes(self):
        """Verify default class attribute values."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        assert BaseSearchEngine.is_public is False
        assert BaseSearchEngine.is_generic is False
        assert BaseSearchEngine.is_scientific is False
        assert BaseSearchEngine.is_local is False
        assert BaseSearchEngine.is_news is False
        assert BaseSearchEngine.is_code is False


class TestEnsureList:
    """Tests for BaseSearchEngine._ensure_list static method."""

    def test_list_passthrough(self):
        """Already-parsed lists pass through unchanged."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list(["a", "b"])
        assert result == ["a", "b"]

    def test_json_string(self):
        """JSON-encoded array strings are parsed."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list('["http://localhost:9200"]')
        assert result == ["http://localhost:9200"]

    def test_json_string_multiple(self):
        """JSON-encoded array with multiple items."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list('["content", "title"]')
        assert result == ["content", "title"]

    def test_comma_separated(self):
        """Comma-separated strings are split."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list("content, title, description")
        assert result == ["content", "title", "description"]

    def test_none_returns_default(self):
        """None returns the default list."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list(None)
        assert result == []

        result = BaseSearchEngine._ensure_list(None, default=["x"])
        assert result == ["x"]

    def test_empty_string_returns_default(self):
        """Empty or whitespace-only strings return the default."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        assert BaseSearchEngine._ensure_list("") == []
        assert BaseSearchEngine._ensure_list("   ") == []

    def test_invalid_json_falls_through_to_comma_split(self):
        """Malformed JSON starting with [ falls back to comma split."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list("[not valid json")
        assert result == ["[not valid json"]

    def test_non_string_non_list_returns_default(self):
        """Non-string, non-list types return the default."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        assert BaseSearchEngine._ensure_list(42) == []
        assert BaseSearchEngine._ensure_list({"a": 1}) == []

    def test_json_string_with_whitespace(self):
        """JSON strings with leading/trailing whitespace are handled."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list('  ["a", "b"]  ')
        assert result == ["a", "b"]

    def test_json_integer_items_become_strings(self):
        """Non-string items in JSON arrays are converted to strings."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        result = BaseSearchEngine._ensure_list("[1, 2, 3]")
        assert result == ["1", "2", "3"]


class TestAdaptiveWait:
    """Tests for AdaptiveWait class."""

    def test_adaptive_wait_calls_function(self):
        """AdaptiveWait calls provided function."""
        from local_deep_research.web_search_engines.search_engine_base import (
            AdaptiveWait,
        )

        wait_func = Mock(return_value=2.5)
        adaptive_wait = AdaptiveWait(wait_func)

        mock_retry_state = Mock()
        result = adaptive_wait(mock_retry_state)

        assert result == 2.5
        wait_func.assert_called_once()


class TestDoiEnrichmentOrdering:
    """Ensure enrichment runs before preview filters.

    Regression guard: the OpenAlex source-id enrichment must populate
    ``openalex_source_id`` on each result BEFORE the preview filters
    (which include JournalReputationFilter) run. If the ordering
    slips back to post-content-fetch, Tier 2 journal lookups silently
    degrade to fragile name matching even when a DOI was available.
    """

    def test_preview_filter_sees_enriched_source_id(self, monkeypatch):
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class FakeEngine(BaseSearchEngine):
            is_scientific = True  # opts us into the enrichment branch

            def _get_previews(self, query):
                return [
                    {
                        "title": "Some paper",
                        "doi": "10.1234/abc",
                    }
                ]

            def _get_full_content(self, items):
                return items

        # Capture the previews the filter sees so we can assert
        # ordering: enrichment must have already run.
        seen_by_filter: list[list[dict]] = []

        class CaptureFilter:
            def filter_results(self, results, query):
                seen_by_filter.append(
                    [dict(r) for r in results]  # snapshot
                )
                return results

        def fake_enrich(results, email=None):
            for r in results:
                r["openalex_source_id"] = "S42"
            return results

        monkeypatch.setattr(
            "local_deep_research.utilities.openalex_enrichment."
            "enrich_results_with_source_ids",
            fake_enrich,
        )

        engine = FakeEngine(programmatic_mode=True)
        engine._preview_filters = [CaptureFilter()]

        engine.run("anything")

        assert seen_by_filter, "preview filter was never called"
        first_result = seen_by_filter[0][0]
        assert first_result.get("openalex_source_id") == "S42", (
            "enrichment must run before preview filters so the "
            "journal reputation filter can use the source_id"
        )

    def test_non_scientific_engine_skips_enrichment(self, monkeypatch):
        """Non-scientific engines don't pay the enrichment cost."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class FakeEngine(BaseSearchEngine):
            # is_scientific defaults False via getattr

            def _get_previews(self, query):
                return [{"title": "Web result", "url": "http://x.com"}]

            def _get_full_content(self, items):
                return items

        called = {"count": 0}

        def fake_enrich(results, email=None):
            called["count"] += 1
            return results

        monkeypatch.setattr(
            "local_deep_research.utilities.openalex_enrichment."
            "enrich_results_with_source_ids",
            fake_enrich,
        )

        engine = FakeEngine(programmatic_mode=True)
        engine.run("anything")

        assert called["count"] == 0, (
            "non-scientific engines should not trigger DOI enrichment"
        )


class TestInitFullSearchForwardsSettingsSnapshot:
    """Issue #3826: ``_init_full_search`` must forward
    ``self.settings_snapshot`` so ``FullSearchResults`` can read the
    ``web.enable_javascript_rendering`` toggle."""

    def _make_engine(self, settings_snapshot):
        """Build a minimal BaseSearchEngine subclass that calls
        ``_init_full_search``. The caller is responsible for patching
        ``FullSearchResults`` around the invocation when it needs to
        capture the constructor kwargs."""
        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        class _Engine(BaseSearchEngine):
            def _get_previews(self, query):
                return []

            def _get_full_content(self, items):
                return items

        engine = _Engine(
            llm=Mock(),
            include_full_content=True,
            settings_snapshot=settings_snapshot,
        )
        engine._init_full_search(web_search=Mock())
        return engine

    def test_forwards_snapshot_with_value(self):
        from unittest.mock import patch

        snapshot = {
            "web.enable_javascript_rendering": {
                "value": True,
                "ui_element": "checkbox",
            }
        }
        with patch(
            "local_deep_research.web_search_engines.engines.full_search.FullSearchResults"
        ) as mock_full_search:
            self._make_engine(snapshot)
        # FullSearchResults must have been constructed with the snapshot
        kwargs = mock_full_search.call_args.kwargs
        assert kwargs.get("settings_snapshot") is snapshot

    def test_forwards_none_when_no_snapshot(self):
        from unittest.mock import patch

        with patch(
            "local_deep_research.web_search_engines.engines.full_search.FullSearchResults"
        ) as mock_full_search:
            self._make_engine(None)
        kwargs = mock_full_search.call_args.kwargs
        # The base class normalizes missing snapshot to {} not None — accept either
        snap = kwargs.get("settings_snapshot")
        assert snap is None or snap == {}


# =============================================================================
# Runtime egress scope verification tests
# =============================================================================


def _scope_snapshot(scope, tool="arxiv"):
    """Build a minimal settings snapshot for egress tests."""
    return {
        "policy.egress_scope": {"value": scope},
        "search.tool": {"value": tool},
    }


def _make_egress_engine(engine_name="", snapshot=None, **kw):
    """Create a concrete BaseSearchEngine with egress fields wired up."""
    from local_deep_research.web_search_engines.search_engine_base import (
        BaseSearchEngine,
    )

    class _E(BaseSearchEngine):
        def _get_previews(self, query):
            return []

        def _get_full_content(self, relevant_items):
            return relevant_items

    _E.__name__ = engine_name or "TestEngine"
    eng = _E(programmatic_mode=True, settings_snapshot=snapshot, **kw)
    if engine_name:
        eng._engine_name = engine_name
    return eng


class TestVerifyEgressScope:
    """Tests for _verify_egress_scope and _check_egress_policy."""

    @staticmethod
    def _fake_engine_class(is_public=None, is_local=None):
        """Build a fake engine class with given flags for patching."""
        return type(
            "FakeEngine", (), {"is_public": is_public, "is_local": is_local}
        )

    def test_no_op_without_snapshot(self):
        """No settings_snapshot → silent pass-through."""
        eng = _make_egress_engine(engine_name="arxiv", snapshot=None)
        eng._verify_egress_scope()  # should not raise

    def test_no_op_without_engine_name(self):
        """No _engine_name → silent pass-through."""
        eng = _make_egress_engine(
            snapshot=_scope_snapshot("private_only", "arxiv")
        )
        assert eng._engine_name == ""
        eng._verify_egress_scope()  # should not raise

    def test_no_op_with_empty_snapshot(self):
        """Empty dict snapshot → silent pass-through."""
        eng = _make_egress_engine(engine_name="arxiv", snapshot={})
        eng._verify_egress_scope()  # should not raise

    def test_denied_raises_on_direct_call(self):
        """_check_egress_policy raises PolicyDeniedError when a public
        engine is used under PRIVATE_ONLY scope."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("private_only", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            with pytest.raises(PolicyDeniedError) as exc_info:
                eng._check_egress_policy()
            assert exc_info.value.target == "arxiv"

    def test_allowed_scope_does_not_raise(self):
        """Public engine under BOTH scope → no error."""
        from unittest.mock import patch

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("both", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            eng._check_egress_policy()  # should not raise

    def test_public_engine_allowed_under_public_only(self):
        """Public engine under PUBLIC_ONLY → allowed."""
        from unittest.mock import patch

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("public_only", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            eng._check_egress_policy()  # should not raise

    def test_local_engine_allowed_under_private_only(self):
        """Local engine under PRIVATE_ONLY → allowed."""
        from unittest.mock import patch

        fake_cls = self._fake_engine_class(is_public=False, is_local=True)
        snap = _scope_snapshot("private_only", "localengine")
        eng = _make_egress_engine(engine_name="localengine", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            eng._check_egress_policy()  # should not raise

    def test_local_engine_denied_under_public_only(self):
        """Local engine under PUBLIC_ONLY → denied."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=False, is_local=True)
        snap = _scope_snapshot("public_only", "localengine")
        eng = _make_egress_engine(engine_name="localengine", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            with pytest.raises(PolicyDeniedError):
                eng._check_egress_policy()

    def test_verify_propagates_policy_denied(self):
        """_verify_egress_scope re-raises PolicyDeniedError — the backstop
        actually denies rather than just logging."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("private_only", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            with pytest.raises(PolicyDeniedError):
                eng._verify_egress_scope()

    def test_verify_swallows_internal_errors(self):
        """Unexpected internal errors in the policy evaluation are logged,
        not propagated — a broken backstop must not break searches the
        factory PEP already approved."""
        from unittest.mock import patch

        snap = _scope_snapshot("both", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch.object(
            eng, "_check_egress_policy", side_effect=RuntimeError("boom")
        ):
            eng._verify_egress_scope()  # should not raise

    def test_snapshot_dict_value_extracted(self):
        """When search.tool is a dict with 'value', the value is extracted."""
        from unittest.mock import patch

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = {
            "policy.egress_scope": {"value": "both"},
            "search.tool": {"value": "arxiv"},
        }
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            eng._check_egress_policy()  # arxiv under BOTH → allowed

    def test_run_raises_on_denied_engine(self):
        """run() propagates PolicyDeniedError from the runtime backstop —
        a denied engine must not execute its search."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("private_only", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            with pytest.raises(PolicyDeniedError):
                eng.run("test query")

    def test_run_allows_permitted_engine(self):
        """run() proceeds normally when engine is allowed."""
        from unittest.mock import patch

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("public_only", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            results = eng.run("test query")
        assert isinstance(results, list)

    def test_verification_memoized_per_snapshot_identity(self):
        """A successful verification is memoized for the SAME snapshot
        object: the policy is not re-evaluated on every run (under
        ADAPTIVE with a URL-configurable primary that evaluation can
        include a DNS lookup). Assigning a refreshed snapshot
        invalidates the memo."""
        from unittest.mock import patch

        snap = _scope_snapshot("both", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch.object(
            eng, "_check_egress_policy", wraps=eng._check_egress_policy
        ) as spy:
            with patch(
                "local_deep_research.security.egress.policy._get_engine_class",
                return_value=self._fake_engine_class(
                    is_public=True, is_local=False
                ),
            ):
                eng._verify_egress_scope()
                eng._verify_egress_scope()
                eng._verify_egress_scope()
            assert spy.call_count == 1

            # A refreshed (new) snapshot object re-verifies.
            eng.settings_snapshot = _scope_snapshot("both", "arxiv")
            with patch(
                "local_deep_research.security.egress.policy._get_engine_class",
                return_value=self._fake_engine_class(
                    is_public=True, is_local=False
                ),
            ):
                eng._verify_egress_scope()
            assert spy.call_count == 2

    def test_in_place_scope_mutation_invalidates_memo(self):
        """Mutating the scope key IN PLACE on the same snapshot dict must
        re-verify (the memo guards on the policy-relevant values, not just
        object identity) — and deny when the new scope forbids the engine."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("both", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            eng._verify_egress_scope()  # allowed and memoized
            # In-place mutation of the SAME dict object.
            eng.settings_snapshot["policy.egress_scope"]["value"] = (
                "private_only"
            )
            with pytest.raises(PolicyDeniedError):
                eng._verify_egress_scope()

    def test_denial_not_memoized(self):
        """Denials raise every time — only successful verifications are
        memoized."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        snap = _scope_snapshot("private_only", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            with pytest.raises(PolicyDeniedError):
                eng._verify_egress_scope()
            with pytest.raises(PolicyDeniedError):
                eng._verify_egress_scope()

    def test_cached_engine_scope_change_denied(self):
        """A cached engine is re-checked against its CURRENT snapshot on
        every run. NB: the backstop reads ``self.settings_snapshot``, so a
        scope change is only caught when the caller refreshes the snapshot
        on the cached instance (as simulated here)."""
        from unittest.mock import patch

        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        fake_cls = self._fake_engine_class(is_public=True, is_local=False)
        # Engine was created under BOTH scope → allowed
        snap = _scope_snapshot("both", "arxiv")
        eng = _make_egress_engine(engine_name="arxiv", snapshot=snap)
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            eng._verify_egress_scope()  # fine

        # Now scope changes to PRIVATE_ONLY on the cached instance
        eng.settings_snapshot = _scope_snapshot("private_only", "arxiv")
        with patch(
            "local_deep_research.security.egress.policy._get_engine_class",
            return_value=fake_cls,
        ):
            with pytest.raises(PolicyDeniedError):
                eng._verify_egress_scope()


class TestCollectionEngineEgress:
    """CollectionSearchEngine.search() bypasses run(), so it applies the
    runtime backstop itself; the engine name is stamped at construction."""

    @staticmethod
    def _make_collection_engine(snapshot):
        from local_deep_research.web_search_engines.engines.search_engine_collection import (
            CollectionSearchEngine,
        )

        return CollectionSearchEngine(
            collection_id="abc-123",
            collection_name="Test Collection",
            settings_snapshot=snapshot,
        )

    def test_engine_name_stamped_in_init(self):
        eng = self._make_collection_engine(snapshot={"_username": "testuser"})
        assert eng._engine_name == "collection_abc-123"

    def test_search_denied_under_public_only(self):
        """A (default-private) collection under PUBLIC_ONLY scope is denied
        by search() before any FAISS/DB work happens. The collection lookup
        fails closed to private in tests (no user DB)."""
        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        snap = {
            "_username": "testuser",
            "policy.egress_scope": {"value": "public_only"},
            "search.tool": {"value": "wikipedia"},
        }
        eng = self._make_collection_engine(snapshot=snap)
        with pytest.raises(PolicyDeniedError):
            eng.search("test query")

    def test_search_allowed_under_private_only(self):
        """A collection under PRIVATE_ONLY passes the backstop: search()
        proceeds PAST the policy check. In tests there is no user DB, so
        the next layer raises a DB error — what matters here is that it is
        NOT PolicyDeniedError. (search() deliberately propagates non-policy
        errors instead of masking them as 'no results'.)"""
        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        snap = {
            "_username": "testuser",
            "policy.egress_scope": {"value": "private_only"},
            "search.tool": {"value": "wikipedia"},
        }
        eng = self._make_collection_engine(snapshot=snap)
        try:
            results = eng.search("test query")
        except PolicyDeniedError:
            pytest.fail(
                "PRIVATE_ONLY must allow a collection engine; the egress "
                "backstop wrongly denied it"
            )
        except Exception:
            # DB-layer failure (no test user DB) — fine: the backstop
            # allowed the search to proceed past the policy check.
            return
        assert isinstance(results, list)


class TestLibraryEngineEgress:
    """LibraryRAGSearchEngine self-stamps 'library' and applies the
    backstop in search(), which is callable directly (bypassing run())."""

    @staticmethod
    def _make_library_engine(snapshot):
        from local_deep_research.web_search_engines.engines.search_engine_library import (
            LibraryRAGSearchEngine,
        )

        return LibraryRAGSearchEngine(settings_snapshot=snapshot)

    def test_engine_name_stamped_in_init(self):
        eng = self._make_library_engine({"_username": "tester"})
        assert eng._engine_name == "library"

    def test_search_denied_under_public_only(self):
        """The library is always private-nature, so PUBLIC_ONLY denies
        search() before any DB/FAISS work happens."""
        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        snap = {
            "_username": "tester",
            "policy.egress_scope": {"value": "public_only"},
            "search.tool": {"value": "wikipedia"},
        }
        eng = self._make_library_engine(snap)
        with pytest.raises(PolicyDeniedError):
            eng.search("test query")


class TestEngineNameSetByFactory:
    """Test that _engine_name is properly tracked."""

    def test_default_engine_name_empty(self):
        eng = _make_egress_engine()
        assert eng._engine_name == ""

    def test_engine_name_set_explicitly(self):
        eng = _make_egress_engine(engine_name="searxng")
        assert eng._engine_name == "searxng"

    def test_engine_name_mutable(self):
        eng = _make_egress_engine()
        assert eng._engine_name == ""
        eng._engine_name = "collection_abc-123"
        assert eng._engine_name == "collection_abc-123"


class TestCreateJournalFilter:
    """Regression tests for ``_create_journal_filter``.

    Academic engine subclasses build their preview filters BEFORE calling
    ``super().__init__()`` (the filter is passed into the parent
    constructor), so ``self.llm`` / ``self.settings_snapshot`` do not exist
    yet at that point. The helper must therefore read ``llm`` and
    ``settings_snapshot`` from its arguments — never from ``self`` — or
    every academic engine crashes at construction with
    ``AttributeError: ... has no attribute 'llm'``.
    """

    def test_reads_args_not_self_and_forwards_them(self):
        from unittest.mock import patch

        from local_deep_research.web_search_engines.search_engine_base import (
            BaseSearchEngine,
        )

        # A bare object with NO .llm / .settings_snapshot attributes,
        # standing in for an engine instance mid-__init__ (pre-super()).
        not_yet_initialized = object()
        sentinel_llm = Mock()
        snapshot = {"journal_reputation.enabled": True}

        with patch(
            "local_deep_research.advanced_search_system.filters."
            "journal_reputation_filter.JournalReputationFilter.create_default"
        ) as mock_create:
            mock_create.return_value = "FILTER"
            result = BaseSearchEngine._create_journal_filter(
                not_yet_initialized,
                "semantic_scholar",
                sentinel_llm,
                snapshot,
            )

        assert result == "FILTER"
        mock_create.assert_called_once_with(
            model=sentinel_llm,
            engine_name="semantic_scholar",
            settings_snapshot=snapshot,
        )
