"""Test smell violation collectors for baseline generation.

These collectors are used by generate_baseline.py to capture current
test smell violations for baseline enforcement.
"""

from __future__ import annotations

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

from tests.quality._baseline import Violation
from tests.quality._helpers import (
    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 _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:
                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 collect_assertion_roulette() -> list[Violation]:
    """Collect assertion roulette violations."""
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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 node.msg is None:
                        assertions_without_msg += 1

            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}",
                    )
                )

    return violations


def collect_conditional_test_logic() -> list[Violation]:
    """Collect conditional test logic violations."""
    violations: list[Violation] = []

    def _contains_assertion(node: ast.AST) -> bool:
        return any(isinstance(child, ast.Assert) for child in ast.walk(node))

    for py_file in find_test_files():
        if "stress" in py_file.parts:
            continue

        tree, error = parse_file_safe(py_file)
        if error or 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):
                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]),
                    )
                )

    return violations


def collect_sleepy_tests() -> list[Violation]:
    """Collect sleepy test violations."""
    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] = []

    for py_file in find_test_files():
        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 or tree is None:
            continue

        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 == "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}",
                                        )
                                    )

    return violations


def collect_unknown_tests() -> list[Violation]:
    """Collect unknown test (no assertion) violations."""
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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
                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:
                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,
                        )
                    )

    return violations


def collect_redundant_prints() -> list[Violation]:
    """Collect redundant print violations in tests."""
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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}",
                            )
                        )

    return violations


def collect_exception_handling() -> list[Violation]:
    """Collect exception handling in tests violations."""
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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):
                    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}",
                                    )
                                )

    return violations


def collect_magic_number_tests() -> list[Violation]:
    """Collect magic number in test assertions violations."""
    allowed_numbers = {0, 1, 2, -1, 100, 1000}
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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):
                    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}",
                                            )
                                        )

    return violations


def collect_sensitive_equality() -> list[Violation]:
    """Collect sensitive equality (str/repr comparison) violations."""
    excluded_test_patterns = {"string", "proto", "conversion", "serializ", "preserves_message"}
    excluded_file_patterns = {"_mixin"}
    violations: list[Violation] = []

    for py_file in find_test_files():
        if any(p in py_file.stem.lower() for p in excluded_file_patterns):
            continue

        tree, error = parse_file_safe(py_file)
        if error or tree is None:
            continue

        rel_path = relative_path(py_file)

        for test_method in _get_test_methods(tree):
            if any(p in test_method.name.lower() for p in excluded_test_patterns):
                continue
            for node in ast.walk(test_method):
                if isinstance(node, ast.Assert):
                    if isinstance(node.test, ast.Compare):
                        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,
                                            )
                                        )

    return violations


def collect_eager_tests() -> list[Violation]:
    """Collect eager test (too many method calls) violations."""
    max_method_calls = 10
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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):
                        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)}",
                    )
                )

    return violations


def collect_duplicate_test_names() -> list[Violation]:
    """Collect duplicate test name violations."""
    test_names: dict[str, list[tuple[Path, int]]] = defaultdict(list)

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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():
        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)}",
            )
        )

    return violations


def collect_long_tests() -> list[Violation]:
    """Collect long test method violations."""
    max_lines = 50
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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}",
                        )
                    )

    return violations


def collect_fixture_missing_type() -> list[Violation]:
    """Collect fixtures missing type hints violations."""
    violations: list[Violation] = []

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

        rel_path = relative_path(py_file)

        for fixture in _get_fixtures(tree):
            if fixture.returns is None:
                if not fixture.name.startswith("_"):
                    violations.append(
                        Violation(
                            rule="fixture_missing_type",
                            relative_path=rel_path,
                            identifier=fixture.name,
                        )
                    )

    return violations


def collect_unused_fixtures() -> list[Violation]:
    """Collect unused fixture parameter violations."""
    violations: list[Violation] = []

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

        rel_path = relative_path(py_file)

        for test_method in _get_test_methods(tree):
            params = [
                arg.arg for arg in test_method.args.args
                if arg.arg not in ("self", "cls")
            ]
            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)
            }
            skip_params = {
                "monkeypatch", "capsys", "capfd", "caplog", "tmp_path",
                "tmp_path_factory", "request", "pytestconfig", "record_property",
                "record_testsuite_property", "recwarn", "event_loop",
            }

            violations.extend(
                Violation(
                    rule="unused_fixture",
                    relative_path=rel_path,
                    identifier=test_method.name,
                    detail=param,
                )
                for param in params
                if param not in used_names and param not in skip_params
            )
    return violations


def collect_fixture_scope_too_narrow() -> list[Violation]:
    """Collect fixtures with potentially wrong scope violations."""
    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] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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):
                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

    return violations


def collect_raises_without_match() -> list[Violation]:
    """Collect pytest.raises without match violations."""
    violations: list[Violation] = []

    for py_file in find_test_files():
        tree, error = parse_file_safe(py_file)
        if error or 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 == "raises":
                            if isinstance(node.func.value, ast.Name):
                                if node.func.value.id == "pytest":
                                    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}",
                                            )
                                        )

    return violations
