"""
Extended tests for security/url_builder.py

Covers additional edge cases, boundary conditions, and integration
scenarios beyond the base test suite for all five public functions
and the URLBuilderError exception.
"""

import pytest

from local_deep_research.security.url_builder import (
    URLBuilderError,
    normalize_bind_address,
    build_base_url_from_settings,
    build_full_url,
    validate_constructed_url,
    mask_sensitive_url,
)


# ---------------------------------------------------------------------------
# normalize_bind_address -- address normalization
# ---------------------------------------------------------------------------
class TestNormalizeBindAddressExtended:
    """Extended tests for normalize_bind_address."""

    def test_ipv4_bind_all_to_localhost(self):
        """0.0.0.0 is converted to localhost."""
        assert normalize_bind_address("0.0.0.0") == "localhost"

    def test_ipv6_bind_all_to_localhost(self):
        """:: is converted to localhost."""
        assert normalize_bind_address("::") == "localhost"

    def test_localhost_unchanged(self):
        """localhost passes through without modification."""
        assert normalize_bind_address("localhost") == "localhost"

    def test_specific_ipv4_unchanged(self):
        """A specific IPv4 address is returned as-is."""
        assert normalize_bind_address("192.168.1.1") == "192.168.1.1"

    def test_local_hostname_unchanged(self):
        """A .local hostname is returned as-is."""
        assert normalize_bind_address("myhost.local") == "myhost.local"

    def test_loopback_127_unchanged(self):
        """127.0.0.1 is not converted (only 0.0.0.0 and :: are)."""
        assert normalize_bind_address("127.0.0.1") == "127.0.0.1"

    def test_fqdn_unchanged(self):
        """A fully-qualified domain name passes through unchanged."""
        assert normalize_bind_address("app.example.com") == "app.example.com"

    def test_ipv6_loopback_unchanged(self):
        """::1 (IPv6 loopback) is not treated as bind-all."""
        assert normalize_bind_address("::1") == "::1"

    def test_empty_string_unchanged(self):
        """Empty string is returned as-is (caller is responsible for validation)."""
        assert normalize_bind_address("") == ""

    def test_10_network_address_unchanged(self):
        """Private 10.x.x.x address is returned unchanged."""
        assert normalize_bind_address("10.0.0.1") == "10.0.0.1"

    def test_172_network_address_unchanged(self):
        """Private 172.16.x.x address is returned unchanged."""
        assert normalize_bind_address("172.16.0.1") == "172.16.0.1"


# ---------------------------------------------------------------------------
# build_base_url_from_settings -- URL construction from config
# ---------------------------------------------------------------------------
class TestBuildBaseUrlFromSettingsExtended:
    """Extended tests for build_base_url_from_settings."""

    def test_external_url_priority_over_host_port(self):
        """external_url takes priority even when host and port are provided."""
        result = build_base_url_from_settings(
            external_url="https://public.example.com",
            host="192.168.1.50",
            port=8080,
        )
        assert result == "https://public.example.com"

    def test_external_url_stripped_and_trailing_slash_removed(self):
        """external_url is stripped of whitespace and trailing slash."""
        result = build_base_url_from_settings(
            external_url="  https://app.example.com/  "
        )
        assert result == "https://app.example.com"

    def test_host_port_constructs_http_url(self):
        """host + port constructs an http:// URL."""
        result = build_base_url_from_settings(
            external_url=None,
            host="myserver.local",
            port=3000,
        )
        assert result == "http://myserver.local:3000"

    def test_bind_all_host_normalized_to_localhost(self):
        """0.0.0.0 host is normalized to localhost in the URL."""
        result = build_base_url_from_settings(
            external_url=None,
            host="0.0.0.0",
            port=5000,
        )
        assert result == "http://localhost:5000"

    def test_fallback_used_when_no_external_or_host_port(self):
        """fallback_base is used when external_url and host/port are absent."""
        result = build_base_url_from_settings(
            external_url=None,
            host=None,
            port=None,
            fallback_base="http://custom-fallback:9090",
        )
        assert result == "http://custom-fallback:9090"

    def test_fallback_trailing_slash_stripped(self):
        """Trailing slash on fallback_base is removed."""
        result = build_base_url_from_settings(
            external_url=None,
            host=None,
            port=None,
            fallback_base="http://fallback.local:9090/",
        )
        assert result == "http://fallback.local:9090"

    def test_empty_external_url_treated_as_not_provided(self):
        """Empty string external_url falls through to host/port or fallback."""
        result = build_base_url_from_settings(
            external_url="",
            host=None,
            port=None,
        )
        assert result == "http://localhost:5000"

    def test_whitespace_only_external_url_treated_as_not_provided(self):
        """Whitespace-only external_url falls through."""
        result = build_base_url_from_settings(
            external_url="   ",
            host=None,
            port=None,
        )
        assert result == "http://localhost:5000"

    def test_port_as_string_is_accepted(self):
        """Port provided as string is converted to int."""
        result = build_base_url_from_settings(
            external_url=None,
            host="localhost",
            port="8080",
        )
        assert result == "http://localhost:8080"

    def test_invalid_port_raises_url_builder_error(self):
        """Non-numeric port string raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="Failed to build base URL"):
            build_base_url_from_settings(
                external_url=None,
                host="localhost",
                port="abc",
            )

    def test_ipv6_bind_all_host_normalized(self):
        """:: host is normalized to localhost in the URL."""
        result = build_base_url_from_settings(
            external_url=None,
            host="::",
            port=5000,
        )
        assert result == "http://localhost:5000"

    def test_external_url_with_multiple_trailing_slashes(self):
        """Multiple trailing slashes are stripped from external_url."""
        result = build_base_url_from_settings(
            external_url="https://example.com///"
        )
        assert result == "https://example.com"

    def test_external_url_with_path(self):
        """external_url that includes a path keeps the path (minus trailing /)."""
        result = build_base_url_from_settings(
            external_url="https://example.com/app/"
        )
        assert result == "https://example.com/app"

    def test_default_fallback_value(self):
        """Default fallback_base is http://localhost:5000."""
        result = build_base_url_from_settings()
        assert result == "http://localhost:5000"

    def test_host_without_port_uses_fallback(self):
        """If host is given but port is None, fallback is used."""
        result = build_base_url_from_settings(
            external_url=None,
            host="somehost",
            port=None,
            fallback_base="http://default:7777",
        )
        assert result == "http://default:7777"

    def test_port_without_host_uses_fallback(self):
        """If port is given but host is None, fallback is used."""
        result = build_base_url_from_settings(
            external_url=None,
            host=None,
            port=5000,
            fallback_base="http://default:7777",
        )
        assert result == "http://default:7777"


