#!/usr/bin/env python3
"""Validate full verbose transcripts, make evidence sheets, optionally upload."""
import argparse
from collections import Counter
import hashlib
import json
from pathlib import Path
import re
import subprocess

parser = argparse.ArgumentParser()
parser.add_argument("run", type=Path)
parser.add_argument("--upload", action="store_true")
args = parser.parse_args()
run = args.run.resolve()
scratch = Path(__file__).resolve().parent
manifest = json.loads((run / "manifest.json").read_text())
assert len(manifest) == 12
names = {
    "S1": "server settings > saves through a symlinked settings file without replacing the link",
    "S2": "AgentSessionScanner > scan > returns the imported project ID through a realpath alias",
    "S3": "AgentSessionScanner > scan > excludes sandboxes reached through a symlink into the worktrees dir",
    "O1": "readOpenCodeUsage > never follows symlinks in the legacy OpenCode store",
    "O2": "openCodeUsageReader > scans canonical instance roots once, including XDG and home defaults",
    "A1": "acpPermissionDisposition > denies mutations through workspace symlinks that escape the writable roots",
    "A2": "acpPermissionDisposition > allows existing and new files beneath canonical writable roots",
    "E1": "AgentSessionScanner > recentThreads > rechecks claudeAgent snapshot cwd after replacement with symlink alias",
    "E2": "AgentSessionScanner > recentThreads > rechecks codex snapshot cwd after replacement with symlink alias",
}
guarded = list(names)[:7]
rows = {}
for entry in manifest:
    row = entry["row"]
    text = (run / entry["transcript"]).read_text()
    assert text.startswith("$ git rev-parse HEAD\n" + entry["sha"] + "\n")
    expected_node = "v24.22.1" if row[0] == "S" else "v26.12.1"
    assert expected_node + "\n" in text and " RUN  v5.0.5 " in text
    assert "e364864529ef79a816183b75d210f52b7fc134bc220b318de3dcaddf93cecdde" in text
    assert ("NODE_OPTIONS=--require=" if entry["preload"] else "NODE_OPTIONS=<unset>") in text
    assert text.endswith(f"exit code: {entry['exit_code']}\n")
    assert "Unhandled Error" not in text and "Unhandled Rejection" not in text
    statuses = {}
    for marker, name in re.findall(r"^ ([✓×↓]) (.+)$", text, re.M):
        name = re.sub(r" \d+(?:\.\d+)?(?:ms|s)$", "", name)
        assert name not in statuses, (row, name)
        statuses[name] = {"✓": "P", "×": "F", "↓": "S"}[marker]
    count = Counter(statuses.values())
    expected_total = {"S": 100, "O": 5, "A": 13}[row[0]]
    expected = Counter(P=expected_total)
    if row.endswith("-E"):
        changed_count = {"S": 3, "O": 2, "A": 2}[row[0]]
        control_count = 2 if row[0] == "S" else 0
        expected = Counter(P=expected_total - changed_count - control_count)
        expected["F" if row[2] == "P" else "S"] += changed_count
        expected["S"] += control_count
    assert count == +expected, (row, count, expected)
    assert entry["exit_code"] == (1 if row.endswith("-P-E") else 0)
    summary = re.search(r"^      Tests  (.+)$", text, re.M).group(1)
    for key, word in [("P", "passed"), ("F", "failed"), ("S", "skipped")]:
        observed = re.search(rf"(\d+) {word}", summary)
        assert (int(observed.group(1)) if observed else 0) == count[key]
    named = {}
    for identifier, suffix in names.items():
        matches = [status for name, status in statuses.items() if name.endswith(" > " + suffix)]
        assert len(matches) == (1 if identifier[0] == row[0] or identifier[0] == "E" and row[0] == "S" else 0), (row, identifier)
        named[identifier] = matches[0] if matches else "—"
    entry.update(counts=dict(count), statuses=statuses, named=named, summary=summary)
    rows[row] = entry
    print(row, summary, "exit", entry["exit_code"], named)

