"""Tests for SSRF protection in url_safety module."""

import socket
from unittest.mock import patch

import httpx

from tools.url_safety import (
    is_safe_url,
    async_is_safe_url,
    is_always_blocked_url,
    normalize_url_for_request,
    redirect_target_from_response,
    create_ssrf_safe_async_client,
    SSRFConnectionBlocked,
    _SSRFGuardedAsyncNetworkBackend,
    _MAX_SSRF_CONNECT_IPS,
    _resolved_http_connect_ips,
    _is_blocked_ip,
    _global_allow_private_urls,
    _reset_allow_private_cache,
)

import ipaddress
import pytest


def _resolves_to(*ips):
    """Patch ``socket.getaddrinfo`` so any hostname resolves to *ips*.

    The address family field is unused by url_safety (it reads ``sockaddr[0]``
    only), so one shape works for both IPv4 and IPv6 answers.
    """
    return patch(
        "socket.getaddrinfo",
        return_value=[
            (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0)) for ip in ips
        ],
    )


class TestNormalizeUrlForRequest:
    @pytest.mark.parametrize("raw, expected", [
        # non-ASCII path is percent-encoded
        ("https://wttr.in/Köln", "https://wttr.in/K%C3%B6ln"),
        # existing escapes are preserved (idempotent)
        ("https://wttr.in/K%C3%B6ln", "https://wttr.in/K%C3%B6ln"),
        # hostname is IDNA-encoded
        ("https://münich.example/Köln", "https://xn--mnich-kva.example/K%C3%B6ln"),
    ])
    def test_encodes_url_parts(self, raw, expected):
        assert normalize_url_for_request(raw) == expected


    def test_does_not_collapse_embedded_scheme_separator_in_query(self):
        assert (
            normalize_url_for_request("https://example.com/r?next=https:// evil.example")
            == "https://example.com/r?next=https://%20evil.example"
        )


class TestIsSafeUrl:
    def test_public_url_allowed(self):
        with _resolves_to("93.184.216.34"):
            assert is_safe_url("https://example.com/image.png") is True

    @pytest.mark.parametrize("url", [
        "ftp://example.com/file.txt",  # only http/https allowed for fetch tools
        "example.com/path",            # bare host/path is ambiguous
        "http://",                     # no hostname
        "",                            # empty
    ])
    def test_unusable_urls_blocked(self, url):
        assert is_safe_url(url) is False

    @pytest.mark.parametrize("ip, url", [
        ("127.0.0.1", "http://localhost:8080/secret"),
        ("10.0.0.1", "http://internal-service.local/api"),
        ("::1", "http://[::1]:8080/"),
    ])
    def test_private_and_loopback_targets_blocked(self, ip, url):
        with _resolves_to(ip):
            assert is_safe_url(url) is False

    def test_dns_failure_blocked(self, monkeypatch):
        """DNS failures fail closed — block the request (no proxy configured)."""
        for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
            monkeypatch.delenv(var, raising=False)
        with patch("socket.getaddrinfo", side_effect=socket.gaierror("Name resolution failed")):
            assert is_safe_url("https://nonexistent.example.com") is False


class TestProxyEnvironmentDnsDelegation:
    """When an HTTP proxy is configured, DNS is delegated to the proxy.

    Sandbox / proxy-only environments (Docker + Squid, NVIDIA OpenShell,
    iron-proxy egress sandboxes) block direct DNS at the network level;
    only HTTP(S) via the proxy works. is_safe_url must not fail closed on
    the pre-flight DNS check there — the proxy is the egress boundary.
    Regression tests for #32217 / PR #68469.
    """

    @pytest.fixture(autouse=True)
    def _clear_proxy_env(self, monkeypatch):
        for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
            monkeypatch.delenv(var, raising=False)

    def test_dns_failure_allowed_when_proxy_configured(self, monkeypatch):
        monkeypatch.setenv("HTTPS_PROXY", "http://host.docker.internal:9090")
        with patch("socket.getaddrinfo", side_effect=socket.gaierror("blocked at network level")):
            assert is_safe_url("https://api.openai.com/v1/models") is True

    def test_metadata_hostname_still_blocked_with_proxy(self, monkeypatch):
        """The blocked-hostname floor runs BEFORE the DNS skip."""
        monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128")
        with patch("socket.getaddrinfo", side_effect=socket.gaierror("no dns")):
            assert is_safe_url("http://metadata.google.internal/computeMetadata/v1/") is False

    def test_literal_metadata_ip_still_blocked_with_proxy(self, monkeypatch):
        """Literal IPs never take the DNS-failure path — floor intact."""
        monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128")
        assert is_safe_url("http://169.254.169.254/latest/meta-data/") is False


    def test_ipv6_scope_id_link_local_blocked(self):
        """fe80::1%eth0 — a scope-ID-bearing link-local address must not bypass
        the guard. ``ipaddress.ip_address`` rejects the ``%scope`` suffix, so
        the scope must be stripped before the block check rather than skipped.
        """
        with _resolves_to("fe80::1%eth0"):
            assert is_safe_url("http://[fe80::1%eth0]/") is False

    def test_unparseable_ip_after_scope_strip_fails_closed(self):
        """An address that is still unparseable after stripping the scope ID
        must fail closed (block), not be silently skipped."""
        with _resolves_to("not-an-ip%garbage"):
            assert is_safe_url("http://example.invalid/") is False

    def test_unexpected_error_fails_closed(self):
        """Unexpected exceptions should block, not allow."""
        with patch("tools.url_safety.urlparse", side_effect=ValueError("bad url")):
            assert is_safe_url("http://evil.com/") is False

    def test_benchmark_ip_blocked_for_non_allowlisted_host(self):
        with _resolves_to("198.18.0.23"):
            assert is_safe_url("https://example.com/file.jpg") is False

    @pytest.mark.parametrize("url, expected", [
        # the allowlisted host itself, over https
        ("https://multimedia.nt.qq.com.cn/download?id=123", True),
        # exception is an exact host match — subdomains stay blocked
        ("https://sub.multimedia.nt.qq.com.cn/download?id=123", False),
        # ... and requires https
        ("http://multimedia.nt.qq.com.cn/download?id=123", False),
    ])
    def test_qq_multimedia_hostname_exception(self, url, expected):
        with _resolves_to("198.18.0.23"):
            assert is_safe_url(url) is expected