# ---------------------------------------------------------------------------
# build_full_url -- base + path combination
# ---------------------------------------------------------------------------
class TestBuildFullUrlExtended:
    """Extended tests for build_full_url."""

    def test_combines_base_and_path(self):
        """Base URL and path are joined correctly."""
        result = build_full_url("https://example.com", "/api/v1", validate=True)
        assert result == "https://example.com/api/v1"

    def test_trailing_slash_removed_from_base(self):
        """Trailing slash on base_url is stripped before joining."""
        result = build_full_url(
            "https://example.com/", "/endpoint", validate=True
        )
        assert result == "https://example.com/endpoint"

    def test_leading_slash_added_to_path(self):
        """Path without leading slash gets one prepended."""
        result = build_full_url(
            "https://example.com", "path/here", validate=True
        )
        assert result == "https://example.com/path/here"

    def test_validate_true_raises_on_invalid_url(self):
        """validate=True raises URLBuilderError for a schemeless base."""
        with pytest.raises(URLBuilderError):
            build_full_url("not-a-url", "/path", validate=True)

    def test_validate_false_skips_validation(self):
        """validate=False allows any string combination through."""
        result = build_full_url("no-scheme", "/path", validate=False)
        assert result == "no-scheme/path"

    def test_allowed_schemes_rejects_wrong_scheme(self):
        """URL with disallowed scheme raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="not in allowed schemes"):
            build_full_url(
                "ftp://files.example.com",
                "/data",
                validate=True,
                allowed_schemes=["http", "https"],
            )

    def test_allowed_schemes_accepts_correct_scheme(self):
        """URL with allowed scheme passes validation."""
        result = build_full_url(
            "https://example.com",
            "/secure",
            validate=True,
            allowed_schemes=["https"],
        )
        assert result == "https://example.com/secure"

    def test_path_already_has_leading_slash(self):
        """Path that already starts with / is not double-slashed."""
        result = build_full_url(
            "https://example.com", "/already/slashed", validate=True
        )
        assert "//already" not in result
        assert result == "https://example.com/already/slashed"

    def test_base_url_with_path_component(self):
        """Base URL that includes a path appends new path correctly."""
        result = build_full_url(
            "https://example.com/app", "/api/test", validate=False
        )
        assert result == "https://example.com/app/api/test"

    def test_empty_path_gets_leading_slash(self):
        """Empty path results in trailing slash after base."""
        result = build_full_url("https://example.com", "", validate=False)
        assert result == "https://example.com/"

    def test_path_with_query_string(self):
        """Path containing query string is appended as-is."""
        result = build_full_url(
            "https://example.com", "/search?q=test", validate=True
        )
        assert result == "https://example.com/search?q=test"

    def test_path_with_fragment(self):
        """Path containing fragment is appended as-is."""
        result = build_full_url(
            "https://example.com", "/page#section", validate=True
        )
        assert result == "https://example.com/page#section"


# ---------------------------------------------------------------------------
# validate_constructed_url -- URL validation
# ---------------------------------------------------------------------------
class TestValidateConstructedUrlExtended:
    """Extended tests for validate_constructed_url."""

    def test_empty_string_raises(self):
        """Empty string raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="non-empty string"):
            validate_constructed_url("")

    def test_none_raises(self):
        """None raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="non-empty string"):
            validate_constructed_url(None)

    def test_no_scheme_raises(self):
        """URL without scheme raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="scheme"):
            validate_constructed_url("example.com/path")

    def test_no_hostname_raises(self):
        """URL without hostname (empty netloc) raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="hostname"):
            validate_constructed_url("http:///path/only")

    def test_valid_http_url_returns_true(self):
        """Valid HTTP URL returns True."""
        assert validate_constructed_url("http://example.com/api") is True

    def test_valid_https_url_returns_true(self):
        """Valid HTTPS URL returns True."""
        assert validate_constructed_url("https://secure.example.com") is True

    def test_wrong_scheme_with_allowed_schemes_raises(self):
        """Scheme not in allowed_schemes list raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="not in allowed schemes"):
            validate_constructed_url(
                "http://example.com", allowed_schemes=["https"]
            )

    def test_correct_scheme_with_allowed_schemes_passes(self):
        """Scheme in allowed_schemes list passes."""
        assert (
            validate_constructed_url(
                "https://example.com", allowed_schemes=["https"]
            )
            is True
        )

    def test_none_allowed_schemes_accepts_any(self):
        """None for allowed_schemes accepts any scheme."""
        assert (
            validate_constructed_url(
                "custom://something.local", allowed_schemes=None
            )
            is True
        )

    def test_ftp_scheme_with_ftp_allowed(self):
        """ftp:// passes when ftp is in allowed_schemes."""
        assert (
            validate_constructed_url(
                "ftp://files.example.com/data",
                allowed_schemes=["ftp", "sftp"],
            )
            is True
        )

    def test_url_with_port(self):
        """URL with port validates successfully."""
        assert validate_constructed_url("https://example.com:8443/api") is True

    def test_url_with_auth_info(self):
        """URL containing user:pass@host validates (netloc is present)."""
        assert (
            validate_constructed_url("https://user:pass@example.com/path")
            is True
        )

    def test_integer_input_raises(self):
        """Non-string input raises URLBuilderError."""
        with pytest.raises(URLBuilderError, match="non-empty string"):
            validate_constructed_url(42)

    def test_url_with_query_and_fragment(self):
        """URL with query string and fragment validates."""
        assert (
            validate_constructed_url("https://example.com/path?key=val#section")
            is True
        )


