#!/usr/bin/env python3
"""release-pretag-docs-gate — PreToolUse gate on irreversible release verbs.

Fires before Bash runs a command that makes a release irreversible — tag
creation (tag-lockstep without --delete), release-tag pushes, publish
dispatch, or release-pack's handoff mode — and requires the release docs
to already be in shape: every lockstep CHANGELOG carries the version's
section (no leftover [Unreleased] content), entry READMEs exist, and the
team release notes are generated at docs/ops/releases/v<X>.md. The actual
checks live in the repo (scripts/release/check-release-docs.mjs) so the
git pre-push hook shares them; this wrapper only classifies the command,
derives the version, and converts the gate's findings into a deny with
the repair commands attached.

Born from the v0.8.4 near-miss (2026-07-17): the promotion step was
skipped, F-N1 fired only after tagging, and the publish shipped tarballs
with [Unreleased] CHANGELOG headers. This gate runs the same checks at
the last reversible moment instead.

Deliberately quiet: non-Bash events, non-release commands, --delete /
--dry runs, and repos without the gate script (consumer installs of this
plugin) all exit 0 with no output.

Usage:
  release-pretag-docs-gate --hook       # PreToolUse mode: event JSON on stdin
  release-pretag-docs-gate selftest     # prove the classifier on fixtures
"""
import json
import os
import re
import subprocess
import sys

GATE_REL = "scripts/release/check-release-docs.mjs"

# Irreversible release verbs -> how to find the version in the command.
VERSION_FLAG = re.compile(r"--version[= ]['\"]?(\d+\.\d+\.\d+)")
# A pushed TAG is a bare token like `v0.8.4`, `web-components-v0.8.4`, or
# the full-form `refs/tags/v0.8.4`; branch refs (`release/v0.8.4`,
# `refs/heads/…`) stay reversible and deliberately don't match, as do tag
# DELETIONS (`:refs/tags/v0.8.4` — leading colon).
TAG_TOKEN = re.compile(r"^(?:([a-z0-9-]+)-)?v(\d+\.\d+\.\d+)$")

# Sentinel: the command is release-irreversible but no single version can
# be derived (e.g. `git push --tags`) — deny with the doctrine message
# rather than fail open.
AMBIGUOUS = "AMBIGUOUS"


def classify(command):
    """Return the target version string when `command` is an irreversible
    release verb, AMBIGUOUS when it is irreversible but version-less
    (fail closed), else None."""
    if not command:
        return None
    if "tag-lockstep.mjs" in command or "dispatch-publish.mjs" in command \
            or ("release-pack.mjs" in command and "--mode handoff" in command):
        # Reversal / preview / selftest invocations only suppress for these
        # script verbs (selftest earned its row on first live fire: a
        # compound running `tag-lockstep.mjs selftest` next to an unrelated
        # --version flag denied — 2026-07-17).
        if "--delete" in command or "--dry" in command or "selftest" in command:
            return None
        m = VERSION_FLAG.search(command)
        return m.group(1) if m else None
    push_m = re.search(r"\bgit\b[^|;&\n]*\bpush\b", command)
    if push_m:
        # Scan ONLY the push command's own segment — a compound command's
        # unrelated text (a PR body mentioning "v0.8.4", a commit message)
        # must not classify a branch push as a tag push (live false
        # positive 2026-07-17: `git push <branch> … && gh pr create
        # --body "…v0.8.4…"` denied).
        segment = re.split(r"[|;&\n]", command[push_m.start():])[0]
        # `git push --dry-run` / `-n` never moves refs.
        if "--dry-run" in segment or re.search(r"\s-n\b", segment):
            return None
        # Blanket tag pushes can carry release tags AND break the
        # one-tag-per-push publish doctrine — fail closed.
        if "--tags" in segment or "--follow-tags" in segment:
            return AMBIGUOUS
        for token in segment.split():
            token = token.strip("\"'")
            if token.startswith("refs/tags/"):
                token = token[len("refs/tags/"):]
            m = TAG_TOKEN.match(token)
            if m:
                return m.group(2)
        return None
    return None


def tag_prefix(command):
    """The per-package prefix of a pushed tag (`adia-plugins-v0.1.0` →
    `adia-plugins`), or None for bare/umbrella tags and non-tag commands.
    The gate script resolves the prefix against the roster and routes a
    class-B (lockstep:false) package to single-package checks (gh#1160 —
    the first live class-B cut hit 15 false lockstep findings without this)."""
    if not command:
        return None
    push_m = re.search(r"\bgit\b[^|;&\n]*\bpush\b", command)
    if not push_m:
        return None
    segment = re.split(r"[|;&\n]", command[push_m.start():])[0]
    for token in segment.split():
        token = token.strip("\"'")
        if token.startswith("refs/tags/"):
            token = token[len("refs/tags/"):]
        m = TAG_TOKEN.match(token)
        if m:
            return m.group(1)
    return None