class TestAsyncIsSafeUrl:
    """async_is_safe_url must match is_safe_url (runs DNS in a thread pool)."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize("ip, url, expected", [
        ("93.184.216.34", "https://example.com/x", True),
        ("127.0.0.1", "http://localhost:8080/", False),
    ])
    async def test_matches_sync_verdict(self, ip, url, expected):
        with _resolves_to(ip):
            assert await async_is_safe_url(url) is expected


class TestSSRFGuardedHttpxClient:
    def test_connect_resolution_checks_private_ip_beyond_candidate_cap(self):
        answers = [
            (socket.AF_INET, socket.SOCK_STREAM, 6, "", (f"93.184.216.{idx}", 80))
            for idx in range(1, _MAX_SSRF_CONNECT_IPS + 1)
        ]
        answers.append(
            (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 80))
        )

        with patch("socket.getaddrinfo", return_value=answers):
            with pytest.raises(SSRFConnectionBlocked, match="metadata"):
                _resolved_http_connect_ips("example.com", 80, "http")


    @pytest.mark.asyncio
    async def test_async_backend_blocks_unix_socket_connects(self):
        import contextvars

        backend = _SSRFGuardedAsyncNetworkBackend(contextvars.ContextVar("test_schemes"))

        with pytest.raises(SSRFConnectionBlocked, match="Unix socket"):
            await backend.connect_unix_socket("/tmp/hermes.sock")

    def test_async_client_rejects_unpatchable_custom_transport(self):
        class CustomTransport(httpx.AsyncBaseTransport):
            async def handle_async_request(self, request):
                return httpx.Response(200, request=request)

        with pytest.raises(SSRFConnectionBlocked, match="Unsupported async httpx transport"):
            create_ssrf_safe_async_client(transport=CustomTransport())

    @pytest.mark.asyncio
    async def test_async_client_preserves_env_proxy_mounts(self, monkeypatch):
        """Installing the guard must not disable or rewrite httpx env proxy setup."""
        for proxy_var in (
            "HTTP_PROXY",
            "HTTPS_PROXY",
            "ALL_PROXY",
            "http_proxy",
            "https_proxy",
            "all_proxy",
            "NO_PROXY",
            "no_proxy",
        ):
            monkeypatch.delenv(proxy_var, raising=False)
        monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")

        client = create_ssrf_safe_async_client(timeout=0.01)
        try:
            proxy_transports = [
                transport
                for transport in client.__dict__.get("_mounts", {}).values()
                if transport is not None
            ]
            assert proxy_transports
            assert type(client._transport._pool._network_backend).__name__ == (
                "_SSRFGuardedAsyncNetworkBackend"
            )
            assert all(
                type(transport._pool._network_backend).__name__
                != "_SSRFGuardedAsyncNetworkBackend"
                for transport in proxy_transports
            )
        finally:
            await client.aclose()


class TestIsBlockedIp:
    """Direct tests for the _is_blocked_ip helper — one per blocked range.

    ``::ffff:`` forms cover the IPv4-mapped IPv6 bypass: Python's ipaddress
    module treats them as distinct from the plain IPv4 address, so membership
    checks miss them without explicit handling.
    """

    @pytest.mark.parametrize("ip_str", [
        "169.254.169.254",          # link-local / cloud metadata
        "0.0.0.0",                  # unspecified
        "224.0.0.1",                # multicast (not is_private)
        "100.64.0.1",               # CGNAT boundary (not is_private)
        "198.18.0.23",              # benchmark range
        "fd12::1",                  # IPv6 unique local
        "::ffff:169.254.169.254",   # IPv4-mapped IPv6 metadata
    ])
    def test_blocked_ips(self, ip_str):
        ip = ipaddress.ip_address(ip_str)
        assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked"

    @pytest.mark.parametrize("ip_str", [
        "100.0.0.1",       # just below the CGNAT range
        "2606:4700::1",    # public IPv6
    ])
    def test_allowed_ips(self, ip_str):
        ip = ipaddress.ip_address(ip_str)
        assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed"


class TestGlobalAllowPrivateUrls:
    """Tests for the security.allow_private_urls config toggle."""

    @pytest.fixture(autouse=True)
    def _reset_cache(self):
        """Reset the module-level toggle cache before and after each test."""
        _reset_allow_private_cache()
        yield
        _reset_allow_private_cache()

    def test_default_is_false(self, monkeypatch):
        """Toggle defaults to False when no env var or config is set."""
        monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
        with patch("hermes_cli.config.read_raw_config", side_effect=Exception("no config")):
            assert _global_allow_private_urls() is False


    def test_config_security_string_false_stays_disabled(self, monkeypatch):
        """Quoted false must not opt out of SSRF protection."""
        monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
        cfg = {"security": {"allow_private_urls": "false"}}
        with patch("hermes_cli.config.read_raw_config", return_value=cfg):
            assert _global_allow_private_urls() is False


    @pytest.mark.parametrize(
        "profile_order",
        [("allowed", "blocked"), ("blocked", "allowed")],
        ids=["allowed-then-blocked", "blocked-then-allowed"],
    )
    def test_profile_scoped_config_does_not_reuse_another_profiles_opt_out(
        self, tmp_path, monkeypatch, profile_order
    ):
        """Multiplexed profiles must resolve their own private-URL policy."""
        from hermes_constants import (
            reset_hermes_home_override,
            set_hermes_home_override,
        )

        monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
        allowed_home = tmp_path / "allowed"
        blocked_home = tmp_path / "blocked"
        allowed_home.mkdir()
        blocked_home.mkdir()
        (allowed_home / "config.yaml").write_text(
            "security:\n  allow_private_urls: true\n", encoding="utf-8"
        )
        (blocked_home / "config.yaml").write_text(
            "security:\n  allow_private_urls: false\n", encoding="utf-8"
        )
        monkeypatch.setattr(
            socket,
            "getaddrinfo",
            lambda *_args, **_kwargs: [(2, 1, 6, "", ("10.0.0.8", 0))],
        )

        def under_profile(home):
            token = set_hermes_home_override(home)
            try:
                return is_safe_url("http://profile-private.test/resource")
            finally:
                reset_hermes_home_override(token)

        homes = {"allowed": allowed_home, "blocked": blocked_home}
        expected = {"allowed": True, "blocked": False}
        for profile in profile_order:
            assert under_profile(homes[profile]) is expected[profile]


class TestAllowPrivateUrlsIntegration:
    """Integration tests: is_safe_url respects the global toggle."""

    @pytest.fixture(autouse=True)
    def _reset_cache(self):
        _reset_allow_private_cache()
        yield
        _reset_allow_private_cache()

    @pytest.mark.parametrize("ip, url", [
        ("192.168.1.1", "http://router.local"),
        # 198.18.x.x (benchmark / OpenWrt proxy range) must pass too
        ("198.18.23.183", "https://nousresearch.com"),
    ])
    def test_private_ip_allowed_when_toggle_on(self, monkeypatch, ip, url):
        monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
        with _resolves_to(ip):
            assert is_safe_url(url) is True

    # --- Cloud metadata always blocked regardless of toggle ---

    @pytest.mark.parametrize("ip, url", [
        ("fd00:ec2::254", "http://[fd00:ec2::254]/latest/"),          # AWS IPv6 IMDS
        ("100.100.100.200", "http://100.100.100.200/latest/meta-data/"),  # Alibaba
    ])
    def test_metadata_ip_blocked_even_with_toggle(self, monkeypatch, ip, url):
        monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
        with _resolves_to(ip):
            assert is_safe_url(url) is False

    def test_metadata_hostname_blocked_even_with_toggle(self, monkeypatch):
        """metadata.google.internal is ALWAYS blocked."""
        monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
        assert is_safe_url("http://metadata.google.internal/computeMetadata/v1/") is False

    def test_dns_failure_still_blocked_with_toggle(self, monkeypatch):
        """DNS failures are still blocked even with toggle on."""
        monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
        for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
            monkeypatch.delenv(var, raising=False)
        with patch("socket.getaddrinfo", side_effect=socket.gaierror("fail")):
            assert is_safe_url("https://nonexistent.example.com") is False


class TestIsAlwaysBlockedUrl:
    """The always-blocked floor — cloud metadata only, narrower than is_safe_url."""

    # -- The sentinel set that must always block --------------------------------

    @pytest.mark.parametrize("url", [
        "http://169.254.42.1/",                      # any /16 link-local (incl. IMDS)
        "http://100.100.100.200/latest/meta-data/",   # Alibaba Cloud
    ])
    def test_literal_imds_ips_always_blocked(self, url):
        assert is_always_blocked_url(url) is True

    def test_gcp_metadata_hostname_always_blocked_even_without_dns(self):
        """metadata.google.internal blocks by hostname, no DNS needed."""
        with patch("socket.getaddrinfo", side_effect=socket.gaierror("nope")):
            assert is_always_blocked_url("http://metadata.google.internal/") is True

    def test_scope_id_imds_in_floor_blocked(self):
        """An attacker-controlled hostname resolving to a scope-ID-bearing,
        IPv4-mapped IMDS address must be caught after the scope is stripped,
        not skipped as unparseable."""
        with _resolves_to("::ffff:169.254.169.254%eth0"):
            assert is_always_blocked_url("http://attacker-controlled.example.com/") is True

    # -- Things the floor must NOT block ----------------------------------------

    def test_public_url_not_blocked(self):
        assert is_always_blocked_url("https://example.com/path") is False

    def test_ordinary_private_urls_not_in_floor(self):
        """Floor is narrower than is_safe_url — ordinary private URLs pass.

        CGNAT is blocked by is_safe_url but must not be claimed by the floor.
        """
        assert is_always_blocked_url("http://127.0.0.1:8080/") is False
        assert is_always_blocked_url("http://100.64.0.1/") is False

    def test_dns_failure_not_in_floor(self):
        """DNS failure on a non-sentinel hostname = not always-blocked.

        Caller's ordinary fail-closed path (is_safe_url) handles that case.
        """
        with patch("socket.getaddrinfo", side_effect=socket.gaierror("fail")):
            assert is_always_blocked_url("http://nonexistent.example.com/") is False

    def test_malformed_url_not_in_floor(self):
        """Parse errors don't claim always-blocked status."""
        assert is_always_blocked_url("not a url at all") is False

    def test_floor_ignores_allow_private_urls_toggle(self, monkeypatch):
        """security.allow_private_urls can NOT unblock cloud metadata."""
        monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
        assert is_always_blocked_url("http://169.254.169.254/") is True


