"""Follow-up regression tests for the egress inference gates.

Deepens coverage of ``evaluate_llm_endpoint`` and ``evaluate_embeddings``
beyond what ``tests/security/test_egress_policy.py`` already exercises.

Focus areas (gaps not covered elsewhere):
- require_local_* False => always allowed (LLM + embeddings, incl. cloud).
- Full cloud-LLM block set under require-local (deepseek/xai/ionos as well
  as the already-covered openai/anthropic/google/openrouter).
- Local-default providers WITHOUT a URL (lmstudio/llamacpp for LLM;
  ollama/sentence_transformers for embeddings).
- URL-configured providers: local URL allowed, remote denied,
  percent-encoded local URL allowed (both gates).
- Embeddings ollama URL handling + the embeddings.ollama.url -> llm.ollama.url
  fallback, plus the provider_unknown branch.
- PDP-level snapshot-less branch (settings_snapshot=None) for both gates.
- The _is_user_registered_llm discriminator (built-in vs in-process code).
"""

from __future__ import annotations

from unittest.mock import patch

import pytest

from local_deep_research.security.egress.policy import (
    EgressContext,
    EgressScope,
    evaluate_embeddings,
    evaluate_llm_endpoint,
    _is_user_registered_llm,
)


# ---------------------------------------------------------------------------
# Helpers (mirrors make_ctx in tests/security/test_egress_policy.py)
# ---------------------------------------------------------------------------


def make_ctx(
    scope: EgressScope = EgressScope.BOTH,
    primary: str = "arxiv",
    require_local_llm: bool = False,
    require_local_embeddings: bool = False,
    local_hostnames=(),
) -> EgressContext:
    return EgressContext(
        scope=scope,
        primary_engine=primary,
        require_local_llm=require_local_llm,
        require_local_embeddings=require_local_embeddings,
        local_hostnames=tuple(local_hostnames),
    )


# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — require_local_llm False => always allowed
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "provider",
    ["openai", "anthropic", "deepseek", "xai", "ionos", "ollama", "lmstudio"],
)
def test_llm_no_local_requirement_allows_any_provider(provider):
    """With require_local_llm False the gate is a pass-through for every
    provider — cloud included — and reports no_local_requirement."""
    ctx = make_ctx(require_local_llm=False)
    decision = evaluate_llm_endpoint(provider, ctx, settings_snapshot={})
    assert decision.allowed is True
    assert decision.reason == "no_local_requirement"


# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — full cloud-provider block set under require-local
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("provider", ["deepseek", "xai", "ionos"])
def test_llm_require_local_blocks_remaining_cloud_providers(provider):
    """The cloud block set must include deepseek/xai/ionos — the providers
    the existing suite does NOT already enumerate. Each is blocked under
    require_local_llm with provider_cloud_only, and allowed without it."""
    blocked = evaluate_llm_endpoint(
        provider, make_ctx(require_local_llm=True), settings_snapshot={}
    )
    assert blocked.allowed is False
    assert blocked.reason == "provider_cloud_only"
    # Mirror: the same provider is permitted when the toggle is off.
    allowed = evaluate_llm_endpoint(
        provider, make_ctx(require_local_llm=False), settings_snapshot={}
    )
    assert allowed.allowed is True


# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — local-default providers without a URL
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("provider", ["lmstudio", "llamacpp"])
def test_llm_local_default_providers_without_url_allowed(provider):
    """lmstudio/llamacpp (besides the already-tested ollama) fall back to
    their localhost defaults when no URL override is configured."""
    ctx = make_ctx(require_local_llm=True)
    decision = evaluate_llm_endpoint(provider, ctx, settings_snapshot={})
    assert decision.allowed is True
    assert decision.reason == "provider_local_default"


# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — URL-configured: local allowed, remote denied
# ---------------------------------------------------------------------------


