"""Follow-up regression tests for downloader egress-policy threading.

These cover GAPS not already exercised by
``tests/content_fetcher/test_security.py`` (which only tests the direct
``_apply_egress_policy_to_downloader`` call with PRIVATE_ONLY / PUBLIC_ONLY /
no-context / no-session) and ``tests/security/test_egress_policy.py`` (which
only tests ``policy_aware_validate_url`` for PRIVATE_ONLY-allows-private +
metadata-blocked).

What is added here:
  * the relaxation reaches a downloader through the REAL ``_get_downloader``
    code path AND survives caching (a second fetch reuses the relaxed session);
  * STRICT / BOTH scopes do NOT relax — only PRIVATE_ONLY does, mirroring
    ``policy_aware_validate_url``;
  * a downloader whose ``.session`` lacks ``allow_private_ips`` is handled
    without mutating it or raising;
  * cloud-metadata IPs stay blocked by ``is_ip_blocked`` / ``validate_url``
    even when private IPs are allowed (the relaxation must not open IMDS);
  * ``policy_aware_validate_url`` scope behaviour: PRIVATE_ONLY permits private
    hosts, every other scope (and no context) stays strict.

All tests import and call the real code under test; each is an allow+deny pair
or an explicit edge case that would FAIL if the guarded behaviour regressed.
"""

from __future__ import annotations

from local_deep_research.content_fetcher import ContentFetcher
from local_deep_research.content_fetcher.url_classifier import URLType
from local_deep_research.security.egress.fetch import policy_aware_validate_url
from local_deep_research.security.egress.policy import EgressScope
from local_deep_research.security.ssrf_validator import (
    ALWAYS_BLOCKED_METADATA_IPS,
    is_ip_blocked,
    validate_url,
)

# Reuse the canonical context builder rather than duplicating a fixture.
from tests.security.test_egress_policy import make_ctx


# ---------------------------------------------------------------------------
# Real _get_downloader path: relaxation applied + survives caching
# ---------------------------------------------------------------------------


def test_get_downloader_real_path_private_only_relaxes_session():
    """Through the REAL ``_get_downloader`` (not a direct call to the helper),
    a PRIVATE_ONLY context relaxes the constructed downloader's SafeSession."""
    cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.PRIVATE_ONLY))
    downloader = cf._get_downloader(URLType.PDF)
    assert downloader is not None
    assert downloader.session.allow_private_ips is True


def test_get_downloader_caches_relaxed_downloader():
    """The cached downloader (returned by a second ``_get_downloader`` call)
    is the SAME object and keeps the relaxation — fetch reuses it per
    fetcher.py, so the relaxation must persist across calls."""
    cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.PRIVATE_ONLY))
    first = cf._get_downloader(URLType.PDF)
    second = cf._get_downloader(URLType.PDF)
    assert first is second  # cache hit, not re-constructed
    assert second.session.allow_private_ips is True


def test_get_downloader_real_path_strict_does_not_relax():
    """Deny side: under STRICT the real ``_get_downloader`` must leave the
    downloader session strict (only PRIVATE_ONLY relaxes)."""
    cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.STRICT))
    downloader = cf._get_downloader(URLType.PDF)
    assert downloader is not None
    assert downloader.session.allow_private_ips is False


# ---------------------------------------------------------------------------
# _apply_egress_policy_to_downloader: scope coverage (STRICT / BOTH) + edge
# ---------------------------------------------------------------------------


def _fake_downloader_with_session():
    from local_deep_research.security.safe_requests import SafeSession

    class _DL:
        def __init__(self):
            self.session = SafeSession()

    return _DL()


def test_apply_strict_scope_keeps_session_strict():
    """STRICT must NOT relax — mirrors policy_aware_validate_url, which only
    threads allow_private_ips for PRIVATE_ONLY. (test_security covers
    PUBLIC_ONLY; STRICT is the uncovered scope.)"""
    cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.STRICT))
    dl = _fake_downloader_with_session()
    cf._apply_egress_policy_to_downloader(dl)
    assert dl.session.allow_private_ips is False


