"""Follow-up edge-case tests for ``evaluate_url`` (egress policy, Stage 1a).

These DEEPEN the existing ``tests/security/test_egress_policy.py`` coverage
without duplicating it. They focus on three properties that the base suite
only touches partially:

1. The metadata invariant ("cloud-metadata is NEVER fetchable, regardless of
   scope") across encodings the base suite doesn't exercise: short-form
   octal/integer IPv4, uppercase-hex, percent-encoded literals, mixed/upper
   case GCP hostnames — all denying with ``blocked_metadata_ip`` under EVERY
   scope, while a NORMAL public IP / hostname is NOT over-blocked.
2. The denial-quota accounting: dangerous (javascript/data/file/vbscript/
   about) and unsupported (ftp/mailto/...) and ``no_hostname`` denials must
   NOT tick ``MAX_DENIED_FETCHES_PER_RUN``, whereas scope-mismatch denials DO
   and eventually return ``denial_quota_exceeded``.
3. Percent-encoded private/metadata hosts are decoded BEFORE classification,
   so the policy sees the real connect target the HTTP client will reach.

It also pins the per-scope reason codes for ordinary private/public hosts
(``allowed_private_host_under_strict``, ``strict_public_host``,
``allowed_private_host``, ``scope_mismatch_*``) which the base suite asserts
only via the boolean ``allowed`` flag.

The ``make_ctx`` helper is imported from the base suite rather than
re-defined, per the no-duplicated-fixtures rule.
"""

from __future__ import annotations

from unittest.mock import patch

import pytest

from local_deep_research.security.egress.policy import (
    EgressScope,
    MAX_DENIED_FETCHES_PER_RUN,
    evaluate_url,
)

# Reuse the canonical EgressContext factory (do NOT re-define it here).
from tests.security.test_egress_policy import make_ctx

_ALL_SCOPES = (
    EgressScope.STRICT,
    EgressScope.PUBLIC_ONLY,
    EgressScope.PRIVATE_ONLY,
    EgressScope.BOTH,
)


# ---------------------------------------------------------------------------
# Metadata invariant — encodings the base suite does not cover
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("scope", _ALL_SCOPES)
@pytest.mark.parametrize(
    "host",
    [
        # Short-form octal/decimal IPv4 the libc resolver expands to the AWS
        # metadata IP (169.254.<16-bit>). inet_aton accepts it; ip_address
        # does not, so it must go through the alt-encoding normalizer.
        "169.254.43518",
        # Uppercase hex 0X prefix — inet_aton accepts it; the base suite only
        # tests the lowercase form.
        "0XA9FEA9FE",
        # Percent-encoded canonical metadata literal: the HTTP client decodes
        # the host before connect, so the policy must classify the DECODED IP.
        "169%2e254%2e169%2e254",
    ],
)
def test_alt_and_encoded_metadata_blocked_every_scope(scope, host):
    """Short-form / uppercase-hex / percent-encoded metadata literals all
    resolve to 169.254.169.254 at connect time and must deny with
    ``blocked_metadata_ip`` under EVERY scope — never leaking into the
    scope-mismatch bucket and never (under STRICT/PRIVATE_ONLY) being
    mistaken for an allowed 'local' link-local host."""
    decision = evaluate_url(
        f"http://{host}/latest/meta-data/", make_ctx(scope=scope)
    )
    assert decision.allowed is False, f"{host} allowed under {scope}"
    assert decision.reason == "blocked_metadata_ip", (
        f"{host} under {scope} -> {decision.reason}"
    )


@pytest.mark.parametrize("scope", _ALL_SCOPES)
@pytest.mark.parametrize(
    "host",
    [
        "Metadata.Google.Internal",  # mixed case
        "METADATA.GOOG",  # uppercase short GCP name
        "metadata.google.internal.",  # trailing dot, lower
        "Metadata.Goog.",  # mixed case + trailing dot
    ],
)
def test_gcp_metadata_hostnames_case_insensitive_blocked(scope, host):
    """GCP IMDS hostnames are matched case-insensitively and with the
    trailing dot stripped, under every scope. is_ip_blocked cannot see these
    (they are not IP literals), so the explicit hostname block must hold."""
    decision = evaluate_url(f"http://{host}/", make_ctx(scope=scope))
    assert decision.allowed is False, f"{host} allowed under {scope}"
    assert decision.reason == "blocked_metadata_ip", (
        f"{host} under {scope} -> {decision.reason}"
    )


