"""Integration tests for the egress-policy PEP at the search-engine factory
call site: ``web_search_engines.search_engine_factory.create_search_engine``.

These drive the REAL factory function (this file deliberately does NOT inherit
the ``tests/core`` factory fixtures that bypass the PDP), so the real
``evaluate_engine`` / ``context_from_snapshot`` enforcement actually fires.
Only the engine's heavy constructor is stubbed out (via ``get_safe_module_class``),
so every assertion is about the POLICY decision — which the factory makes
BEFORE it ever loads or instantiates the engine class.

Engine classifications used here come from the live ``ENGINE_REGISTRY``:
  - arxiv / wikipedia : public  (is_public=True,  is_local=False)
  - paperless         : local   (is_public=False, is_local=True)

Each scope axis is covered with an allow+deny pair so that reverting the PEP
(or loosening a scope check) flips a deny into an allow and fails the suite.
"""

from __future__ import annotations

from contextlib import contextmanager
from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.security import clear_active_context
from local_deep_research.security.egress.policy import PolicyDeniedError
import local_deep_research.web_search_engines.search_engine_factory as factory


class _FakeEngine:
    """Lightweight stand-in for a real search-engine class.

    Constructing it does no network/DB work, so the factory *reaching* this
    constructor is proof the PEP ALLOWED the engine — the denial path raises
    long before instantiation.
    """

    needs_llm_relevance_filter = False

    def __init__(self, **kwargs):
        self.llm = kwargs.get("llm")
        self.init_kwargs = kwargs


@pytest.fixture(autouse=True)
def _no_leaked_armed_context():
    """The factory PEP reads scope from the snapshot, not the armed audit-hook
    context. But clear the active context before and after each test anyway so
    a context leaked by another test file can never mask a decision here, and so
    this file never leaks one outward (hard rule: never leak armed context)."""
    clear_active_context()
    yield
    clear_active_context()


def _snapshot(scope, primary, *engines, **extra):
    """Build a minimal settings snapshot the factory accepts.

    ``scope`` -> ``policy.egress_scope``; ``primary`` -> ``search.tool`` (the
    engine the EgressContext treats as primary); every name in ``engines`` is
    injected under ``search.engine.web`` so ``search_config()`` lists it — the
    factory fails closed on unknown names BEFORE the PEP, so a real engine must
    be present for the policy decision to be the thing under test.
    """
    snap = {
        "policy.egress_scope": scope,
        "search.tool": primary,
    }
    for name in engines:
        snap[f"search.engine.web.{name}.display_name"] = name
    snap.update(extra)
    return snap


@contextmanager
def _stub_engine_construction():
    """Patch the factory's class loader so an ALLOWED engine is built as a cheap
    ``_FakeEngine`` instead of doing real network/DB construction.

    Yields the ``MagicMock`` loader so tests can assert whether construction was
    even attempted: for a DENIED engine the PEP must raise first and the loader
    must never be called. (``evaluate_engine`` reads engine class *flags* via a
    separate import in the policy module, so this mock only tracks the factory's
    own instantiation call site.)
    """
    loader = MagicMock(return_value=_FakeEngine)
    with patch.object(factory, "get_safe_module_class", loader):
        yield loader


# ---------------------------------------------------------------------------
# PRIVATE_ONLY: public engines refused, local engines allowed.
# ---------------------------------------------------------------------------


class TestPrivateOnlyScope:
    def test_public_engine_denied(self):
        # arxiv is a public engine; PRIVATE_ONLY must refuse it at the factory
        # PEP, before any engine class is loaded.
        snap = _snapshot("private_only", "library", "arxiv")
        with _stub_engine_construction() as loader:
            with pytest.raises(PolicyDeniedError) as exc:
                factory.create_search_engine("arxiv", settings_snapshot=snap)
        assert exc.value.decision.reason == "scope_mismatch_private_only"
        loader.assert_not_called()

    def test_local_engine_allowed(self):
        # paperless is a local engine; PRIVATE_ONLY permits it (allow pair).
        snap = _snapshot("private_only", "paperless", "paperless")
        with _stub_engine_construction() as loader:
            engine = factory.create_search_engine(
                "paperless", settings_snapshot=snap
            )
        assert isinstance(engine, _FakeEngine)
        loader.assert_called()


# ---------------------------------------------------------------------------
# PUBLIC_ONLY: local engines refused, public engines allowed.
# ---------------------------------------------------------------------------


