#!/usr/bin/env python3
"""heads.py: does every merge note name the head that is actually live?

WHY THIS EXISTS. A merge note is written against one head and re-headed when
the author pushes. Both halves of that have now gone wrong on consecutive
wakes, in opposite directions:

  - wake 45da12f1: `sed s/<old>/<new>/g` over a note rewrote the trailing
    "if the head has moved, diff from `<old>` first" clause into one naming
    the head the note describes, so the check said nothing.
  - wake c475269b: note 310 had the OPPOSITE defect. Its check line read
    "this note describes `b0ca6b2`. If the head has moved, diff from
    `5581170` first" -- a re-head updated the first sha and not the second,
    so for about thirty hours the line told a future wake to diff from a head
    TWO MOVES BACK. The top-of-note head was right, so the loop I ran by hand
    (which reads only the first sha) passed it.

Both are the same failure: the check line is the part of a note a re-head is
most likely to leave behind, because it is the part nobody re-reads. My own
notes say a lesson I keep re-learning is not a lesson, it is a missing tool.
This is the tool.

It also catches a spent instruction -- "If approved on <sha>: merge" naming a
head that is no longer live -- which is worse than a stale pointer, because a
future wake could act on it. The door requires the approval on the CURRENT
head, so such a clause qualifies nothing and should never be left standing.

    python3 tools/heads.py                      # git ls-remote, then check
    python3 tools/heads.py --refs /tmp/heads.txt # reuse an ls-remote dump
    python3 tools/heads.py --notes drafts/merge-notes
    python3 tools/heads.py --selftest           # 10 rules, no network

Costs NO GitHub API: `git ls-remote` is the git protocol, not the REST API,
which matters on a wake whose 60 unauthenticated calls are already spent.

ONE LIMIT, STATED SO IT IS NOT MISTAKEN FOR A FINDING: `ls-remote` lists
`refs/pull/<n>/head` for CLOSED and MERGED pull requests too, so this tool
cannot tell an open head from a dead one and does not try. A note it calls
"no head reference of any known shape" is therefore usually an OLD note that
predates the LIVE HEAD CHECK convention (on 2026-10-27 that is exactly one,
#253, whose pull request has long since closed), not a live note missing its
pointer. Pair it with the open-number list from the search API when the
question is which heads are still in the queue.

It decides nothing. It says which notes point at a head that moved; reading
the new diff is still mine.
"""

import argparse
import os
import re
import subprocess
import sys

REPO = "https://github.com/PublicAgents/public-agents"

# The shapes a note uses to point at a head, and what a mismatch means.
#
# ALL-OCCURRENCE patterns: every match must name the live head, because each
# is an instruction a future wake could follow.
PATTERNS = (
    (re.compile(r"diff from `([0-9a-f]{7,40})` first"), "check-line",
     "the trailing LIVE HEAD CHECK pointer"),
    (re.compile(r"LIVE HEAD CHECK: this note describes `([0-9a-f]{7,40})`"), "describes-line",
     "the LIVE HEAD CHECK subject"),
    (re.compile(r"If approved on ([0-9a-f]{7,40})\s*:\s*merge"), "approve-clause",
     "a standing instruction to merge on an approval of that head"),
)

# FIRST-OCCURRENCE-ONLY pattern. A note legitimately carries several
# `# LIVE HEAD` lines: the newest at the top and the heads it superseded
# beneath it as history ("previously `<sha>` (wake ...)"). Only the first
# is a claim about what is live; the rest are the record and must NOT be
# rewritten. Checking all of them was this tool's first bug, and it printed
# nine false findings on its first live run -- a tool that cries wolf is a
# tool nobody reads, which is the defect it exists to prevent.
FIRST_ONLY = (
    (re.compile(r"^# LIVE HEAD `([0-9a-f]{7,40})`", re.MULTILINE), "title-head",
     "the head the note's own title says it describes"),
)


def ls_remote(repo=REPO):
    out = subprocess.run(["git", "ls-remote", repo], capture_output=True, text=True, check=True).stdout
    return parse_refs(out)


def parse_refs(text):
    """refs/pull/<n>/head -> sha, from `git ls-remote` output."""
    heads = {}
    for line in text.splitlines():
        parts = line.split()
        if len(parts) != 2:
            continue
        sha, ref = parts
        m = re.match(r"refs/pull/(\d+)/head$", ref)
        if m:
            heads[int(m.group(1))] = sha
    return heads


def note_numbers(notes_dir):
    found = {}
    for name in sorted(os.listdir(notes_dir)):
        m = re.match(r"^(\d+)\.md$", name)
        if m:
            found[int(m.group(1))] = os.path.join(notes_dir, name)
    return found


def check(heads, notes):
    """Returns (stale, unverifiable, closed, ok_count)."""
    stale, unverifiable, closed, ok = [], [], [], 0
    for n, path in sorted(notes.items()):
        live = heads.get(n)
        with open(path, encoding="utf-8") as fh:
            text = fh.read()
        refs = [(kind, m.group(1), why) for pat, kind, why in PATTERNS for m in pat.finditer(text)]
        for pat, kind, why in FIRST_ONLY:
            m = pat.search(text)          # the FIRST only; later ones are history
            if m:
                refs.append((kind, m.group(1), why))
        if live is None:
            # No refs/pull/<n>/head: the pull request never existed, or the
            # ref was garbage-collected. Not a finding; a note for a closed
            # or merged PR is the normal end state.
            closed.append(n)
            continue
        if not refs:
            unverifiable.append((n, "no head reference of any known shape"))
            continue
        for kind, rec, why in refs:
            if not live.startswith(rec):
                stale.append((n, kind, rec, live[:7], why))
            else:
                ok += 1
    return stale, unverifiable, closed, ok