CD_PREFIX = re.compile(r"""^\s*cd\s+(?:--\s+)?(['"]?)([^'";&|\n]+)\1\s*(?:&&|;)""")


def resolve_start_dir(command, cwd):
    """A command that opens with `cd <path> && …` targets THAT checkout, not
    the session's cwd. Live false FAIL (v0.8.10 handoff, 2026-07-20): the
    session sat pinned in a stale worktree (its own copy of the gate script +
    a branch with no [0.8.10] CHANGELOG sections) while the command cd'd to
    the primary checkout on post-merge main — the gate validated the stale
    worktree and denied a genuinely clean release. Prefer the cd target when
    it is a real directory; otherwise fall back to the event cwd."""
    m = CD_PREFIX.match(command or "")
    if m:
        # Mirror the shell: ~ expands only when the operand is UNQUOTED —
        # `cd "~"` targets a literal ./~ (and typically fails), so resolving
        # $HOME here would let a quoted-tilde push bypass the gate whenever
        # $HOME lacks the gate script (review finding, PR #394).
        target = m.group(2).strip()
        if not m.group(1):
            target = os.path.expanduser(target)
        if not os.path.isabs(target) and cwd:
            target = os.path.join(cwd, target)
        if os.path.isdir(target):
            return target
    return cwd


def find_repo_gate(start):
    """Walk up from `start` to the git root looking for the gate script —
    a release command launched from a subdirectory must not bypass the
    gate just because cwd isn't the repo root."""
    d = os.path.abspath(start or os.getcwd())
    for _ in range(12):
        gate = os.path.join(d, GATE_REL)
        if os.path.isfile(gate):
            return d, gate
        if os.path.isdir(os.path.join(d, ".git")):
            return None, None  # repo root reached, no gate — consumer repo
        parent = os.path.dirname(d)
        if parent == d:
            return None, None
        d = parent
    return None, None


def deny(reason):
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }))
    sys.exit(0)


def hook_mode():
    try:
        event = json.load(sys.stdin)
    except Exception:
        sys.exit(0)
    if event.get("tool_name") != "Bash":
        sys.exit(0)
    command = (event.get("tool_input") or {}).get("command", "")
    version = classify(command)
    if not version:
        sys.exit(0)
    root, gate = find_repo_gate(resolve_start_dir(command, event.get("cwd")))
    if not gate:
        sys.exit(0)  # consumer repo — nothing to enforce here
    if version == AMBIGUOUS:
        deny(
            "release-pretag-docs-gate · blanket tag push in the release monorepo\n"
            "`git push --tags`/`--follow-tags` can carry release tags unchecked AND\n"
            "batched tag pushes drop the publish-on-tag triggers (invariant 4).\n"
            "Push release tags ONE per `git push origin <tag>` instead."
        )
    gate_argv = ["node", gate, "--version", version]
    prefix = tag_prefix(command)
    if prefix:
        gate_argv += ["--tag-prefix", prefix]
    try:
        proc = subprocess.run(
            gate_argv,
            capture_output=True, text=True, cwd=root, timeout=60,
        )
    except (subprocess.TimeoutExpired, OSError) as e:
        # Fail CLOSED on the release path — an erroring gate is not a pass.
        deny(
            "release-pretag-docs-gate · the docs gate itself errored (" + type(e).__name__ + ")\n"
            "Run it directly to diagnose: node " + GATE_REL + " --version " + version
        )
    if proc.returncode == 0:
        sys.exit(0)
    findings = (proc.stderr or proc.stdout).strip()
    deny(
        "release-pretag-docs-gate · release docs not ready for v" + version + "\n"
        + findings + "\n"
        "This command is irreversible (tags/publish) — land the doc fixes via PR first\n"
        "(invariant 3), then re-run. If a finding seems wrong, report it against\n"
        "scripts/release/check-release-docs.mjs; do not bypass the gate."
    )


