#!/usr/bin/env python
"""PRD Plugin test-scope guard (PreToolUse hook).

The rule this enforces, in the owner's words: **full tests only before commits,
scoped tests for everything else.**

An agent that has changed two files does not learn anything by re-running a
1160-test suite; it burns minutes of wall clock producing no output, which reads
as nothing happening. `scripts/prd_test_scope.py` already builds a scoped plan,
but it is off in every shipped profile and answers `test_scope_disabled` when
asked — so consulting it teaches nothing, and not consulting it costs nothing.
A planner the agent must choose to call is not a guard. This is, because
PreToolUse fires whether or not the agent thought about it.

Fail-open by design, exactly like the pre-commit gate:
- Only inspects Bash commands that are a WHOLE-suite invocation.
- Allows whenever a commit is imminent (something is staged), the change set is
  broad, a core path moved, nothing changed at all, or no scoped command can be
  named. Refusing in those cases would leave the agent unable to verify.
- Any internal error, missing git, or unreadable payload resolves to ALLOW.
- `PRD_FULL_SUITE=1` in the command is an explicit, greppable override.
"""

import json
import os
import re
import subprocess
import sys
from pathlib import Path

# A whole-suite invocation: unittest discovery, bare pytest, or `npm test`.
# `unittest tests.test_x` and `pytest tests/test_x.py` name a target and are
# scoped, so they must not match.
FULL_SUITE_RE = re.compile(
    r"(?:\bunittest\b[^|&;\n]*\bdiscover\b)"
    r"|(?:\bpytest\b(?![^|&;\n]*(?:/|\\|::|\.py\b)))"
    r"|(?:\bnpm\b[^|&;\n]*\brun\s+test\b)"
    r"|(?:\bnpm\s+test\b)"
)
OVERRIDE = "PRD_FULL_SUITE=1"

# Change any of these and scoped tests prove nothing: the blast radius really is
# the whole tree.
CORE_PATHS = (
    "package.json", "package-lock.json", "pyproject.toml",
    ".github/workflows/", "templates/config.json", "mcp/server.cjs",
)
# Above this many changed files, picking modules stops being cheaper than
# running everything.
BROAD_CHANGE_FILES = 12


def is_full_suite(command):
    """Whether this command runs the WHOLE suite.

    The override does not make a command "not a full suite" - it only waives the
    block. Reporting it as scoped here would also suppress the upfront notice,
    and opting out of the block is not opting out of being told.
    """
    if not command:
        return False
    return bool(FULL_SUITE_RE.search(command))


def _git(root, *args):
    try:
        out = subprocess.run(("git",) + args, cwd=str(root), capture_output=True,
                             text=True, timeout=15, stdin=subprocess.DEVNULL)
    except (OSError, subprocess.SubprocessError):
        return []
    if out.returncode != 0:
        return []
    return [line.strip().replace("\\", "/") for line in out.stdout.splitlines() if line.strip()]


def changed_files(root):
    return _git(root, "diff", "--name-only", "HEAD")


def staged_files(root):
    return _git(root, "diff", "--name-only", "--cached")


def scoped_command(changed, root):
    """The `python -m unittest ...` command covering `changed`, or "".

    A source file maps to `tests/test_<stem>.py` when that module exists; a
    changed test file maps to itself. Anything we cannot map contributes
    nothing, and if nothing maps the caller allows the full run rather than
    leaving the agent with no way to verify.
    """
    modules = []
    tests_dir = Path(root) / "tests"
    for rel in changed:
        name = Path(rel).name
        if rel.startswith("tests/") and name.startswith("test_") and name.endswith(".py"):
            module = name[:-3]
        else:
            module = "test_" + Path(rel).stem
        if module in modules:
            continue
        if (tests_dir / f"{module}.py").is_file():
            modules.append(module)
    if not modules:
        return ""
    return "python -m unittest " + " ".join(f"tests.{m}" for m in modules) + " -q"


# Prose, not program. Editing these cannot change what a unit test asserts, so
# staging them is not a reason to run the whole suite - only the tests that
# check the docs themselves.
DOC_SUFFIXES = (".md", ".mdx", ".txt", ".rst")
DOC_TESTS = "python -m unittest tests.test_skills_manifest tests.test_workflow_chml_audit -q"


def _is_doc(rel):
    return rel.lower().endswith(DOC_SUFFIXES)


def decide(changed, staged, core_paths, root="."):
    """(allow, reason, suggested_command). Never raises."""
    if staged:
        # "Before a commit" was too coarse. A SKILL.md edit stages files, which
        # waved through a seven-minute run that could not exercise the change.
        if all(_is_doc(rel) for rel in staged):
            return False, ("the staged change is documentation only, so no source is under "
                           "test"), DOC_TESTS
        return True, "a commit is imminent (staged changes present)", ""
    if not changed:
        return True, "nothing has changed", ""
    for rel in changed:
        for core in core_paths:
            if rel == core or rel.startswith(core):
                return True, f"core path changed ({rel})", ""
    if len(changed) > BROAD_CHANGE_FILES:
        return True, f"broad change ({len(changed)} files)", ""
    suggestion = scoped_command(changed, root)
    if not suggestion:
        return True, "no scoped command covers these files", ""
    return False, (f"{len(changed)} file(s) changed and nothing is staged; run the scoped "
                   f"tests instead"), suggestion


def notice(allow, reason, pre_commit):
    """The line printed BEFORE an allowed full run starts.

    Silence was half the original complaint: a seven-minute command with no
    output reads as a hung agent. ASCII only, for the same reason the refusal
    is (REQ-159).
    """
    if not allow:
        return ""
    if pre_commit:
        return ("[PRD Plugin] Starting the FULL test suite - the sanctioned pre-commit run. "
                "Expect several minutes with no output.\n")
    return ("[PRD Plugin] Starting the FULL test suite, and this is not a pre-commit run. "
            f"Allowed because: {reason}. Expect several minutes with no output. "
            "Scoped tests are the norm mid-work.\n")


def main():
    try:
        payload = json.loads(sys.stdin.read() or "{}")
    except (ValueError, OSError):
        return 0
    if not isinstance(payload, dict):
        return 0
    if payload.get("tool_name") not in ("Bash", "bash", "shell"):
        return 0
    command = (payload.get("tool_input") or {}).get("command", "")
    if not is_full_suite(command):
        return 0

    root = payload.get("cwd") or os.getcwd()
    if OVERRIDE in command:
        sys.stdout.write(notice(True, f"explicit {OVERRIDE} override", pre_commit=False))
        return 0
    try:
        staged = staged_files(root)
        allow, reason, suggestion = decide(
            changed_files(root), staged, CORE_PATHS, root=root)
    except Exception:
        return 0
    if allow:
        # stdout, not stderr: the dispatcher forwards stdout on every path and
        # only surfaces stderr when a handler blocks.
        sys.stdout.write(notice(allow, reason, pre_commit=bool(staged)))
        return 0
    # ASCII only: hook output lands on whatever console the host has, and a
    # cp1252 one turns an em dash into mojibake (REQ-159, demonstrated by this
    # very message before it was changed).
    sys.stderr.write(
        "PRD Plugin: full suite blocked. Full tests run before a COMMIT, scoped tests "
        f"everywhere else.\n  Why: {reason}.\n  Run instead: {suggestion}\n"
        f"  Stage your work first, or add {OVERRIDE} to the command if you genuinely "
        "need the whole suite now.\n")
    return 2


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