"""Tests for SSRF-safe HTTP utilities.

This module tests the ssrf.py module which provides SSRF-protected HTTP fetching.
"""

from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from fastmcp.server.auth.ssrf import (
    SSRFError,
    SSRFFetchError,
    is_ip_allowed,
    ssrf_safe_fetch,
    validate_url,
)


class TestIsIPAllowed:
    """Tests for is_ip_allowed function."""

    def test_public_ipv4_allowed(self):
        """Public IPv4 addresses should be allowed."""
        assert is_ip_allowed("8.8.8.8") is True
        assert is_ip_allowed("1.1.1.1") is True
        assert is_ip_allowed("93.184.216.34") is True

    def test_private_ipv4_blocked(self):
        """Private IPv4 addresses should be blocked."""
        assert is_ip_allowed("192.168.1.1") is False
        assert is_ip_allowed("10.0.0.1") is False
        assert is_ip_allowed("172.16.0.1") is False

    def test_loopback_blocked(self):
        """Loopback addresses should be blocked."""
        assert is_ip_allowed("127.0.0.1") is False
        assert is_ip_allowed("::1") is False

    def test_link_local_blocked(self):
        """Link-local addresses (AWS metadata) should be blocked."""
        assert is_ip_allowed("169.254.169.254") is False

    def test_rfc6598_cgnat_blocked(self):
        """RFC6598 Carrier-Grade NAT addresses should be blocked."""
        assert is_ip_allowed("100.64.0.1") is False
        assert is_ip_allowed("100.100.100.100") is False

    def test_ipv4_mapped_ipv6_blocked_if_private(self):
        """IPv4-mapped IPv6 addresses should check the embedded IPv4."""
        assert is_ip_allowed("::ffff:127.0.0.1") is False
        assert is_ip_allowed("::ffff:192.168.1.1") is False

    @pytest.mark.parametrize(
        "address",
        [
            pytest.param("64:ff9b::7f00:1", id="loopback"),
            pytest.param("64:ff9b::0a00:1", id="private"),
            pytest.param("64:ff9b::a9fe:a9fe", id="link-local"),
            pytest.param("64:ff9b::6440:1", id="cgnat"),
        ],
    )
    def test_nat64_ipv6_blocked_if_embedded_ipv4_blocked(self, address: str):
        """NAT64 IPv6 addresses should check the embedded IPv4."""
        assert is_ip_allowed(address) is False

    def test_nat64_ipv6_allowed_if_embedded_ipv4_allowed(self):
        """NAT64 IPv6 addresses should stay allowed for public embedded IPv4."""
        assert is_ip_allowed("64:ff9b::0808:0808") is True


class TestValidateURL:
    """Tests for validate_url function."""

    async def test_http_rejected(self):
        """HTTP URLs should be rejected (HTTPS required)."""
        with pytest.raises(SSRFError, match="must use HTTPS"):
            await validate_url("http://example.com/path")

    async def test_missing_host_rejected(self):
        """URLs without host should be rejected."""
        with pytest.raises(SSRFError, match="must have a host"):
            await validate_url("https:///path")

    async def test_root_path_rejected_when_required(self):
        """Root paths should be rejected when require_path=True."""
        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["93.184.216.34"],
        ):
            with pytest.raises(SSRFError, match="non-root path"):
                await validate_url("https://example.com/", require_path=True)

    async def test_private_ip_rejected(self):
        """URLs resolving to private IPs should be rejected."""
        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["192.168.1.1"],
        ):
            with pytest.raises(SSRFError, match="blocked IP"):
                await validate_url("https://example.com/path")

    async def test_nat64_private_ip_rejected(self):
        """URLs resolving to NAT64-wrapped private IPs should be rejected."""
        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["64:ff9b::0a00:1"],
        ):
            with pytest.raises(SSRFError, match="blocked IP"):
                await validate_url("https://example.com/path")


