"""Tests for detecting test smells in the test suite.

Based on research from testsmells.org and xUnit Test Patterns.

Detects:
- Assertion Roulette: Multiple assertions without descriptive messages
- Conditional Test Logic: if/for/while in tests
- Empty Test: Test methods without executable statements
- Sleepy Test: time.sleep() calls causing flakiness
- Unknown Test: Tests without assertions
- Redundant Assertion: Assertions that always pass (assert True)
- Magic Number Test: Unexplained numeric literals in assertions
- Eager Test: Tests calling too many production methods
- Exception Handling: try/except instead of pytest.raises
- Redundant Print: print() statements in tests
- Ignored Test: Skipped tests without reason
- Sensitive Equality: Using str()/repr() for object comparison
"""

from __future__ import annotations

import ast
import re
from collections import defaultdict
from pathlib import Path

from tests.quality._baseline import (
    Violation,
    assert_no_new_violations,
    content_hash,
)
from tests.quality._helpers import (
    collect_parse_errors,
    find_test_files,
    parse_file_safe,
    read_file_safe,
    relative_path,
)


def get_test_methods(tree: ast.AST) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
    """Extract test methods from AST."""
    tests: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if node.name.startswith("test_"):
                tests.append(node)
    return tests


def count_assertions(node: ast.AST) -> int:
    """Count assertion statements in a node."""
    count = 0
    for child in ast.walk(node):
        if isinstance(child, ast.Assert):
            count += 1
        elif isinstance(child, ast.Call):
            if isinstance(child.func, ast.Attribute):
                if child.func.attr in {
                    "assertEqual",
                    "assertNotEqual",
                    "assertTrue",
                    "assertFalse",
                    "assertIs",
                    "assertIsNot",
                    "assertIsNone",
                    "assertIsNotNone",
                    "assertIn",
                    "assertNotIn",
                    "assertRaises",
                    "assertWarns",
                }:
                    count += 1
    return count


def has_assertion_message(node: ast.Assert) -> bool:
    """Check if an assert statement has a message."""
    return node.msg is not None


def test_no_assertion_roulette() -> None:
    """Detect tests with multiple assertions without descriptive messages.

    Assertion Roulette occurs when a test has multiple assertions without
    messages, making it hard to determine which assertion failed.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            assertions_without_msg = 0

            for node in ast.walk(test_method):
                if isinstance(node, ast.Assert):
                    if not has_assertion_message(node):
                        assertions_without_msg += 1

            # Flag if >3 assertions without messages
            if assertions_without_msg > 3:
                violations.append(
                    Violation(
                        rule="assertion_roulette",
                        relative_path=rel_path,
                        identifier=test_method.name,
                        detail=f"assertions={assertions_without_msg}",
                    )
                )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("assertion_roulette", violations)


def _contains_assertion(node: ast.AST) -> bool:
    """Check if a node contains an assertion statement."""
    return any(isinstance(child, ast.Assert) for child in ast.walk(node))


def test_no_conditional_test_logic() -> None:
    """Detect tests containing conditional logic (if/for/while) with assertions inside.

    Conditional Test Logic makes tests harder to understand and may cause
    some code paths to never execute, hiding bugs.

    Note: Loops/conditionals used only for setup (without assertions) are allowed.
    Stress tests are excluded as they intentionally use loops for thorough testing.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        # Skip stress tests - they intentionally use loops for thorough testing
        if "stress" in py_file.parts:
            continue

        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            conditionals: list[str] = []

            for node in ast.walk(test_method):
                # Only flag conditionals that contain assertions inside them
                if isinstance(node, ast.If) and _contains_assertion(node):
                    conditionals.append(f"if@{node.lineno}")
                elif isinstance(node, ast.For) and _contains_assertion(node):
                    conditionals.append(f"for@{node.lineno}")
                elif isinstance(node, ast.While) and _contains_assertion(node):
                    conditionals.append(f"while@{node.lineno}")

            if conditionals:
                violations.append(
                    Violation(
                        rule="conditional_test_logic",
                        relative_path=rel_path,
                        identifier=test_method.name,
                        detail=",".join(conditionals[:3]),
                    )
                )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("conditional_test_logic", violations)