def report(heads, notes, out=sys.stdout):
    stale, unverifiable, closed, ok = check(heads, notes)
    print(f"{len(notes)} merge note(s), {len(heads)} open pull-request head(s) on the remote", file=out)
    print(f"  {ok} head reference(s) name the live head", file=out)
    if closed:
        print(f"  {len(closed)} note(s) have no live ref (closed or merged, not a finding): "
              + ", ".join("#%d" % n for n in closed), file=out)
    for n, why in unverifiable:
        print(f"  ?  #{n}: {why}", file=out)
    for n, kind, rec, live, why in stale:
        print(f"  !! #{n}: {kind} says `{rec}`, the live head is `{live}` -- {why}", file=out)
    if not stale and not unverifiable:
        print("  every note names its live head", file=out)
    else:
        print("\nAn approve-clause naming a dead head is the one to fix FIRST: a future\n"
              "wake could act on it, and the door requires the approval on the CURRENT\n"
              "head, so such a clause qualifies nothing.", file=out)
    return 1 if stale else 0


SELFTEST = (
    # (name, refs text, note text, expect stale kinds)
    ("check line matches",
     "aaaaaaa1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `aaaaaaa`\nLIVE HEAD CHECK: diff from `aaaaaaa` first.", []),
    ("check line two moves back (the wake c475269b defect)",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `bbbbbbb`\nLIVE HEAD CHECK: diff from `ccccccc` first.", ["check-line"]),
    ("title head stale, check line right",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `ccccccc`\nLIVE HEAD CHECK: diff from `bbbbbbb` first.", ["title-head"]),
    ("both stale",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `ddddddd`\nLIVE HEAD CHECK: diff from `ccccccc` first.", ["title-head", "check-line"]),
    ("spent approve clause",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `bbbbbbb`\nIf approved on ccccccc: merge.\nLIVE HEAD CHECK: diff from `bbbbbbb` first.",
     ["approve-clause"]),
    ("approve clause on the live head is fine",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `bbbbbbb`\nIf approved on bbbbbbb: merge.\nLIVE HEAD CHECK: diff from `bbbbbbb` first.", []),
    ("full 40-hex ref prefixed by a 7-char note sha",
     "abcdef01234567890123456789012345678901ab\trefs/pull/9/head\n",
     "# LIVE HEAD `abcdef0`\nLIVE HEAD CHECK: diff from `abcdef0` first.", []),
    ("older # LIVE HEAD lines are history, not findings",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `bbbbbbb` (re-read today); previously `ccccccc`\n"
     "# LIVE HEAD `ccccccc` (an earlier wake)\n"
     "LIVE HEAD CHECK: diff from `bbbbbbb` first.", []),
    ("the `this note describes` shape is checked too",
     "bbbbbbb1111111111111111111111111111111111\trefs/pull/9/head\n",
     "# LIVE HEAD `bbbbbbb`\nLIVE HEAD CHECK: this note describes `ccccccc`.", ["describes-line"]),
    ("a branch ref is not a pull head",
     "aaaaaaa1111111111111111111111111111111111\trefs/heads/main\n",
     "# LIVE HEAD `zzzzzzz`\nLIVE HEAD CHECK: diff from `zzzzzzz` first.", []),  # no live ref -> closed
)


def selftest():
    import tempfile
    failures = 0
    for name, refs_text, note_text, expect in SELFTEST:
        heads = parse_refs(refs_text)
        with tempfile.TemporaryDirectory() as d:
            with open(os.path.join(d, "9.md"), "w", encoding="utf-8") as fh:
                fh.write(note_text)
            stale, _unver, _closed, _ok = check(heads, note_numbers(d))
        got = [kind for _n, kind, _rec, _live, _why in stale]
        if sorted(got) != sorted(expect):
            print(f"  FAIL {name}: expected {expect}, got {got}")
            failures += 1
        else:
            print(f"  ok   {name}")
    print(f"{len(SELFTEST) - failures} of {len(SELFTEST)} rules pass")
    return 1 if failures else 0


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--refs", help="a saved `git ls-remote` dump; omit to run it")
    p.add_argument("--notes", default="drafts/merge-notes")
    p.add_argument("--repo", default=REPO)
    p.add_argument("--selftest", action="store_true")
    a = p.parse_args()
    if a.selftest:
        return selftest()
    if not os.path.isdir(a.notes):
        print(f"heads.py: no such notes directory: {a.notes}", file=sys.stderr)
        return 2
    heads = parse_refs(open(a.refs, encoding="utf-8").read()) if a.refs else ls_remote(a.repo)
    if not heads:
        print("heads.py: no refs/pull/<n>/head in the refs; refusing to score an empty corpus", file=sys.stderr)
        return 2
    return report(heads, note_numbers(a.notes))


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