"""Meta-tests that validate the test suite itself.

These tests ensure the test infrastructure stays healthy:
- No empty test files
- All test files importable
- No duplicate test names
- All fixtures used
- Minimum test count maintained

Note: Uses subprocess git to read files to avoid filesystem timeout issues
on some environments (see MEMORY.md).
"""

from __future__ import annotations

import ast
import subprocess
from pathlib import Path

import pytest

TESTS_DIR = Path(__file__).parent
ROOT_DIR = TESTS_DIR.parent
SOURCE_PACKAGES = ["autodebug", "autodebug_probe", "autodebug_replay", "api", "worker"]


def _git_read(path: Path) -> str | None:
    """Read file content via git to avoid filesystem timeout issues."""
    try:
        rel = path.relative_to(ROOT_DIR)
        result = subprocess.run(
            ["git", "show", f"HEAD:{rel}"],
            capture_output=True, text=True, timeout=5, cwd=str(ROOT_DIR),
        )
        if result.returncode == 0:
            return result.stdout
    except (subprocess.TimeoutExpired, Exception):
        pass
    # Fallback: try direct read with timeout protection
    try:
        return path.read_text(encoding="utf-8")
    except (TimeoutError, OSError):
        return None


def _safe_read(path: Path) -> str | None:
    """Read file content, handling filesystem timeouts gracefully."""
    content = _git_read(path)
    if content is not None:
        return content
    try:
        return path.read_text(encoding="utf-8")
    except (TimeoutError, OSError):
        return None


def _collect_test_files() -> list[Path]:
    """Collect all test_*.py files from git ls-files to avoid filesystem issues."""
    result = subprocess.run(
        ["git", "ls-files", "tests/"],
        capture_output=True, text=True, timeout=10, cwd=str(ROOT_DIR),
    )
    files = []
    for line in result.stdout.strip().split("\n"):
        p = ROOT_DIR / line
        if p.name.startswith("test_") and p.suffix == ".py":
            files.append(p)
    return sorted(files)


def _parse_ast_safe(path: Path) -> ast.Module | None:
    """Parse a Python file into an AST, return None on failure."""
    content = _safe_read(path)
    if content is None:
        return None
    try:
        return ast.parse(content, filename=str(path))
    except SyntaxError:
        return None


def _extract_test_functions(tree: ast.Module) -> list[str]:
    """Extract all test function names from an AST."""
    names = []
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if node.name.startswith("test_"):
                names.append(node.name)
    return names


def _extract_fixture_names(path: Path) -> list[str]:
    """Extract fixture names from a conftest file."""
    tree = _parse_ast_safe(path)
    if tree is None:
        return []
    fixtures = []
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            for decorator in node.decorator_list:
                deco_name = ""
                if isinstance(decorator, ast.Name):
                    deco_name = decorator.id
                elif isinstance(decorator, ast.Attribute):
                    deco_name = decorator.attr
                elif isinstance(decorator, ast.Call):
                    if isinstance(decorator.func, ast.Name):
                        deco_name = decorator.func.id
                    elif isinstance(decorator.func, ast.Attribute):
                        deco_name = decorator.func.attr
                if deco_name == "fixture":
                    fixtures.append(node.name)
    return fixtures


class TestNoEmptyTestFiles:
    """Every test file must contain at least one test function."""

    def test_no_test_file_is_empty(self):
        test_files = _collect_test_files()
        assert len(test_files) > 0, "No test files found"

        empty_files = []
        skipped = 0
        for tf in test_files:
            tree = _parse_ast_safe(tf)
            if tree is None:
                skipped += 1
                continue
            funcs = _extract_test_functions(tree)
            if len(funcs) == 0:
                empty_files.append(str(tf.relative_to(TESTS_DIR)))

        assert empty_files == [], f"Test files with no test functions: {empty_files}"
        # Allow up to 5 skipped files due to filesystem issues
        assert skipped <= 5, f"Too many unreadable test files: {skipped}"


