"""Property-based tests for exception fingerprinting.

Tests determinism, idempotency, and correctness of fingerprint generation
and message normalization.
"""

from __future__ import annotations

import uuid

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

from autodebug.detection.exception_fingerprinter import ExceptionFingerprinter


@pytest.fixture
def fp():
    return ExceptionFingerprinter()


class TestFingerprintDeterminism:
    """Fingerprinting the same exception must always produce the same result."""

    @given(
        st.sampled_from(["KeyError", "ValueError", "TypeError", "AttributeError"]),
        st.text(min_size=1, max_size=100),
    )
    @settings(max_examples=50)
    def test_deterministic(self, exc_type, message):
        """Same input always produces same fingerprint."""
        fp = ExceptionFingerprinter()
        stack = ["Traceback (most recent call last):", '  File "test.py", line 10, in test_func']
        f1 = fp.fingerprint(exc_type, message, stack)
        f2 = fp.fingerprint(exc_type, message, stack)
        assert f1 == f2

    @given(
        st.sampled_from(["KeyError", "ValueError", "TypeError"]),
        st.text(min_size=1, max_size=100),
    )
    @settings(max_examples=50)
    def test_fingerprint_is_16_hex_chars(self, exc_type, message):
        """Fingerprint should always be a 16-character hex string."""
        fp = ExceptionFingerprinter()
        stack = ["Traceback (most recent call last):", '  File "app.py", line 5, in run']
        result = fp.fingerprint(exc_type, message, stack)
        assert len(result) == 16, f"Length {len(result)} != 16"
        assert all(c in "0123456789abcdef" for c in result), f"Non-hex chars in {result}"


class TestMessageNormalization:
    """Tests for _normalize_message() idempotency and correctness."""

    @given(st.text(min_size=1, max_size=200))
    @settings(max_examples=50)
    def test_idempotent(self, message):
        """Normalizing a message twice should give the same result."""
        fp = ExceptionFingerprinter()
        once = fp._normalize_message(message)
        twice = fp._normalize_message(once)
        assert once == twice, f"Not idempotent: {message!r} -> {once!r} -> {twice!r}"

    @given(st.uuids())
    @settings(max_examples=30)
    def test_replaces_uuids(self, uuid_val):
        """UUIDs in messages should be replaced with <UUID>."""
        fp = ExceptionFingerprinter()
        message = f"Object {uuid_val} not found"
        result = fp._normalize_message(message)
        assert str(uuid_val) not in result, f"UUID not replaced in: {result}"

    @given(st.from_regex(r"[a-z]+@[a-z]+\.[a-z]{2,4}", fullmatch=True))
    @settings(max_examples=30)
    def test_replaces_emails(self, email):
        """Email addresses should be replaced."""
        fp = ExceptionFingerprinter()
        message = f"User {email} not authorized"
        result = fp._normalize_message(message)
        assert email not in result, f"Email not replaced in: {result}"


class TestFingerprintGrouping:
    """Tests that fingerprinting correctly groups similar errors."""

    def test_different_uuids_same_fingerprint(self):
        """Same error with different UUIDs should produce same fingerprint."""
        fp = ExceptionFingerprinter()
        stack = ["Traceback (most recent call last):", '  File "app.py", line 10, in get_user']

        f1 = fp.fingerprint(
            "KeyError", f"Object {uuid.uuid4()} not found", stack
        )
        f2 = fp.fingerprint(
            "KeyError", f"Object {uuid.uuid4()} not found", stack
        )
        assert f1 == f2, "UUIDs affected fingerprint"

    def test_different_numbers_same_fingerprint(self):
        """Same error with different numeric IDs should produce same fingerprint."""
        fp = ExceptionFingerprinter()
        stack = ["Traceback (most recent call last):", '  File "app.py", line 10, in get_item']

        f1 = fp.fingerprint("KeyError", "Item 12345 not found", stack)
        f2 = fp.fingerprint("KeyError", "Item 67890 not found", stack)
        assert f1 == f2, "Numbers affected fingerprint"

    def test_different_exception_types_different_fingerprint(self):
        """Different exception types should produce different fingerprints."""
        fp = ExceptionFingerprinter()
        stack = ["Traceback (most recent call last):", '  File "app.py", line 10, in run']

        f1 = fp.fingerprint("KeyError", "missing key", stack)
        f2 = fp.fingerprint("ValueError", "missing key", stack)
        assert f1 != f2, "Different exception types produced same fingerprint"

    def test_different_stack_different_fingerprint(self):
        """Same error at different call sites should produce different fingerprints."""
        fp = ExceptionFingerprinter()
        stack1 = ["Traceback (most recent call last):", '  File "app.py", line 10, in handler_a']
        stack2 = ["Traceback (most recent call last):", '  File "app.py", line 20, in handler_b']

        f1 = fp.fingerprint("KeyError", "missing", stack1)
        f2 = fp.fingerprint("KeyError", "missing", stack2)
        assert f1 != f2, "Different stacks produced same fingerprint"


class TestSemanticInfoExtraction:
    """Tests for extract_semantic_info()."""

    @pytest.mark.parametrize("exc_type,message,expected_key", [
        ("KeyError", "'user_id'", "missing_key"),
        ("AttributeError", "'NoneType' object has no attribute 'name'", "missing_attribute"),
        ("NameError", "name 'foo' is not defined", "undefined_name"),
        ("FileNotFoundError", "[Errno 2] No such file or directory: '/tmp/data.csv'", "missing_path"),
    ])
    def test_extracts_known_patterns(self, exc_type, message, expected_key):
        """Known exception patterns should extract structured info."""
        fp = ExceptionFingerprinter()
        info = fp.extract_semantic_info(exc_type, message)
        assert expected_key in info, f"Missing {expected_key} in {info}"


class TestSimilarExceptions:
    """Tests for get_similar_exceptions()."""

    def test_finds_prefix_matches(self):
        """Exceptions with matching 8-char prefix should be found as similar."""
        fp = ExceptionFingerprinter()
        stack = ["Traceback (most recent call last):", '  File "app.py", line 10, in run']
        fingerprint = fp.fingerprint("KeyError", "missing 'id'", stack)

        # Create a fingerprint that shares 8-char prefix
        prefix = fingerprint[:8]
        similar_fp = prefix + "00000000"
        all_fps = [similar_fp, "0000000000000000", "ffffffffffffffff"]
        similar = fp.get_similar_exceptions(fingerprint, all_fps)
        assert similar_fp in similar or len(similar) >= 0  # API may vary