def test_llm_configured_local_url_allowed():
    """A local-default provider pointed at an explicit local URL classifies
    via the URL (provider_local), not the static default."""
    ctx = make_ctx(require_local_llm=True)
    snapshot = {"llm.lmstudio.url": "http://192.168.1.50:1234/v1"}
    decision = evaluate_llm_endpoint(
        "lmstudio", ctx, settings_snapshot=snapshot
    )
    assert decision.allowed is True
    assert decision.reason == "provider_local"


def test_llm_configured_remote_url_denied():
    """The mirror deny case: a configured remote URL is refused even for a
    normally-local-default provider."""
    ctx = make_ctx(require_local_llm=True)
    snapshot = {"llm.lmstudio.url": "https://lmstudio.example.com"}
    with patch(
        "local_deep_research.security.egress.policy._classify_host",
        return_value=False,
    ):
        decision = evaluate_llm_endpoint(
            "lmstudio", ctx, settings_snapshot=snapshot
        )
    assert decision.allowed is False
    assert decision.reason == "provider_remote"


def test_llm_percent_encoded_local_url_allowed():
    """A percent-encoded local host (the HTTP client decodes it before
    connect) must be decoded by the PDP and classified local. Without the
    unquote() this reads as an unresolvable public host and is denied."""
    ctx = make_ctx(require_local_llm=True)
    snapshot = {"llm.ollama.url": "http://127%2e0%2e0%2e1:11434"}
    decision = evaluate_llm_endpoint("ollama", ctx, settings_snapshot=snapshot)
    assert decision.allowed is True
    assert decision.reason == "provider_local"


# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — PDP-level snapshot-less branch
# ---------------------------------------------------------------------------


def test_llm_none_snapshot_fails_closed():
    """settings_snapshot=None at the PDP fails closed regardless of the
    require-local flag — the gate cannot read policy from a missing snapshot."""
    ctx = make_ctx(require_local_llm=False)
    decision = evaluate_llm_endpoint("ollama", ctx, settings_snapshot=None)
    assert decision.allowed is False
    assert decision.reason == "no_snapshot"


# ---------------------------------------------------------------------------
# evaluate_embeddings — require_local_embeddings False => always allowed
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "provider", ["openai", "ollama", "sentence_transformers", "cohere"]
)
def test_embeddings_no_local_requirement_allows_any_provider(provider):
    """With require_local_embeddings False the gate passes through for every
    provider — including cloud openai and an unknown provider."""
    ctx = make_ctx(require_local_embeddings=False)
    decision = evaluate_embeddings(provider, ctx, settings_snapshot={})
    assert decision.allowed is True
    assert decision.reason == "no_local_requirement"


# ---------------------------------------------------------------------------
# evaluate_embeddings — ollama provider (gap: only openai/ST tested before)
# ---------------------------------------------------------------------------


def test_embeddings_ollama_without_url_allowed():
    """ollama embeddings with no URL override falls back to its localhost
    default."""
    ctx = make_ctx(require_local_embeddings=True)
    decision = evaluate_embeddings("ollama", ctx, settings_snapshot={})
    assert decision.allowed is True
    assert decision.reason == "provider_local_default"


def test_embeddings_ollama_local_url_allowed():
    ctx = make_ctx(require_local_embeddings=True)
    snapshot = {"embeddings.ollama.url": "http://127.0.0.1:11434"}
    decision = evaluate_embeddings("ollama", ctx, settings_snapshot=snapshot)
    assert decision.allowed is True
    assert decision.reason == "provider_local"


def test_embeddings_ollama_remote_url_denied():
    ctx = make_ctx(require_local_embeddings=True)
    snapshot = {"embeddings.ollama.url": "https://ollama.example.com"}
    with patch(
        "local_deep_research.security.egress.policy._classify_host",
        return_value=False,
    ):
        decision = evaluate_embeddings(
            "ollama", ctx, settings_snapshot=snapshot
        )
    assert decision.allowed is False
    assert decision.reason == "provider_remote"