class TestIPv4MappedIPv6SSRF:
    """Regression tests for SSRF bypass via IPv4-mapped IPv6 addresses.

    DNS resolvers may return ``::ffff:x.x.x.x`` for IPv4-only hosts, which
    Python's ipaddress module treats as distinct from the plain IPv4 address.
    """

    @pytest.mark.parametrize("ip, url", [
        ("::ffff:169.254.169.254", "http://aws-metadata.internal/"),
        # in the CGNAT range, so a different block branch than link-local
        ("::ffff:100.100.100.200", "http://aliyun-metadata.internal/"),
    ])
    def test_ipv4_mapped_metadata_blocked(self, ip, url):
        with _resolves_to(ip):
            assert is_safe_url(url) is False


class _FakeResponse:
    """Minimal stand-in for an httpx response as seen inside a response hook."""

    def __init__(self, *, is_redirect, location=None, url="", next_request=None):
        self.is_redirect = is_redirect
        self.headers = {"location": location} if location else {}
        self.url = url
        self.next_request = next_request


class _FakeNextRequest:
    def __init__(self, url):
        self.url = url


class TestRedirectTargetFromResponse:
    """redirect_target_from_response is the SSRF-guard boundary for httpx hooks.

    Inside httpx AsyncClient response hooks, ``response.next_request`` is often
    ``None`` even for a real redirect, so a guard keyed only on it silently
    never fires. Resolving from the ``Location`` header closes that hole.
    """

    def test_absolute_location_without_next_request(self):
        # The exact bypass: redirect present, next_request unset, private target.
        resp = _FakeResponse(
            is_redirect=True,
            location="http://169.254.169.254/latest/meta-data",
            url="https://public.example/image.png",
        )
        assert (
            redirect_target_from_response(resp)
            == "http://169.254.169.254/latest/meta-data"
        )


    def test_falls_back_to_next_request_when_no_location(self):
        resp = _FakeResponse(
            is_redirect=True,
            next_request=_FakeNextRequest("http://10.0.0.1/meta"),
        )
        assert redirect_target_from_response(resp) == "http://10.0.0.1/meta"
