#!/usr/bin/env python3
"""Lintel's thaw rehearsal: if every approval arrived tomorrow, would the queue land?

The merge queue has been frozen since 2026-10-14 because no open pull
request carries the reviewer's approval. Every other sweep I own answers a
question about ONE head. This one answers the question that only the merge
seat can ask: taken in order, do the open heads actually compose into a
green `main`, or is there a point in the sequence where they stop?

It merges every open head into a scratch branch IN FILING ORDER, runs the
registry's own `validate` after each one, and reports the first head at
which the sequence stops being mergeable or stops being valid. Nothing is
pushed, nothing is approved, nothing is qualified: a rehearsal is evidence
about the queue's shape, never a substitute for the reviewer's approval on
a head or for my own read of a diff.

    python3 tools/thaw.py --clone <path> [--open <open.json>] [--order number|date]
                          [--only-data] [--json] [--final-tests]

`--open` is the body of `GET /repos/<repo>/pulls?state=open&per_page=100`,
one API call for the whole queue; without it the script refuses rather than
spending sixty-three. `--final-tests` additionally runs `tsc --noEmit` and
`vitest` on the accumulated tree, which costs about a minute.

Reading the output, in the order the rows matter:

  CONFLICT     this head does not merge into the accumulated tree. The row
               names the paths and the EARLIER pr that owns them, because a
               conflict is a property of a pair, not of a head.
  INVALID      the merge succeeded and `validate` went red afterwards. This
               is the interesting class: two heads each green alone whose
               UNION the registry refuses. CI cannot see it by construction
               (one head, one base, one run).
  MERGED       landed clean, registry still valid.

A head that conflicts is re-tried in a second pass against a tree that
does NOT contain the head it collided with, so the report can say whether
the collision is one pair or a genuine fork in the queue.
"""
import argparse
import json
import os
import subprocess
import sys

DATA_PREFIXES = ("registry/agents/", "registry/tools/", "registry/jobs/", "registry/evidence/")
SCRATCH = "lintel-thaw-rehearsal"


class ToolchainBroken(RuntimeError):
    """`validate` could not run at all -- missing node_modules, a missing
    binary. Distinct from `validate` running and refusing, because the two
    want opposite reactions: fix the clone, versus read the diff."""


def git(clone, *args, check=True):
    r = subprocess.run(["git", "-C", clone, *args], capture_output=True, text=True)
    if check and r.returncode != 0:
        raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()[:300]}")
    return r


def classify(clone, base, head):
    """The local mirror of src/scripts/pr-class.ts, including its rename rule."""
    out = git(clone, "diff", "--name-status", "-M", "--diff-filter=ACDMR", f"{base}...{head}").stdout
    changes = []
    for line in out.splitlines():
        if not line.strip():
            continue
        parts = line.split("\t")
        status = parts[0]
        if status.startswith("R"):
            changes.append((parts[1], "D"))
            changes.append((parts[2], "A"))
        else:
            changes.append((parts[1], status[0]))
    if not changes:
        return "code", []
    for path, status in changes:
        if status == "D" or not path.startswith(DATA_PREFIXES):
            return "code", [p for p, _ in changes]
    return "data", [p for p, _ in changes]


def validate(clone, base=None):
    """What the `validate` check actually runs, in its order.

    Bare `validate` is NOT the check. validate.yml runs lint:prose,
    check:schemas, validate (with --base on a pull request, which is where
    the version-bump and timestamp rules live), test and typecheck. A
    rehearsal that runs only the middle one would miss exactly the rules
    that two heads touching the same entry would trip.
    """
    steps = [
        ("lint:prose", ["npm", "run", "--silent", "lint:prose"]),
        ("check:schemas", ["npm", "run", "--silent", "check:schemas"]),
        ("validate", ["node", "src/scripts/validate.ts"] + (["--base", base] if base else [])),
    ]
    for name, cmd in steps:
        r = subprocess.run(cmd, cwd=clone, capture_output=True, text=True)
        if r.returncode != 0:
            both = r.stdout + r.stderr
            tail = both.strip().splitlines()
            # "validate could not run" is not "the registry refuses this union",
            # and reporting the first as the second is the third bug of this
            # family I have fixed here (wake fe48cdd6: a missing committer
            # identity read as 63 CONFLICTs; wake 490cb0b6: an unfetched head
            # read as a green queue, and a clone with no node_modules read as
            # every head INVALID). A broken toolchain is raised, not scored.
            if ("ERR_MODULE_NOT_FOUND" in both or "Cannot find module" in both
                    or "command not found" in both or "ENOENT" in both):
                raise ToolchainBroken(f"{name}: " + " / ".join(tail[-3:]))
            return False, [f"{name}:"] + tail[-4:]
    return True, [(r.stdout + r.stderr).strip().splitlines() or [""]][0][-1:]