class TestNoDuplicateTestNames:
    """No test class should have duplicate test function names."""

    def test_no_duplicate_test_names(self):
        test_files = _collect_test_files()
        duplicates = []

        for tf in test_files:
            tree = _parse_ast_safe(tf)
            if tree is None:
                continue

            # Check class-level duplicates
            for node in ast.walk(tree):
                if isinstance(node, ast.ClassDef):
                    methods = []
                    for item in node.body:
                        if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                            if item.name.startswith("test_"):
                                if item.name in methods:
                                    rel = tf.relative_to(TESTS_DIR)
                                    duplicates.append(f"{rel}::{node.name}::{item.name}")
                                methods.append(item.name)

            # Check module-level duplicates
            top_level = []
            for node in ast.iter_child_nodes(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    if node.name.startswith("test_"):
                        if node.name in top_level:
                            rel = tf.relative_to(TESTS_DIR)
                            duplicates.append(f"{rel}::{node.name}")
                        top_level.append(node.name)

        assert duplicates == [], f"Duplicate test names: {duplicates}"


class TestFixturesUsed:
    """All fixtures defined in conftest.py should be used by at least one test."""

    def test_conftest_fixtures_all_used(self):
        conftest = TESTS_DIR / "conftest.py"
        if not conftest.exists():
            pytest.skip("No conftest.py found")

        fixtures = _extract_fixture_names(conftest)
        if not fixtures:
            pytest.skip("No fixtures found in conftest.py")

        # Read all test file contents + all conftest files to check fixture references
        all_test_content = ""
        for tf in _collect_test_files():
            content = _safe_read(tf)
            if content:
                all_test_content += content

        # Include all conftest files (fixtures can depend on other fixtures)
        result = subprocess.run(
            ["git", "ls-files", "tests/"],
            capture_output=True, text=True, timeout=10, cwd=str(ROOT_DIR),
        )
        for line in result.stdout.strip().split("\n"):
            if "conftest.py" in line:
                p = ROOT_DIR / line
                content = _safe_read(p)
                if content:
                    all_test_content += content

        unused = []
        for fixture_name in fixtures:
            if fixture_name not in all_test_content:
                unused.append(fixture_name)

        assert unused == [], f"Unused fixtures in conftest.py: {unused}"


class TestNoBareAssertTrue:
    """Tests should not contain bare 'assert True' which always pass."""

    def test_no_bare_assert_true(self):
        test_files = _collect_test_files()
        offenders = []

        for tf in test_files:
            tree = _parse_ast_safe(tf)
            if tree is None:
                continue

            for node in ast.walk(tree):
                if isinstance(node, ast.Assert):
                    if isinstance(node.test, ast.Constant) and node.test.value is True:
                        rel = tf.relative_to(TESTS_DIR)
                        offenders.append(f"{rel}:{node.lineno}")

        assert offenders == [], f"Bare 'assert True' found: {offenders}"


class TestAllSourcePackagesHaveTests:
    """Each major source package should have at least 5 test functions."""

    @pytest.mark.parametrize("package", SOURCE_PACKAGES)
    def test_package_has_tests(self, package):
        test_count = 0
        for tf in _collect_test_files():
            content = _safe_read(tf)
            if content is None:
                continue
            if f"from {package}" in content or f"import {package}" in content:
                tree = _parse_ast_safe(tf)
                if tree:
                    test_count += len(_extract_test_functions(tree))

        assert test_count >= 5, (
            f"Package '{package}' has only {test_count} tests referencing it (minimum 5)"
        )


class TestTestCountNotRegressed:
    """The total test count should not drop below the known minimum."""

    MINIMUM_TEST_COUNT = 1970

    def test_test_count_not_regressed(self):
        total = 0
        for tf in _collect_test_files():
            tree = _parse_ast_safe(tf)
            if tree:
                total += len(_extract_test_functions(tree))

        assert total >= self.MINIMUM_TEST_COUNT, (
            f"Test count regressed: {total} < {self.MINIMUM_TEST_COUNT}. "
            f"Did someone accidentally delete tests?"
        )


class TestCoverageConfigured:
    """Coverage tool configuration should exist."""

    def test_coverage_config_exists(self):
        content = _git_read(ROOT_DIR / "pyproject.toml")
        assert content is not None, "Cannot read pyproject.toml"
        assert "[tool.coverage.run]" in content, "Coverage run config missing"
        assert "[tool.coverage.report]" in content, "Coverage report config missing"

    def test_coverage_source_includes_main_packages(self):
        content = _git_read(ROOT_DIR / "pyproject.toml")
        assert content is not None, "Cannot read pyproject.toml"
        for pkg in ["autodebug", "autodebug_probe", "autodebug_replay"]:
            assert pkg in content, f"Coverage source missing package: {pkg}"
