"""Centralized helpers for quality tests.

All quality tests should use these helpers to ensure consistent
file discovery and avoid gaps in coverage.

Usage:
    from tests.quality._helpers import find_source_files, parse_file_safe, relative_path

    for py_file in find_source_files():
        tree, error = parse_file_safe(py_file)
        if error:
            parse_errors.append(error)
            continue
        # ... process tree ...
"""

from __future__ import annotations

import ast
from pathlib import Path

# Root paths
PROJECT_ROOT = Path(__file__).parent.parent.parent
SRC_ROOT = PROJECT_ROOT / "src" / "noteflow"
TESTS_ROOT = PROJECT_ROOT / "tests"

# Excluded patterns (generated code)
GENERATED_PATTERNS = {"*_pb2.py", "*_pb2_grpc.py", "*_pb2.pyi"}

# Excluded directories
EXCLUDED_DIRS = {".venv", "__pycache__", "node_modules", ".git"}


def find_source_files(
    root: Path | None = None,
    *,
    include_tests: bool = False,
    include_conftest: bool = False,
    include_migrations: bool = False,
    include_quality: bool = False,
    include_init: bool = True,
) -> list[Path]:
    """Find Python source files with consistent exclusions.

    Args:
        root: Root directory to search (default: SRC_ROOT)
        include_tests: Include test files (test_*.py)
        include_conftest: Include conftest.py files
        include_migrations: Include Alembic migration files
        include_quality: Include tests/quality/ files
        include_init: Include __init__.py files (default True)

    Returns:
        List of Path objects for matching files, sorted for determinism.
    """
    if root is None:
        root = SRC_ROOT

    files: list[Path] = []

    for py_file in root.rglob("*.py"):
        # Skip excluded directories
        if any(d in py_file.parts for d in EXCLUDED_DIRS):
            continue

        # Skip generated files
        if any(py_file.match(p) for p in GENERATED_PATTERNS):
            continue

        # Skip conftest unless included
        if not include_conftest and py_file.name == "conftest.py":
            continue

        # Skip __init__.py unless included
        if not include_init and py_file.name == "__init__.py":
            continue

        # Skip migrations unless included
        if not include_migrations and "migrations" in py_file.parts:
            continue

        # Handle test file filtering
        if "tests" in py_file.parts:
            # Skip quality tests unless included (prevents recursion)
            if not include_quality and "quality" in py_file.parts:
                continue
            # Skip all tests unless include_tests is True
            if not include_tests:
                continue

        files.append(py_file)

    return sorted(files)


def find_test_files(
    root: Path | None = None,
    *,
    include_quality: bool = False,
    include_conftest: bool = False,
) -> list[Path]:
    """Find test files with consistent exclusions.

    Args:
        root: Root directory to search (default: TESTS_ROOT)
        include_quality: Include tests/quality/ files
        include_conftest: Include conftest.py files

    Returns:
        List of test file paths, sorted for determinism.
    """
    if root is None:
        root = TESTS_ROOT

    files: list[Path] = []

    for py_file in root.rglob("*.py"):
        # Skip excluded directories
        if any(d in py_file.parts for d in EXCLUDED_DIRS):
            continue

        # Skip quality tests unless included
        if not include_quality and "quality" in py_file.parts:
            continue

        # Handle conftest files
        if py_file.name == "conftest.py":
            if include_conftest:
                files.append(py_file)
            continue

        # Only include test_*.py files
        if py_file.name.startswith("test_"):
            files.append(py_file)

    return sorted(files)


def parse_file_safe(file_path: Path) -> tuple[ast.AST | None, str | None]:
    """Parse a Python file, returning AST or error message.

    Unlike bare `ast.parse`, this never silently fails. Use this
    instead of try/except SyntaxError: continue patterns.

    Args:
        file_path: Path to the Python file.

    Returns:
        (ast, None) on success
        (None, error_message) on failure
    """
    try:
        source = file_path.read_text(encoding="utf-8")
        tree = ast.parse(source)
        return tree, None
    except SyntaxError as e:
        return None, f"{file_path}: SyntaxError at line {e.lineno}: {e.msg}"
    except UnicodeDecodeError as e:
        return None, f"{file_path}: UnicodeDecodeError: {e}"
    except Exception as e:
        return None, f"{file_path}: {type(e).__name__}: {e}"


def read_file_safe(file_path: Path) -> tuple[str | None, str | None]:
    """Read a file, returning content or error message.

    Args:
        file_path: Path to the file.

    Returns:
        (content, None) on success
        (None, error_message) on failure
    """
    try:
        content = file_path.read_text(encoding="utf-8")
        return content, None
    except UnicodeDecodeError as e:
        return None, f"{file_path}: UnicodeDecodeError: {e}"
    except Exception as e:
        return None, f"{file_path}: {type(e).__name__}: {e}"


def relative_path(file_path: Path, root: Path | None = None) -> str:
    """Get path relative to project root for stable IDs.

    Args:
        file_path: Absolute path to file.
        root: Root to make relative to (default: PROJECT_ROOT)

    Returns:
        Relative path as string, or absolute path if not under root.
    """
    if root is None:
        root = PROJECT_ROOT

    try:
        return str(file_path.relative_to(root))
    except ValueError:
        return str(file_path)


def get_function_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
    """Get the fully qualified name of a function within a file.

    For class methods, returns "ClassName.method_name".
    For module-level functions, returns just the function name.

    Args:
        node: The function AST node.

    Returns:
        The function name (may include class prefix).
    """
    return node.name


def get_class_context(
    tree: ast.AST,
    func_lineno: int,
) -> str | None:
    """Find the class containing a function at a given line number.

    Args:
        tree: The module AST.
        func_lineno: Line number of the function.

    Returns:
        Class name if function is a method, None otherwise.
    """
    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef):
            if node.end_lineno is None:
                continue
            if node.lineno <= func_lineno <= node.end_lineno:
                return node.name
    return None


def collect_parse_errors(errors: list[str]) -> None:
    """Assert that no parse errors occurred.

    Call this at the end of a test that uses parse_file_safe.

    Args:
        errors: List of error messages from parse_file_safe.

    Raises:
        AssertionError: If any parse errors occurred.
    """
    if errors:
        raise AssertionError(
            f"Quality scan hit {len(errors)} parse error(s):\n"
            + "\n".join(errors)
        )