changes = {}
for group in "SOA":
    for capability in "NE":
        before = rows[f"{group}-P-{capability}"]["statuses"]
        after = rows[f"{group}-H-{capability}"]["statuses"]
        assert before.keys() == after.keys()
        diff = {name: [status, after[name]] for name, status in before.items() if status != after[name]}
        assert len(diff) == (0 if capability == "N" else {"S": 3, "O": 2, "A": 2}[group])
        assert all(any(name.endswith(" > " + names[key]) for key in guarded) and transition == ["F", "S"] for name, transition in diff.items())
        changes[f"{group}-{capability}"] = diff
(run / "observed-outcomes.json").write_text(json.dumps({"rows": rows, "changes": changes}, indent=2) + "\n")

links = {}
upload_log = scratch / "upload-output.txt"
def upload(paths):
    result = subprocess.run(["ghdrop", "upload", "-m", *map(str, paths)], check=True, stdout=subprocess.PIPE, text=True)
    print(result.stdout, end="", flush=True)
    with upload_log.open("a") as output:
        output.write(result.stdout)
    for line in result.stdout.splitlines():
        match = re.fullmatch(r"!?\[([^]]+)\]\((https://[^)]+)\)", line)
        if match:
            links[match[1]] = match[2]
    for path in paths:
        assert path.name in links, path
    (scratch / "uploaded-links.json").write_text(json.dumps(links, indent=2) + "\n")

raw = [run / entry["transcript"] for entry in manifest] + [scratch / "capture.py", scratch / "summarize.py", scratch / "no-symlink-preload.cjs", scratch / "proof-matrix.md", scratch / "parent-install.txt", run / "manifest.json", run / "observed-outcomes.json"]
if args.upload:
    upload(raw)

def link(filename, label=None):
    return f"[{label or filename}]({links.get(filename, filename)})"

def sha_link(sha):
    return f"[{sha[:10]}](https://git.cafe/pingdotgg/t3code/commit/{sha})"

