from fnmatch import fnmatch
from pathlib import Path

CPP_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"})
DEFAULT_EXCLUDES = (
    ".harness/build/**",
    "build/**",
    "out/**",
    "cmake-build-*/**",
    "third_party/**",
    "external/**",
    "vendor/**",
    "generated/**",
)


def is_excluded_path(path: Path, root: Path, extra: tuple[str, ...] = ()) -> bool:
    try:
        relative = path.resolve().relative_to(root.resolve()).as_posix()
    except ValueError:
        return False
    return any(fnmatch(relative, pattern) for pattern in DEFAULT_EXCLUDES + extra)


def find_cpp_files(
    root: Path,
    extra_excludes: tuple[str, ...] = (),
) -> tuple[Path, ...]:
    found = (
        path
        for path in root.resolve().rglob("*")
        if path.is_file()
        and path.suffix.lower() in CPP_SUFFIXES
        and not is_excluded_path(path, root, extra_excludes)
    )
    return tuple(sorted(found))


def is_test_file(path: Path, root: Path, config) -> bool:
    resolved = path.resolve()
    if any(root == resolved or root in resolved.parents for root in config.test_roots):
        return True
    relative = resolved.relative_to(root.resolve())
    if {"test", "tests"} & {part.lower() for part in relative.parts}:
        return True
    stem = resolved.stem.lower()
    return stem.startswith("test_") or stem.endswith(("_test", "_tests", ".test"))


def matching_test_exists(path: Path, config) -> bool:
    names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
    roots = (
        *config.test_roots,
        path.parent / "tests",
        path.parent / "test",
    )
    for root in roots:
        if not root.is_dir():
            continue
        for name in names:
            if any(candidate.is_file() for candidate in root.rglob(name)):
                return True
    return False


def evaluate_paths(paths, root: Path, config) -> str | None:
    root = root.resolve()
    for raw in paths:
        path = raw.resolve()
        try:
            path.relative_to(root)
        except ValueError:
            return f"TDD GUARD: '{path}' is outside the repository"
        if path.suffix.lower() not in CPP_SUFFIXES:
            continue
        if (
            path.name.lower() == "main.cpp"
            or is_excluded_path(path, root, config.exclude)
            or is_test_file(path, root, config)
        ):
            continue
        if not matching_test_exists(path, config):
            expected = config.test_patterns[0].format(stem=path.stem)
            return (
                f"TDD GUARD: '{path.name}' requires an existing test such as "
                f"'{expected}'. Add the test in a configured test root first."
            )
    return None
