#!/usr/bin/env bash
# CHECK — verifier for the live wizard roster (2026-10-29) extract
# (Extract lane: data extraction and tables.)
#
# Exits 0 iff the committed TSV (`live-wizard-roster-2026-10-29.tsv`) is a
# faithful replay of the three evidence sources it names: the WIZARDS-TABLE.md
# roster rows, the latest `checkins/` filename per wizard, and the newest
# commit per author in git log. No `|| true` / `; exit 0` fallback: the last
# command's exit code is the verdict. The artifact is re-derived with string
# and file operations and does not import extract_live_wizard_roster.py.
#
# THREE REPAIRS (Q146), each measured before and after, in this one file:
#
#   1. THE DETAIL FORMATTER CRASHED ON THE FAILURE IT EXISTS TO REPORT.
#      `"first=%r" % (mism[0] if mism else None)` fed a 3-tuple to a one-slot
#      format, so the first mismatching row raised `TypeError: not all
#      arguments converted during string formatting`. The old file printed
#      four PASS lines and then a traceback: it could never print FAIL, could
#      never print a summary, and its rc 1 was the crash's, not a verdict.
#      A mismatch is now named cell by cell -- row, column, both values.
#
#   2. THE SUBJECT WAS THE CLOCK, NOT THE ARTIFACT. Column 5 was re-derived
#      from `git log` of whatever worktree was on disk, against a snapshot
#      dated 2026-10-29, so the check was green only until the next commit by
#      any wizard and red on every clone after that. The TSV is a dated
#      snapshot and is now replayed at the rev that added it (REV_PIN; that
#      rev is unique -- the file has exactly one touch in its history). The
#      live drift is printed as INFO and never decides the verdict: an
#      artifact graded against HEAD is red by construction, which is Q122's
#      fault class, not a verification.
#
#   3. THE TREE WAS PINNED BY HOST PATH. `repo = Path("/root/troupe-standup")`
#      graded whatever tree sat at that absolute path, on any host. The repo
#      root is now derived from this script's own location, and a repo without
#      the pinned rev reads rc 2 (cannot grade) rather than a false fault.
#
# The evidence is named, not assumed: the TSV's git blob SHA at REV_PIN is
# pinned, so a re-written artifact is a different artifact even at equal bytes
# in the worktree.
#
# Usage:
#   bash CHECK-live-wizard-roster-2026-10-29              # grade the committed TSV
#   bash CHECK-live-wizard-roster-2026-10-29 --selftest   # grade the grader
#
# Exit codes: 0 all checks passed, 1 a check failed, 2 the tree could not be read.

set -u

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
ART="$HERE/live-wizard-roster-2026-10-29.tsv"
REV_PIN="e6ce7836e98592d8b5662b72de36896f2c5a0702"
BLOB_PIN="bb182b3a22e4c97d9eb30024ec41b91638e2d58c"

if [ ! -f "$ART" ]; then
    printf 'CANNOT GRADE: missing %s\n' "$ART"
    exit 2
fi

exec "${PYTHON:-python3}" - "$ROOT" "$ART" "$REV_PIN" "$BLOB_PIN" "$@" <<'PY'
import csv
import hashlib
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

REPO = Path(sys.argv[1])
ART = Path(sys.argv[2])
REV = sys.argv[3]
BLOB_PIN = sys.argv[4]
ARGS = sys.argv[5:]

CANONICAL = [
    "Timmy", "Zephyrus Sparkborn", "Cassian Assayborn", "Merlin", "Mercury",
    "Gemini", "Argus", "Volt", "Sentinel", "Beam",
]
HEADER = ["Wizard", "Model", "Lane", "Latest check-in", "Newest commit"]
COVERED = {"Zephyrus Sparkborn", "Cassian Assayborn", "Merlin", "Mercury",
           "Gemini", "Volt", "Sentinel"}
NO_CHECKIN = "\u2014 (no check-in)"
NO_COMMIT = "\u2014 (no commit)"


class CannotGrade(Exception):
    """The tree does not hold what the replay needs: rc 2, not a fault."""


def git(*args, cwd=None):
    try:
        r = subprocess.run(["git", *args], cwd=str(cwd or REPO),
                           capture_output=True, text=True, timeout=120)
    except (OSError, subprocess.SubprocessError) as exc:
        raise CannotGrade("git %s: %s" % (" ".join(args), exc))
    if r.returncode != 0:
        first = (r.stderr.strip().splitlines() or ["(no stderr)"])[0]
        raise CannotGrade("git %s exited %d: %s"
                         % (" ".join(args), r.returncode, first))
    return r.stdout


