#!/usr/bin/env python3
"""
FlyDocs Hook: post-pr-check.py
Triggered: PostToolUse (Bash)
Purpose: Warn when PRs are created without the standard template

Detects direct `gh pr create` or `glab mr create` commands that bypass
the `issues.py pr` dispatcher, and checks if the PR body contains
required sections.

Exit codes:
  0 - Success (optional message output)
  Non-zero - Non-blocking warning
"""

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


REQUIRED_SECTIONS = ["## Summary", "## Test Plan"]

PR_CREATE_PATTERNS = [
    re.compile(r"^g(?:h)\s+pr\s+create"),
    re.compile(r"^glab\s+mr\s+create"),
]

# Pattern for our own dispatcher — don't warn on these
DISPATCHER_PATTERN = re.compile(
    r"python3?\s+.*flydocs-workflow/scripts/issues\.py\s+pr"
)

# Flags whose value IS the body.
INLINE_BODY_FLAGS = {"--body", "-b", "--description"}

# Flags whose value is a PATH to the body. These were folded into the inline
# set by 0d168b6, which meant `check_body()` ran against the path string: every
# `--body-file` PR was warned "missing required sections: ## Summary, ## Test
# Plan" no matter what the file said, because `.flydocs/scratch/pr-1451.md`
# contains neither heading. AGENTS.md tells agents to put long bodies in a file
# and pass the path, so the documented happy path was the one that always
# warned. A hook that cries wolf on correct PRs trains people to ignore hook
# output, so these are resolved by reading the file (FLY-1533).
BODY_FILE_FLAGS = {"--body-file", "-F"}

ALL_BODY_FLAGS = INLINE_BODY_FLAGS | BODY_FILE_FLAGS


class _UnresolvableBody:
    """A body WAS supplied, but its content is not recoverable from the command.

    `--body-file -` reads stdin, which is gone by PostToolUse; a scratch path
    may already be cleaned up. Distinct from None (no body flag at all) so the
    hook can stay silent instead of warning about sections it cannot see.
    """

    def __repr__(self) -> str:  # pragma: no cover - debugging aid
        return "UNRESOLVABLE_BODY"

    def __bool__(self) -> bool:
        return False


UNRESOLVABLE_BODY = _UnresolvableBody()


def is_pr_create_command(command: str) -> bool:
    """Check if command creates a PR/MR directly (not through dispatcher)."""
    if DISPATCHER_PATTERN.search(command):
        return False
    return any(p.search(command) for p in PR_CREATE_PATTERNS)


def _find_body_arg(command: str) -> tuple[str, str] | None:
    """Return (flag, raw value) for the first body-bearing flag, or None.

    Walks the shlex-tokenised command. shlex correctly handles quoting,
    including heredoc bodies of the form `"$(cat <<'EOF' ... EOF)"` — the
    previous regex-only approach matched the first quote it saw and truncated
    heredoc bodies to "$(cat <<", falsely flagging required sections as
    missing on every heredoc PR (0d168b6).
    """
    try:
        tokens = shlex.split(command, posix=True)
    except ValueError:
        return _legacy_find_body_arg(command)

    for i, tok in enumerate(tokens):
        if tok in ALL_BODY_FLAGS and i + 1 < len(tokens):
            return tok, tokens[i + 1]
        for flag in ALL_BODY_FLAGS:
            if tok.startswith(f"{flag}="):
                return flag, tok[len(flag) + 1:]

    return _legacy_find_body_arg(command)


def _legacy_find_body_arg(command: str) -> tuple[str, str] | None:
    """Best-effort regex fallback when shlex tokenisation fails. Heredoc
    runs FIRST so single quotes inside `$(cat <<'EOF' …)` aren't matched
    by the simpler quoted-string pattern."""
    match = re.search(
        r"--body\s+\"\$\(\s*cat\s+<<['\"]?(\w+)['\"]?\n(.+?)\n\1\b",
        command,
        re.DOTALL,
    )
    if match:
        return "--body", match.group(2)
    match = re.search(r'--body\s+["\'](.+?)["\']', command, re.DOTALL)
    if match:
        return "--body", match.group(1)
    match = re.search(r'--description\s+["\'](.+?)["\']', command, re.DOTALL)
    if match:
        return "--description", match.group(1)
    match = re.search(r"--body-file[=\s]+(\S+)", command)
    if match:
        return "--body-file", match.group(1)
    return None


def _read_body_file(path: str, cwd: str | None):
    """Read a `--body-file` target. Returns its text, or UNRESOLVABLE_BODY.

    The path is relative to the shell that ran the command, not to the hook —
    a PR filed from a child repo of a multi-repo workspace passes
    `.flydocs/scratch/…` relative to that repo. The PostToolUse payload
    carries that directory as `cwd`.
    """
    if path == "-":
        return UNRESOLVABLE_BODY  # stdin; content is gone by PostToolUse

    candidate = Path(os.path.expanduser(path))
    if not candidate.is_absolute() and cwd:
        candidate = Path(cwd) / candidate
    try:
        return candidate.read_text(encoding="utf-8")
    except OSError:
        return UNRESOLVABLE_BODY


def extract_body(command: str, cwd: str | None = None):
    """Extract the PR body from command arguments.

    Returns the body text, None when no body flag is present, or
    UNRESOLVABLE_BODY when one is present but its content cannot be read.
    """
    found = _find_body_arg(command)
    if found is None:
        return None

    flag, value = found
    if flag in BODY_FILE_FLAGS:
        return _read_body_file(value, cwd)
    return value


def check_body(body: str) -> list[str]:
    """Check if PR body contains required sections. Returns list of missing sections."""
    missing = []
    for section in REQUIRED_SECTIONS:
        if section.lower() not in body.lower():
            missing.append(section)
    return missing


def main() -> None:
    try:
        input_data = json.loads(sys.stdin.read())
    except (json.JSONDecodeError, ValueError):
        sys.exit(0)

    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})

    if tool_name != "Bash":
        sys.exit(0)

    command = tool_input.get("command", "")

    if not is_pr_create_command(command):
        sys.exit(0)

    # Check if body contains required sections
    body = extract_body(command, input_data.get("cwd"))

    if body is UNRESOLVABLE_BODY:
        sys.exit(0)  # a body was given; say nothing rather than guess

    if body is None:
        print(json.dumps({"hookSpecificOutput": {
            "hookEventName": "PostToolUse",
            "additionalContext": (
                "[PR Template Notice] PR created without using `issues.py pr`. "
                "For consistent PR descriptions with auto-populated issue context, "
                "use: `python3 .claude/skills/flydocs-workflow/scripts/issues.py pr --issue <ref>`"
            ),
        }}))
        sys.exit(0)

    missing = check_body(body)
    if missing:
        sections = ", ".join(missing)
        print(json.dumps({"hookSpecificOutput": {
            "hookEventName": "PostToolUse",
            "additionalContext": (
                f"[PR Template Notice] PR body is missing required sections: {sections}. "
                "Consider using `issues.py pr --issue <ref>` for standard template."
            ),
        }}))

    sys.exit(0)


if __name__ == "__main__":
    main()