# ---------------------------------------------------------------------------
# mask_sensitive_url -- sensitive data masking for logging
# ---------------------------------------------------------------------------
class TestMaskSensitiveUrlExtended:
    """Extended tests for mask_sensitive_url."""

    def test_password_masked(self):
        """Password in user:pass@host URL is replaced with ***."""
        result = mask_sensitive_url("https://admin:s3cret@example.com/api")
        assert "s3cret" not in result
        assert "admin" in result
        assert "***" in result
        assert "example.com" in result

    def test_long_path_token_masked(self):
        """Path segments >= 20 alphanumeric chars are masked."""
        token = "a" * 20  # exactly 20 chars
        result = mask_sensitive_url(f"https://hooks.slack.com/services/{token}")
        assert token not in result
        assert "/***" in result

    def test_query_string_masked(self):
        """Query string is replaced with ?***."""
        result = mask_sensitive_url(
            "https://api.example.com/data?api_key=supersecret&format=json"
        )
        assert "api_key" not in result
        assert "supersecret" not in result
        assert "?***" in result

    def test_url_without_sensitive_parts_unchanged(self):
        """URL with no password, long tokens, or query is mostly preserved."""
        url = "https://example.com/api/users"
        result = mask_sensitive_url(url)
        assert "example.com" in result
        assert "/api/users" in result
        assert "https://" in result

    def test_short_path_segments_not_masked(self):
        """Path segments shorter than 20 chars are NOT masked."""
        result = mask_sensitive_url("https://example.com/short/path/here")
        assert "/short/path/here" in result

    def test_invalid_url_returns_partial_mask(self):
        """Malformed URL without netloc does not crash and returns a string.

        urlparse handles most strings without raising, so the function
        reconstructs from whatever components it can extract.  The except
        branch (returning 'scheme://***') only fires for truly exceptional
        inputs.  Here we verify graceful handling rather than the exact
        fallback format.
        """
        result = mask_sensitive_url("http:not-valid-at-all")
        assert result is not None
        assert isinstance(result, str)
        assert "http" in result

    def test_webhook_url_with_long_token_masked(self):
        """Webhook URL with a long token in the path gets the token masked."""
        token = "T0123456789abcdefghij"  # 21 chars
        result = mask_sensitive_url(
            f"https://hooks.slack.com/services/{token}/more"
        )
        assert token not in result
        assert "/***" in result
        assert "hooks.slack.com" in result

    def test_url_with_port_preserved(self):
        """Port number in URL is preserved after masking."""
        result = mask_sensitive_url("https://example.com:8443/api/short")
        assert ":8443" in result

    def test_multiple_long_path_segments_masked(self):
        """Multiple long path segments are each masked independently."""
        token1 = "abcdefghijklmnopqrstu"  # 21 chars
        token2 = "12345678901234567890XX"  # 22 chars
        result = mask_sensitive_url(
            f"https://hooks.example.com/{token1}/{token2}"
        )
        assert token1 not in result
        assert token2 not in result

    def test_combined_password_token_and_query(self):
        """URL with password, long token, and query gets all masked."""
        token = "aabbccddee1122334455ff"  # 22 chars
        result = mask_sensitive_url(
            f"https://user:password@api.example.com/hook/{token}?secret=yes"
        )
        assert "password" not in result
        assert token not in result
        assert "secret=yes" not in result
        assert "?***" in result
        assert "user" in result

    def test_scheme_preserved(self):
        """The URL scheme is always preserved."""
        result = mask_sensitive_url("http://user:pass@example.com/path")
        assert result.startswith("http://")

    def test_path_segment_exactly_19_chars_not_masked(self):
        """A path segment of exactly 19 chars is NOT masked (threshold is 20)."""
        segment = "a" * 19
        result = mask_sensitive_url(f"https://example.com/{segment}")
        assert segment in result

    def test_path_segment_exactly_20_chars_masked(self):
        """A path segment of exactly 20 chars IS masked."""
        segment = "b" * 20
        result = mask_sensitive_url(f"https://example.com/{segment}")
        assert segment not in result
        assert "/***" in result

    def test_url_with_only_query_no_password(self):
        """URL with query but no password masks only the query."""
        result = mask_sensitive_url("https://example.com/api?token=abc123")
        assert "token=abc123" not in result
        assert "?***" in result
        assert "example.com" in result
        assert "/api" in result

    def test_url_with_hyphen_underscore_token_masked(self):
        """Long tokens containing hyphens and underscores are masked."""
        token = "abc-def_ghi-jkl_mno-pqrs"  # 24 chars with hyphens/underscores
        result = mask_sensitive_url(f"https://example.com/hook/{token}")
        assert token not in result

    def test_empty_path_no_crash(self):
        """URL with empty path does not crash."""
        result = mask_sensitive_url("https://example.com")
        assert "example.com" in result