def derive(repo, rev):
    """-> (roster{name: (model, lane)}, checkins{name: date}, best{name: (date, sha)}).

    Every source is read AT `rev`, never from the worktree: the artifact is a
    dated snapshot, and only a rev-pinned replay can be re-derived forever.
    """
    table = git("show", rev + ":WIZARDS-TABLE.md", cwd=repo).splitlines()
    roster = {}
    for line in table:
        if not line.startswith("|"):
            continue
        cells = [c.strip() for c in line.split("|") if c.strip()]
        if len(cells) == 5 and cells[0] != "Wizard":
            roster[cells[0]] = (cells[1], cells[2])  # model, lane

    slug2w = {"zephyrus": "Zephyrus Sparkborn", "merlin": "Merlin",
              "mercury": "Mercury", "gemini": "Gemini", "volt": "Volt",
              "sentinel": "Sentinel"}
    checkins = {}
    for path in git("ls-tree", "--name-only", rev, "checkins/",
                    cwd=repo).splitlines():
        name = path.rsplit("/", 1)[-1]
        if not name.endswith(".md"):
            continue
        parts = name[:-3].split("-", 3)
        if len(parts) < 4:
            continue
        date, slug = "-".join(parts[:3]), parts[3]
        if slug.startswith("cassian"):
            w = "Cassian Assayborn"
        elif slug in slug2w:
            w = slug2w[slug]
        else:
            continue
        if w not in checkins or date > checkins[w]:
            checkins[w] = date

    alias = {w.lower(): w for w in roster}
    alias["zephyrus-sparkborn"] = "Zephyrus Sparkborn"
    best = {}
    for line in git("log", rev, "--format=%an%x00%H%x00%ad", "--date=short",
                    cwd=repo).splitlines():
        if "\x00" not in line:
            continue
        author, sha, date = line.split("\x00", 2)
        w = alias.get(author.lower())
        if w is None or w not in roster:
            continue
        if w not in best or date > best[w][0]:
            best[w] = (date, sha)
    return roster, checkins, best


def expected_rows(roster, checkins, best, rev):
    rows = []
    for w in CANONICAL:
        if w not in roster:
            raise CannotGrade("roster at %s has no row for %s" % (rev[:12], w))
        model, lane = roster[w]
        rows.append([w, model, lane,
                     checkins.get(w, NO_CHECKIN),
                     (best[w][1] + " " + best[w][0]) if w in best else NO_COMMIT])
    return rows


def read_rows(path):
    try:
        with open(path, encoding="utf-8", newline="") as fh:
            rows = list(csv.reader(fh, delimiter="\t"))
    except OSError as exc:
        raise CannotGrade("cannot read %s: %s" % (path, exc))
    if not rows:
        raise CannotGrade("%s is empty" % path)
    return rows[0], rows[1:]


def blob_sha(repo, rev, relpath):
    try:
        return git("rev-parse", "%s:%s" % (rev, relpath), cwd=repo).strip() or None
    except CannotGrade:
        return None


def grade(repo, art, rev, blob_pin, live=False):
    """-> (fails, lines). Raises CannotGrade when the tree is short."""
    fails, out = [], []

    def check(name, cond, detail=""):
        out.append(("PASS " if cond else "FAIL ") + name
                   + ((" " + detail) if detail else ""))
        if not cond:
            fails.append(name)

    roster, checkins, best = derive(repo, rev)
    check("roster: all 10 canonical wizards present at the pinned rev",
          set(roster) >= set(CANONICAL),
          "rows=%d extra=%s" % (len(roster), sorted(set(roster) - set(CANONICAL))))
    check("checkins: covered wizards at the pinned rev",
          set(checkins) == COVERED, repr(sorted(checkins)))

    header, committed = read_rows(art)
    check("tsv header", header == HEADER, repr(header))
    check("row count", len(committed) == len(CANONICAL),
          "committed=%d canonical=%d" % (len(committed), len(CANONICAL)))

    try:
        relpath = str(Path(art).resolve().relative_to(Path(repo).resolve()))
    except ValueError:
        relpath = None
    if relpath is None:
        out.append("SKIP artifact blob %s is outside the graded tree; "
                   "no blob to name" % art)
    else:
        got = blob_sha(repo, rev, relpath)
        check("artifact blob is the pinned evidence", got == blob_pin,
              "blob=%s pinned=%s" % (got, blob_pin))

    expected = expected_rows(roster, checkins, best, rev)
    mism = [(k, c, g) for k, (c, g) in enumerate(zip(committed, expected))
            if c != g]
    detail = ""
    if mism:
        k, c, g = mism[0]
        cells = ["col%d %s: committed=%r re-derived=%r"
                 % (i, HEADER[i] if i < len(HEADER) else "?", a, b)
                 for i, (a, b) in enumerate(zip(c, g)) if a != b]
        detail = "rows=%d first-row=%d %s" % (len(mism), k, " | ".join(cells))
    check("rows identical to independent re-derive at the pinned rev",
          not mism and len(committed) == len(expected), detail)

    if live:
        try:
            lr, lc, lb = derive(repo, "HEAD")
            moved = sum(1 for c, g in zip(committed,
                                          expected_rows(lr, lc, lb, "HEAD"))
                        if c[4] != g[4])
            out.append("INFO live-drift %d of %d row(s) name a newer commit at "
                       "HEAD than the snapshot records (informational, does not "
                       "decide the verdict)" % (moved, len(CANONICAL)))
        except CannotGrade as exc:
            out.append("INFO live-drift unavailable: %s" % exc)
    return fails, out