def test_no_empty_tests() -> None:
    """Detect test methods with no executable statements.

    Empty tests pass silently, giving false confidence in code quality.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            # Filter out docstrings and pass statements
            executable_stmts = [
                s
                for s in test_method.body
                if not (isinstance(s, ast.Pass))
                and not (isinstance(s, ast.Expr) and isinstance(s.value, ast.Constant))
            ]

            if not executable_stmts:
                violations.append(
                    Violation(
                        rule="empty_test",
                        relative_path=rel_path,
                        identifier=test_method.name,
                    )
                )

    collect_parse_errors(parse_errors)

    # Empty tests should be removed entirely - zero tolerance
    assert not violations, (
        f"Found {len(violations)} empty tests (should be removed or implemented):\n"
        + "\n".join(str(v) for v in violations[:10])
    )


def test_no_sleepy_tests() -> None:
    """Detect tests using time.sleep() which causes flakiness.

    Sleepy tests are slow and unreliable. Use mocking or async patterns instead.

    Exceptions:
        - tests/stress/: Stress tests legitimately need sleep for resource cleanup
        - tests/integration/test_signal_handling.py: Tests async task cancellation
        - tests/integration/test_database_resilience.py: Tests connection pool timing
        - tests/grpc/test_stream_lifecycle.py: Tests streaming cleanup timing
    """
    # Paths where sleep is legitimately needed for stress/resilience testing
    allowed_sleepy_paths = {
        "tests/stress/",
        "tests/integration/test_signal_handling.py",
        "tests/integration/test_database_resilience.py",
        "tests/grpc/test_stream_lifecycle.py",
    }

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        # Skip files where sleep is legitimately needed for stress/resilience testing
        rel_path = relative_path(py_file)
        if any(allowed in rel_path for allowed in allowed_sleepy_paths):
            continue

        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Call):
                    # Check for time.sleep()
                    if isinstance(node.func, ast.Attribute):
                        if node.func.attr == "sleep":
                            if isinstance(node.func.value, ast.Name):
                                if node.func.value.id in {"time", "asyncio"}:
                                    violations.append(
                                        Violation(
                                            rule="sleepy_test",
                                            relative_path=rel_path,
                                            identifier=test_method.name,
                                            detail=f"line={node.lineno}",
                                        )
                                    )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("sleepy_test", violations)


def test_no_unknown_tests() -> None:
    """Detect tests without any assertions.

    Tests without assertions pass even when behavior is wrong, providing
    false confidence. Every test should assert something.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            has_assertion = False
            has_raises = False

            for node in ast.walk(test_method):
                if isinstance(node, ast.Assert):
                    has_assertion = True
                    break
                # Check for pytest.raises context manager
                if isinstance(node, ast.With):
                    for item in node.items:
                        if isinstance(item.context_expr, ast.Call):
                            call = item.context_expr
                            if isinstance(call.func, ast.Attribute):
                                if call.func.attr in {"raises", "warns"}:
                                    has_raises = True

            if not has_assertion and not has_raises:
                # Check if it's a smoke test (just calling a function)
                # These are valid for checking no exceptions are raised
                has_call = any(isinstance(n, ast.Call) for n in ast.walk(test_method))
                if not has_call:
                    violations.append(
                        Violation(
                            rule="unknown_test",
                            relative_path=rel_path,
                            identifier=test_method.name,
                        )
                    )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("unknown_test", violations)


