#!/usr/bin/env python3
"""Pre-commit hook: verify test paths referenced in GitHub workflows exist.

GitHub Actions steps frequently invoke pytest on specific files or
directories, e.g.::

    pdm run python -m pytest tests/security/test_ssrf_validator.py -v

When such a file is later renamed or deleted (as happened with
tests/security/test_input_validation.py, removed in #4243), the workflow
keeps pointing at the now-missing path. pytest then exits with code 5
("no tests collected") and the CI gate fails for *every* PR that triggers
it -- a failure unrelated to the triggering change and easy to miss until
release time (#4411).

This hook scans changed workflow YAML files for ``tests/...`` paths that
are passed as arguments to ``pytest`` (files or directories) and fails if
any do not exist on disk, so the drift is caught at commit time instead
of in CI.

Scope is deliberately limited to pytest arguments. Other ``tests/...``
references -- e.g. ``upload-artifact`` ``path:`` entries pointing at
runtime-generated output dirs like ``tests/screenshots/`` -- are not
validated, because those legitimately do not exist until a CI run creates
them.

False positives are further avoided by:
- ignoring the part of a line after an inline ``#`` comment,
- following backslash line-continuations so a multi-file pytest
  invocation is analysed as one command, and
- skipping any path that is itself protected by a shell existence guard
  (``[ -f <path> ]`` / ``[ -e <path> ]`` / ``test -f <path>``) anywhere
  in the same file -- that is the established "legacy tests if they
  exist" fallback pattern, which is intentionally tolerant of absence.
"""

import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"

# A test path token: starts at "tests/", runs over word chars, dots,
# dashes and slashes. Stops at whitespace, quotes, ":" (pytest node ids),
# "*" (globs) etc. We only act on tokens that clearly denote a concrete
# file (".py") or directory (trailing "/") -- globs and bare prefixes are
# left alone because they cannot be validated by simple existence checks.
PATH_TOKEN = re.compile(r"tests/[\w.\-/]+")

# Paths shielded by a shell existence check, e.g. `if [ -f tests/x.py ]`.
GUARD_PATTERN = re.compile(r"(?:\[\s*-[fe]|test\s+-[fe])\s+(tests/[\w.\-/]+)")


def strip_inline_comment(line: str) -> str:
    """Drop everything from the first '#' onwards (YAML and shell comment)."""
    hash_index = line.find("#")
    return line if hash_index == -1 else line[:hash_index]


def iter_logical_lines(lines: list[str]):
    """Yield (start_line_number, text) merging backslash continuations.

    Inline comments are stripped per physical line first, so a trailing
    "\\" inside a comment never joins lines.
    """
    buffer = ""
    start = None
    for line_num, raw_line in enumerate(lines, 1):
        line = strip_inline_comment(raw_line).rstrip()
        if start is None:
            start = line_num
        if line.endswith("\\"):
            buffer += line[:-1] + " "
        else:
            yield start, buffer + line
            buffer = ""
            start = None
    if buffer:
        yield start, buffer


def collect_guarded_paths(lines: list[str]) -> set[str]:
    guarded: set[str] = set()
    for line in lines:
        for match in GUARD_PATTERN.finditer(line):
            guarded.add(match.group(1))
    return guarded


def path_exists(token: str) -> bool:
    target = REPO_ROOT / token
    if token.endswith("/"):
        return target.is_dir()
    return target.is_file()


def check_workflow(path: Path) -> list[tuple[int, str]]:
    """Return [(line_number, missing_path), ...] for a single workflow file."""
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except (UnicodeDecodeError, OSError):
        return []

    guarded = collect_guarded_paths(lines)
    violations: list[tuple[int, str]] = []

    for start, logical in iter_logical_lines(lines):
        if "pytest" not in logical:
            continue
        # Only inspect the portion that is (part of) a pytest invocation.
        args = logical[logical.index("pytest") :]
        for match in PATH_TOKEN.finditer(args):
            token = match.group(0)
            # Only validate concrete files / directories.
            if not (token.endswith(".py") or token.endswith("/")):
                continue
            if token in guarded:
                continue
            if not path_exists(token):
                violations.append((start, token))

    return violations


def main() -> int:
    args = [Path(a) for a in sys.argv[1:]]
    if args:
        files = [
            p
            for p in args
            if p.suffix in (".yml", ".yaml") and "workflows" in p.parts
        ]
    else:
        files = sorted(WORKFLOW_DIR.glob("*.y*ml"))

    all_violations: list[tuple[Path, int, str]] = []
    for wf in files:
        for line_num, token in check_workflow(wf):
            all_violations.append((wf, line_num, token))

    if all_violations:
        print("❌ WORKFLOW REFERENCES A NON-EXISTENT TEST PATH")
        print("=" * 60)
        print("A workflow invokes pytest on a path that does not exist.")
        print("pytest exits with code 5 (no tests collected) on a missing")
        print("target, which fails the CI gate for every PR that runs it.")
        print("=" * 60)
        for wf, line_num, token in all_violations:
            try:
                rel = wf.relative_to(REPO_ROOT)
            except ValueError:
                rel = wf
            print(f"\n📄 {rel}")
            print(f"   Line {line_num}: {token}")
        print("\n" + "=" * 60)
        print("FIX:")
        print("- Update the path to the file/directory's new location, or")
        print("- Remove the step if the tests were intentionally deleted, or")
        print("- Guard optional paths with `if [ -f <path> ]; then ...`")
        print("=" * 60)
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())