def table(entries):
    output = ["| Row / raw transcript | SHA | EPERM preload | P/F/S | S1 | S2 | S3 | O1 | O2 | A1 | A2 | E1 | E2 |", "|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
    for entry in entries:
        count = entry["counts"]
        output.append("| " + " | ".join([link(entry["transcript"], entry["row"]), sha_link(entry["sha"]), "yes" if entry["preload"] else "no", "/".join(str(count.get(key, 0)) for key in "PFS")] + list(entry["named"].values())) + " |")
    return "\n".join(output)

environment = "Linux x86_64, VM fs-35, kernel 6.1.102; package-selected Node v24.22.1 for S rows and v26.12.1 for O/A rows (direct and vp exec agree on both revisions); Vite+ 1.4.0 / Vitest v5.0.5, default forks pool, verbose reporter. Harness fixtures only; no rendered UI. Browser/device emulation, DPR, font scaling, viewport, theme, screenshots, diff thresholds and frozen clock: N/A. The existing tests seed their own scoped synthetic temporary files; no production database was read or seeded."
definitions = "\n".join(f"- **{key}**: `{name}`" + (" (existing control)" if key.startswith("E") else " (new guard)") for key, name in names.items())
scripts = "\n".join("- " + link(path.name) for path in raw[12:])
common = f"{environment}\n\nP=passed, F=failed, S=skipped, —=test belongs to another package and is not registered in this row. Normal rows clear NODE_OPTIONS; EPERM rows require the downloaded preload, SHA-256 `e364864529ef79a816183b75d210f52b7fc134bc220b318de3dcaddf93cecdde`. Raw transcript headers contain the actual SHA, cwd, UTC, runtime, NODE_OPTIONS, preload hash and exact command; each ends with its exit code. Test stdout/stderr are complete and unedited.\n"
for group, title in [("S", "Server settings and session scanner"), ("O", "OpenCode usage"), ("A", "ACP permissions")]:
    selected = [entry for entry in manifest if entry["row"].startswith(group + "-")]
    sheet = f"# {title}: evidence sheet\n\n{common}\n{table(selected)}\n\n## Before / after pairs\n\n| Capability; viewport/theme N/A | Before / SHA | After / SHA | Per-test outcome diff (Diff0) | DiffT |\n|---|---|---|---|---|\n"
    for capability in "NE":
        before, after = rows[f"{group}-P-{capability}"], rows[f"{group}-H-{capability}"]
        diff = changes[f"{group}-{capability}"]
        description = "No test status changes" if not diff else "; ".join(f"{key}: F → S" for key in guarded if key.startswith(group))
        sheet += f"| {'Normal' if capability == 'N' else 'EPERM'} | {link(before['transcript'], before['row'])}<br>{sha_link(before['sha'])} | {link(after['transcript'], after['row'])}<br>{sha_link(after['sha'])} | {description} | N/A: transcript outcomes, no pixels |\n"
    sheet += "\n## Parent EPERM stack excerpts\n\nExcerpts below quote each failed test's EPERM cause and stack. The linked raw transcript preserves the entire failure, including the outer Effect error and source frames.\n"
    text = (run / f"{group}-P-E.txt").read_text()
    for failure in re.split(r"(?=^ FAIL  )", text, flags=re.M)[1:]:
        heading = failure.splitlines()[0].strip()
        excerpt = re.search(r"^Caused by: Error: EPERM[^\n]*\n(?:[^\n]+\n)+", failure, re.M)
        assert excerpt, heading
        sheet += f"\n### {heading}\n\n```text\n{excerpt.group(0)}```\n"
    sheet += f"\n## What else changed\n\nNone. Every registered test name was compared between parent and head for both capability modes. Only the newly guarded tests above changed status, from failure to skip under EPERM. Normal runs retained all passes. The existing E1/E2 controls (server sheet) pass normally and skip under EPERM on both revisions.\n\n## Test identifiers\n\n{definitions}\n\n## Reproduction inputs and drivers\n\n{scripts}\n\n## Unverified\n\nW: **cannot-verify windows**. This Linux VM has no native Windows host; fault injection verifies EPERM behavior, not Windows path semantics.\n"
    (scratch / f"{group}-sheet.md").write_text(sheet)
if args.upload:
    upload([scratch / f"{group}-sheet.md" for group in "SOA"])

index = f"# Evidence for PR [#25970](https://git.cafe/pingdotgg/t3code/pull/25970)\n\nCaptured **12/13 matrix rows**: all nine required Linux rows and all three optional normal parent baselines. W remains **cannot-verify windows**. Every affected executable row has its own complete raw test-run transcript. No code, PR body, server, browser or live data changes.\n\n{common}\n## Per-row summary\n\n{table(manifest)}\n\n## Evidence sheets\n\n" + "\n".join("- " + link(f"{group}-sheet.md", title) for group, title in [("S", "Server settings and session scanner"), ("O", "OpenCode usage"), ("A", "ACP permissions")])
index += f"\n\nEach sheet includes Before/After transcript links, revision SHAs, all guarded test statuses, outcome comparisons, and an EPERM stack excerpt for every parent failure. Diff0 means exact per-test outcome comparison; image thresholds are N/A.\n\n## What else changed\n\nNone: all 118 unique test names were compared across revisions under both modes. Normal mode: 118 passes on each revision. EPERM mode: parent 109P/7F/2S (three expected nonzero runs), head 109P/0F/9S (all runs exit zero). Only S1/S2/S3/O1/O2/A1/A2 change F → S; E1/E2 remain skipped under EPERM. No other failures, skips, added/missing test names or unhandled errors.\n\n## Test identifiers\n\n{definitions}\n\n## Reproduction inputs and drivers\n\n{scripts}\n\nTo reproduce: create the detached parent checkout and run `vp install`; leave the supplied head checkout at its exact SHA. Download the preload beside `capture.py`, then run `python3 capture.py`. It creates a fresh timestamped run directory and captures all twelve exact matrix commands. Run `python3 summarize.py /absolute/path/to/run-directory` to validate every row and generate the sheets, or add `--upload` to upload with ghdrop. The fixture seeds are the checked-in test bodies; no separate seed script was needed.\n\n## Unverified rows\n\n- W: **cannot-verify windows** — native Windows with symlink permissions denied is unavailable on this Linux VM. EPERM injection does not establish native Windows behavior. No Linux rows are missing.\n\nFRICTION: infra | Native Windows unavailable, as stated in brief; W cannot run here. | A Windows runner without Developer Mode/elevation would verify W.\n"
(scratch / "index.md").write_text(index)
if args.upload:
    upload([scratch / "index.md"])
(scratch / "validated-summary.txt").write_text(table(manifest) + "\n")
print("Validation complete: 12 transcripts; exact expected counts, exits, names, guarded statuses and seven EPERM-only transitions; no other outcome changes.")
