"""Follow-up regression tests for ADAPTIVE scope resolution and
``context_from_snapshot`` cross-field coupling (PR #4300 egress policy).

These cover gaps not already exercised by
``tests/security/test_egress_policy.py``:

* ADAPTIVE resolution of *registered retriever* primaries (local => PRIVATE_ONLY,
  public => PUBLIC_ONLY) via the retriever registry path inside
  ``_resolve_adaptive_scope``.
* Stray removed meta-engine names (auto / meta / parallel /
  parallel_scientific) being unclassifiable and resolving to BOTH under
  ADAPTIVE, and leaving STRICT intact (no ValueError) under STRICT.
* The PRIVATE_ONLY -> require_local_llm/require_local_embeddings *coupling* on a
  direct (non-adaptive) PRIVATE_ONLY scope, and the deliberate absence of that
  coupling under STRICT.
* Non-dict snapshot ValueError contract.
* ``allow_dns=False`` skipping ``_resolve_with_timeout`` and falling back to the
  engine's static classification (vs. the DNS-driven result with allow_dns=True).
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.security.egress.policy import (
    EgressScope,
    PolicyDeniedError,
    _resolve_adaptive_scope,
    context_from_snapshot,
)
from local_deep_research.web_search_engines.retriever_registry import (
    retriever_registry,
)


def _adaptive_snapshot(tool: str, **extra) -> dict:
    """Nested-value snapshot selecting ADAPTIVE scope with the given tool."""
    snap = {
        "policy.egress_scope": {"value": "adaptive"},
        "search.tool": {"value": tool},
    }
    snap.update(extra)
    return snap


# ---------------------------------------------------------------------------
# ADAPTIVE: concrete engine classification (allow + deny pairs)
# ---------------------------------------------------------------------------


def test_resolve_adaptive_concrete_public_engine_is_public_only():
    """_resolve_adaptive_scope: a concrete public engine -> PUBLIC_ONLY."""
    scope = _resolve_adaptive_scope(
        "arxiv",
        {},
        username=None,
        local_hostnames=(),
    )
    assert scope == EgressScope.PUBLIC_ONLY


def test_resolve_adaptive_concrete_private_engine_is_private_only():
    """_resolve_adaptive_scope: a concrete local engine -> PRIVATE_ONLY."""
    scope = _resolve_adaptive_scope(
        "paperless",
        {},
        username=None,
        local_hostnames=(),
    )
    assert scope == EgressScope.PRIVATE_ONLY


# ---------------------------------------------------------------------------
# ADAPTIVE: stray removed meta-engine names -> BOTH (unclassifiable fallback)
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "picker", ["auto", "meta", "parallel", "parallel_scientific"]
)
def test_resolve_adaptive_stray_meta_names_resolve_to_both(picker):
    # The meta engines were removed; a stray name left in the DB is simply
    # unclassifiable and falls through to BOTH (no special-case branch).
    scope = _resolve_adaptive_scope(
        picker,
        {},
        username=None,
        local_hostnames=(),
    )
    assert scope == EgressScope.BOTH


def test_resolve_adaptive_empty_primary_is_both():
    """A missing/empty primary cannot be classified -> permissive BOTH."""
    scope = _resolve_adaptive_scope(
        "",
        {},
        username=None,
        local_hostnames=(),
    )
    assert scope == EgressScope.BOTH


@pytest.mark.parametrize("picker", ["meta", "parallel", "parallel_scientific"])
def test_context_adaptive_stray_meta_names_resolve_to_both(picker):
    """Through the public entrypoint: ADAPTIVE + each stray removed meta name
    -> BOTH and leaves inference requirements untouched (no PRIVATE_ONLY
    coupling)."""
    ctx = context_from_snapshot(
        _adaptive_snapshot(picker), primary_engine=picker
    )
    assert ctx.scope == EgressScope.BOTH
    assert ctx.require_local_llm is False
    assert ctx.require_local_embeddings is False


# ---------------------------------------------------------------------------
# ADAPTIVE: registered retriever primaries (registry path)
# ---------------------------------------------------------------------------


def test_resolve_adaptive_local_retriever_is_private_only():
    """A registered LOCAL retriever (not in ENGINE_REGISTRY) resolves via the
    retriever registry to PRIVATE_ONLY."""
    name = "_adaptive_followup_local_kb"
    retriever_registry.register(name, MagicMock(), is_local=True)
    try:
        scope = _resolve_adaptive_scope(
            name,
            {},
            username=None,
            local_hostnames=(),
        )
        assert scope == EgressScope.PRIVATE_ONLY
    finally:
        retriever_registry.unregister(name)


def test_resolve_adaptive_public_retriever_is_public_only():
    """A registered PUBLIC retriever resolves to PUBLIC_ONLY."""
    name = "_adaptive_followup_public_idx"
    retriever_registry.register(name, MagicMock(), is_local=False)
    try:
        scope = _resolve_adaptive_scope(
            name,
            {},
            username=None,
            local_hostnames=(),
        )
        assert scope == EgressScope.PUBLIC_ONLY
    finally:
        retriever_registry.unregister(name)


def test_context_adaptive_local_retriever_forces_local_inference():
    """End-to-end: ADAPTIVE + a registered local retriever primary resolves to
    PRIVATE_ONLY AND inherits the require_local_* coupling."""
    name = "_adaptive_followup_local_kb2"
    retriever_registry.register(name, MagicMock(), is_local=True)
    try:
        ctx = context_from_snapshot(
            _adaptive_snapshot(name), primary_engine=name
        )
        assert ctx.scope == EgressScope.PRIVATE_ONLY
        assert ctx.require_local_llm is True
        assert ctx.require_local_embeddings is True
    finally:
        retriever_registry.unregister(name)


def test_context_adaptive_public_retriever_does_not_force_local():
    """ADAPTIVE + a registered public retriever -> PUBLIC_ONLY, which does NOT
    force local inference (public scope is orthogonal to local-inference)."""
    name = "_adaptive_followup_public_idx2"
    retriever_registry.register(name, MagicMock(), is_local=False)
    try:
        ctx = context_from_snapshot(
            _adaptive_snapshot(name), primary_engine=name
        )
        assert ctx.scope == EgressScope.PUBLIC_ONLY
        assert ctx.require_local_llm is False
        assert ctx.require_local_embeddings is False
    finally:
        retriever_registry.unregister(name)


def test_resolve_adaptive_unknown_primary_falls_back_to_both():
    """A name that is neither a static engine nor a registered retriever is
    unclassifiable -> BOTH (permissive fallback, never a hard fail), and no
    require_local coupling is applied."""
    name = "_adaptive_followup_not_registered_anywhere"
    # Ensure it is genuinely absent from the retriever registry.
    assert retriever_registry.get_metadata(name) is None
    ctx = context_from_snapshot(_adaptive_snapshot(name), primary_engine=name)
    assert ctx.scope == EgressScope.BOTH
    assert ctx.require_local_llm is False
    assert ctx.require_local_embeddings is False


# ---------------------------------------------------------------------------
# PRIVATE_ONLY coupling vs STRICT non-coupling
# ---------------------------------------------------------------------------


def test_direct_private_only_forces_local_llm_and_embeddings():
    """A directly-selected PRIVATE_ONLY scope (not via ADAPTIVE) forces both
    require_local_llm and require_local_embeddings even when the user left
    those flags at their permissive default. This is the core coupling that
    prevents silent exfiltration through cloud inference."""
    ctx = context_from_snapshot(
        {
            "policy.egress_scope": {"value": "private_only"},
            "llm.require_local_endpoint": {"value": False},
            "embeddings.require_local": {"value": False},
        },
        primary_engine="arxiv",
    )
    assert ctx.scope == EgressScope.PRIVATE_ONLY
    assert ctx.require_local_llm is True
    assert ctx.require_local_embeddings is True


def test_strict_does_not_force_local_inference():
    """STRICT restricts the search-engine set but is deliberately orthogonal to
    where inference runs: it must NOT force require_local_*."""
    ctx = context_from_snapshot(
        {
            "policy.egress_scope": {"value": "strict"},
            "llm.require_local_endpoint": {"value": False},
            "embeddings.require_local": {"value": False},
        },
        primary_engine="arxiv",
    )
    assert ctx.scope == EgressScope.STRICT
    assert ctx.require_local_llm is False
    assert ctx.require_local_embeddings is False


def test_strict_preserves_explicit_local_inference_flags():
    """STRICT does not force the flags, but it must preserve a user who DID
    opt in (no silent reset)."""
    ctx = context_from_snapshot(
        {
            "policy.egress_scope": {"value": "strict"},
            "llm.require_local_endpoint": {"value": True},
            "embeddings.require_local": {"value": True},
        },
        primary_engine="arxiv",
    )
    assert ctx.scope == EgressScope.STRICT
    assert ctx.require_local_llm is True
    assert ctx.require_local_embeddings is True


# ---------------------------------------------------------------------------
# STRICT + stray removed meta name -> STRICT context (no ValueError); the
# stray name only ever matches itself under the STRICT identity check, and as
# an unknown engine it is denied (engine_unknown) anyway.
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "picker", ["auto", "meta", "parallel", "parallel_scientific"]
)
def test_strict_with_stray_meta_name_builds_strict_context(picker):
    ctx = context_from_snapshot(
        {"policy.egress_scope": {"value": "strict"}},
        primary_engine=picker,
    )
    assert ctx.scope == EgressScope.STRICT


def test_strict_with_concrete_primary_does_not_raise():
    """Mirror: STRICT + a concrete primary is the supported, allowed combo."""
    ctx = context_from_snapshot(
        {"policy.egress_scope": {"value": "strict"}},
        primary_engine="arxiv",
    )
    assert ctx.scope == EgressScope.STRICT


# ---------------------------------------------------------------------------
# Snapshot validity contract
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "bad_snapshot",
    [
        ["policy.egress_scope", "both"],  # list
        "policy.egress_scope=both",  # str
        42,  # int
        None,  # None (the explicit "required" branch)
    ],
)
def test_non_dict_snapshot_raises_value_error(bad_snapshot):
    with pytest.raises(ValueError):
        context_from_snapshot(bad_snapshot, primary_engine="arxiv")


def test_unknown_scope_string_raises_policy_denied_with_target():
    """An unrecognised scope string is fail-closed as PolicyDeniedError whose
    target carries the offending value (so the operator can see what was
    rejected) rather than silently degrading to BOTH."""
    with pytest.raises(PolicyDeniedError) as excinfo:
        context_from_snapshot(
            {"policy.egress_scope": {"value": "PUBLIC"}},  # not a valid member
            primary_engine="arxiv",
        )
    assert excinfo.value.decision.reason == "unknown_egress_scope"
    assert excinfo.value.target == "PUBLIC"


# ---------------------------------------------------------------------------
# allow_dns=False: skip _resolve_with_timeout, fall back to static flags
# ---------------------------------------------------------------------------


def test_adaptive_allow_dns_false_skips_dns_and_uses_static_flags():
    """With allow_dns=False the URL-configurable primary's hostname is NOT
    DNS-resolved: _resolve_with_timeout must not be called, and resolution
    falls back to the engine's STATIC classification (searxng is_public=True
    -> PUBLIC_ONLY)."""
    snap = _adaptive_snapshot(
        "searxng",
        **{
            "search.engine.web.searxng.default_params.instance_url": (
                "http://searx.internal.lab:8080"
            )
        },
    )
    with patch(
        "local_deep_research.security.egress.policy._resolve_with_timeout"
    ) as mock_resolve:
        ctx = context_from_snapshot(
            snap, primary_engine="searxng", allow_dns=False
        )
        mock_resolve.assert_not_called()
    # Static fallback: searxng's declared is_public=True -> PUBLIC_ONLY.
    assert ctx.scope == EgressScope.PUBLIC_ONLY


def test_adaptive_allow_dns_true_uses_dns_resolution():
    """Contrast: with allow_dns=True the fail-up URL override DOES DNS-
    resolve (via _resolve_with_timeout) — for a LOCAL-nature engine. A
    paperless primary whose configured api_url resolves to a PUBLIC host is
    reclassified public, so ADAPTIVE resolves PUBLIC_ONLY instead of
    PRIVATE_ONLY. Proves allow_dns toggles the DNS path, not a no-op.

    NB: searxng no longer exercises DNS here — engine nature comes from
    static class flags and the URL override is fail-up only (it never
    relaxes a public engine to private), so a localhost searxng stays
    PUBLIC_ONLY with or without DNS."""
    snap = _adaptive_snapshot(
        "paperless",
        **{
            "search.engine.web.paperless.default_params.api_url": (
                "http://paperless.example.org:8930"
            )
        },
    )
    # NB: a real public IP — the RFC 5737 documentation ranges
    # (192.0.2.x / 198.51.100.x / 203.0.113.x) classify as PRIVATE under
    # Python's ipaddress.is_private and would defeat the fail-up here.
    public_addrinfo = [(None, None, None, None, ("93.184.216.34", 0))]
    with patch(
        "local_deep_research.security.egress.policy._resolve_with_timeout",
        return_value=public_addrinfo,
    ) as mock_resolve:
        ctx = context_from_snapshot(
            snap, primary_engine="paperless", allow_dns=True
        )
        mock_resolve.assert_called()
    assert ctx.scope == EgressScope.PUBLIC_ONLY
    # A public-resolving primary does NOT force the local-inference coupling.
    assert ctx.require_local_llm is False
    assert ctx.require_local_embeddings is False