class TestSSRFSafeFetch:
    """Tests for ssrf_safe_fetch function."""

    async def test_private_ip_blocked(self):
        """Fetch to private IP should be blocked."""
        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["192.168.1.1"],
        ):
            with pytest.raises(SSRFError, match="blocked IP"):
                await ssrf_safe_fetch("https://internal.example.com/api")

    async def test_cgnat_blocked(self):
        """Fetch to RFC6598 CGNAT IP should be blocked."""
        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["100.64.0.1"],
        ):
            with pytest.raises(SSRFError, match="blocked IP"):
                await ssrf_safe_fetch("https://cgnat.example.com/api")

    async def test_connects_to_pinned_ip(self):
        """Verify connection uses pinned IP, not re-resolved DNS."""
        resolved_ip = "93.184.216.34"

        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=[resolved_ip],
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {"content-length": "15"}
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)

            async def aiter_bytes():
                yield b'{"data": "test"}'

            mock_stream.aiter_bytes = aiter_bytes

            mock_client = AsyncMock()
            mock_client.stream = MagicMock(return_value=mock_stream)
            mock_client.__aenter__.return_value = mock_client
            mock_client.__aexit__ = AsyncMock(return_value=None)
            mock_client_class.return_value = mock_client

            await ssrf_safe_fetch("https://example.com/api")

            # Verify URL contains pinned IP
            call_args = mock_client.stream.call_args
            url_called = call_args[0][1]
            assert resolved_ip in url_called

    async def test_fallback_to_second_ip(self):
        """If the first IP fails, the next resolved IP should be tried."""
        resolved_ips = ["2001:4860:4860::8888", "93.184.216.34"]

        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=resolved_ips,
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            request = httpx.Request("GET", "https://example.com/api")

            first_client = AsyncMock()
            first_client.stream = MagicMock(
                side_effect=httpx.RequestError("boom", request=request)
            )
            first_client.__aenter__.return_value = first_client
            first_client.__aexit__ = AsyncMock(return_value=None)

            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {"content-length": "2"}
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)

            async def aiter_bytes():
                yield b"ok"

            mock_stream.aiter_bytes = aiter_bytes

            second_client = AsyncMock()
            second_client.stream = MagicMock(return_value=mock_stream)
            second_client.__aenter__.return_value = second_client
            second_client.__aexit__ = AsyncMock(return_value=None)

            mock_client_class.side_effect = [first_client, second_client]

            content = await ssrf_safe_fetch("https://example.com/api")
            assert content == b"ok"

            call_args = second_client.stream.call_args
            url_called = call_args[0][1]
            assert resolved_ips[1] in url_called

    async def test_host_header_set(self):
        """Verify Host header is set to original hostname."""
        resolved_ip = "93.184.216.34"
        original_host = "example.com"

        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=[resolved_ip],
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {"content-length": "15"}
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)

            async def aiter_bytes():
                yield b'{"data": "test"}'

            mock_stream.aiter_bytes = aiter_bytes

            mock_client = AsyncMock()
            mock_client.stream = MagicMock(return_value=mock_stream)
            mock_client.__aenter__.return_value = mock_client
            mock_client.__aexit__ = AsyncMock(return_value=None)
            mock_client_class.return_value = mock_client

            await ssrf_safe_fetch(f"https://{original_host}/api")

            # Verify Host header
            call_kwargs = mock_client.stream.call_args[1]
            assert call_kwargs["headers"]["Host"] == original_host

    async def test_response_size_limit(self):
        """Verify response size limit is enforced via streaming."""
        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=["93.184.216.34"],
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            # Response larger than default 5KB (no Content-Length, so streaming enforces)
            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {}  # No Content-Length to force streaming check
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)

            async def aiter_bytes():
                # Yield 10KB total
                for _ in range(10):
                    yield b"x" * 1024

            mock_stream.aiter_bytes = aiter_bytes

            mock_client = AsyncMock()
            mock_client.stream = MagicMock(return_value=mock_stream)
            mock_client.__aenter__.return_value = mock_client
            mock_client.__aexit__ = AsyncMock(return_value=None)
            mock_client_class.return_value = mock_client

            with pytest.raises(SSRFFetchError, match="too large"):
                await ssrf_safe_fetch("https://example.com/api")


class TestJWKSSSRFProtection:
    """Tests for SSRF protection in JWTVerifier JWKS fetching."""

    async def test_jwks_private_ip_blocked(self):
        """JWKS fetch to private IP should be blocked."""
        from fastmcp.server.auth.providers.jwt import JWTVerifier

        verifier = JWTVerifier(
            jwks_uri="https://internal.example.com/.well-known/jwks.json",
            issuer="https://issuer.example.com",
            ssrf_safe=True,
        )

        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["192.168.1.1"],
        ):
            with pytest.raises(ValueError, match="Failed to fetch JWKS"):
                # Create a dummy token to trigger JWKS fetch
                await verifier._get_jwks_key("test-kid")

    async def test_jwks_cgnat_blocked(self):
        """JWKS fetch to RFC6598 CGNAT IP should be blocked."""
        from fastmcp.server.auth.providers.jwt import JWTVerifier

        verifier = JWTVerifier(
            jwks_uri="https://cgnat.example.com/.well-known/jwks.json",
            issuer="https://issuer.example.com",
            ssrf_safe=True,
        )

        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["100.64.0.1"],
        ):
            with pytest.raises(ValueError, match="Failed to fetch JWKS"):
                await verifier._get_jwks_key("test-kid")

    async def test_jwks_loopback_blocked(self):
        """JWKS fetch to loopback should be blocked."""
        from fastmcp.server.auth.providers.jwt import JWTVerifier

        verifier = JWTVerifier(
            jwks_uri="https://localhost/.well-known/jwks.json",
            issuer="https://issuer.example.com",
            ssrf_safe=True,
        )

        with patch(
            "fastmcp.server.auth.ssrf.resolve_hostname",
            return_value=["127.0.0.1"],
        ):
            with pytest.raises(ValueError, match="Failed to fetch JWKS"):
                await verifier._get_jwks_key("test-kid")


