"""Property-based tests for SBFL fault localization algorithms.

Tests mathematical invariants of Ochiai, Tarantula, DStar, Jaccard, and OP2
scoring formulas, plus I/O-SBFL normalization functions.
"""

from __future__ import annotations

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

from autodebug.detection.io_sbfl import (
    _normalize_sql,
    _normalize_url,
)
from autodebug.detection.sbfl import (
    CoverageData,
    LineStats,
    SBFLAnalyzer,
    SBFLFormula,
)

# --- Strategy helpers ---

@st.composite
def line_stats(draw, min_total=1):
    """Generate valid LineStats with positive totals."""
    ef = draw(st.integers(min_value=0, max_value=50))
    ep = draw(st.integers(min_value=0, max_value=50))
    nf = draw(st.integers(min_value=0, max_value=50))
    np_ = draw(st.integers(min_value=0, max_value=50))
    total_failed = ef + nf
    total_passed = ep + np_
    assume(total_failed + total_passed >= min_total)
    return LineStats(
        ef=ef, ep=ep, nf=nf, np=np_,
        total_failed=total_failed, total_passed=total_passed,
    )


# --- Ochiai formula tests ---


class TestOchiaiProperties:
    """Property tests for the Ochiai formula."""

    @given(line_stats())
    @settings(max_examples=100)
    def test_score_bounded_zero_to_one(self, stats):
        """Ochiai score must always be in [0, 1]."""
        analyzer = SBFLAnalyzer(SBFLFormula.OCHIAI)
        score = analyzer._calculate_score(stats)
        assert 0.0 <= score <= 1.0, f"Ochiai out of bounds: {score} for {stats}"

    @given(st.integers(min_value=0, max_value=50), st.integers(min_value=1, max_value=50))
    @settings(max_examples=50)
    def test_zero_when_never_executed_in_failures(self, ep, nf):
        """If ef=0 (never executed in failing tests), score must be 0."""
        stats = LineStats(ef=0, ep=ep, nf=nf, np=0,
                         total_failed=nf, total_passed=ep)
        analyzer = SBFLAnalyzer(SBFLFormula.OCHIAI)
        score = analyzer._calculate_score(stats)
        assert score == 0.0, f"Ochiai non-zero with ef=0: {score}"

    @given(st.integers(min_value=1, max_value=50))
    @settings(max_examples=30)
    def test_maximum_when_only_in_failures(self, ef):
        """If ef == total_failed and ep == 0, score should be 1.0."""
        stats = LineStats(ef=ef, ep=0, nf=0, np=10,
                         total_failed=ef, total_passed=10)
        analyzer = SBFLAnalyzer(SBFLFormula.OCHIAI)
        score = analyzer._calculate_score(stats)
        assert abs(score - 1.0) < 1e-9, f"Expected 1.0, got {score}"


# --- Tarantula formula tests ---


class TestTarantulaProperties:
    """Property tests for the Tarantula formula."""

    @given(line_stats(min_total=2))
    @settings(max_examples=100)
    def test_score_bounded_zero_to_one(self, stats):
        """Tarantula score must always be in [0, 1]."""
        assume(stats.total_failed > 0 and stats.total_passed > 0)
        analyzer = SBFLAnalyzer(SBFLFormula.TARANTULA)
        score = analyzer._calculate_score(stats)
        assert 0.0 <= score <= 1.0, f"Tarantula out of bounds: {score} for {stats}"


# --- DStar formula tests ---


class TestDStarProperties:
    """Property tests for the DStar formula."""

    @given(line_stats())
    @settings(max_examples=100)
    def test_score_non_negative(self, stats):
        """DStar score must always be >= 0."""
        analyzer = SBFLAnalyzer(SBFLFormula.DStar)
        score = analyzer._calculate_score(stats)
        assert score >= 0.0, f"DStar negative: {score}"

    @given(st.integers(min_value=0, max_value=50), st.integers(min_value=1, max_value=50))
    @settings(max_examples=50)
    def test_zero_when_never_in_failures(self, ep, nf):
        """If ef=0, DStar score must be 0."""
        stats = LineStats(ef=0, ep=ep, nf=nf, np=0,
                         total_failed=nf, total_passed=ep)
        analyzer = SBFLAnalyzer(SBFLFormula.DStar)
        score = analyzer._calculate_score(stats)
        assert score == 0.0


# --- Jaccard formula tests ---


class TestJaccardProperties:
    """Property tests for the Jaccard formula."""

    @given(line_stats())
    @settings(max_examples=100)
    def test_score_bounded_zero_to_one(self, stats):
        """Jaccard score must always be in [0, 1]."""
        analyzer = SBFLAnalyzer(SBFLFormula.JACCARD)
        score = analyzer._calculate_score(stats)
        assert 0.0 <= score <= 1.0, f"Jaccard out of bounds: {score}"