def test_apply_both_scope_keeps_session_strict():
    """BOTH must NOT relax either: it allows public hosts via normal SSRF
    rules but does not blanket-permit private IPs the way PRIVATE_ONLY does."""
    cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.BOTH))
    dl = _fake_downloader_with_session()
    cf._apply_egress_policy_to_downloader(dl)
    assert dl.session.allow_private_ips is False


def test_apply_session_lacking_allow_private_ips_attr_is_handled():
    """A downloader whose ``.session`` is a non-SafeSession object lacking the
    ``allow_private_ips`` attribute must not raise and must not have the
    attribute injected (the helper guards with ``hasattr``)."""
    cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.PRIVATE_ONLY))

    class _BareSession:
        pass

    class _DL:
        def __init__(self):
            self.session = _BareSession()

    dl = _DL()
    cf._apply_egress_policy_to_downloader(dl)  # must not raise
    assert not hasattr(dl.session, "allow_private_ips")


# ---------------------------------------------------------------------------
# Cloud-metadata IPs stay blocked even when private IPs are allowed
# ---------------------------------------------------------------------------


def test_is_ip_blocked_metadata_blocked_even_with_allow_private_ips():
    """The relaxation a PRIVATE_ONLY downloader receives is
    ``allow_private_ips=True``; that must NOT open cloud-metadata IPs.
    ``is_ip_blocked`` must still reject every IMDS endpoint."""
    for ip in ALWAYS_BLOCKED_METADATA_IPS:
        assert is_ip_blocked(ip, allow_private_ips=True) is True, (
            f"metadata IP {ip} must stay blocked with allow_private_ips"
        )


def test_is_ip_blocked_private_allow_deny_pair():
    """Allow+deny pair: an ordinary RFC1918 host is blocked by default but
    permitted once allow_private_ips is set — proving the relaxation flag is
    what unlocks private (and only private) reachability."""
    assert is_ip_blocked("192.168.1.5") is True  # strict default
    assert is_ip_blocked("192.168.1.5", allow_private_ips=True) is False


def test_relaxed_validate_url_still_blocks_metadata():
    """SafeSession.request forwards allow_private_ips into validate_url. With
    the relaxation active, a private host validates but a metadata host is
    still rejected — the exact behaviour a relaxed downloader session relies
    on (allow+deny pair)."""
    assert (
        validate_url("http://192.168.1.5/api", allow_private_ips=True) is True
    )
    assert (
        validate_url(
            "http://169.254.169.254/latest/meta-data/", allow_private_ips=True
        )
        is False
    )


# ---------------------------------------------------------------------------
# policy_aware_validate_url scope behaviour
# ---------------------------------------------------------------------------


def test_policy_aware_validate_url_private_only_allows_private_hosts():
    """PRIVATE_ONLY permits loopback and RFC1918 lab hosts (this is the side
    the downloader relaxation mirrors)."""
    ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY)
    assert policy_aware_validate_url("http://127.0.0.1:11434", ctx) is True
    assert policy_aware_validate_url("http://192.168.1.5/api", ctx) is True
    assert policy_aware_validate_url("http://10.1.2.3/api", ctx) is True


def test_policy_aware_validate_url_non_private_scopes_stay_strict():
    """Deny side: STRICT, PUBLIC_ONLY and BOTH must NOT thread
    allow_private_ips, so a private IP literal is rejected under each."""
    private_url = "http://192.168.1.5/api"
    for scope in (
        EgressScope.STRICT,
        EgressScope.PUBLIC_ONLY,
        EgressScope.BOTH,
    ):
        ctx = make_ctx(scope=scope)
        assert policy_aware_validate_url(private_url, ctx) is False, (
            f"private host must be rejected under {scope}"
        )


def test_policy_aware_validate_url_no_context_is_strict():
    """No egress context falls back to strict validate_url: a private host is
    rejected just like the un-relaxed default."""
    assert policy_aware_validate_url("http://192.168.1.5/api", None) is False