def selftest():
    cases = [
        ("node .claude/skills/package-release/scripts/tag-lockstep.mjs --version 0.8.4", "0.8.4"),
        ("node .claude/skills/package-release/scripts/tag-lockstep.mjs --version 0.8.4 --delete", None),
        ("node scripts/x/dispatch-publish.mjs --version 1.2.3 --verify-triggered", "1.2.3"),
        ("node release-pack.mjs --mode handoff --version 0.9.0 --yes", "0.9.0"),
        ("node release-pack.mjs --mode cut --version 0.9.0", None),
        ("node release-pack.mjs --mode handoff --version 0.9.0 --dry", None),
        ("git push origin web-components-v0.8.4", "0.8.4"),
        ("git push origin v0.8.4", "0.8.4"),
        ('git -C "$REPO" push origin v0.8.4', "0.8.4"),
        ("git push origin refs/tags/v0.8.4", "0.8.4"),
        ("git push origin :refs/tags/v0.8.4", None),      # tag DELETION is a reversal
        ("git push origin v0.8.4 --dry-run", None),
        ("git push --tags origin", AMBIGUOUS),
        ("git push --follow-tags origin main", AMBIGUOUS),
        ("node tag-lockstep.mjs --version '0.8.4'", "0.8.4"),
        ("git push origin main", None),
        ("git push -u origin release/v0.8.4", None),  # branch push is reversible
        # Compound: branch push + unrelated version text in a later command
        # (PR bodies) must NOT classify as a tag push.
        ('git push -u origin feat/x 2>&1 | tail -1\ngh pr create --body "the four v0.8.4 defects"', None),
        ('git push origin v0.8.4 && echo done', "0.8.4"),
        ("npm run build", None),
        ("", None),
    ]
    for cmd, want in cases:
        got = classify(cmd)
        if got != want:
            print(f"selftest FAIL: classify({cmd!r}) = {got!r}, want {want!r}", file=sys.stderr)
            sys.exit(1)

    # tag_prefix: per-package prefix rides through; umbrella/bare tags and
    # non-tag commands stay None (negative controls).
    prefix_cases = [
        ("git push origin adia-plugins-v0.1.0", "adia-plugins"),
        ("git push origin web-components-v0.8.4", "web-components"),
        ("git push origin v0.8.4", None),
        ("git push origin refs/tags/adia-plugins-v0.1.0", "adia-plugins"),
        ("git push origin main", None),
        ("npm run build", None),
    ]
    for cmd, want in prefix_cases:
        got = tag_prefix(cmd)
        if got != want:
            print(f"selftest FAIL: tag_prefix({cmd!r}) = {got!r}, want {want!r}", file=sys.stderr)
            sys.exit(1)

    # find_repo_gate walks up from a subdirectory (tempdir fixture — the
    # v1 bug checked cwd only, so any subdir cwd bypassed the gate).
    import tempfile
    with tempfile.TemporaryDirectory() as td:
        os.makedirs(os.path.join(td, ".git"))
        os.makedirs(os.path.join(td, "scripts", "release"))
        open(os.path.join(td, GATE_REL), "w").write("// fixture\n")
        sub = os.path.join(td, "packages", "deep", "nested")
        os.makedirs(sub)
        root, gate = find_repo_gate(sub)
        if root != td or not gate:
            print(f"selftest FAIL: find_repo_gate walk-up ({root!r})", file=sys.stderr)
            sys.exit(1)
    with tempfile.TemporaryDirectory() as td:
        os.makedirs(os.path.join(td, ".git"))
        sub = os.path.join(td, "src")
        os.makedirs(sub)
        root, gate = find_repo_gate(sub)
        if gate is not None:
            print("selftest FAIL: consumer repo must yield no gate", file=sys.stderr)
            sys.exit(1)
    # resolve_start_dir: a `cd <path> && …` prefix must win over the session
    # cwd (the v0.8.10 pinned-worktree false FAIL); anything else falls back.
    with tempfile.TemporaryDirectory() as td:
        real = os.path.join(td, "checkout")
        os.makedirs(real)
        cases_dir = [
            (f"cd {real} && node release-pack.mjs --mode handoff --version 0.9.0", "/elsewhere", real),
            (f'cd "{real}" && git push origin v0.9.0', "/elsewhere", real),
            (f"cd {td}/missing && node release-pack.mjs --mode handoff --version 0.9.0", "/elsewhere", "/elsewhere"),
            ("node release-pack.mjs --mode handoff --version 0.9.0", "/elsewhere", "/elsewhere"),
            ("", "/elsewhere", "/elsewhere"),
            # Quoted tilde: bash cd's to a literal ./~, NOT $HOME — the gate
            # must not resolve $HOME either (quoted-tilde bypass, PR #394).
            ('cd "~" && git push origin v0.9.0', "/elsewhere", "/elsewhere"),
        ]
        for cmd, cwd, want in cases_dir:
            got = resolve_start_dir(cmd, cwd)
            if got != want:
                print(f"selftest FAIL: resolve_start_dir({cmd!r}, {cwd!r}) = {got!r}, want {want!r}", file=sys.stderr)
                sys.exit(1)

    print("selftest OK")


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "selftest":
        selftest()
    elif len(sys.argv) > 1 and sys.argv[1] == "--hook":
        hook_mode()
    else:
        print(__doc__)