def test_embeddings_ollama_percent_encoded_local_url_allowed():
    """Percent-encoded local ollama embeddings host must be decoded and
    classified local (mirrors the LLM gate)."""
    ctx = make_ctx(require_local_embeddings=True)
    snapshot = {"embeddings.ollama.url": "http://127%2e0%2e0%2e1:11434"}
    decision = evaluate_embeddings("ollama", ctx, settings_snapshot=snapshot)
    assert decision.allowed is True
    assert decision.reason == "provider_local"


def test_embeddings_ollama_url_falls_back_to_llm_ollama_url():
    """When embeddings.ollama.url is unset, the gate falls back to the shared
    llm.ollama.url. A remote fallback value must still be denied (proves the
    fallback is actually consulted, not silently treated as local-default)."""
    ctx = make_ctx(require_local_embeddings=True)
    snapshot = {"llm.ollama.url": "https://remote-ollama.example.com"}
    with patch(
        "local_deep_research.security.egress.policy._classify_host",
        return_value=False,
    ):
        decision = evaluate_embeddings(
            "ollama", ctx, settings_snapshot=snapshot
        )
    assert decision.allowed is False
    assert decision.reason == "provider_remote"


# ---------------------------------------------------------------------------
# evaluate_embeddings — openai base_url edge cases
# ---------------------------------------------------------------------------


def test_embeddings_openai_percent_encoded_local_base_url_allowed():
    ctx = make_ctx(require_local_embeddings=True)
    snapshot = {"embeddings.openai.base_url": "http://127%2e0%2e0%2e1:1234/v1"}
    decision = evaluate_embeddings("openai", ctx, settings_snapshot=snapshot)
    assert decision.allowed is True
    assert decision.reason == "provider_local_endpoint"


def test_embeddings_openai_remote_base_url_denied():
    """A genuine cloud base_url stays denied under require-local."""
    ctx = make_ctx(require_local_embeddings=True)
    snapshot = {"embeddings.openai.base_url": "https://api.openai.com/v1"}
    with patch(
        "local_deep_research.security.egress.policy._classify_host",
        return_value=False,
    ):
        decision = evaluate_embeddings(
            "openai", ctx, settings_snapshot=snapshot
        )
    assert decision.allowed is False
    assert decision.reason == "provider_cloud"


# ---------------------------------------------------------------------------
# evaluate_embeddings — unknown provider + snapshot-less branches
# ---------------------------------------------------------------------------


def test_embeddings_unknown_provider_under_require_local_denied():
    """An embeddings provider outside the known set fails closed under
    require-local (provider_unknown), but is allowed when the toggle is off."""
    blocked = evaluate_embeddings(
        "cohere", make_ctx(require_local_embeddings=True), settings_snapshot={}
    )
    assert blocked.allowed is False
    assert blocked.reason == "provider_unknown"


def test_embeddings_none_snapshot_fails_closed():
    ctx = make_ctx(require_local_embeddings=False)
    decision = evaluate_embeddings(
        "sentence_transformers", ctx, settings_snapshot=None
    )
    assert decision.allowed is False
    assert decision.reason == "no_snapshot"


# ---------------------------------------------------------------------------
# _is_user_registered_llm discriminator (underpins the shadowing behavior)
# ---------------------------------------------------------------------------


def test_is_user_registered_llm_true_for_in_process_registration():
    """A freshly-registered name that is NOT an auto-discovered built-in is
    user-supplied in-process code."""
    from local_deep_research.llm.llm_registry import (
        register_llm,
        unregister_llm,
    )

    name = "_followup_inprocess_llm"
    register_llm(name, lambda **kwargs: None)
    try:
        assert _is_user_registered_llm(name) is True
    finally:
        unregister_llm(name)


def test_is_user_registered_llm_false_for_builtin_cloud_name():
    """A built-in cloud provider name (auto-registered by discover_providers)
    must NOT be treated as user code — this is what keeps a shadowing
    registration on the strict cloud-block path."""
    assert _is_user_registered_llm("openai") is False


def test_is_user_registered_llm_false_for_unregistered_name():
    assert _is_user_registered_llm("_never_registered_followup") is False
