"""
Extended tests for the SSRF validator module.

Provides comprehensive coverage of is_ip_blocked, get_safe_url, validate_url,
and module constants (ALWAYS_BLOCKED_METADATA_IPS, ALLOWED_SCHEMES).
"""

from unittest.mock import patch

import pytest

from local_deep_research.security.ssrf_validator import (
    ALWAYS_BLOCKED_METADATA_IPS,
    ALLOWED_SCHEMES,
    get_safe_url,
    is_ip_blocked,
    validate_url,
)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


class TestAlwaysBlockedMetadataIPsConstant:
    """Verify the ALWAYS_BLOCKED_METADATA_IPS frozenset."""

    def test_contains_aws_imds(self):
        """AWS IMDS / Azure / OCI / DigitalOcean shared endpoint."""
        assert "169.254.169.254" in ALWAYS_BLOCKED_METADATA_IPS

    def test_contains_aws_ecs_v3_and_v4(self):
        assert "169.254.170.2" in ALWAYS_BLOCKED_METADATA_IPS
        assert "169.254.170.23" in ALWAYS_BLOCKED_METADATA_IPS

    def test_contains_alibaba_and_tencent(self):
        assert "100.100.100.200" in ALWAYS_BLOCKED_METADATA_IPS
        assert "169.254.0.23" in ALWAYS_BLOCKED_METADATA_IPS


class TestAllowedSchemesConstant:
    """Verify the ALLOWED_SCHEMES constant."""

    def test_http_in_allowed_schemes(self):
        """http must be an allowed scheme."""
        assert "http" in ALLOWED_SCHEMES

    def test_https_in_allowed_schemes(self):
        """https must be an allowed scheme."""
        assert "https" in ALLOWED_SCHEMES

    def test_ftp_not_in_allowed_schemes(self):
        """ftp must NOT be an allowed scheme."""
        assert "ftp" not in ALLOWED_SCHEMES

    def test_file_not_in_allowed_schemes(self):
        """file must NOT be an allowed scheme."""
        assert "file" not in ALLOWED_SCHEMES

    def test_allowed_schemes_is_a_set(self):
        """ALLOWED_SCHEMES should be a set for O(1) lookups."""
        assert isinstance(ALLOWED_SCHEMES, set)

    def test_allowed_schemes_contains_only_expected_values(self):
        """ALLOWED_SCHEMES should contain exactly http and https."""
        assert ALLOWED_SCHEMES == {"http", "https"}


# ---------------------------------------------------------------------------
# is_ip_blocked -- public / safe IPs
# ---------------------------------------------------------------------------


class TestIsIpBlockedPublicIPs:
    """Public IPs must never be blocked regardless of flags."""

    @pytest.mark.parametrize(
        "ip",
        [
            "8.8.8.8",
            "8.8.4.4",
            "1.1.1.1",
            "1.0.0.1",
            "208.67.222.222",
            "142.250.185.206",
            "93.184.216.34",
            "198.51.100.1",
            "203.0.113.5",
        ],
    )
    def test_public_ipv4_not_blocked(self, ip):
        """Well-known public IPv4 addresses are not blocked."""
        assert is_ip_blocked(ip) is False

    @pytest.mark.parametrize(
        "ip",
        [
            "8.8.8.8",
            "1.1.1.1",
            "93.184.216.34",
        ],
    )
    def test_public_ipv4_not_blocked_with_allow_localhost(self, ip):
        """Public IPs remain unblocked when allow_localhost=True."""
        assert is_ip_blocked(ip, allow_localhost=True) is False

    @pytest.mark.parametrize(
        "ip",
        [
            "8.8.8.8",
            "1.1.1.1",
        ],
    )
    def test_public_ipv4_not_blocked_with_allow_private_ips(self, ip):
        """Public IPs remain unblocked when allow_private_ips=True."""
        assert is_ip_blocked(ip, allow_private_ips=True) is False

    def test_public_ipv6_not_blocked(self):
        """Public IPv6 addresses are not blocked."""
        # Google public DNS IPv6
        assert is_ip_blocked("2001:4860:4860::8888") is False

    def test_public_ipv6_not_blocked_with_flags(self):
        """Public IPv6 remains unblocked regardless of flags."""
        assert (
            is_ip_blocked("2001:4860:4860::8888", allow_localhost=True) is False
        )
        assert (
            is_ip_blocked("2001:4860:4860::8888", allow_private_ips=True)
            is False
        )