class TestIPv6URLFormatting:
    """Tests for proper IPv6 address bracketing in URLs."""

    def test_format_ip_for_url_ipv4(self):
        """IPv4 addresses should not be bracketed."""
        from fastmcp.server.auth.ssrf import format_ip_for_url

        assert format_ip_for_url("8.8.8.8") == "8.8.8.8"
        assert format_ip_for_url("192.168.1.1") == "192.168.1.1"

    def test_format_ip_for_url_ipv6(self):
        """IPv6 addresses should be bracketed for URL use."""
        from fastmcp.server.auth.ssrf import format_ip_for_url

        assert format_ip_for_url("2001:db8::1") == "[2001:db8::1]"
        assert format_ip_for_url("::1") == "[::1]"
        assert format_ip_for_url("fe80::1") == "[fe80::1]"

    def test_format_ip_for_url_invalid(self):
        """Invalid IP strings should be returned unchanged."""
        from fastmcp.server.auth.ssrf import format_ip_for_url

        assert format_ip_for_url("not-an-ip") == "not-an-ip"
        assert format_ip_for_url("") == ""

    async def test_ipv6_pinned_url_is_valid(self):
        """Verify IPv6 addresses are properly bracketed in pinned URLs."""
        resolved_ipv6 = "2001:4860:4860::8888"

        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=[resolved_ipv6],
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {"content-length": "10"}
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)

            async def aiter_bytes():
                yield b'{"key": 1}'

            mock_stream.aiter_bytes = aiter_bytes

            mock_client = AsyncMock()
            mock_client.stream = MagicMock(return_value=mock_stream)
            mock_client.__aenter__.return_value = mock_client
            mock_client.__aexit__ = AsyncMock(return_value=None)
            mock_client_class.return_value = mock_client

            await ssrf_safe_fetch("https://example.com/api")

            # Verify the URL contains bracketed IPv6 address
            call_args = mock_client.stream.call_args
            url_called = call_args[0][1]

            # IPv6 should be bracketed: https://[2001:4860:4860::8888]:443/path
            assert f"[{resolved_ipv6}]" in url_called, (
                f"Expected bracketed IPv6 [{resolved_ipv6}] in URL, got {url_called}"
            )


class TestStreamingResponseSizeLimit:
    """Tests for streaming-based response size enforcement."""

    async def test_size_limit_enforced_during_streaming(self):
        """Verify that size limit is enforced as chunks are received, not after."""
        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=["93.184.216.34"],
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            chunks_yielded = []

            async def aiter_bytes():
                # Yield chunks that exceed the limit
                for i in range(10):
                    chunk = b"x" * 1024  # 1KB per chunk
                    chunks_yielded.append(chunk)
                    yield chunk

            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {}  # No content-length to force streaming check
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)
            mock_stream.aiter_bytes = aiter_bytes

            mock_client = AsyncMock()
            mock_client.stream = MagicMock(return_value=mock_stream)
            mock_client.__aenter__.return_value = mock_client
            mock_client.__aexit__ = AsyncMock(return_value=None)
            mock_client_class.return_value = mock_client

            with pytest.raises(SSRFFetchError, match="too large"):
                await ssrf_safe_fetch("https://example.com/api", max_size=5120)

            # Verify we stopped after exceeding the limit (should be ~6 chunks for 5KB limit)
            # This confirms we're enforcing during streaming, not after downloading all
            assert len(chunks_yielded) <= 7, (
                f"Downloaded {len(chunks_yielded)} chunks (expected <=7 for streaming enforcement)"
            )

    async def test_content_length_header_checked_first(self):
        """Verify Content-Length header is checked before streaming."""
        with (
            patch(
                "fastmcp.server.auth.ssrf.resolve_hostname",
                return_value=["93.184.216.34"],
            ),
            patch("httpx.AsyncClient") as mock_client_class,
        ):
            mock_stream = MagicMock()
            mock_stream.status_code = 200
            mock_stream.headers = {"content-length": "10240"}  # 10KB
            mock_stream.__aenter__ = AsyncMock(return_value=mock_stream)
            mock_stream.__aexit__ = AsyncMock(return_value=None)

            # aiter_bytes should never be called if Content-Length is checked
            mock_stream.aiter_bytes = MagicMock(
                side_effect=AssertionError("Should not stream")
            )

            mock_client = AsyncMock()
            mock_client.stream = MagicMock(return_value=mock_stream)
            mock_client.__aenter__.return_value = mock_client
            mock_client.__aexit__ = AsyncMock(return_value=None)
            mock_client_class.return_value = mock_client

            with pytest.raises(SSRFFetchError, match="too large"):
                await ssrf_safe_fetch("https://example.com/api", max_size=5120)
