"""Integration tests for the egress-policy PEP that pre-filters the LangGraph
lead-agent tool list in
``advanced_search_system/strategies/langgraph_agent_strategy.py``
(``LangGraphAgentStrategy._build_tools`` specialized-engine loop, ~L608-695).

This is the *silent-expansion* fix. The search-engine factory PEP already
refuses a forbidden engine at instantiation time, but that is a runtime stop:
the LLM still SEES the forbidden tool name in its schema, and the latency of a
denied tool call leaks policy state. Filtering the tool list HERE means a
forbidden engine's name never reaches ``create_agent()`` — the model never
learns it exists. These tests assert exactly that property: which engine names
survive into the built tool list under each scope.

The tests drive the REAL ``_build_tools`` loop and the REAL
``evaluate_engine`` / ``evaluate_retriever`` PDP. Only the leaf tool factories
(``_make_web_search_tool``, ``_make_specialized_search_tool``,
``build_fetch_tool``) and the engine/retriever *discovery* (``get_available_engines``,
``retriever_registry``) are mocked — so every assertion is about the FILTER
decision, made before any tool is actually constructed.

Engine classifications come from the live ``ENGINE_REGISTRY``:
  - arxiv / wikipedia : public (is_public=True,  is_local=False)
  - paperless         : local  (is_public=False, is_local=True; no api_url in
                        the snapshot, so no DNS lookup occurs)

Each scope axis is covered with an allow+deny pair: reverting the filter (or
loosening a scope check) would let a denied name survive (or drop an allowed
one) and flip an assertion.
"""

from __future__ import annotations

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

import pytest

import local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy as strat  # noqa: E501
from local_deep_research.security import clear_active_context


# Engine discovery configs (what get_available_engines would return). The
# inner dicts mirror the real config shape consumed by the loop: description,
# strengths, is_retriever. For static engines the is_public/is_local
# classification is read from the engine CLASS, not from these dicts, so the
# decision under test is the real registry classification.
_PUBLIC_ENGINE = "arxiv"
_PUBLIC_ENGINE_2 = "wikipedia"
_PRIVATE_ENGINE = "paperless"