# ---------------------------------------------------------------------------
# Integration -- combining multiple functions
# ---------------------------------------------------------------------------
class TestURLBuilderIntegrationExtended:
    """Integration scenarios combining multiple url_builder functions."""

    def test_settings_to_full_url_to_mask_workflow(self):
        """Full workflow: settings -> base URL -> full URL -> masked for logging."""
        base = build_base_url_from_settings(
            external_url="https://app.example.com/"
        )
        full = build_full_url(base, "/api/v1/research/42")
        assert validate_constructed_url(full) is True
        masked = mask_sensitive_url(full)
        assert "app.example.com" in masked

    def test_host_port_to_full_url_normalized(self):
        """Build from 0.0.0.0 host, construct full URL, validate."""
        base = build_base_url_from_settings(host="0.0.0.0", port=8080)
        assert "localhost" in base
        full = build_full_url(base, "health/check")
        assert full == "http://localhost:8080/health/check"
        assert validate_constructed_url(full) is True

    def test_fallback_to_full_url(self):
        """Fallback base URL + path produces valid URL."""
        base = build_base_url_from_settings(
            external_url="",
            host=None,
            port=None,
            fallback_base="http://fallback:7000/",
        )
        full = build_full_url(base, "/status")
        assert full == "http://fallback:7000/status"
        assert validate_constructed_url(full) is True

    def test_mask_url_with_credentials_from_build(self):
        """Masking a URL built with embedded credentials hides the password."""
        full = build_full_url(
            "https://admin:topsecret@internal.host.com",
            "/api/data",
            validate=False,
        )
        masked = mask_sensitive_url(full)
        assert "topsecret" not in masked
        assert "admin" in masked
        assert "***" in masked

    def test_validate_rejects_then_build_without_validate(self):
        """Demonstrate that validate=False allows construction that would fail."""
        # This should fail with validation
        with pytest.raises(URLBuilderError):
            build_full_url("noscheme", "/path", validate=True)

        # But succeeds without validation
        result = build_full_url("noscheme", "/path", validate=False)
        assert result == "noscheme/path"