# ---------------------------------------------------------------------------
# is_ip_blocked -- localhost / loopback
# ---------------------------------------------------------------------------


class TestIsIpBlockedLoopback:
    """Loopback addresses blocked by default, allowed via flags."""

    def test_127_0_0_1_blocked_by_default(self):
        """Standard IPv4 loopback is blocked."""
        assert is_ip_blocked("127.0.0.1") is True

    def test_127_0_0_2_blocked_by_default(self):
        """Alternate IPv4 loopback address in 127/8 is blocked."""
        assert is_ip_blocked("127.0.0.2") is True

    def test_127_255_255_255_blocked_by_default(self):
        """End of IPv4 loopback range is blocked."""
        assert is_ip_blocked("127.255.255.255") is True

    def test_ipv6_loopback_blocked_by_default(self):
        """IPv6 loopback ::1 is blocked."""
        assert is_ip_blocked("::1") is True

    def test_127_0_0_1_allowed_with_allow_localhost(self):
        """allow_localhost=True unblocks 127.0.0.1."""
        assert is_ip_blocked("127.0.0.1", allow_localhost=True) is False

    def test_127_range_allowed_with_allow_localhost(self):
        """allow_localhost=True unblocks the whole 127/8 range."""
        assert is_ip_blocked("127.0.0.2", allow_localhost=True) is False
        assert is_ip_blocked("127.255.255.255", allow_localhost=True) is False

    def test_ipv6_loopback_allowed_with_allow_localhost(self):
        """allow_localhost=True unblocks ::1."""
        assert is_ip_blocked("::1", allow_localhost=True) is False

    def test_127_0_0_1_allowed_with_allow_private_ips(self):
        """allow_private_ips=True also unblocks loopback."""
        assert is_ip_blocked("127.0.0.1", allow_private_ips=True) is False

    def test_ipv6_loopback_allowed_with_allow_private_ips(self):
        """allow_private_ips=True also unblocks IPv6 loopback."""
        assert is_ip_blocked("::1", allow_private_ips=True) is False


# ---------------------------------------------------------------------------
# is_ip_blocked -- RFC1918 private ranges
# ---------------------------------------------------------------------------


class TestIsIpBlockedRFC1918:
    """RFC1918 private addresses blocked by default, allowed with allow_private_ips."""

    # -- 10.0.0.0/8 --

    def test_10_0_0_1_blocked(self):
        assert is_ip_blocked("10.0.0.1") is True

    def test_10_255_255_255_blocked(self):
        assert is_ip_blocked("10.255.255.255") is True

    def test_10_x_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("10.0.0.1", allow_private_ips=True) is False
        assert is_ip_blocked("10.255.255.255", allow_private_ips=True) is False

    def test_10_x_still_blocked_with_allow_localhost(self):
        """allow_localhost does NOT unblock RFC1918."""
        assert is_ip_blocked("10.0.0.1", allow_localhost=True) is True

    # -- 172.16.0.0/12 --

    def test_172_16_0_1_blocked(self):
        assert is_ip_blocked("172.16.0.1") is True

    def test_172_31_255_255_blocked(self):
        assert is_ip_blocked("172.31.255.255") is True

    def test_172_15_255_255_not_blocked(self):
        """172.15.x.x is outside 172.16.0.0/12 and therefore public."""
        assert is_ip_blocked("172.15.255.255") is False

    def test_172_32_0_0_not_blocked(self):
        """172.32.x.x is outside 172.16.0.0/12 and therefore public."""
        assert is_ip_blocked("172.32.0.0") is False

    def test_172_16_x_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("172.16.0.1", allow_private_ips=True) is False
        assert is_ip_blocked("172.31.255.255", allow_private_ips=True) is False

    def test_172_16_x_still_blocked_with_allow_localhost(self):
        assert is_ip_blocked("172.16.0.1", allow_localhost=True) is True

    # -- 192.168.0.0/16 --

    def test_192_168_1_1_blocked(self):
        assert is_ip_blocked("192.168.1.1") is True

    def test_192_168_0_1_blocked(self):
        assert is_ip_blocked("192.168.0.1") is True

    def test_192_168_255_255_blocked(self):
        assert is_ip_blocked("192.168.255.255") is True

    def test_192_168_x_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("192.168.1.1", allow_private_ips=True) is False
        assert is_ip_blocked("192.168.255.255", allow_private_ips=True) is False

    def test_192_168_x_still_blocked_with_allow_localhost(self):
        assert is_ip_blocked("192.168.1.1", allow_localhost=True) is True