@pytest.fixture(autouse=True)
def _no_leaked_armed_context():
    """The tool-list filter reads scope from the snapshot, not the armed
    audit-hook context. Clear the active context before and after each test so
    a context leaked by another 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()


@contextmanager
def _patched_tool_factories():
    """Replace the leaf tool builders with cheap name-carrying sentinels.

    ``_make_specialized_search_tool`` is a MagicMock whose side effect returns a
    sentinel tagged with the engine name, so a DENIED engine is proven excluded
    two ways: its name is absent from the built list AND the factory was never
    called for it (the filter ``continue``s before construction). ``build_fetch_tool``
    returns None so only the primary + surviving specialized tools remain.
    """
    spec = MagicMock(
        side_effect=lambda name, *a, **k: SimpleNamespace(name=name)
    )
    with (
        patch.object(
            strat,
            "_make_web_search_tool",
            lambda *a, **k: SimpleNamespace(name="web_search"),
        ),
        patch.object(strat, "build_fetch_tool", lambda *a, **k: None),
        patch.object(strat, "_make_specialized_search_tool", spec),
    ):
        yield spec


def _build_tool_names(
    scope,
    available,
    *,
    retriever_meta=None,
    snapshot_extra=None,
    primary="searxng",
):
    """Construct a minimal strategy under ``scope`` and run the REAL
    ``_build_tools`` loop against the mocked engine discovery ``available``.

    Returns ``(tool_names, spec_mock)`` where ``tool_names`` is the list of
    ``.name`` values on the built tools (the primary appears as "web_search")
    and ``spec_mock`` is the specialized-tool factory mock for call assertions.

    ``retriever_meta`` (e.g. ``{"is_local": True}``) patches the retriever
    registry's metadata lookup so the retriever branch can be exercised.

    ``primary`` sets ``search.tool`` (a real run always has one). Pass
    ``primary=None`` to omit it entirely and exercise the fail-closed path.
    """
    snapshot = {
        "policy.egress_scope": scope,
        "search.fetch.mode": "disabled",
    }
    # A real run always has a configured primary. Explicit scopes ignore it
    # (they don't resolve ADAPTIVE); the ADAPTIVE tests override it via
    # snapshot_extra to drive scope resolution. primary=None omits it so
    # resolve_run_primary_engine fails closed (raises) — by design.
    if primary is not None:
        snapshot["search.tool"] = primary
    if snapshot_extra:
        snapshot.update(snapshot_extra)

    reg = MagicMock()
    reg.get_metadata.return_value = retriever_meta

    with _patched_tool_factories() as spec:
        with (
            patch(
                "local_deep_research.web_search_engines.search_engines_config"
                ".get_available_engines",
                return_value=available,
            ),
            patch(
                "local_deep_research.web_search_engines.retriever_registry"
                ".retriever_registry",
                reg,
            ),
        ):
            # search=MagicMock() so the primary web_search tool is present (its
            # class name "magicmock" becomes the run's current/primary engine,
            # which never collides with a real engine name). citation_handler
            # is supplied so the strategy ctor skips building a real one.
            strategy = strat.LangGraphAgentStrategy(
                model=MagicMock(),
                search=MagicMock(),
                citation_handler=object(),
                include_sub_research=False,
                settings_snapshot=snapshot,
            )
            tools = strategy._build_tools("overall query")
    return [t.name for t in tools], spec


def _specialized(names):
    """Names of specialized tools that survived (excluding the primary)."""
    return {n for n in names if n != "web_search"}


# ---------------------------------------------------------------------------
# PRIVATE_ONLY: public engines are excluded from the tool list; local allowed.
# ---------------------------------------------------------------------------


class TestPrivateOnlyFilter:
    def test_public_engine_name_never_reaches_agent(self):
        names, spec = _build_tool_names(
            "private_only",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
        )
        # The public engine is silently excluded: its name is absent and the
        # tool factory was never invoked for it.
        assert _PUBLIC_ENGINE not in names
        called = {c.args[0] for c in spec.call_args_list}
        assert _PUBLIC_ENGINE not in called
        # The local engine is allowed (allow side of the pair).
        assert _PRIVATE_ENGINE in names
        assert _specialized(names) == {_PRIVATE_ENGINE}


# ---------------------------------------------------------------------------
# PUBLIC_ONLY: local/collection engines excluded; public allowed.
# ---------------------------------------------------------------------------


class TestPublicOnlyFilter:
    def test_private_engine_name_never_reaches_agent(self):
        names, spec = _build_tool_names(
            "public_only",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
        )
        assert _PRIVATE_ENGINE not in names
        called = {c.args[0] for c in spec.call_args_list}
        assert _PRIVATE_ENGINE not in called
        assert _PUBLIC_ENGINE in names
        assert _specialized(names) == {_PUBLIC_ENGINE}


# ---------------------------------------------------------------------------
# STRICT: no specialized engines at all — only the primary web_search tool.
# ---------------------------------------------------------------------------


class TestStrictFilter:
    def test_no_specialized_engines_registered(self):
        names, spec = _build_tool_names(
            "strict",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PUBLIC_ENGINE_2: {"description": "wiki"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
        )
        # Deny side: every specialized engine is dropped, regardless of bucket,
        # and none were ever constructed.
        assert _specialized(names) == set()
        spec.assert_not_called()
        # Allow side: the primary tool IS present — STRICT yields a single-tool
        # agent, not an empty one.
        assert names == ["web_search"]


# ---------------------------------------------------------------------------
# BOTH: the filter does not over-exclude — every discovered engine survives.
# This is the allow baseline proving the PRIVATE/PUBLIC exclusions above are
# scope-driven, not a blanket drop.
# ---------------------------------------------------------------------------


class TestBothScopeAllowsAll:
    def test_public_and_private_engines_both_present(self):
        names, _ = _build_tool_names(
            "both",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
        )
        assert _specialized(names) == {_PUBLIC_ENGINE, _PRIVATE_ENGINE}


# ---------------------------------------------------------------------------
# Retriever branch: registered retrievers route through evaluate_retriever
# (classified by registry is_local metadata), filtered by the same loop.
# ---------------------------------------------------------------------------


class TestRetrieverFilter:
    _RET = "my_private_kb"

    def test_local_retriever_excluded_under_public_only(self):
        names, spec = _build_tool_names(
            "public_only",
            {self._RET: {"description": "kb", "is_retriever": True}},
            retriever_meta={"is_local": True},
        )
        # Deny: a local retriever must not surface under PUBLIC_ONLY.
        assert self._RET not in names
        spec.assert_not_called()
        assert _specialized(names) == set()

    def test_local_retriever_allowed_under_private_only(self):
        names, spec = _build_tool_names(
            "private_only",
            {self._RET: {"description": "kb", "is_retriever": True}},
            retriever_meta={"is_local": True},
        )
        # Allow: same retriever IS registered under PRIVATE_ONLY.
        assert self._RET in names
        assert _specialized(names) == {self._RET}
        assert spec.call_args_list[0].args[0] == self._RET


# ---------------------------------------------------------------------------
# ADAPTIVE (the DEFAULT scope): the concrete scope FOLLOWS the run's primary
# engine (search.tool) — the same value the factory PEP uses. These cover the
# real-world bug the explicit-scope tests above miss: a private collection
# primary must pull the run into PRIVATE_ONLY so public specialized engines
# never reach the agent. Regression for the silent under-filter where
# _build_egress_context derived the primary from the engine CLASS name instead
# of search.tool — a collection primary classified as unknown -> BOTH -> public
# engines (e.g. pubmed) stayed in the agent's tool list, and the factory then
# hard-denied them mid-run (scope_mismatch_private_only).
# ---------------------------------------------------------------------------


class TestAdaptiveScopeFollowsPrimary:
    def test_private_primary_excludes_public_engine(self):
        names, spec = _build_tool_names(
            "adaptive",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
            snapshot_extra={"search.tool": _PRIVATE_ENGINE},
        )
        # ADAPTIVE + private primary => PRIVATE_ONLY: the public engine is
        # filtered before it ever reaches the agent (name absent AND never
        # constructed), the local engine survives.
        assert _PUBLIC_ENGINE not in names
        called = {c.args[0] for c in spec.call_args_list}
        assert _PUBLIC_ENGINE not in called
        assert _specialized(names) == {_PRIVATE_ENGINE}

    def test_public_primary_excludes_private_engine(self):
        names, spec = _build_tool_names(
            "adaptive",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
            snapshot_extra={"search.tool": _PUBLIC_ENGINE},
        )
        # ADAPTIVE + public primary => PUBLIC_ONLY: the mirror image — the
        # local engine is filtered, the public one survives.
        assert _PRIVATE_ENGINE not in names
        called = {c.args[0] for c in spec.call_args_list}
        assert _PRIVATE_ENGINE not in called
        assert _specialized(names) == {_PUBLIC_ENGINE}


# ---------------------------------------------------------------------------
# No configured primary: resolve_run_primary_engine raises ValueError inside
# _build_egress_context, which catches it and returns None — the advisory
# tool-list filter degrades to UNFILTERED (the factory PEP still enforces at
# instantiation) instead of crashing the agent. This locks in the fail-closed
# contract so the resolve call can't be moved back outside the try/except.
# ---------------------------------------------------------------------------


class TestMissingPrimaryDegradesToUnfiltered:
    def test_no_primary_does_not_crash_and_leaves_tools_unfiltered(self):
        # Restrictive scope + NO search.tool. Without the in-try catch this
        # would raise out of _build_tools; with it, policy_ctx is None so the
        # filter loop never runs and every discovered engine survives (the
        # public one is refused later by the factory PEP, not here).
        names, _ = _build_tool_names(
            "private_only",
            {
                _PUBLIC_ENGINE: {"description": "papers"},
                _PRIVATE_ENGINE: {"description": "docs"},
            },
            primary=None,
        )
        assert _specialized(names) == {_PUBLIC_ENGINE, _PRIVATE_ENGINE}