def test_metadata_hostname_match_is_exact_not_substring():
    """The GCP hostname guard must be an EXACT match, not a substring check:
    an attacker-controlled host that merely *starts with* the metadata name
    (``metadata.google.internal.attacker.example``) resolves to the
    attacker's box and must be classified normally — not over-blocked as a
    metadata IP (which would mask the real classification) and not allowed
    as if it were the real IMDS."""
    with patch(
        "local_deep_research.security.egress.policy._classify_host",
        return_value=False,  # attacker host resolves public
    ):
        decision = evaluate_url(
            "http://metadata.google.internal.attacker.example/",
            make_ctx(scope=EgressScope.PUBLIC_ONLY),
        )
    assert decision.reason != "blocked_metadata_ip"
    assert decision.allowed is True
    assert decision.reason == "allowed_public_host"


# ---------------------------------------------------------------------------
# No over-block of ordinary public hosts (allow side of the metadata guard)
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "scope,expected_allowed,expected_reason",
    [
        (EgressScope.PUBLIC_ONLY, True, "allowed_public_host"),
        (EgressScope.BOTH, True, "allowed_both_scope"),
        (EgressScope.PRIVATE_ONLY, False, "scope_mismatch_private_only"),
        (EgressScope.STRICT, False, "strict_public_host"),
    ],
)
def test_normal_public_ip_reason_codes(
    scope, expected_allowed, expected_reason
):
    """A normal public IP (documentation-range 93.184.216.34) is NOT
    over-blocked by the metadata/alt-encoding machinery; it gets the correct
    per-scope reason code. Literal IPs never trigger network DNS."""
    decision = evaluate_url("http://93.184.216.34/", make_ctx(scope=scope))
    assert decision.allowed is expected_allowed
    assert decision.reason == expected_reason


@pytest.mark.parametrize(
    "scope,expected_allowed,expected_reason",
    [
        (EgressScope.PUBLIC_ONLY, True, "allowed_public_host"),
        (EgressScope.STRICT, False, "strict_public_host"),
        (EgressScope.PRIVATE_ONLY, False, "scope_mismatch_private_only"),
    ],
)
def test_normal_public_hostname_reason_codes(
    scope, expected_allowed, expected_reason
):
    """A normal public HOSTNAME (DNS path, mocked to resolve public) is not
    over-blocked and carries the correct per-scope reason. Exercises the
    non-literal classification branch with reason-code assertions the base
    suite leaves to the boolean flag."""
    with patch(
        "local_deep_research.security.egress.policy._classify_host",
        return_value=False,
    ):
        decision = evaluate_url(
            "https://example.com/page", make_ctx(scope=scope)
        )
    assert decision.allowed is expected_allowed
    assert decision.reason == expected_reason


# ---------------------------------------------------------------------------
# Per-scope reason codes for ordinary PRIVATE hosts
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "scope,expected_allowed,expected_reason",
    [
        (EgressScope.STRICT, True, "allowed_private_host_under_strict"),
        (EgressScope.PRIVATE_ONLY, True, "allowed_private_host"),
        (EgressScope.PUBLIC_ONLY, False, "scope_mismatch_public_only"),
        (EgressScope.BOTH, True, "allowed_both_scope"),
    ],
)
def test_normal_private_ip_reason_codes(
    scope, expected_allowed, expected_reason
):
    """A normal RFC1918 host (10.0.0.5) is allowed under STRICT/PRIVATE_ONLY/
    BOTH with the scope-specific allow reason, and denied under PUBLIC_ONLY —
    pinning the reason codes the metadata guard must not clobber."""
    decision = evaluate_url("http://10.0.0.5/api", make_ctx(scope=scope))
    assert decision.allowed is expected_allowed
    assert decision.reason == expected_reason


# ---------------------------------------------------------------------------
# Percent-encoded hosts decoded BEFORE classification
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "scope,expected_allowed,expected_reason",
    [
        (EgressScope.PRIVATE_ONLY, True, "allowed_private_host"),
        (EgressScope.STRICT, True, "allowed_private_host_under_strict"),
        (EgressScope.BOTH, True, "allowed_both_scope"),
        (EgressScope.PUBLIC_ONLY, False, "scope_mismatch_public_only"),
    ],
)
def test_percent_encoded_private_host_decoded(
    scope, expected_allowed, expected_reason
):
    """``http://192%2e168%2e1%2e1/`` decodes to the private 192.168.1.1 — the
    address the HTTP client actually connects to. The policy must classify
    the DECODED host (private), not the encoded form (which would fail DNS
    and read as public, a PUBLIC_ONLY scope bypass)."""
    decision = evaluate_url("http://192%2e168%2e1%2e1/", make_ctx(scope=scope))
    assert decision.allowed is expected_allowed
    assert decision.reason == expected_reason