class TestPublicOnlyScope:
    def test_local_engine_denied(self):
        # paperless is local; PUBLIC_ONLY must refuse it before construction.
        snap = _snapshot("public_only", "arxiv", "paperless")
        with _stub_engine_construction() as loader:
            with pytest.raises(PolicyDeniedError) as exc:
                factory.create_search_engine(
                    "paperless", settings_snapshot=snap
                )
        assert exc.value.decision.reason == "scope_mismatch_public_only"
        loader.assert_not_called()

    def test_public_engine_allowed(self):
        # arxiv is public; PUBLIC_ONLY permits it (allow pair).
        snap = _snapshot("public_only", "arxiv", "arxiv")
        with _stub_engine_construction() as loader:
            engine = factory.create_search_engine(
                "arxiv", settings_snapshot=snap
            )
        assert isinstance(engine, _FakeEngine)
        loader.assert_called()


# ---------------------------------------------------------------------------
# STRICT: only the primary engine may be built.
# ---------------------------------------------------------------------------


class TestStrictScope:
    def test_non_primary_engine_denied(self):
        # STRICT permits ONLY the primary. With primary=arxiv, asking for a
        # different (also public) engine must be refused as strict_not_primary,
        # proving STRICT is enforced on the engine identity, not just the
        # public/local bucket.
        snap = _snapshot("strict", "arxiv", "arxiv", "wikipedia")
        with _stub_engine_construction() as loader:
            with pytest.raises(PolicyDeniedError) as exc:
                factory.create_search_engine(
                    "wikipedia", settings_snapshot=snap
                )
        assert exc.value.decision.reason == "strict_not_primary"
        loader.assert_not_called()

    def test_primary_engine_allowed(self):
        # The primary itself is permitted under STRICT (allow pair).
        snap = _snapshot("strict", "arxiv", "arxiv")
        with _stub_engine_construction() as loader:
            engine = factory.create_search_engine(
                "arxiv", settings_snapshot=snap
            )
        assert isinstance(engine, _FakeEngine)
        loader.assert_called()


# ---------------------------------------------------------------------------
# Removed meta-pickers (auto/meta/parallel/parallel_scientific) fail closed.
# ---------------------------------------------------------------------------


class TestRemovedMetaEnginesFailClosed:
    """The meta-picker engines were removed. Their names no longer get a
    skip-the-PEP fast path at the factory: they are unknown engine names and
    must be rejected (ValueError) before any construction — never silently
    delegated or rewritten to a concrete engine."""

    @pytest.mark.parametrize(
        "name", ["auto", "meta", "parallel", "parallel_scientific"]
    )
    def test_removed_meta_name_raises_value_error(self, name):
        snap = _snapshot("both", "arxiv", "arxiv")
        with _stub_engine_construction() as loader:
            with pytest.raises(ValueError, match="removed"):
                factory.create_search_engine(name, settings_snapshot=snap)
        loader.assert_not_called()

    def test_strict_with_stray_meta_primary_still_denies_non_primary(self):
        # A stray 'meta' primary left in the DB under STRICT no longer raises
        # at context construction; the STRICT identity check still refuses any
        # concrete engine that isn't the (nonexistent) primary — fail closed.
        snap = _snapshot("strict", "meta", "arxiv")
        with _stub_engine_construction() as loader:
            with pytest.raises(PolicyDeniedError) as exc:
                factory.create_search_engine("arxiv", settings_snapshot=snap)
        assert exc.value.decision.reason == "strict_not_primary"
        loader.assert_not_called()


# ---------------------------------------------------------------------------
# Unknown engine names fail closed (must not silently become 'auto' and
# bypass evaluate_engine — see factory comment at the unknown-name branch).
# ---------------------------------------------------------------------------


class TestUnknownEngineFailsClosed:
    def test_unknown_name_raises_value_error_not_constructed(self):
        # A name that isn't a registered engine, retriever, or collection must
        # be rejected (ValueError) rather than silently rewritten to 'auto',
        # which historically bypassed the egress PEP entirely.
        snap = _snapshot("both", "arxiv", "arxiv")
        with _stub_engine_construction() as loader:
            with pytest.raises(ValueError):
                factory.create_search_engine(
                    "definitely_not_a_real_engine", settings_snapshot=snap
                )
        loader.assert_not_called()