def test_no_redundant_assertions() -> None:
    """Detect assertions that always pass (assert True, assert 1 == 1).

    Redundant assertions provide no value and may indicate incomplete tests.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Assert):
                    test_expr = node.test
                    # Check for assert True, assert False (False would fail anyway)
                    if isinstance(test_expr, ast.Constant):
                        if test_expr.value is True:
                            violations.append(
                                Violation(
                                    rule="redundant_assertion",
                                    relative_path=rel_path,
                                    identifier=test_method.name,
                                    detail="assert_true",
                                )
                            )
                    # Check for assert x == x
                    elif isinstance(test_expr, ast.Compare):
                        if len(test_expr.ops) == 1 and isinstance(
                            test_expr.ops[0], ast.Eq
                        ):
                            left = ast.dump(test_expr.left)
                            right = ast.dump(test_expr.comparators[0])
                            if left == right:
                                violations.append(
                                    Violation(
                                        rule="redundant_assertion",
                                        relative_path=rel_path,
                                        identifier=test_method.name,
                                        detail="self_comparison",
                                    )
                                )

    collect_parse_errors(parse_errors)

    # Redundant assertions should be removed - zero tolerance
    assert not violations, (
        f"Found {len(violations)} redundant assertions:\n"
        + "\n".join(str(v) for v in violations[:10])
    )


def test_no_redundant_prints() -> None:
    """Detect print statements in tests.

    Print statements in tests are noise during automated runs. Use logging
    or pytest's capture mechanism instead.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
                    if node.func.id == "print":
                        violations.append(
                            Violation(
                                rule="redundant_print",
                                relative_path=rel_path,
                                identifier=test_method.name,
                                detail=f"line={node.lineno}",
                            )
                        )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("redundant_print", violations)


def test_no_ignored_tests_without_reason() -> None:
    """Detect skipped tests without a reason.

    Skipped tests should have a reason explaining why they're skipped,
    otherwise it's unclear if they should be fixed or removed.
    """
    violations: list[Violation] = []

    # Pattern for skip without reason: @pytest.mark.skip or @pytest.mark.skip()
    skip_pattern = re.compile(r"@pytest\.mark\.skip\s*(?:\(\s*\))?$", re.MULTILINE)
    # Pattern for skipif without reason= keyword
    # Matches @pytest.mark.skipif(...) where ... doesn't contain "reason="
    skipif_pattern = re.compile(
        r"@pytest\.mark\.skipif\s*\([^)]*\)\s*$", re.MULTILINE
    )

    for py_file in find_test_files():
        content, error = read_file_safe(py_file)
        if error or content is None:
            continue

        rel_path = relative_path(py_file)
        lines = content.splitlines()

        for i, line in enumerate(lines, start=1):
            stripped = line.strip()
            # Check for @pytest.mark.skip without reason
            if skip_pattern.search(stripped):
                violations.append(
                    Violation(
                        rule="ignored_test_no_reason",
                        relative_path=rel_path,
                        identifier=content_hash(f"{i}:{stripped}"),
                        detail="skip_no_reason",
                    )
                )
            # Check for @pytest.mark.skipif without reason=
            elif skipif_pattern.search(stripped) and "reason=" not in stripped:
                violations.append(
                    Violation(
                        rule="ignored_test_no_reason",
                        relative_path=rel_path,
                        identifier=content_hash(f"{i}:{stripped}"),
                        detail="skipif_no_reason",
                    )
                )

    # Skipped tests without reason should be fixed - zero tolerance
    assert not violations, (
        f"Found {len(violations)} skipped tests without reason:\n"
        + "\n".join(str(v) for v in violations)
    )