# ---------------------------------------------------------------------------
# is_ip_blocked -- AWS metadata endpoint (ALWAYS blocked)
# ---------------------------------------------------------------------------


class TestIsIpBlockedAWSMetadata:
    """169.254.169.254 must ALWAYS be blocked -- the #1 SSRF target."""

    def test_aws_metadata_blocked_default(self):
        assert is_ip_blocked("169.254.169.254") is True

    def test_aws_metadata_blocked_with_allow_localhost(self):
        assert is_ip_blocked("169.254.169.254", allow_localhost=True) is True

    def test_aws_metadata_blocked_with_allow_private_ips(self):
        """Critical: even when all private IPs are allowed, AWS metadata stays blocked."""
        assert is_ip_blocked("169.254.169.254", allow_private_ips=True) is True

    def test_aws_metadata_blocked_with_both_flags(self):
        """Both flags together must not unblock AWS metadata."""
        assert (
            is_ip_blocked(
                "169.254.169.254", allow_localhost=True, allow_private_ips=True
            )
            is True
        )


# ---------------------------------------------------------------------------
# is_ip_blocked -- CGNAT (100.64.0.0/10)
# ---------------------------------------------------------------------------


class TestIsIpBlockedCGNAT:
    """CGNAT range used by Podman/rootless containers."""

    def test_cgnat_start_blocked(self):
        assert is_ip_blocked("100.64.0.1") is True

    def test_cgnat_middle_blocked(self):
        assert is_ip_blocked("100.100.100.100") is True

    def test_cgnat_end_blocked(self):
        assert is_ip_blocked("100.127.255.255") is True

    def test_cgnat_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("100.64.0.1", allow_private_ips=True) is False
        assert is_ip_blocked("100.100.100.100", allow_private_ips=True) is False
        assert is_ip_blocked("100.127.255.255", allow_private_ips=True) is False

    def test_cgnat_blocked_with_allow_localhost_only(self):
        """allow_localhost does NOT unblock CGNAT."""
        assert is_ip_blocked("100.64.0.1", allow_localhost=True) is True

    def test_just_outside_cgnat_not_blocked(self):
        """100.128.0.0 is outside the 100.64.0.0/10 range and public."""
        assert is_ip_blocked("100.128.0.0") is False


# ---------------------------------------------------------------------------
# is_ip_blocked -- link-local (169.254.0.0/16)
# ---------------------------------------------------------------------------


class TestIsIpBlockedLinkLocal:
    """Link-local addresses (169.254.x.x) blocked by default."""

    def test_link_local_blocked(self):
        assert is_ip_blocked("169.254.1.1") is True
        assert is_ip_blocked("169.254.100.100") is True

    def test_link_local_beginning_blocked(self):
        assert is_ip_blocked("169.254.0.1") is True

    def test_link_local_end_blocked(self):
        assert is_ip_blocked("169.254.255.255") is True

    def test_non_aws_link_local_allowed_with_allow_private_ips(self):
        """Non-AWS link-local addresses allowed with allow_private_ips."""
        assert is_ip_blocked("169.254.1.1", allow_private_ips=True) is False
        assert is_ip_blocked("169.254.100.100", allow_private_ips=True) is False
        assert is_ip_blocked("169.254.0.1", allow_private_ips=True) is False

    def test_aws_metadata_still_blocked_within_link_local(self):
        """AWS metadata is a special case within link-local; always blocked."""
        assert is_ip_blocked("169.254.169.254", allow_private_ips=True) is True

    def test_link_local_blocked_with_allow_localhost_only(self):
        """allow_localhost does NOT unblock link-local."""
        assert is_ip_blocked("169.254.1.1", allow_localhost=True) is True


# ---------------------------------------------------------------------------
# is_ip_blocked -- IPv6 private ranges
# ---------------------------------------------------------------------------