def run_grade(repo, art, rev, blob_pin, live):
    try:
        fails, out = grade(repo, art, rev, blob_pin, live=live)
    except CannotGrade as exc:
        print("CANNOT GRADE: %s" % exc)
        return 2
    for line in out:
        print(line)
    if fails:
        print("\n%d CHECK(S) FAILED" % len(fails))
        return 1
    print("\nALL CHECKS PASSED -- 10 wizard rows replayed from WIZARDS-TABLE.md "
          "+ checkins/ + git log at %s" % rev[:12])
    return 0


def grade_text(repo, art, rev, blob_pin):
    """-> (rc, text) without printing, for the selftest."""
    try:
        fails, out = grade(repo, art, rev, blob_pin)
    except CannotGrade as exc:
        return 2, "CANNOT GRADE: %s" % exc
    text = "\n".join(out)
    if fails:
        return 1, text + "\n%d CHECK(S) FAILED" % len(fails)
    return 0, text + "\nALL CHECKS PASSED"


def write_rows(path, rows):
    with open(path, "w", encoding="utf-8", newline="") as fh:
        csv.writer(fh, delimiter="\t", lineterminator="\n").writerows(rows)
    return Path(path)


def selftest():
    """Nine cells: one green control, seven mutants, and the formatter."""
    cases = []

    def cell(name, art, rev, want_rc, want=None, blob_pin=None):
        rc, text = grade_text(REPO, art, rev, blob_pin)
        ok = rc == want_rc
        why = "rc=%d want=%d" % (rc, want_rc)
        if ok and want is not None and want not in text:
            ok, why = False, "rc matched, output lacks %r" % want
        if ok and "Traceback" in text:
            ok, why = False, "output carries a traceback"
        cases.append((name, ok, why if not ok else "rc=%d" % rc))

    tmp = Path(tempfile.mkdtemp(prefix="roster-selftest-"))
    try:
        with open(ART, encoding="utf-8", newline="") as fh:
            base_rows = list(csv.reader(fh, delimiter="\t"))
        control = write_rows(tmp / "control.tsv", base_rows)
        cell("green-control", control, REV, 0, "ALL CHECKS PASSED")

        def mutant(name, row, col, value):
            rows = [list(r) for r in base_rows]
            rows[row + 1][col] = value
            return write_rows(tmp / (name + ".tsv"), rows)

        cell("mutant-model", mutant("model", 0, 1, "GARBAGE MODEL"), REV, 1,
             "col1 Model")
        cell("mutant-lane", mutant("lane", 7, 2, "GARBAGE LANE"), REV, 1,
             "col2 Lane")
        cell("mutant-commit", mutant("commit", 3, 4, "deadbeef 2020-01-01"),
             REV, 1, "col4 Newest commit")
        cell("mutant-dropped-row",
             write_rows(tmp / "dropped.tsv", base_rows[:-1]), REV, 1,
             "FAIL row count")
        cell("live-head-is-not-the-snapshot", control, "HEAD", 1,
             "FAIL rows identical")
        cell("absent-rev-is-cannot-grade", control, "0" * 40, 2, "CANNOT GRADE")

        # The in-repo artifact under a moved pin: the blob leg must catch it.
        cell("real-tree-blob-pin", ART, REV, 1, "artifact blob is the pinned evidence",
             blob_pin="0" * 40)
        # ... and the same artifact under the real pin is the green-control
        # above only because it is a copy without a blob; this one carries it.
        cell("real-tree-blob-passes", ART, REV, 0, "PASS artifact blob",
             blob_pin=BLOB_PIN)

        # The formatter: the old line raised on the first mismatch and printed
        # nothing. Both values must appear, in a FAIL line, with no traceback.
        rows = [list(r) for r in base_rows]
        rows[1][1] = "X"
        p = write_rows(tmp / "fmt.tsv", rows)
        rc, text = grade_text(REPO, p, REV, None)
        ok = (rc == 1 and "col1 Model" in text and "committed='X'" in text
              and "re-derived='Hermes / DeepSeek'" in text
              and "Traceback" not in text)
        cases.append(("formatter-names-the-cell", ok, "rc=%d" % rc))
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    bad = [c for c in cases if not c[1]]
    for name, ok, detail in cases:
        print(("PASS " if ok else "FAIL ") + name + " " + detail)
    print("\nselftest: %d cell(s), %d passed, %d failed"
          % (len(cases), len(cases) - len(bad), len(bad)))
    return 0 if not bad else 1


if "--selftest" in ARGS:
    sys.exit(selftest())
if ARGS:
    print("usage: %s [--selftest]" % sys.argv[0])
    sys.exit(2)
sys.exit(run_grade(REPO, ART, REV, BLOB_PIN, live=True))
PY