def owners_of(paths, merged):
    """Which already-merged pull requests touched any of these paths."""
    hits = []
    for n, info in merged:
        if set(info["paths"]) & set(paths):
            hits.append(n)
    return hits


def rehearse(clone, prs, order, only_data, final_tests):
    # A rehearsal commits locally, so the clone needs an identity. Set it here
    # rather than rely on the container's: a merge that fails for want of a
    # committer name is not a conflict, and the first version of this script
    # read all 63 of those as conflicts.
    git(clone, "config", "user.email", "lintel@rehearsal.invalid")
    git(clone, "config", "user.name", "lintel thaw rehearsal")
    # A plain `git clone` carries the repo's own branches and NOT refs/pull/*,
    # so every head filed from a fork is absent. Wake 490cb0b6: run against a
    # fresh clone, this script scored 63 of 64 heads UNFETCHED, rehearsed the
    # ONE head that happened to live in the repo, and printed a green final
    # tree -- a reassuring wrong answer, which is worse than the alarming one
    # (wake fe48cdd6's 63 phantom CONFLICTs) because nothing about it invites
    # a second look. Fetch the refs here rather than require the caller to
    # remember, and refuse to score the queue if any head is still missing.
    git(clone, "fetch", "-q", "origin", "+refs/pull/*/head:refs/remotes/pull/*", check=False)
    base = git(clone, "rev-parse", "origin/main").stdout.strip()
    git(clone, "checkout", "-q", base)
    git(clone, "branch", "-q", "-D", SCRATCH, check=False)
    git(clone, "checkout", "-q", "-b", SCRATCH, base)

    rows, merged = [], []
    for pr in prs:
        n, head = pr["number"], pr["head"]["sha"]
        try:
            git(clone, "cat-file", "-e", head + "^{commit}")
        except RuntimeError:
            rows.append({"pr": n, "verdict": "UNFETCHED", "head": head[:7], "note": "head not in this clone; fetch the pull refs"})
            continue
        cls, paths = classify(clone, base, head)
        if only_data and cls != "data":
            rows.append({"pr": n, "verdict": "SKIPPED", "class": cls, "head": head[:7], "files": len(paths)})
            continue
        r = git(clone, "merge", "--no-ff", "--no-edit", "-m", f"rehearsal: pr {n} at {head[:7]}", head, check=False)
        if r.returncode != 0:
            conflicted = git(clone, "diff", "--name-only", "--diff-filter=U").stdout.split()
            git(clone, "merge", "--abort", check=False)
            # A merge can fail for reasons that are not a conflict (no committer
            # identity, a dirty tree, a refusal by a hook). Reporting those as
            # CONFLICT is how this script lied to me on its first run: 63 of 63
            # "conflicts" in two seconds, all of them a missing git identity.
            if not conflicted:
                rows.append({
                    "pr": n, "verdict": "MERGE-REFUSED", "class": cls, "head": head[:7],
                    "paths": [], "against": [],
                    "stderr": (r.stdout + r.stderr).strip().splitlines()[:3],
                })
                continue
            rows.append({
                "pr": n, "verdict": "CONFLICT", "class": cls, "head": head[:7],
                "paths": conflicted, "against": owners_of(conflicted, merged),
            })
            continue
        # The faithful simulation: each real merge would move main, so head k+1's
        # own CI would run its change rules against the tree that already holds
        # k..1, not against ae1170b. HEAD~1 is that tree.
        prev = git(clone, "rev-parse", "HEAD~1").stdout.strip()
        ok, tail = validate(clone, base=prev)
        if not ok:
            git(clone, "reset", "-q", "--hard", "HEAD~1")
            rows.append({"pr": n, "verdict": "INVALID", "class": cls, "head": head[:7], "paths": paths, "validate": tail})
            continue
        merged.append((n, {"paths": paths, "class": cls}))
        rows.append({"pr": n, "verdict": "MERGED", "class": cls, "head": head[:7], "files": len(paths)})

    out = {"base": base[:7], "order": order, "only_data": only_data, "rows": rows,
           "merged": [n for n, _ in merged]}

    # Second pass: a head that conflicted may be fine on a tree without its
    # partner, which is the difference between one pair and a forked queue.
    retry = [r for r in rows if r["verdict"] == "CONFLICT"]
    out["retried"] = []
    for r in retry:
        git(clone, "checkout", "-q", base)
        git(clone, "branch", "-q", "-D", SCRATCH + "-solo", check=False)
        git(clone, "checkout", "-q", "-b", SCRATCH + "-solo", base)
        pr = next(p for p in prs if p["number"] == r["pr"])
        m = git(clone, "merge", "--no-ff", "--no-edit", "-m", "solo", pr["head"]["sha"], check=False)
        alone_ok = m.returncode == 0
        ok = validate(clone, base=base)[0] if alone_ok else False
        git(clone, "merge", "--abort", check=False)
        out["retried"].append({"pr": r["pr"], "merges_alone": alone_ok, "valid_alone": ok, "against": r["against"]})
        git(clone, "checkout", "-q", SCRATCH)
        git(clone, "branch", "-q", "-D", SCRATCH + "-solo", check=False)

    if final_tests:
        git(clone, "checkout", "-q", SCRATCH)
        t = subprocess.run(["npx", "tsc", "--noEmit"], cwd=clone, capture_output=True, text=True)
        v = subprocess.run(["npx", "vitest", "run", "--reporter=basic"], cwd=clone, capture_output=True, text=True)
        out["final"] = {
            "tsc_exit": t.returncode,
            "tsc_tail": (t.stdout + t.stderr).strip().splitlines()[-5:],
            "vitest_exit": v.returncode,
            "vitest_tail": [l for l in (v.stdout + v.stderr).splitlines() if "Tests" in l or "passed" in l or "failed" in l][-4:],
        }
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone", required=True)
    ap.add_argument("--open", required=True, help="body of GET /pulls?state=open&per_page=100")
    ap.add_argument("--order", choices=["number", "date"], default="number")
    ap.add_argument("--only-data", action="store_true")
    ap.add_argument("--final-tests", action="store_true")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args()

    if not os.path.isdir(os.path.join(a.clone, ".git")):
        sys.exit(f"REFUSED: {a.clone} is not a git clone. These scripts do not clone for you.")
    prs = json.load(open(a.open))
    if not isinstance(prs, list) or not prs:
        sys.exit("REFUSED: --open did not hold a list of pull requests, so the rehearsal would score an empty queue.")
    prs.sort(key=(lambda p: p["number"]) if a.order == "number" else (lambda p: p["created_at"]))

    try:
        out = rehearse(a.clone, prs, a.order, a.only_data, a.final_tests)
    except ToolchainBroken as e:
        sys.exit(f"REFUSED: `validate` could not RUN in {a.clone}: {e}\n"
                 "That is a broken clone, not an invalid queue. Run `npm ci` there "
                 "and rehearse again; scoring it would report every head INVALID.")

    # A rehearsal of SOME of the queue is not a weaker answer about the queue,
    # it is an answer to a different question, and the number it prints looks
    # exactly like the real one. So an unfetched head is fatal to the report
    # rather than one more row in it.
    unfetched = [r["pr"] for r in out["rows"] if r["verdict"] == "UNFETCHED"]
    if unfetched:
        out["final"] = None
        out["REFUSED"] = (f"{len(unfetched)} of {len(out['rows'])} heads are not in this clone "
                          f"({', '.join('#%d' % n for n in unfetched[:8])}"
                          f"{', ...' if len(unfetched) > 8 else ''}). The fetch of refs/pull/* "
                          "did not bring them, so this run scored a DIFFERENT QUEUE than the "
                          "open one and its verdicts say nothing about the real one.")
        if a.json:
            print(json.dumps(out, indent=2))
        else:
            print("REFUSED: " + out["REFUSED"])
        sys.exit(3)

    if a.json:
        print(json.dumps(out, indent=2))
        return

    counts = {}
    for r in out["rows"]:
        counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
    print(f"thaw rehearsal over {out['base']}, {len(out['rows'])} open heads in {out['order']} order")
    print("  " + ", ".join(f"{v} {k}" for k, v in sorted(counts.items(), key=lambda x: -x[1])))
    for r in out["rows"]:
        if r["verdict"] in ("MERGED", "SKIPPED"):
            continue
        print(f"  #{r['pr']} {r['head']} {r['verdict']} ({r.get('class','?')})")
        if r["verdict"] == "CONFLICT":
            print(f"      paths: {', '.join(r['paths'])}")
            print(f"      against already-merged: {r['against'] or 'none (conflicts with the BASE, not with a queue-mate)'}")
        if r["verdict"] == "INVALID":
            print(f"      validate: {' '.join(r['validate'])}")
            print(f"      files: {', '.join(r['paths'][:6])}")
    for r in out["retried"]:
        print(f"  #{r['pr']} alone: merges={r['merges_alone']} valid={r['valid_alone']} (collided with {r['against'] or 'the base'})")
    if "final" in out:
        f = out["final"]
        print(f"  final tree: tsc exit {f['tsc_exit']}, vitest exit {f['vitest_exit']}")
        for l in f["tsc_tail"] + f["vitest_tail"]:
            print("      " + l)
    print(f"  {len(out['merged'])} of {len(out['rows'])} heads compose into one tree: " + " ".join(f"#{n}" for n in out["merged"]))
    print("  a rehearsal is evidence about the queue, never an approval and never a merge.")


if __name__ == "__main__":
    main()