class TestIsIpBlockedIPv6Private:
    """IPv6 ULA (fc00::/7) and link-local (fe80::/10) blocked by default."""

    # -- Unique Local Addresses (fc00::/7) --

    def test_fc00_blocked(self):
        assert is_ip_blocked("fc00::1") is True

    def test_fd00_blocked(self):
        assert is_ip_blocked("fd00::1") is True

    def test_fd_range_blocked(self):
        """fd12:3456:789a::1 is in fc00::/7."""
        assert is_ip_blocked("fd12:3456:789a::1") is True

    def test_fc00_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("fc00::1", allow_private_ips=True) is False

    def test_fd00_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("fd00::1", allow_private_ips=True) is False

    def test_fc00_blocked_with_allow_localhost_only(self):
        assert is_ip_blocked("fc00::1", allow_localhost=True) is True

    # -- Link-Local (fe80::/10) --

    def test_fe80_blocked(self):
        assert is_ip_blocked("fe80::1") is True

    def test_fe80_with_interface_id_blocked(self):
        assert is_ip_blocked("fe80::1234:5678") is True

    def test_fe80_allowed_with_allow_private_ips(self):
        assert is_ip_blocked("fe80::1", allow_private_ips=True) is False
        assert is_ip_blocked("fe80::1234:5678", allow_private_ips=True) is False

    def test_fe80_blocked_with_allow_localhost_only(self):
        assert is_ip_blocked("fe80::1", allow_localhost=True) is True


# ---------------------------------------------------------------------------
# is_ip_blocked -- "this" network (0.0.0.0/8)
# ---------------------------------------------------------------------------


class TestIsIpBlockedZeroNetwork:
    """The 0.0.0.0/8 'this' network is blocked."""

    def test_0_0_0_0_blocked(self):
        assert is_ip_blocked("0.0.0.0") is True

    def test_0_x_x_x_blocked(self):
        assert is_ip_blocked("0.1.2.3") is True

    def test_0_255_255_255_blocked(self):
        assert is_ip_blocked("0.255.255.255") is True


# ---------------------------------------------------------------------------
# is_ip_blocked -- invalid / edge-case inputs
# ---------------------------------------------------------------------------


class TestIsIpBlockedInvalidInputs:
    """Invalid or non-IP inputs must return False (not blocked)."""

    def test_invalid_ip_string_returns_false(self):
        assert is_ip_blocked("not-an-ip") is False

    def test_empty_string_returns_false(self):
        assert is_ip_blocked("") is False

    def test_hostname_returns_false(self):
        assert is_ip_blocked("example.com") is False

    def test_url_returns_false(self):
        """A full URL is not a valid IP string."""
        assert is_ip_blocked("http://127.0.0.1") is False

    def test_ip_with_port_returns_false(self):
        """IP:port is not a valid IP address."""
        assert is_ip_blocked("127.0.0.1:8080") is False

    def test_ip_with_trailing_space_returns_false(self):
        """Whitespace around an IP makes it invalid."""
        assert is_ip_blocked(" 127.0.0.1 ") is False

    def test_ip_with_cidr_returns_false(self):
        """CIDR notation is not a single IP address."""
        assert is_ip_blocked("10.0.0.0/8") is False

    def test_negative_numbers_returns_false(self):
        assert is_ip_blocked("-1.0.0.0") is False

    def test_oversized_octet_returns_false(self):
        assert is_ip_blocked("256.256.256.256") is False


# ---------------------------------------------------------------------------
# is_ip_blocked -- combined flags
# ---------------------------------------------------------------------------


class TestIsIpBlockedCombinedFlags:
    """Test behaviour when multiple flags are provided together."""

    def test_both_flags_allows_loopback(self):
        assert (
            is_ip_blocked(
                "127.0.0.1", allow_localhost=True, allow_private_ips=True
            )
            is False
        )

    def test_both_flags_allows_private(self):
        assert (
            is_ip_blocked(
                "192.168.1.1", allow_localhost=True, allow_private_ips=True
            )
            is False
        )

    def test_both_flags_still_blocks_aws_metadata(self):
        assert (
            is_ip_blocked(
                "169.254.169.254", allow_localhost=True, allow_private_ips=True
            )
            is True
        )

    def test_both_flags_does_not_block_public(self):
        assert (
            is_ip_blocked(
                "8.8.8.8", allow_localhost=True, allow_private_ips=True
            )
            is False
        )


# ---------------------------------------------------------------------------
# is_ip_blocked -- boundary IPs
# ---------------------------------------------------------------------------