def test_no_exception_handling_in_tests() -> None:
    """Detect try/except blocks in tests instead of pytest.raises.

    Tests should use pytest.raises() for expected exceptions, not try/except.
    Manual exception handling can hide bugs.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Try):
                    # Check if it's a bare except or catching specific exceptions
                    for handler in node.handlers:
                        if handler.type is None:
                            violations.append(
                                Violation(
                                    rule="exception_handling",
                                    relative_path=rel_path,
                                    identifier=test_method.name,
                                    detail="bare_except",
                                )
                            )
                        elif isinstance(handler.type, ast.Name):
                            if handler.type.id in {"Exception", "BaseException"}:
                                violations.append(
                                    Violation(
                                        rule="exception_handling",
                                        relative_path=rel_path,
                                        identifier=test_method.name,
                                        detail=f"catches_{handler.type.id}",
                                    )
                                )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("exception_handling", violations)


def test_no_magic_numbers_in_assertions() -> None:
    """Detect magic numbers in assertions without explanation.

    Assertions like `assert result == 42` are unclear. Use named constants
    or variables with descriptive names.
    """
    # Allowed magic numbers in tests
    allowed_numbers = {0, 1, 2, -1, 100, 1000}

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Assert):
                    # Find numeric literals in assertions
                    for child in ast.walk(node):
                        if isinstance(child, ast.Constant):
                            if isinstance(child.value, (int, float)):
                                if child.value not in allowed_numbers:
                                    if abs(float(child.value)) > 10.0:
                                        violations.append(
                                            Violation(
                                                rule="magic_number_test",
                                                relative_path=rel_path,
                                                identifier=test_method.name,
                                                detail=f"value={child.value}",
                                            )
                                        )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("magic_number_test", violations)


def test_no_sensitive_equality() -> None:
    """Detect tests using str()/repr() for equality comparison.

    Comparing string representations is fragile - changes to __str__ or
    __repr__ break tests even when behavior is correct.

    Exclusions:
    - Tests with "string" in name - explicitly testing string conversion
    - Tests with "proto" in name - testing protobuf field serialization
    - Tests with "conversion" in name - testing type conversion
    - Tests with "serializ" in name - testing serialization
    """
    # Test names that legitimately use str() comparisons
    excluded_test_patterns = {
        "string",  # Testing string conversion behavior
        "proto",  # Testing protobuf field serialization
        "conversion",  # Testing type conversion
        "serializ",  # Testing serialization
        "preserves_message",  # Testing error message preservation
    }

    # File patterns where str() comparison is expected (gRPC field serialization)
    excluded_file_patterns = {
        "_mixin",  # gRPC mixin tests compare ID fields
    }

    def _is_excluded_test(test_name: str) -> bool:
        """Check if test legitimately uses str() comparison."""
        test_name_lower = test_name.lower()
        return any(p in test_name_lower for p in excluded_test_patterns)

    def _is_excluded_file(file_path: Path) -> bool:
        """Check if file legitimately uses str() comparisons."""
        file_name_lower = file_path.stem.lower()
        return any(p in file_name_lower for p in excluded_file_patterns)

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        # Skip files that legitimately use str() comparisons
        if _is_excluded_file(py_file):
            continue

        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            # Skip tests that legitimately test string behavior
            if _is_excluded_test(test_method.name):
                continue
            for node in ast.walk(test_method):
                if isinstance(node, ast.Assert):
                    # Check for str(x) == "..." or repr(x) == "..." as direct comparators
                    # Only flag when str()/repr() is a direct operand, not nested in other calls
                    if isinstance(node.test, ast.Compare):
                        # Check left operand and all comparators (right operands)
                        operands = [node.test.left, *node.test.comparators]
                        for operand in operands:
                            if isinstance(operand, ast.Call):
                                if isinstance(operand.func, ast.Name):
                                    if operand.func.id in {"str", "repr"}:
                                        violations.append(
                                            Violation(
                                                rule="sensitive_equality",
                                                relative_path=rel_path,
                                                identifier=test_method.name,
                                                detail=operand.func.id,
                                            )
                                        )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("sensitive_equality", violations)


def test_no_eager_tests() -> None:
    """Detect tests that call too many different production methods.

    Eager tests are hard to maintain because failures don't pinpoint
    the problem. Each test should focus on one behavior.
    """
    violations: list[Violation] = []
    parse_errors: list[str]  = []
    max_method_calls = 10  # Threshold for "too many" method calls

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            method_calls: set[str] = set()

            for node in ast.walk(test_method):
                if isinstance(node, ast.Call):
                    if isinstance(node.func, ast.Attribute):
                        # Skip assert methods and common test utilities
                        if not node.func.attr.startswith("assert"):
                            method_calls.add(node.func.attr)

            if len(method_calls) > max_method_calls:
                violations.append(
                    Violation(
                        rule="eager_test",
                        relative_path=rel_path,
                        identifier=test_method.name,
                        detail=f"methods={len(method_calls)}",
                    )
                )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("eager_test", violations)


def test_no_duplicate_test_names() -> None:
    """Detect duplicate test function names across the test suite.

    Duplicate test names can cause confusion and may result in tests
    being shadowed or not run.
    """
    test_names: dict[str, list[tuple[Path, int]]] = defaultdict(list)
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        for test_method in get_test_methods(tree):
            test_names[test_method.name].append((py_file, test_method.lineno))

    duplicates = {name: locs for name, locs in test_names.items() if len(locs) > 1}

    violations: list[Violation] = []
    for name, locs in duplicates.items():
        # Use first location as the identifier
        first_path = relative_path(locs[0][0])
        violations.append(
            Violation(
                rule="duplicate_test_name",
                relative_path=first_path,
                identifier=name,
                detail=f"count={len(locs)}",
            )
        )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("duplicate_test_name", violations)


def test_no_hardcoded_test_data_paths() -> None:
    """Detect hardcoded file paths in tests.

    Hardcoded paths make tests non-portable. Use fixtures, tmp_path,
    or pathlib relative to __file__.
    """
    # Patterns for hardcoded paths
    path_patterns = [
        r'["\'][A-Za-z]:\\',  # Windows paths
        r'["\']\/home\/\w+',  # Linux home paths
        r'["\']\/tmp\/[^"\']+["\']',  # Hardcoded /tmp paths (not tmp_path fixture)
        r'["\']\/var\/\w+',  # Hardcoded /var paths
    ]

    violations: list[Violation] = []

    for py_file in find_test_files():
        content, error = read_file_safe(py_file)
        if error or content is None:
            continue

        rel_path = relative_path(py_file)
        lines = content.splitlines()

        for i, line in enumerate(lines, start=1):
            # Skip comments
            stripped = line.strip()
            if stripped.startswith("#"):
                continue

            for pattern in path_patterns:
                if match := re.search(pattern, line):
                    # Check if pattern appears before a comment
                    comment_pos = line.find("#")
                    if comment_pos != -1 and comment_pos < match.start():
                        continue
                    violations.append(
                        Violation(
                            rule="hardcoded_path",
                            relative_path=rel_path,
                            identifier=content_hash(f"{i}:{stripped}"),
                        )
                    )
                    break

    # Hardcoded paths should not exist - zero tolerance
    assert not violations, (
        f"Found {len(violations)} hardcoded paths in tests:\n"
        + "\n".join(str(v) for v in violations[:10])
    )


def test_no_long_test_methods() -> None:
    """Detect test methods that are too long.

    Long tests are hard to understand and maintain. Break them into
    smaller, focused tests or extract helper functions.
    """
    max_lines = 50

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            if test_method.end_lineno:
                lines = test_method.end_lineno - test_method.lineno + 1
                if lines > max_lines:
                    violations.append(
                        Violation(
                            rule="long_test",
                            relative_path=rel_path,
                            identifier=test_method.name,
                            detail=f"lines={lines}",
                        )
                    )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("long_test", violations)


# =============================================================================
# Pytest Compliance and Fixture Tests
# =============================================================================


def _get_fixtures(tree: ast.AST) -> list[ast.FunctionDef]:
    """Extract pytest fixtures from AST."""
    fixtures: list[ast.FunctionDef] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef):
            for decorator in node.decorator_list:
                # Check for @pytest.fixture or @fixture
                if isinstance(decorator, ast.Attribute):
                    if decorator.attr == "fixture":
                        fixtures.append(node)
                        break
                elif isinstance(decorator, ast.Call):
                    if isinstance(decorator.func, ast.Attribute):
                        if decorator.func.attr == "fixture":
                            fixtures.append(node)
                            break
                elif isinstance(decorator, ast.Name) and decorator.id == "fixture":
                    fixtures.append(node)
                    break
    return fixtures


def _get_fixture_scope(node: ast.FunctionDef) -> str | None:
    """Extract fixture scope from decorator."""
    for decorator in node.decorator_list:
        if isinstance(decorator, ast.Call):
            for keyword in decorator.keywords:
                if keyword.arg == "scope":
                    if isinstance(keyword.value, ast.Constant):
                        return str(keyword.value.value)
    return None


def _get_module_level_fixtures(tree: ast.AST) -> list[ast.FunctionDef]:
    """Extract only module-level pytest fixtures from AST (not class-scoped)."""
    fixtures: list[ast.FunctionDef] = []
    # Only check top-level function definitions, not methods inside classes
    if not isinstance(tree, ast.Module):
        return fixtures
    for node in tree.body:
        if isinstance(node, ast.FunctionDef):
            for decorator in node.decorator_list:
                # Check for @pytest.fixture or @fixture
                if isinstance(decorator, ast.Attribute):
                    if decorator.attr == "fixture":
                        fixtures.append(node)
                        break
                elif isinstance(decorator, ast.Call):
                    if isinstance(decorator.func, ast.Attribute):
                        if decorator.func.attr == "fixture":
                            fixtures.append(node)
                            break
                elif isinstance(decorator, ast.Name) and decorator.id == "fixture":
                    fixtures.append(node)
                    break
    return fixtures


def test_no_unittest_style_assertions() -> None:
    """Detect unittest-style assertions instead of plain assert.

    Pytest works best with plain assert statements. Using unittest-style
    assertions (self.assertEqual, etc.) loses pytest's assertion introspection.
    """
    unittest_assertions = {
        "assertEqual",
        "assertNotEqual",
        "assertTrue",
        "assertFalse",
        "assertIs",
        "assertIsNot",
        "assertIsNone",
        "assertIsNotNone",
        "assertIn",
        "assertNotIn",
        "assertIsInstance",
        "assertNotIsInstance",
        "assertGreater",
        "assertGreaterEqual",
        "assertLess",
        "assertLessEqual",
        "assertAlmostEqual",
        "assertNotAlmostEqual",
        "assertRegex",
        "assertNotRegex",
        "assertCountEqual",
        "assertMultiLineEqual",
        "assertSequenceEqual",
        "assertListEqual",
        "assertTupleEqual",
        "assertSetEqual",
        "assertDictEqual",
    }

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Call):
                    if isinstance(node.func, ast.Attribute):
                        if node.func.attr in unittest_assertions:
                            violations.append(
                                Violation(
                                    rule="unittest_assertion",
                                    relative_path=rel_path,
                                    identifier=test_method.name,
                                    detail=node.func.attr,
                                )
                            )

    collect_parse_errors(parse_errors)

    # unittest assertions should not be used - zero tolerance
    assert not violations, (
        f"Found {len(violations)} unittest-style assertions (use plain assert):\n"
        + "\n".join(str(v) for v in violations[:10])
    )


def test_no_session_scoped_fixtures_with_mutation() -> None:
    """Detect session-scoped fixtures that may mutate state.

    Session-scoped fixtures that mutate lists, dicts, or objects can cause
    test pollution. They should return immutable or fresh copies.
    """
    # Patterns indicating mutation in fixture body
    mutation_patterns = [
        r"\.append\(",
        r"\.extend\(",
        r"\.insert\(",
        r"\.pop\(",
        r"\.remove\(",
        r"\.clear\(",
        r"\.update\(",
        r"\[.+\]\s*=",  # dict/list assignment
    ]

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        content, _ = read_file_safe(py_file)
        if content is None:
            continue

        rel_path = relative_path(py_file)

        for fixture in _get_fixtures(tree):
            scope = _get_fixture_scope(fixture)
            if scope in ("session", "module"):
                if fixture_source := ast.get_source_segment(content, fixture):
                    for pattern in mutation_patterns:
                        if re.search(pattern, fixture_source):
                            violations.append(
                                Violation(
                                    rule="session_fixture_mutation",
                                    relative_path=rel_path,
                                    identifier=fixture.name,
                                    detail=f"scope={scope}",
                                )
                            )
                            break

    collect_parse_errors(parse_errors)

    # Session fixtures with mutation should not exist - zero tolerance
    assert not violations, (
        f"Found {len(violations)} session/module fixtures with potential mutation:\n"
        + "\n".join(str(v) for v in violations[:10])
    )


def test_fixtures_have_type_hints() -> None:
    """Detect fixtures missing return type annotations.

    Fixtures should have return type annotations for better IDE support
    and documentation. Use -> T or -> Generator[T, None, None] for yields.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for fixture in _get_fixtures(tree):
            # Check if fixture has return type annotation
            if fixture.returns is None:
                # Skip fixtures that start with _ (internal helpers)
                if not fixture.name.startswith("_"):
                    violations.append(
                        Violation(
                            rule="fixture_missing_type",
                            relative_path=rel_path,
                            identifier=fixture.name,
                        )
                    )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("fixture_missing_type", violations)