# ---------------------------------------------------------------------------
# Denial-quota accounting: which reasons tick the per-run quota
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "url,expected_reason",
    [
        ("javascript:alert(1)", "dangerous_scheme"),
        ("data:text/html,<b>x</b>", "dangerous_scheme"),
        ("file:///etc/passwd", "dangerous_scheme"),
        ("vbscript:msgbox(1)", "dangerous_scheme"),
        ("about:blank", "dangerous_scheme"),
        ("ftp://example.com/x", "unsupported_scheme"),
        ("mailto:a@example.com", "unsupported_scheme"),
        ("tel:+15551234", "unsupported_scheme"),
        ("http:///path-only", "no_hostname"),
        ("", "url_malformed"),
    ],
)
def test_scheme_and_parse_denial_reason_codes(url, expected_reason):
    """Each non-fetchable scheme / parse failure denies with its specific
    machine reason. Breadth over the dangerous + unsupported scheme sets
    plus the no-hostname / malformed parse failures."""
    decision = evaluate_url(url, make_ctx(scope=EgressScope.PUBLIC_ONLY))
    assert decision.allowed is False
    assert decision.reason == expected_reason


def test_benign_denials_never_tick_quota_then_legit_url_passes():
    """A document full of dangerous/unsupported/no-hostname hrefs must NOT
    exhaust the anti-loop quota: flooding well past MAX_DENIED_FETCHES_PER_RUN
    leaves the counter at zero, and a legitimate public URL still passes.
    Covers file:/vbscript:/about:/ftp:/no_hostname together (the base suite
    only floods javascript:/data: and mailto: individually)."""
    ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY)
    for _ in range(MAX_DENIED_FETCHES_PER_RUN + 5):
        assert not evaluate_url("file:///etc/passwd", ctx).allowed
        assert not evaluate_url("ftp://host/x", ctx).allowed
        assert not evaluate_url("vbscript:x", ctx).allowed
        assert not evaluate_url("about:blank", ctx).allowed
        assert not evaluate_url("http:///nohost", ctx).allowed
    assert ctx._fetch_denial_count["count"] == 0
    # The quota was never consumed, so a real public URL is still allowed.
    decision = evaluate_url("http://93.184.216.34/", ctx)
    assert decision.allowed is True
    assert decision.reason == "allowed_public_host"


def test_scope_mismatch_denials_eventually_exhaust_quota():
    """Scope-mismatch denials ARE security-relevant and DO tick the quota:
    exactly MAX_DENIED_FETCHES_PER_RUN private-host denials under PUBLIC_ONLY
    are processed normally, then the next call fails closed with
    ``denial_quota_exceeded``. Uses a literal RFC1918 IP so no DNS fires."""
    ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY)
    for _ in range(MAX_DENIED_FETCHES_PER_RUN):
        d = evaluate_url("http://10.0.0.5/", ctx)
        assert d.allowed is False
        assert d.reason == "scope_mismatch_public_only"
    capped = evaluate_url("http://10.0.0.5/", ctx)
    assert capped.allowed is False
    assert capped.reason == "denial_quota_exceeded"


def test_metadata_denials_count_toward_quota():
    """A metadata-IP denial is security-relevant (``blocked_metadata_ip`` is
    NOT in the non-quota set), so it ticks the counter — preventing an
    injected doc from looping the agent on IMDS targets indefinitely."""
    ctx = make_ctx(scope=EgressScope.BOTH)
    before = ctx._fetch_denial_count["count"]
    evaluate_url("http://169.254.169.254/", ctx)
    assert ctx._fetch_denial_count["count"] == before + 1


def test_quota_cap_precedes_metadata_check():
    """Once the quota is exhausted, even a metadata URL short-circuits to
    ``denial_quota_exceeded`` (the quota gate is the first check in
    evaluate_url) — the run is uniformly fail-closed past the cap."""
    ctx = make_ctx(scope=EgressScope.BOTH)
    ctx._fetch_denial_count["count"] = MAX_DENIED_FETCHES_PER_RUN
    decision = evaluate_url("http://169.254.169.254/", ctx)
    assert decision.allowed is False
    assert decision.reason == "denial_quota_exceeded"