class TestIsIpBlockedBoundaries:
    """Test IPs at the exact boundaries of blocked ranges."""

    def test_first_ip_of_10_range(self):
        assert is_ip_blocked("10.0.0.0") is True

    def test_last_ip_of_10_range(self):
        assert is_ip_blocked("10.255.255.255") is True

    def test_just_before_10_range_not_blocked(self):
        """9.255.255.255 is just before 10.0.0.0/8."""
        assert is_ip_blocked("9.255.255.255") is False

    def test_just_after_10_range_not_blocked(self):
        """11.0.0.0 is just after 10.0.0.0/8."""
        assert is_ip_blocked("11.0.0.0") is False

    def test_first_ip_of_172_16_range(self):
        assert is_ip_blocked("172.16.0.0") is True

    def test_last_ip_of_172_16_range(self):
        assert is_ip_blocked("172.31.255.255") is True

    def test_ip_172_15_255_255_not_blocked(self):
        """Just below the 172.16.0.0/12 range."""
        assert is_ip_blocked("172.15.255.255") is False

    def test_ip_172_32_0_0_not_blocked(self):
        """Just above the 172.16.0.0/12 range."""
        assert is_ip_blocked("172.32.0.0") is False

    def test_first_ip_of_192_168_range(self):
        assert is_ip_blocked("192.168.0.0") is True

    def test_last_ip_of_192_168_range(self):
        assert is_ip_blocked("192.168.255.255") is True

    def test_ip_192_167_255_255_not_blocked(self):
        assert is_ip_blocked("192.167.255.255") is False

    def test_ip_192_169_0_0_not_blocked(self):
        assert is_ip_blocked("192.169.0.0") is False

    def test_first_ip_of_cgnat(self):
        assert is_ip_blocked("100.64.0.0") is True

    def test_last_ip_of_cgnat(self):
        assert is_ip_blocked("100.127.255.255") is True

    def test_ip_100_63_255_255_not_blocked(self):
        """Just below CGNAT range."""
        assert is_ip_blocked("100.63.255.255") is False

    def test_ip_100_128_0_0_not_blocked(self):
        """Just above CGNAT range."""
        assert is_ip_blocked("100.128.0.0") is False


# ---------------------------------------------------------------------------
# get_safe_url -- None / empty handling
# ---------------------------------------------------------------------------


class TestGetSafeUrlNoneAndEmpty:
    """get_safe_url returns default for None or empty strings."""

    def test_none_returns_default_none(self):
        assert get_safe_url(None) is None

    def test_none_returns_custom_default(self):
        assert (
            get_safe_url(None, default="https://fallback.com")
            == "https://fallback.com"
        )

    def test_empty_string_returns_default_none(self):
        assert get_safe_url("") is None

    def test_empty_string_returns_custom_default(self):
        assert (
            get_safe_url("", default="https://fallback.com")
            == "https://fallback.com"
        )


class TestGetSafeUrlPassthrough:
    """get_safe_url passes through valid URLs and blocks unsafe ones."""

    def test_non_empty_url_returned(self):
        with patch(
            "socket.getaddrinfo",
            return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
        ):
            result = get_safe_url("https://example.com")
        assert result == "https://example.com"

    def test_http_url_returned(self):
        with patch(
            "socket.getaddrinfo",
            return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
        ):
            result = get_safe_url("http://example.com")
        assert result == "http://example.com"

    def test_url_with_path_returned(self):
        with patch(
            "socket.getaddrinfo",
            return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
        ):
            result = get_safe_url("https://example.com/path?q=1")
        assert result == "https://example.com/path?q=1"

    def test_localhost_url_returns_default(self):
        """validate_url blocks localhost, so get_safe_url returns default."""
        result = get_safe_url("http://127.0.0.1:8080", default="safe")
        assert result == "safe"

    def test_default_not_used_for_valid_url(self):
        with patch(
            "socket.getaddrinfo",
            return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
        ):
            result = get_safe_url(
                "https://example.com", default="https://fallback.com"
            )
        assert result == "https://example.com"


# ---------------------------------------------------------------------------
# validate_url -- real validation (no test bypass)
# ---------------------------------------------------------------------------


class TestValidateUrlRealValidation:
    """Confirm validate_url performs real SSRF validation."""

    def test_blocks_private_ip_url(self):
        """Private IP URL is blocked."""
        assert validate_url("http://192.168.1.1") is False

    def test_blocks_localhost_url(self):
        """Localhost URL is blocked."""
        assert validate_url("http://127.0.0.1") is False

    def test_blocks_aws_metadata_url(self):
        """AWS metadata URL is blocked."""
        assert validate_url("http://169.254.169.254/latest/meta-data") is False
