"""Property-based tests for URL/header/body canonicalization.

Tests idempotency, determinism, and invariant properties of
canonicalization functions used in probe recording and replay matching.
"""

from __future__ import annotations

import gzip
import hashlib
import json

import hypothesis.strategies as st
from hypothesis import assume, given, settings

from autodebug_probe.canonicalize import (
    IGNORED_HEADERS_DEFAULT,
    body_hash,
    canonical_url,
    decode_gzip,
    normalize_headers,
)

# --- URL Canonicalization ---


class TestCanonicalUrlProperties:
    """Property tests for canonical_url()."""

    @given(st.sampled_from([
        "http://example.com/path",
        "HTTP://EXAMPLE.COM/PATH",
        "https://api.example.com/v1/users?b=2&a=1",
        "http://localhost:8000/api/test",
        "http://127.0.0.1:5432/db",
        "http://0.0.0.0:3000/health",
        "http://[::1]:9090/metrics",
        "https://example.com/path?z=1&a=2&m=3",
        "http://example.com/path?",
        "http://example.com",
    ]))
    @settings(max_examples=50)
    def test_idempotent(self, url):
        """canonical_url(canonical_url(x)) == canonical_url(x)."""
        once = canonical_url(url)
        twice = canonical_url(once)
        assert once == twice, f"Not idempotent: {url!r} -> {once!r} -> {twice!r}"

    @given(st.sampled_from([
        "http://example.com/path",
        "HTTP://EXAMPLE.COM/PATH",
        "Http://Example.Com/Path",
    ]))
    @settings(max_examples=30)
    def test_scheme_case_insensitive(self, url):
        """Scheme and host should be lowercased."""
        result = canonical_url(url)
        assert "HTTP://" not in result
        assert "EXAMPLE.COM" not in result.split("//", 1)[-1].split("/")[0]

    @given(
        st.sampled_from(["http://example.com/path"]),
        st.permutations(["a=1", "b=2", "c=3"]),
    )
    @settings(max_examples=30)
    def test_query_order_invariant(self, base, params):
        """Same query params in different order should produce same canonical URL."""
        url = f"{base}?{'&'.join(params)}"
        result = canonical_url(url)
        expected = canonical_url(f"{base}?a=1&b=2&c=3")
        assert result == expected, f"Query order matters: {url!r}"

    @given(st.sampled_from([
        "http://localhost:8000/test",
        "http://127.0.0.1:8000/test",
        "http://0.0.0.0:8000/test",
    ]))
    @settings(max_examples=30)
    def test_localhost_variants_normalize(self, url):
        """localhost, 127.0.0.1, 0.0.0.0 should normalize to same host."""
        result = canonical_url(url, normalize_localhost_port=True)
        assert "127.0.0.1" not in result
        assert "0.0.0.0" not in result

    @given(st.sampled_from([
        "http://example.com:8080/path",
        "https://api.prod.example.com:443/v1",
    ]))
    @settings(max_examples=20)
    def test_non_localhost_preserves_port(self, url):
        """Non-localhost URLs should preserve their port."""
        result = canonical_url(url, normalize_localhost_port=True)
        # Port should still be present for non-localhost
        assert ":" in result.split("//", 1)[-1].split("/")[0]


# --- Body Hash ---


class TestBodyHashProperties:
    """Property tests for body_hash()."""

    @given(st.binary(min_size=1, max_size=1000))
    @settings(max_examples=50)
    def test_deterministic(self, data):
        """Same input always produces same hash."""
        h1 = body_hash(data, "application/octet-stream")
        h2 = body_hash(data, "application/octet-stream")
        assert h1 == h2

    @given(st.dictionaries(
        st.text(min_size=1, max_size=10, alphabet="abcdefghij"),
        st.one_of(st.integers(-100, 100), st.text(max_size=20)),
        min_size=2, max_size=5,
    ))
    @settings(max_examples=50)
    def test_json_key_order_invariant(self, d):
        """JSON bodies with same keys in different order should hash the same."""
        body1 = json.dumps(d, sort_keys=False).encode()
        body2 = json.dumps(dict(reversed(list(d.items())))).encode()
        h1 = body_hash(body1, "application/json")
        h2 = body_hash(body2, "application/json")
        assert h1 == h2, "JSON key order affected hash"

    @given(st.binary(min_size=1, max_size=500))
    @settings(max_examples=30)
    def test_non_json_uses_raw_bytes(self, data):
        """Non-JSON content type should use SHA256 (possibly truncated)."""
        h = body_hash(data, "text/plain")
        full = hashlib.sha256(data).hexdigest()
        # body_hash may truncate to 16 chars
        assert full.startswith(h), f"Hash mismatch: {h!r} not prefix of {full!r}"

    @given(st.binary(min_size=0, max_size=500))
    @settings(max_examples=30)
    def test_hash_is_hex_string(self, data):
        """Hash result should always be a hex string."""
        h = body_hash(data, "application/octet-stream")
        assert all(c in "0123456789abcdef" for c in h)


# --- Gzip Decode ---


class TestDecodeGzipProperties:
    """Property tests for decode_gzip()."""

    @given(st.binary(min_size=1, max_size=1000))
    @settings(max_examples=30)
    def test_roundtrip(self, data):
        """Compressing then decompressing should return original data."""
        compressed = gzip.compress(data)
        decompressed, success = decode_gzip(compressed)
        assert success is True
        assert decompressed == data

    @given(st.binary(min_size=1, max_size=100))
    @settings(max_examples=30)
    def test_invalid_gzip_returns_original(self, data):
        """Non-gzip data should return the original bytes unchanged."""
        assume(not data.startswith(b"\x1f\x8b"))  # Skip actual gzip magic bytes
        result, success = decode_gzip(data)
        assert success is False
        assert result == data


# --- Header Normalization ---


class TestNormalizeHeadersProperties:
    """Property tests for normalize_headers()."""

    @given(st.lists(
        st.tuples(
            st.sampled_from(["Content-Type", "X-Custom", "Accept", "User-Agent"]),
            st.text(min_size=1, max_size=50),
        ),
        min_size=1, max_size=5,
    ))
    @settings(max_examples=50)
    def test_canonical_keys_lowercase(self, headers):
        """All canonical header keys should be lowercase."""
        canonical, _, _ = normalize_headers(headers)
        for key in canonical:
            assert key == key.lower(), f"Non-lowercase key in canonical: {key}"

    @given(st.lists(
        st.tuples(
            st.sampled_from(list(IGNORED_HEADERS_DEFAULT)),
            st.text(min_size=1, max_size=50),
        ),
        min_size=1, max_size=3,
    ))
    @settings(max_examples=30)
    def test_ignored_headers_excluded_from_canonical(self, headers):
        """Headers in IGNORED_HEADERS_DEFAULT should not appear in canonical dict."""
        canonical, _, ignored = normalize_headers(headers)
        for key in canonical:
            assert key not in IGNORED_HEADERS_DEFAULT, (
                f"Ignored header {key} in canonical dict"
            )

    @given(st.lists(
        st.tuples(
            st.sampled_from(["Content-Type", "Accept", "Authorization"]),
            st.text(min_size=1, max_size=50),
        ),
        min_size=1, max_size=3,
    ))
    @settings(max_examples=30)
    def test_non_ignored_headers_in_canonical(self, headers):
        """Non-ignored headers should appear in the canonical dict."""
        canonical, _, _ = normalize_headers(headers)
        for key, _ in headers:
            assert key.lower() in canonical, f"{key} missing from canonical"