def test_no_unused_fixture_parameters() -> None:
    """Detect test functions that request fixtures but don't use them.

    Requesting unused fixtures wastes resources and clutters the test signature.
    Remove unused fixture parameters or mark them with underscore prefix.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            # Get parameter names (excluding self)
            params = [
                arg.arg
                for arg in test_method.args.args
                if arg.arg not in ("self", "cls")
            ]

            # Skip parameters that start with _ (explicitly unused)
            params = [p for p in params if not p.startswith("_")]

            used_names: set[str] = {
                node.id
                for node in ast.walk(test_method)
                if isinstance(node, ast.Name)
            }
            # Find unused parameters
            for param in params:
                if param not in used_names:
                    # Skip common pytest fixtures that have side effects
                    if param in (
                        "monkeypatch",
                        "capsys",
                        "capfd",
                        "caplog",
                        "tmp_path",
                        "tmp_path_factory",
                        "request",
                        "pytestconfig",
                        "record_property",
                        "record_testsuite_property",
                        "recwarn",
                        "event_loop",
                    ):
                        continue
                    violations.append(
                        Violation(
                            rule="unused_fixture",
                            relative_path=rel_path,
                            identifier=test_method.name,
                            detail=param,
                        )
                    )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("unused_fixture", violations)


def test_conftest_fixtures_not_duplicated() -> None:
    """Detect fixtures defined in both conftest.py and test files.

    Fixtures should be defined in conftest.py for reuse. Defining the same
    fixture in multiple test files causes confusion and maintenance issues.

    Only checks conftest files that would be visible to the test file
    (same directory or parent directories).
    """
    # Use the project root for finding conftest files
    project_root = Path(__file__).parent.parent.parent
    tests_root = project_root / "tests"

    # Collect fixtures from conftest files, organized by directory
    conftest_by_dir: dict[Path, dict[str, int]] = {}
    for conftest in tests_root.rglob("conftest.py"):
        if ".venv" in conftest.parts:
            continue
        content, error = read_file_safe(conftest)
        if error or content is None:
            continue
        try:
            tree = ast.parse(content)
        except SyntaxError:
            continue
        conftest_dir = conftest.parent
        conftest_by_dir[conftest_dir] = {
            fixture.name: fixture.lineno for fixture in _get_fixtures(tree)
        }
    # Check test files for duplicate fixture definitions
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)
        test_dir = py_file.parent

        # Find all conftest directories visible to this test file
        visible_conftest_fixtures: dict[str, Path] = {}
        for conftest_dir, fixtures in conftest_by_dir.items():
            # conftest is visible if it's in the same dir or a parent dir
            try:
                test_dir.relative_to(conftest_dir)
                # conftest_dir is a parent of test_dir (or same)
                for name in fixtures:
                    visible_conftest_fixtures[name] = conftest_dir / "conftest.py"
            except ValueError:
                # Not a parent, skip
                continue

        # Only check module-level fixtures (class-scoped fixtures are intentional)
        for fixture in _get_module_level_fixtures(tree):
            if fixture.name in visible_conftest_fixtures:
                violations.append(
                    Violation(
                        rule="duplicate_fixture",
                        relative_path=rel_path,
                        identifier=fixture.name,
                        detail=str(visible_conftest_fixtures[fixture.name]),
                    )
                )

    collect_parse_errors(parse_errors)

    # Duplicate fixtures should be consolidated - zero tolerance
    assert not violations, (
        f"Found {len(violations)} fixtures duplicated from conftest:\n"
        + "\n".join(str(v) for v in violations[:10])
    )


def test_fixture_scope_appropriate() -> None:
    """Detect fixtures with potentially inappropriate scope.

    - Function-scoped fixtures that create expensive resources should use module/session
    - Session-scoped fixtures that yield mutable objects should use function scope
    """
    # Patterns suggesting expensive setup
    expensive_patterns = [
        r"asyncpg\.connect",
        r"create_async_engine",
        r"aiohttp\.ClientSession",
        r"httpx\.AsyncClient",
        r"subprocess\.Popen",
        r"docker\.",
        r"testcontainers\.",
    ]

    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        content, _ = read_file_safe(py_file)
        if content is None:
            continue

        rel_path = relative_path(py_file)

        for fixture in _get_fixtures(tree):
            scope = _get_fixture_scope(fixture)
            if fixture_source := ast.get_source_segment(content, fixture):
                # Check for expensive operations in function-scoped fixtures
                if scope is None or scope == "function":
                    for pattern in expensive_patterns:
                        if re.search(pattern, fixture_source):
                            violations.append(
                                Violation(
                                    rule="fixture_scope_too_narrow",
                                    relative_path=rel_path,
                                    identifier=fixture.name,
                                    detail="expensive_setup",
                                )
                            )
                            break

    collect_parse_errors(parse_errors)
    assert_no_new_violations("fixture_scope_too_narrow", violations)


def test_no_pytest_raises_without_match() -> None:
    """Detect pytest.raises without match parameter.

    Using pytest.raises without match= can hide bugs where the wrong exception
    is raised. Always specify match= to verify the exception message.
    """
    violations: list[Violation] = []
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in get_test_methods(tree):
            for node in ast.walk(test_method):
                if isinstance(node, ast.Call):
                    # Check for pytest.raises
                    if isinstance(node.func, ast.Attribute):
                        if node.func.attr == "raises":
                            if isinstance(node.func.value, ast.Name):
                                if node.func.value.id == "pytest":
                                    # Check if match= is provided
                                    has_match = any(
                                        kw.arg == "match" for kw in node.keywords
                                    )
                                    if not has_match:
                                        violations.append(
                                            Violation(
                                                rule="raises_without_match",
                                                relative_path=rel_path,
                                                identifier=test_method.name,
                                                detail=f"line={node.lineno}",
                                            )
                                        )

    collect_parse_errors(parse_errors)
    assert_no_new_violations("raises_without_match", violations)


def test_no_cross_file_fixture_duplicates() -> None:
    """Detect module-level fixtures with the same name in multiple test files.

    Fixtures defined with the same name in multiple test files should be
    consolidated to a shared conftest.py for better reuse and maintainability.

    Class-scoped fixtures are excluded as they are intentionally isolated.
    """
    # Collect module-level fixtures by name
    fixture_locations: dict[str, list[tuple[Path, int]]] = defaultdict(list)
    parse_errors: list[str] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        if tree is None:
            continue

        for fixture in _get_module_level_fixtures(tree):
            fixture_locations[fixture.name].append((py_file, fixture.lineno))

    # Find fixtures defined in multiple files
    duplicates = {
        name: locs for name, locs in fixture_locations.items() if len(locs) > 1
    }

    violations: list[Violation] = []
    for name, locations in duplicates.items():
        first_path = relative_path(locations[0][0])
        violations.append(
            Violation(
                rule="cross_file_fixture_duplicate",
                relative_path=first_path,
                identifier=name,
                detail=f"files={len(locations)}",
            )
        )

    collect_parse_errors(parse_errors)

    # Cross-file duplicates should be consolidated - zero tolerance
    assert not violations, (
        f"Found {len(violations)} fixtures duplicated across test files "
        "(consolidate to conftest.py):\n"
        + "\n".join(str(v) for v in violations[:10])
    )