# --- OP2 formula tests ---


class TestOP2Properties:
    """Property tests for the OP2 formula."""

    @given(line_stats())
    @settings(max_examples=100)
    def test_score_bounded(self, stats):
        """OP2 score should be bounded by ef (max when ep=0)."""
        analyzer = SBFLAnalyzer(SBFLFormula.OP2)
        score = analyzer._calculate_score(stats)
        assert score <= stats.ef + 0.01, f"OP2 exceeds ef: {score} > {stats.ef}"


# --- Cross-formula tests ---


class TestCrossFormulaProperties:
    """Properties that should hold across all formulas."""

    @pytest.mark.parametrize("formula", list(SBFLFormula))
    def test_empty_coverage_returns_empty(self, formula):
        """Analyzing empty coverage should return empty results."""
        analyzer = SBFLAnalyzer(formula)
        coverage = CoverageData(
            line_to_tests={},
            test_results={},
            line_to_function={},
        )
        results = analyzer.analyze(coverage)
        assert results == []

    @pytest.mark.parametrize("formula", list(SBFLFormula))
    def test_all_passing_returns_empty(self, formula):
        """If all tests pass, no location should be suspicious."""
        analyzer = SBFLAnalyzer(formula)
        coverage = CoverageData(
            line_to_tests={("file.py", 1): {"test_a", "test_b"}},
            test_results={"test_a": True, "test_b": True},
            line_to_function={("file.py", 1): "func"},
        )
        results = analyzer.analyze(coverage)
        # All-pass means no failures, so no suspicious locations
        assert len(results) == 0

    @pytest.mark.parametrize("formula", list(SBFLFormula))
    def test_results_sorted_descending(self, formula):
        """Results should be sorted by score (descending)."""
        analyzer = SBFLAnalyzer(formula)
        coverage = CoverageData(
            line_to_tests={
                ("file.py", 1): {"test_fail"},
                ("file.py", 2): {"test_fail", "test_pass"},
                ("file.py", 3): {"test_pass"},
            },
            test_results={"test_fail": False, "test_pass": True},
            line_to_function={
                ("file.py", 1): "func_a",
                ("file.py", 2): "func_b",
                ("file.py", 3): "func_c",
            },
        )
        results = analyzer.analyze(coverage)
        scores = [r.score for r in results]
        assert scores == sorted(scores, reverse=True), "Results not sorted descending"


# --- I/O SBFL normalization tests ---


class TestIOSBFLNormalizationProperties:
    """Property tests for URL and SQL normalization in io_sbfl."""

    @given(st.sampled_from([
        "/api/users/123/orders",
        "/api/items/456",
        "/v1/550e8400-e29b-41d4-a716-446655440000/details",
        "/path/without/numbers",
        "/mixed/123/items/456/sub",
    ]))
    @settings(max_examples=30)
    def test_url_normalization_idempotent(self, url):
        """_normalize_url(x) applied twice should give same result."""
        once = _normalize_url(url)
        twice = _normalize_url(once)
        assert once == twice, f"Not idempotent: {url!r} -> {once!r} -> {twice!r}"

    @given(st.sampled_from([
        "SELECT * FROM users WHERE id = 42",
        "INSERT INTO orders (name) VALUES ('test')",
        "UPDATE items SET price = 99.99 WHERE id = 7",
        "DELETE FROM sessions WHERE token = 'abc123'",
        "SELECT * FROM runs WHERE id = '550e8400-e29b-41d4-a716-446655440000'",
    ]))
    @settings(max_examples=30)
    def test_sql_normalization_idempotent(self, sql):
        """_normalize_sql(x) applied twice should give same result."""
        once = _normalize_sql(sql)
        twice = _normalize_sql(once)
        assert once == twice, f"Not idempotent: {sql!r} -> {once!r} -> {twice!r}"

    @given(st.text(min_size=1, max_size=300))
    @settings(max_examples=30)
    def test_sql_normalization_bounded_length(self, sql):
        """Normalized SQL should be at most 200 characters."""
        result = _normalize_sql(sql)
        assert len(result) <= 200, f"Normalized SQL too long: {len(result)} chars"

    @given(st.sampled_from([
        "SELECT * FROM users WHERE id = 1",
        "SELECT * FROM users WHERE id = 999",
        "SELECT * FROM users WHERE id = 42",
    ]))
    @settings(max_examples=20)
    def test_sql_numbers_normalized_to_same(self, sql):
        """Different numeric values in same query should normalize identically."""
        result = _normalize_sql(sql)
        expected = _normalize_sql("SELECT * FROM users WHERE id = 1")
        assert result == expected, f"Numeric values not normalized: {result!r}"
